Files
aicc-capsule/testbed/tests/test_bridge.py
T

300 lines
9.9 KiB
Python

"""Tests for the testbed bridge: tool behavior over the in-process transport."""
import asyncio
import base64
import io
import math
import pytest
from aicc.client import AICCClient
from aicc.errors import ToolError
from aicc.transport.in_process import InProcessTransport
from testbed.bridge import (
DEPTH_HEIGHT,
DEPTH_WIDTH,
VISION_HEIGHT,
VISION_WIDTH,
build_bridge,
)
@pytest.fixture
async def bridge():
b = build_bridge()
yield b
@pytest.fixture
async def client(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
yield client
async def test_manifest_lists_all_tools(client):
m = await client.handshake()
ids = [t.id for t in m.tools]
for expected in (
"proprioception",
"vision",
"depth",
"hear",
"world_query",
"move",
"turn",
"look_at",
"interact",
"echo",
"boom",
"bump",
):
assert expected in ids
assert "testbed_room_01" in m.world.name
async def test_proprioception(client):
await client.handshake()
out = (await client.call_tool("proprioception", {})).output
assert out["position"] == {"x": 1.5, "y": 0.0, "z": 1.5}
assert out["rotation"]["yaw_deg"] == 45.0
assert out["health"] == 100.0
assert out["tick"] == 1
async def test_vision_returns_png(client):
await client.handshake()
out = (await client.call_tool("vision", {})).output
assert out["width"] == VISION_WIDTH
assert out["height"] == VISION_HEIGHT
raw = base64.b64decode(out["png_b64"])
assert raw[:8] == b"\x89PNG\r\n\x1a\n"
img = io.BytesIO(raw)
from PIL import Image
assert Image.open(img).size == (VISION_WIDTH, VISION_HEIGHT)
async def test_move_and_collision_event(client):
await client.handshake()
out = (await client.call_tool("move", {"forward": 2.0})).output
assert out["moved"] == pytest.approx(2.0)
assert out["collision"] is False
out2 = (await client.call_tool("move", {"forward": 5.0})).output
assert out2["collision"] is True
assert out2["collision_normal"] is not None
event = await client.next_event(timeout=1.0)
assert event.topic == "collision"
assert "other" in event.payload
async def test_look_at_and_interact(client):
await client.handshake()
la = (await client.call_tool("look_at", {"target": "beacon"})).output
assert abs(la["rotation"]["yaw_deg"] - 45.0) < 0.1
res = (await client.call_tool("interact", {})).output
assert res["success"] is False
assert "too far" in res["message"]
async def test_look_at_unknown_target(client):
await client.handshake()
with pytest.raises(ToolError) as ei:
await client.call_tool("look_at", {"target": "moon"})
assert ei.value.code == "execution_failed"
async def test_hear_empty_then_hum(client, bridge):
await client.handshake()
out = (await client.call_tool("hear", {})).output
assert out["sounds"] == []
bridge.world.capsule.x = bridge.world.beacon.x - 3.0
bridge.world.capsule.z = bridge.world.beacon.z
out = (await client.call_tool("hear", {})).output
assert any(s["kind"] == "beacon_hum" for s in out["sounds"])
async def test_depth_and_world_query(client):
await client.handshake()
d = (await client.call_tool("depth", {})).output
assert d["width"] == DEPTH_WIDTH and d["height"] == DEPTH_HEIGHT
assert len(d["depth"]) == DEPTH_HEIGHT and len(d["depth"][0]) == DEPTH_WIDTH
wq = (await client.call_tool("world_query", {})).output
assert wq["beacon"]["id"] == "beacon"
assert len(wq["obstacles"]) == 3
async def test_unknown_tool(client):
await client.handshake()
with pytest.raises(ToolError) as ei:
await client.call_tool("definitely_not_a_tool", {})
assert ei.value.code == "tool_unknown"
async def test_boom_execution_failed(client):
await client.handshake()
with pytest.raises(ToolError) as ei:
await client.call_tool("boom", {})
assert ei.value.code == "execution_failed"
async def test_bump_emits_event(client):
await client.handshake()
out = (await client.call_tool("bump", {})).output
assert out == {"bumped": True}
event = await client.next_event(timeout=1.0)
assert event.topic == "collision"
async def test_echo(client):
await client.handshake()
out = (await client.call_tool("echo", {"value": "hello"})).output
assert out == {"value": "hello"}
async def test_interact_success_near_beacon(client, bridge):
await client.handshake()
bridge.world.capsule.x = bridge.world.beacon.x - 1.0
bridge.world.capsule.z = bridge.world.beacon.z
res = (await client.call_tool("interact", {})).output
assert res["success"] is True
assert bridge.world.beacon.active
async def test_move_invalid_input(client):
await client.handshake()
with pytest.raises(ToolError) as ei:
await client.call_tool("move", {"forward": 99.0})
assert ei.value.code == "execution_failed"
async def test_world_reset_between_sessions():
"""Each fresh bridge owns a fresh Room."""
b1, b2 = build_bridge(), build_bridge()
assert b1.world is not b2.world
async def test_single_source_of_truth():
"""Manifest must carry no world state."""
bridge = build_bridge()
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
m = await client.handshake()
dumped = m.model_dump(by_alias=True)
world = dumped["world"]
assert set(world.keys()) == {"name", "kind"}
assert "beacon" not in dumped
async def test_animated_move_is_smooth_and_observable(bridge):
"""A move with duration glides: a concurrent client sees intermediate
positions, and the final displacement is exact."""
# A transport is a single session: one per client.
t1 = InProcessTransport.start(bridge)
t2 = InProcessTransport.start(bridge)
async with t1, t2:
mover = AICCClient(t1)
viewer = AICCClient(t2)
async with mover, viewer:
await mover.handshake()
await viewer.handshake()
start = (await mover.call_tool("proprioception", {})).output["position"]
assert start["x"] == 1.5 and start["z"] == 1.5
move_task = asyncio.create_task(
mover.call_tool("move", {"forward": 2.0, "duration": 0.8})
)
await asyncio.sleep(0.25)
mid = (await viewer.call_tool("proprioception", {})).output["position"]
# Moving along the 45-degree diagonal: strictly between start and end.
assert 1.5 < mid["x"] < 3.5 and 1.5 < mid["z"] < 3.5
res = (await move_task).output
assert res["moved"] == pytest.approx(2.0, abs=1e-3)
assert res["position"]["x"] == pytest.approx(
1.5 + 2.0 * math.sin(math.radians(45)), abs=1e-3
)
assert res["duration"] == 0.8
async def test_animated_move_stops_on_collision(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
bridge.world.capsule.yaw_deg = 180.0 # straight at the north wall
res = (
await client.call_tool("move", {"forward": 5.0, "duration": 0.6})
).output
assert res["collision"] is True
assert res["moved"] < 5.0
assert res["position"]["z"] == pytest.approx(
bridge.world.capsule.radius, abs=1e-3
)
async def test_move_accepts_duration_zero_default(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
res = (await client.call_tool("move", {"forward": 1.0})).output
assert res["moved"] == pytest.approx(1.0)
assert res["duration"] == 0.0
async def test_report_verifies_near_marker(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
out = (await client.call_tool("report", {"discovery": "triangle"})).output
assert out["success"] is True
assert out["verified"] is False # far away from spawn
pos = bridge.world.marker_world_pos()
bridge.world.capsule.x = pos[0]
bridge.world.capsule.z = pos[2]
out2 = (await client.call_tool("report", {})).output
assert out2["verified"] is True
assert "verified" in out2["message"]
async def test_turn_with_duration_is_smooth(bridge):
t1 = InProcessTransport.start(bridge)
t2 = InProcessTransport.start(bridge)
async with t1, t2:
mover = AICCClient(t1)
viewer = AICCClient(t2)
async with mover, viewer:
await mover.handshake()
await viewer.handshake()
task = asyncio.create_task(
mover.call_tool("turn", {"yaw_deg": 90.0, "duration": 0.8})
)
await asyncio.sleep(0.3)
mid = (await viewer.call_tool("proprioception", {})).output["rotation"]["yaw_deg"]
assert 0.0 < mid < 90.0, f"expected intermediate rotation, got {mid}"
res = (await task).output
assert res["rotation"]["yaw_deg"] == pytest.approx(135.0, abs=0.5)
async def test_look_at_crate(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
out = (await client.call_tool("look_at", {"target": "crate_red"})).output
dx = bridge.world.boxes[0].cx - bridge.world.capsule.x
dz = bridge.world.boxes[0].cz - bridge.world.capsule.z
assert out["rotation"]["yaw_deg"] == pytest.approx(
math.degrees(math.atan2(dx, dz)) % 360.0, abs=0.5
)