diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..e9f7c21 --- /dev/null +++ b/tests/fakes.py @@ -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"