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