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