test_game.pyΒΆ

Part of Testing Your Game.

  1"""Three levels of headless tests for the Coin Run game in `main.py`.
  2
  3Level 1 tests the pure rules with no engine at all. Level 2 loads the real
  4scene into a SceneRunner and drives frames. Level 3 plays the game through
  5InputSimulator, the same input path a keyboard uses. Run with:
  6
  7    uv run pytest examples/tutorials/testing_your_game/test_game.py
  8"""
  9
 10import sys
 11from pathlib import Path
 12
 13import pytest
 14
 15# The game is a plain script beside this file, not an installed package, so
 16# make its directory importable however pytest was invoked.
 17sys.path.insert(0, str(Path(__file__).parent))
 18
 19from main import COIN_SPOTS, HEIGHT, MARGIN, PICKUP_RADIUS, WIDTH, Coin, CoinRun, clamp_to_field, within_pickup
 20
 21from simvx.core import Key, Vec2
 22from simvx.core.testing import InputSimulator, SceneRunner
 23
 24
 25@pytest.fixture(autouse=True)
 26def _clean_input():
 27    """Release every key when a test ends, however it ends.
 28
 29    Input is a process-wide singleton. A test that presses a key and then
 30    fails would otherwise leak that press into every later test.
 31    """
 32    yield
 33    InputSimulator().reset()
 34
 35
 36@pytest.fixture
 37def game():
 38    """A freshly mounted game, one frame in, plus the runner driving it."""
 39    runner = SceneRunner(screen_size=(WIDTH, HEIGHT))
 40    root = CoinRun(name="CoinRun")
 41    runner.load(root)
 42    runner.advance_frames(1)
 43    return runner, root
 44
 45
 46# ---------------------------------------------------------------------------
 47# Level 1: pure logic. No nodes, no tree, no engine state. These run in
 48# microseconds and pin down the rules everything else is built on.
 49# ---------------------------------------------------------------------------
 50
 51
 52class TestRules:
 53    def test_clamp_holds_every_edge(self):
 54        clamped = clamp_to_field(Vec2(-100.0, -100.0))
 55        assert (clamped.x, clamped.y) == (MARGIN, MARGIN)
 56        clamped = clamp_to_field(Vec2(WIDTH + 100.0, HEIGHT + 100.0))
 57        assert (clamped.x, clamped.y) == (WIDTH - MARGIN, HEIGHT - MARGIN)
 58
 59    def test_clamp_leaves_interior_points_alone(self):
 60        inside = Vec2(WIDTH / 2, HEIGHT / 2)
 61        clamped = clamp_to_field(inside)
 62        assert (clamped.x, clamped.y) == (inside.x, inside.y)
 63
 64    def test_pickup_radius_is_a_hard_boundary(self):
 65        coin = Vec2(300.0, 300.0)
 66        assert within_pickup(coin + Vec2(PICKUP_RADIUS, 0.0), coin)
 67        assert not within_pickup(coin + Vec2(PICKUP_RADIUS + 0.1, 0.0), coin)
 68
 69    def test_pickup_range_is_radial_not_axis_aligned(self):
 70        coin = Vec2(300.0, 300.0)
 71        diagonal = PICKUP_RADIUS / (2.0**0.5)
 72        assert within_pickup(coin + Vec2(diagonal - 0.1, diagonal - 0.1), coin)
 73        assert not within_pickup(coin + Vec2(diagonal + 0.1, diagonal + 0.1), coin)
 74
 75
 76# ---------------------------------------------------------------------------
 77# Level 2: the scene tree. SceneRunner mounts the real root and ticks real
 78# frames, so lifecycle hooks, signals and deferred destruction all run, with
 79# no window and no GPU. State is asserted straight off the nodes.
 80# ---------------------------------------------------------------------------
 81
 82
 83class TestSceneTree:
 84    def test_scene_starts_complete(self, game):
 85        runner, root = game
 86        assert root.score == 0
 87        assert not root.won
 88        assert len(runner.find_all(Coin)) == len(COIN_SPOTS)
 89        assert runner.find("Player") is root.player
 90
 91    def test_standing_on_a_coin_collects_it(self, game):
 92        runner, root = game
 93        coin = root.coins[0]
 94        root.player.position = Vec2(coin.position.x, coin.position.y)
 95        runner.advance_frames(2)  # one frame to collect, one for the deferred destroy
 96        assert root.score == 1
 97        assert len(runner.find_all(Coin)) == len(COIN_SPOTS) - 1
 98
 99    def test_a_coin_is_collected_exactly_once(self, game):
100        runner, root = game
101        coin = root.coins[0]
102        root.player.position = Vec2(coin.position.x, coin.position.y)
103        runner.advance_frames(10)  # linger on the spot for several frames
104        assert root.score == 1
105
106    def test_clearing_the_field_wins(self, game):
107        runner, root = game
108        for coin in list(root.coins):
109            root.player.position = Vec2(coin.position.x, coin.position.y)
110            runner.advance_frames(2)
111        assert root.score == len(COIN_SPOTS)
112        assert root.won
113        assert len(runner.find_all(Coin)) == 0
114
115
116# ---------------------------------------------------------------------------
117# Level 3: input-driven. InputSimulator writes the same state a keyboard
118# would, so the player node moves because its update code read the action,
119# not because the test teleported it. The score is observed only through
120# the `score_changed` signal, the same seam a HUD would use.
121# ---------------------------------------------------------------------------
122
123
124class TestInputDriven:
125    def test_walking_right_collects_a_coin_and_fires_the_signal(self, game):
126        runner, root = game
127        events: list[int] = []
128        root.score_changed.connect(events.append)
129
130        # Park the player a short walk left of the first coin.
131        coin = root.coins[0]
132        root.player.position = Vec2(coin.position.x - 80.0, coin.position.y)
133
134        sim = InputSimulator()
135        sim.press_key(Key.D)
136        collected = runner.simulate_until(lambda r: r.score > 0, max_ticks=180)
137        sim.release_key(Key.D)
138
139        assert collected, "walking right for 3 seconds never reached the coin"
140        assert events == [1]
141
142    def test_the_walls_stop_the_player(self, game):
143        runner, root = game
144        root.player.position = Vec2(WIDTH / 2, MARGIN + 10.0)
145        sim = InputSimulator()
146        sim.press_key(Key.UP)
147        runner.advance_time(2.0)
148        sim.release_key(Key.UP)
149        assert root.player.position.y == MARGIN
150
151    def test_releasing_the_key_stops_the_player(self, game):
152        runner, root = game
153        sim = InputSimulator()
154        sim.press_key(Key.RIGHT)
155        runner.advance_frames(30)
156        sim.release_key(Key.RIGHT)
157        runner.advance_frames(1)  # the release lands on the next frame's state
158        x_after_release = root.player.position.x
159        runner.advance_frames(30)
160        assert root.player.position.x == x_after_release