From b9b5e23455d4ec7c31f2c85cbf172dc907d20d7d Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 8 Aug 2026 03:43:53 +0300 Subject: [PATCH] testbed: physics fixes (rest-at-contact), pytest suite (33 tests green) --- pyproject.toml | 3 + testbed/room/world.py | 59 ++++++----- testbed/tests/__init__.py | 0 testbed/tests/test_bridge.py | 189 +++++++++++++++++++++++++++++++++++ testbed/tests/test_render.py | 53 ++++++++++ testbed/tests/test_world.py | 133 ++++++++++++++++++++++++ 6 files changed, 412 insertions(+), 25 deletions(-) create mode 100644 pyproject.toml create mode 100644 testbed/tests/__init__.py create mode 100644 testbed/tests/test_bridge.py create mode 100644 testbed/tests/test_render.py create mode 100644 testbed/tests/test_world.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e2f9527 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["testbed/tests"] diff --git a/testbed/room/world.py b/testbed/room/world.py index bfdfcde..17f3028 100644 --- a/testbed/room/world.py +++ b/testbed/room/world.py @@ -183,29 +183,50 @@ class Room: fx, fz = self.capsule.forward() while remaining > 0.0 and hit is None: step = min(0.1, remaining) + bx, bz = self.capsule.x, self.capsule.z ok, h = self._try_displace(fx * step, fz * step) + moved += math.hypot(self.capsule.x - bx, self.capsule.z - bz) if not ok and h is not None: - h.impulse = remaining + h.impulse = max(0.0, distance - moved) hit = h break - moved += step remaining -= step self.capsule.speed = moved / 0.1 if moved > 0 else 0.0 return moved, hit def _try_displace(self, dx: float, dz: float) -> tuple[bool, Hit | None]: - """Move by (dx, dz) with circle-vs-AABB resolution. Returns (moved, hit).""" + """Move by (dx, dz) with circle-vs-AABB resolution. Returns (moved, hit). + + The capsule always ends at the resolved position: on a blocked step it + rests at the contact point (wall or box face). + """ cap = self.capsule + r = cap.radius nx = cap.x + dx nz = cap.z + dz - # Room walls: clamp to bounds (the capsule cannot leave the room). - r = cap.radius - if nx < r or nx > ROOM_SIZE - r or nz < r or nz > ROOM_SIZE - r: - hit = self._wall_hit(nx, nz) - return False, hit - blocked: Hit | None = None + if nx < r: + nx, blocked = ( + r, + Hit(other="wall_west", normal_x=1.0, normal_z=0.0, impulse=0.0), + ) + elif nx > ROOM_SIZE - r: + nx, blocked = ( + ROOM_SIZE - r, + Hit(other="wall_east", normal_x=-1.0, normal_z=0.0, impulse=0.0), + ) + elif nz < r: + nz, blocked = ( + r, + Hit(other="wall_north", normal_x=0.0, normal_z=1.0, impulse=0.0), + ) + elif nz > ROOM_SIZE - r: + nz, blocked = ( + ROOM_SIZE - r, + Hit(other="wall_south", normal_x=0.0, normal_z=-1.0, impulse=0.0), + ) + for _ in range(8): contact = self._box_contact(nx, nz, r) if contact is None: @@ -214,22 +235,10 @@ class Room: blocked = Hit( other=other, normal_x=normal_x, normal_z=normal_z, impulse=0.0 ) - if blocked is not None: - return False, blocked cap.x = nx cap.z = nz - return True, None - - def _wall_hit(self, nx: float, nz: float) -> Hit: - r = self.capsule.radius - if nx < r: - return Hit(other="wall_west", normal_x=1.0, normal_z=0.0, impulse=0.0) - if nx > ROOM_SIZE - r: - return Hit(other="wall_east", normal_x=-1.0, normal_z=0.0, impulse=0.0) - if nz < r: - return Hit(other="wall_north", normal_x=0.0, normal_z=1.0, impulse=0.0) - return Hit(other="wall_south", normal_x=0.0, normal_z=-1.0, impulse=0.0) + return blocked is None, blocked def _box_contact( self, cx: float, cz: float, r: float @@ -265,10 +274,10 @@ class Room: return (cx, nz, 0.0, 1.0 if cz < box.cz else -1.0, box.id) return None - def turn(self, yaw_delta: float = 0.0, pitch_delta: float = 0.0) -> None: + def turn(self, yaw_deg: float = 0.0, pitch_deg: float = 0.0) -> None: cap = self.capsule - cap.yaw_deg = (cap.yaw_deg + yaw_delta) % 360.0 - cap.pitch_deg = min(85.0, max(-85.0, cap.pitch_deg + pitch_delta)) + cap.yaw_deg = (cap.yaw_deg + yaw_deg) % 360.0 + cap.pitch_deg = min(85.0, max(-85.0, cap.pitch_deg + pitch_deg)) def face(self, yaw_deg: float, pitch_deg: float) -> None: self.capsule.yaw_deg = yaw_deg % 360.0 diff --git a/testbed/tests/__init__.py b/testbed/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testbed/tests/test_bridge.py b/testbed/tests/test_bridge.py new file mode 100644 index 0000000..cb0f621 --- /dev/null +++ b/testbed/tests/test_bridge.py @@ -0,0 +1,189 @@ +"""Tests for the testbed bridge: tool behavior over the in-process transport.""" + +import base64 +import io + +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, +) +from testbed.room.world import Room + + +@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 diff --git a/testbed/tests/test_render.py b/testbed/tests/test_render.py new file mode 100644 index 0000000..2d1927e --- /dev/null +++ b/testbed/tests/test_render.py @@ -0,0 +1,53 @@ +"""Tests for the headless raycaster: honest frames from real world state.""" + +from testbed.room.render import Raycaster +from testbed.room.world import Room + + +def test_spawn_view_shows_crate_and_occludes_beacon(): + """From spawn the beacon is behind crate_red, so the frame center is the + crate's color, not a cyan glow.""" + room = Room() + img, grid = Raycaster().render(room) + px = img.load() + r, g, b = px[80, 60] + assert r > 120 and g < 100 and b < 90 # reddish crate, distance-fogged + assert len(grid) == 30 and len(grid[0]) == 40 + # The crate is ~5m away: center depth block must be well below 10. + assert 2.0 < grid[17][20] < 8.0 + + +def test_wall_nearby_is_bright_gray(): + room = Room() + room.capsule.yaw_deg = 180.0 # face north wall 1.5m away + px = Raycaster().render(room)[0].load() + r, g, b = px[80, 60] + assert abs(r - g) < 8 and r > 80 # gray wall, little fog at 1.5m + + +def test_beacon_glow_visible_when_facing_it(): + room = Room() + room.capsule.x, room.capsule.z, room.capsule.yaw_deg = 11.0, 11.0, 45.0 + img, _ = Raycaster().render(room) + px = img.load() + r, g, b = px[80, 60] + assert b > r # inactive beacon glows cyan + room.beacon.active = True + px = Raycaster().render(room)[0].load() + r, g, b = px[80, 60] + assert r > b # active beacon glows warm yellow + + +def test_beacon_occluded_by_crate(): + room = Room() + # Beacon behind crate_red from spawn: center pixel must not be cyan. + px = Raycaster().render(room)[0].load() + r, g, b = px[80, 60] + assert b < 90 + + +def test_depth_grid_aligned_and_bounded(): + room = Room() + _, grid = Raycaster().render(room, max_depth=10.0) + assert all(0.0 <= v <= 10.0 for row in grid for v in row) + assert any(v > 0 for row in grid for v in row) diff --git a/testbed/tests/test_world.py b/testbed/tests/test_world.py new file mode 100644 index 0000000..f908813 --- /dev/null +++ b/testbed/tests/test_world.py @@ -0,0 +1,133 @@ +"""Tests for the room world: physics, collision, beacon, audio.""" + +import math + +import pytest + +from testbed.room.world import ROOM_SIZE, Beacon, Box, Room + + +def test_spawn_state(): + room = Room() + assert room.capsule.x == 1.5 and room.capsule.z == 1.5 + assert room.capsule.yaw_deg == 45.0 + assert len(room.boxes) == 3 + + +def test_move_forward_free(): + room = Room() + moved, hit = room.move_forward(2.0) + assert moved == pytest.approx(2.0) + assert hit is None + assert room.capsule.x == pytest.approx(1.5 + 2.0 * math.sin(math.radians(45))) + assert room.capsule.z == pytest.approx(1.5 + 2.0 * math.cos(math.radians(45))) + + +def test_move_blocked_by_wall(): + room = Room() + room.capsule.yaw_deg = 180.0 # toward north wall (z=0) + moved, hit = room.move_forward(10.0) + assert moved == pytest.approx(1.5 - room.capsule.radius, abs=1e-6) + assert hit is not None + assert hit.other == "wall_north" + assert hit.normal_z == pytest.approx(1.0) + assert hit.impulse == pytest.approx(10.0 - moved) + assert room.capsule.z == pytest.approx(room.capsule.radius) + + +def test_move_blocked_by_box(): + room = Room() + # Crate red sits at (6,6) with half-extents 1. From spawn heading 45deg. + room.capsule.yaw_deg = 45.0 + moved, hit = room.move_forward(5.0) + assert hit is not None + assert hit.other == "crate_red" + assert moved < 5.0 + # Capsule must not overlap the box. + b = room.boxes[0] + near_x = min(max(room.capsule.x, b.x_min()), b.x_max()) + near_z = min(max(room.capsule.z, b.z_min()), b.z_max()) + assert ( + math.hypot(room.capsule.x - near_x, room.capsule.z - near_z) + >= room.capsule.radius - 1e-6 + ) + + +def test_corner_push_out_of_box(): + room = Room() + room.capsule.x = 6.0 + room.capsule.z = 6.0 # inside crate_red + ok, hit = room._try_displace(0.1, 0.1) + assert not ok + assert hit is not None and hit.other == "crate_red" + near_x = min(max(room.capsule.x, room.boxes[0].x_min()), room.boxes[0].x_max()) + near_z = min(max(room.capsule.z, room.boxes[0].z_min()), room.boxes[0].z_max()) + assert ( + math.hypot(room.capsule.x - near_x, room.capsule.z - near_z) + >= room.capsule.radius - 1e-6 + ) + + +def test_turn_clamps_pitch(): + room = Room() + room.turn(yaw_deg=30.0, pitch_deg=200.0) + assert room.capsule.pitch_deg == 85.0 + room.turn(pitch_deg=-500.0) + assert room.capsule.pitch_deg == -85.0 + assert room.capsule.yaw_deg == pytest.approx(75.0) + + +def test_look_at_beacon(): + room = Room() + yaw, pitch = room.look_at_beacon() + dx = room.beacon.x - room.capsule.x + dz = room.beacon.z - room.capsule.z + assert yaw == pytest.approx(math.degrees(math.atan2(dx, dz))) + assert room.capsule.yaw_deg == pytest.approx(yaw) + + +def test_interact_requires_reach(): + room = Room() + ok, msg = room.interact_beacon() + assert not ok + assert "too far" in msg + room.capsule.x = room.beacon.x - 1.0 + room.capsule.z = room.beacon.z + ok, msg = room.interact_beacon() + assert ok + assert room.beacon.active + ok2, _ = room.interact_beacon() + assert ok2 # already active is still a success + + +def test_hear_beacon_hum_only_in_range(): + room = Room() + sounds = room.hear_now() + assert sounds == [] + room.capsule.x = room.beacon.x - 3.0 + room.capsule.z = room.beacon.z + sounds = room.hear_now() + assert any(s["kind"] == "beacon_hum" for s in sounds) + assert sounds[0]["intensity"] > 0.0 + + +def test_queue_and_drain_audio(): + room = Room() + room.queue_audio("thud", 90.0, 0.5) + sounds = room.drain_audio() + assert len(sounds) == 1 + assert room.drain_audio() == [] + + +def test_tick_advances(): + room = Room() + assert room.advance_tick() == 1 + assert room.advance_tick() == 2 + + +def test_describe_layout(): + room = Room() + d = room.describe() + assert d["room"]["width"] == ROOM_SIZE + assert len(d["obstacles"]) == 3 + assert d["beacon"]["id"] == "beacon"