182 lines
5.8 KiB
Python
182 lines
5.8 KiB
Python
"""Tests for the room world: physics, collision, beacon, audio."""
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from testbed.room.world import ROOM_SIZE, 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"
|
|
|
|
|
|
def test_marker_is_on_a_back_face():
|
|
"""The triangle must sit on a face pointing away from the spawn corner."""
|
|
for _ in range(20):
|
|
room = Room()
|
|
pos = room.marker_world_pos()
|
|
assert pos is not None
|
|
box = next(b for b in room.boxes if b.id == room.marker["box"])
|
|
face = room.marker["face"]
|
|
# spawn is at (1.5, 1.5); the marker face normal must point away from it
|
|
to_spawn = (1.5 - box.cx, 1.5 - box.cz)
|
|
normal = {
|
|
"x0": (-1, 0), "x1": (1, 0), "z0": (0, -1), "z1": (0, 1),
|
|
}[face]
|
|
assert normal[0] * to_spawn[0] + normal[1] * to_spawn[1] < 0
|
|
assert pos[0] == pytest.approx(box.x_max()) if face == "x1" else True
|
|
|
|
|
|
def test_marker_not_visible_from_spawn():
|
|
"""Looking toward the beacon from spawn must not show the triangle."""
|
|
from testbed.room.render import Raycaster
|
|
|
|
room = Room()
|
|
# Spawn looks at the beacon; the marker is on a back face, so the anchor
|
|
# pixel of the marker must be occluded (different surface) or off-frame.
|
|
pos = room.marker_world_pos()
|
|
assert pos is not None
|
|
# Stand on the marker's side of the crate, facing it.
|
|
face = room.marker["face"]
|
|
offsets = {
|
|
"x0": (-1.0, 0.0, 90.0), # west face: stand west, face +x
|
|
"x1": (1.0, 0.0, 270.0), # east face: stand east, face -x
|
|
"z0": (0.0, -1.0, 0.0), # south face: stand south, face +z
|
|
"z1": (0.0, 1.0, 180.0), # north face: stand north, face -z
|
|
}[face]
|
|
room.capsule.x = pos[0] + offsets[0]
|
|
room.capsule.z = pos[2] + offsets[1]
|
|
room.capsule.yaw_deg = offsets[2]
|
|
img, _ = Raycaster().render(room)
|
|
px = img.load()
|
|
orange = 0
|
|
for y in range(0, 120, 2):
|
|
for x in range(0, 160, 2):
|
|
r, g, b = px[x, y]
|
|
if r > 200 and 100 < g < 190 and b < 90:
|
|
orange += 1
|
|
assert orange > 3, "triangle must be visible when looking at the marker face"
|