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, a controller and a finger
  6use.
  7"""
  8
  9import sys
 10from pathlib import Path
 11
 12import pytest
 13
 14# The game is a plain script beside this file, not an installed package, so
 15# make its directory importable however pytest was invoked.
 16sys.path.insert(0, str(Path(__file__).parent))
 17
 18from main import (
 19    COIN_SPOTS,
 20    MARGIN,
 21    PICKUP_RADIUS,
 22    Coin,
 23    CoinRun,
 24    clamp_to_field,
 25    coin_positions,
 26    within_pickup,
 27)
 28
 29from simvx.core import Controller, Key, Mouse, Vec2
 30from simvx.core.testing import InputSimulator, SceneRunner
 31
 32# The window these tests run the game in. The game measures its own field from
 33# the viewport, so the size is a choice the test makes rather than a constant
 34# the game carries.
 35FIELD = Vec2(960.0, 540.0)
 36
 37
 38@pytest.fixture
 39def game():
 40    """A freshly mounted game, one frame in, plus the runner driving it.
 41
 42    When the test ends, however it ends, every key, button and axis is
 43    released. The device record is process-wide: a test that presses a key,
 44    holds the pointer or pushes a stick and then fails would otherwise leak
 45    that state into every later test.
 46    """
 47    runner = SceneRunner(screen_size=(FIELD.x, FIELD.y))
 48    root = CoinRun(name="CoinRun")
 49    runner.load(root)
 50    runner.advance_frames(1)
 51    yield runner, root
 52    InputSimulator(runner.tree).reset()
 53
 54
 55# ---------------------------------------------------------------------------
 56# Level 1: pure logic. No nodes, no tree, no engine state. These run in
 57# microseconds and pin down the rules everything else is built on.
 58# ---------------------------------------------------------------------------
 59
 60
 61class TestRules:
 62    def test_clamp_holds_every_edge(self):
 63        clamped = clamp_to_field(Vec2(-100.0, -100.0), FIELD)
 64        assert (clamped.x, clamped.y) == (MARGIN, MARGIN)
 65        clamped = clamp_to_field(Vec2(FIELD.x + 100.0, FIELD.y + 100.0), FIELD)
 66        assert (clamped.x, clamped.y) == (FIELD.x - MARGIN, FIELD.y - MARGIN)
 67
 68    def test_clamp_leaves_interior_points_alone(self):
 69        inside = Vec2(FIELD.x / 2, FIELD.y / 2)
 70        clamped = clamp_to_field(inside, FIELD)
 71        assert (clamped.x, clamped.y) == (inside.x, inside.y)
 72
 73    def test_clamp_holds_the_field_it_is_given(self):
 74        """The rule takes the field as a value, so it is right in any window."""
 75        half = Vec2(FIELD.x / 2, FIELD.y / 2)
 76        clamped = clamp_to_field(Vec2(FIELD.x, FIELD.y), half)
 77        assert (clamped.x, clamped.y) == (half.x - MARGIN, half.y - MARGIN)
 78
 79    def test_coin_spots_scale_with_the_field(self):
 80        full = coin_positions(FIELD)
 81        half = coin_positions(Vec2(FIELD.x / 2, FIELD.y / 2))
 82        assert len(full) == len(COIN_SPOTS)
 83        assert all(abs(h.x * 2 - f.x) < 1e-3 and abs(h.y * 2 - f.y) < 1e-3 for f, h in zip(full, half, strict=True))
 84        assert all(MARGIN < p.x < FIELD.x - MARGIN and MARGIN < p.y < FIELD.y - MARGIN for p in full)
 85
 86    def test_pickup_radius_is_a_hard_boundary(self):
 87        coin = Vec2(300.0, 300.0)
 88        assert within_pickup(coin + Vec2(PICKUP_RADIUS, 0.0), coin)
 89        assert not within_pickup(coin + Vec2(PICKUP_RADIUS + 0.1, 0.0), coin)
 90
 91    def test_pickup_range_is_radial_not_axis_aligned(self):
 92        coin = Vec2(300.0, 300.0)
 93        diagonal = PICKUP_RADIUS / (2.0**0.5)
 94        assert within_pickup(coin + Vec2(diagonal - 0.1, diagonal - 0.1), coin)
 95        assert not within_pickup(coin + Vec2(diagonal + 0.1, diagonal + 0.1), coin)
 96
 97
 98# ---------------------------------------------------------------------------
 99# Level 2: the scene tree. SceneRunner mounts the real root and ticks real
100# frames, so lifecycle hooks, signals and deferred destruction all run, with
101# no window and no GPU. State is asserted straight off the nodes.
102# ---------------------------------------------------------------------------
103
104
105class TestSceneTree:
106    def test_scene_starts_complete(self, game):
107        runner, root = game
108        assert root.score == 0
109        assert not root.won
110        assert len(runner.find_all(Coin)) == len(COIN_SPOTS)
111        assert runner.find("Player") is root.player
112
113    def test_standing_on_a_coin_collects_it(self, game):
114        runner, root = game
115        coin = root.coins[0]
116        root.player.position = Vec2(coin.position.x, coin.position.y)
117        runner.advance_frames(2)  # one frame to collect, one for the deferred destroy
118        assert root.score == 1
119        assert len(runner.find_all(Coin)) == len(COIN_SPOTS) - 1
120
121    def test_a_coin_is_collected_exactly_once(self, game):
122        runner, root = game
123        coin = root.coins[0]
124        root.player.position = Vec2(coin.position.x, coin.position.y)
125        runner.advance_frames(10)  # linger on the spot for several frames
126        assert root.score == 1
127
128    def test_clearing_the_field_wins(self, game):
129        runner, root = game
130        for coin in list(root.coins):
131            root.player.position = Vec2(coin.position.x, coin.position.y)
132            runner.advance_frames(2)
133        assert root.score == len(COIN_SPOTS)
134        assert root.won
135        assert len(runner.find_all(Coin)) == 0
136
137    def test_resizing_the_window_moves_the_field(self, game):
138        """The game lays out against the live viewport, not the size it started at."""
139        runner, root = game
140        root.player.position = Vec2(FIELD.x - MARGIN, FIELD.y - MARGIN)
141        runner.tree.screen_size = (FIELD.x / 2, FIELD.y / 2)
142        runner.advance_frames(1)
143
144        expected = coin_positions(Vec2(FIELD.x / 2, FIELD.y / 2))
145        assert [(round(c.position.x, 3), round(c.position.y, 3)) for c in root.coins] == [
146            (round(p.x, 3), round(p.y, 3)) for p in expected
147        ]
148        # The player was standing where the old field ended; the new one has
149        # already pulled it back inside.
150        assert root.player.position.x == FIELD.x / 2 - MARGIN
151        assert root.player.position.y == FIELD.y / 2 - MARGIN
152
153
154# ---------------------------------------------------------------------------
155# Level 3: input-driven. InputSimulator writes the same state a keyboard, a
156# controller or a finger would, so the player node moves because its update
157# code read the action, not because the test teleported it. The score is
158# observed only through the `score_changed` signal, the same seam a HUD uses.
159# ---------------------------------------------------------------------------
160
161
162class TestInputDriven:
163    def test_walking_right_collects_a_coin_and_fires_the_signal(self, game):
164        runner, root = game
165        events: list[int] = []
166        root.score_changed.connect(events.append)
167
168        # Park the player a short walk left of the first coin.
169        coin = root.coins[0]
170        root.player.position = Vec2(coin.position.x - 80.0, coin.position.y)
171
172        sim = InputSimulator(runner.tree)
173        sim.press_key(Key.D)
174        collected = runner.simulate_until(lambda r: r.score > 0, max_ticks=180)
175        sim.release_key(Key.D)
176
177        assert collected, "walking right for 3 seconds never reached the coin"
178        assert events == [1]
179
180    def test_the_left_stick_drives_the_same_action(self, game):
181        """A controller is another source of `move`, so it needs no code of its own."""
182        runner, root = game
183        root.player.position = Vec2(FIELD.x / 2, FIELD.y / 2)
184        start = Vec2(root.player.position)
185
186        sim = InputSimulator(runner.tree)
187        sim.set_controller_axis(Controller.LEFT_X, 1.0)
188        runner.advance_frames(30)
189        sim.set_controller_axis(Controller.LEFT_X, 0.0)
190
191        assert root.player.position.x > start.x + 100.0
192        assert root.player.position.y == start.y
193
194    def test_holding_the_pointer_steers_onto_a_coin(self, game):
195        """The pointer path: `steer` is bound to `Pointer.PRIMARY`, which the mouse button presses."""
196        runner, root = game
197        events: list[int] = []
198        root.score_changed.connect(events.append)
199
200        coin = root.coins[0]
201        root.player.position = Vec2(coin.position.x - 80.0, coin.position.y)
202
203        sim = InputSimulator(runner.tree)
204        sim.press_mouse(Mouse.LEFT, (float(coin.position.x), float(coin.position.y)))
205        collected = runner.simulate_until(lambda r: r.score > 0, max_ticks=180)
206        sim.release_mouse(Mouse.LEFT)
207
208        assert collected, "steering towards the coin for 3 seconds never reached it"
209        assert events == [1]
210
211    def test_releasing_the_pointer_stops_the_player(self, game):
212        runner, root = game
213        root.player.position = Vec2(FIELD.x / 2, FIELD.y / 2)
214
215        sim = InputSimulator(runner.tree)
216        sim.press_mouse(Mouse.LEFT, (FIELD.x - MARGIN, FIELD.y / 2))
217        runner.advance_frames(10)
218        sim.release_mouse(Mouse.LEFT)
219        runner.advance_frames(1)  # the release lands on the next frame's state
220        parked = Vec2(root.player.position)
221        runner.advance_frames(10)
222
223        assert parked.x > FIELD.x / 2
224        assert root.player.position.x == parked.x
225
226    def test_the_walls_stop_the_player(self, game):
227        runner, root = game
228        root.player.position = Vec2(FIELD.x / 2, MARGIN + 10.0)
229        sim = InputSimulator(runner.tree)
230        sim.press_key(Key.UP)
231        runner.advance_time(2.0)
232        sim.release_key(Key.UP)
233        assert root.player.position.y == MARGIN
234
235    def test_releasing_the_key_stops_the_player(self, game):
236        runner, root = game
237        sim = InputSimulator(runner.tree)
238        sim.press_key(Key.RIGHT)
239        runner.advance_frames(30)
240        sim.release_key(Key.RIGHT)
241        runner.advance_frames(1)  # the release lands on the next frame's state
242        x_after_release = root.player.position.x
243        runner.advance_frames(30)
244        assert root.player.position.x == x_after_release