Adopt the test fakes

Copied unchanged from the base (020-snake-game, commit 1a638c9): the fake segment, the fake screen and the fake Turtle base class that record their calls, so every test runs without a display. The fake turtle module is installed with monkeypatch, so it is undone after each test.

Task: MIL-002#1
Closes #8
This commit is contained in:
2026-10-08 18:31:01 +08:00
parent 68c516e32b
commit 5575658503
+300
View File
@@ -0,0 +1,300 @@
"""Fakes that stand in for turtles and the screen, so that no test opens a window."""
import importlib
import math
import os
import subprocess
import sys
import types
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
import pytest
from snake_game.snake import Snake
SRC = Path(__file__).resolve().parents[1] / "src"
# Modules whose classes inherit from `Turtle`: they bind their base class when they
# are imported, so each test that installs the fake `turtle` module imports them anew.
INHERITING_MODULES = ("snake_game.food", "snake_game.scoreboard")
class FakeTerminatorError(Exception):
"""Stands in for `turtle.Terminator`."""
class FakeTclError(Exception):
"""Stands in for `tkinter.TclError`."""
class FakeSegment:
"""A turtle that records what is done to it, in order, and keeps its state.
It is a segment of the snake, and also the base class that stands in for
`turtle.Turtle` when `Food` and `Scoreboard` are tested.
"""
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.x = 0.0
self.y = 0.0
self.angle = 0.0
def shape(self, name: str, /) -> None:
self.calls.append(("shape", (name,)))
def shapesize(self, stretch_wid: float, stretch_len: float) -> None:
self.calls.append(("shapesize", (stretch_wid, stretch_len)))
def color(self, color: str, /) -> None:
self.calls.append(("color", (color,)))
def speed(self, speed: str) -> None:
self.calls.append(("speed", (speed,)))
def penup(self) -> None:
self.calls.append(("penup", ()))
def hideturtle(self) -> None:
self.calls.append(("hideturtle", ()))
def clear(self) -> None:
self.calls.append(("clear", ()))
def write(
self,
arg: str,
align: str = "left",
font: tuple[str, int, str] = ("Arial", 8, "normal"),
) -> None:
self.calls.append(("write", (arg, align, font)))
def goto(self, x: float | tuple[float, float], y: float | None = None, /) -> None:
if isinstance(x, tuple):
x, y = x
assert y is not None
self.calls.append(("goto", (x, y)))
self.x, self.y = x, y
def xcor(self) -> float:
return self.x
def ycor(self) -> float:
return self.y
def position(self) -> tuple[float, float]:
return (self.x, self.y)
def distance(self, x: tuple[float, float], /) -> float:
return math.hypot(self.x - x[0], self.y - x[1])
def forward(self, distance: float, /) -> None:
self.calls.append(("forward", (distance,)))
radians = math.radians(self.angle)
self.x += round(distance * math.cos(radians), 10)
self.y += round(distance * math.sin(radians), 10)
def heading(self) -> float:
return self.angle
def setheading(self, to_angle: float, /) -> None:
self.calls.append(("setheading", (to_angle,)))
self.angle = float(to_angle) % 360
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
class FakeFood:
"""A food that counts how often it was moved."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
self.refreshes = 0
def refresh(self) -> None:
self.refreshes += 1
def position(self) -> tuple[float, float]:
return (self.x, self.y)
class FakeScoreboard:
"""A scoreboard that counts how often the score was raised."""
def __init__(self) -> None:
self.increases = 0
self.game_overs = 0
def increase_score(self) -> None:
self.increases += 1
def game_over(self) -> None:
self.game_overs += 1
class FakeScreen:
"""A screen that records what is done to it, in order.
After `frames_before_close` updates it behaves like a closed window: the next
`update` raises `closing_error`.
"""
def __init__(
self,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
self.bindings: dict[str, Callable[[], object]] = {}
self._updates_left = frames_before_close
self._closing_error = closing_error
def setup(self, width: float, height: float) -> None:
self.calls.append(("setup", (width, height)))
def bgcolor(self, color: str, /) -> None:
self.calls.append(("bgcolor", (color,)))
def title(self, titlestring: str, /) -> None:
self.calls.append(("title", (titlestring,)))
def tracer(self, n: int, /) -> None:
self.calls.append(("tracer", (n,)))
def listen(self) -> None:
self.calls.append(("listen", ()))
def onkey(self, fun: Callable[[], object], key: str) -> None:
self.calls.append(("onkey", (key,)))
self.bindings[key] = fun
def update(self) -> None:
self.calls.append(("update", ()))
if self._updates_left == 0:
raise self._closing_error
self._updates_left -= 1
def exitonclick(self) -> None:
self.calls.append(("exitonclick", ()))
def call_names(self) -> list[str]:
"""Return the names of the calls, in the order they were made."""
return [name for name, _ in self.calls]
def make_snake() -> tuple[Snake, list[FakeSegment]]:
"""Make a snake whose segments are fakes, and return the fakes too."""
created: list[FakeSegment] = []
def factory() -> FakeSegment:
segment = FakeSegment()
created.append(segment)
return segment
return Snake(segment_factory=factory), created
def install_fake_turtle(
monkeypatch: pytest.MonkeyPatch,
*,
frames_before_close: int = 0,
closing_error: type[Exception] = FakeTerminatorError,
) -> tuple[list[FakeSegment], list[FakeScreen]]:
"""Replace the `turtle` and `tkinter` modules with fakes for one test.
The fake screen acts like a window that the player closes after
`frames_before_close` updates, by raising `closing_error` from `update`.
Every turtle the code under test creates, including a `Food` or a `Scoreboard`
whose base class is the fake `Turtle`, is added to the first list returned;
every screen is added to the second. `food` and `scoreboard` are forgotten, so
that they are imported again against the fake and removed again after the test.
"""
segments: list[FakeSegment] = []
screens: list[FakeScreen] = []
class RegisteredSegment(FakeSegment):
def __init__(self) -> None:
super().__init__()
segments.append(self)
def make_screen() -> FakeScreen:
screen = FakeScreen(frames_before_close, closing_error)
screens.append(screen)
return screen
turtle_module = types.ModuleType("turtle")
turtle_module.__dict__["Turtle"] = RegisteredSegment
turtle_module.__dict__["Screen"] = make_screen
turtle_module.__dict__["Terminator"] = FakeTerminatorError
tkinter_module = types.ModuleType("tkinter")
tkinter_module.__dict__["TclError"] = FakeTclError
monkeypatch.setitem(sys.modules, "turtle", turtle_module)
monkeypatch.setitem(sys.modules, "tkinter", tkinter_module)
for name in INHERITING_MODULES:
# Set, then delete: when the test ends monkeypatch undoes both in reverse
# order and the key is gone again, whatever the test imported meanwhile.
monkeypatch.setitem(sys.modules, name, types.ModuleType(name))
monkeypatch.delitem(sys.modules, name)
return segments, screens
def script_randint(
monkeypatch: pytest.MonkeyPatch, values: Sequence[int]
) -> list[tuple[int, int]]:
"""Make `random.randint` return the given values in turn, then the upper bound.
Returns the list that collects the bounds of every call.
"""
queue = iter(values)
bounds: list[tuple[int, int]] = []
def fake_randint(low: int, high: int) -> int:
bounds.append((low, high))
return next(queue, high)
monkeypatch.setattr("random.randint", fake_randint)
return bounds
def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Food` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Food` (`refresh`) and
the recording methods of the fake base class (`calls`, `position`) at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.food")
return module.Food()
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
The result is typed `Any` because it has the methods of `Scoreboard`
(`increase_score`) and the recording methods of the fake base class (`calls`)
at once.
"""
install_fake_turtle(monkeypatch)
module = importlib.import_module("snake_game.scoreboard")
return module.Scoreboard()
def imports_turtle_or_tkinter(module_name: str) -> bool:
"""Tell whether importing a module in a fresh interpreter loads a display module."""
code = (
f"import sys, {module_name}; "
"print('turtle' in sys.modules or 'tkinter' in sys.modules)"
)
result = subprocess.run(
[sys.executable, "-c", code],
env={**os.environ, "PYTHONPATH": str(SRC)},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
return result.stdout.strip() == "True"