Merge pull request 'MIL-004: High score file' (#38) from mil-004-high-score-file into main
This commit was merged in pull request #38.
This commit is contained in:
@@ -19,12 +19,10 @@ This repository continues
|
|||||||
and starts from its finished game; `docs/project-plan.md` tells how the work is
|
and starts from its finished game; `docs/project-plan.md` tells how the work is
|
||||||
split into milestones.
|
split into milestones.
|
||||||
|
|
||||||
> **Status:** in progress. The game of day 21 is adopted and now follows the
|
> **Status:** in progress. The game of day 21 is adopted, follows the day-24
|
||||||
> day-24 lectures on continuous play: the scoreboard shows the score and the high
|
> lectures on continuous play and the high score, and now keeps the high score in
|
||||||
> score, and a collision resets the snake and the score instead of ending the
|
> the file `data.txt`. The wiki in three languages (milestone 5) follows. The
|
||||||
> game. The high score is kept in memory only; the high score file (milestone 4)
|
> sections below describe the game as it is now.
|
||||||
> and the wiki (milestone 5) follow. The sections below describe the game as it
|
|
||||||
> is now; they are updated in each milestone.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -113,8 +111,18 @@ The game does not end. When the head passes the wall (more than 280 pixels from
|
|||||||
the centre on any side) or touches the tail, the old snake is sent off the
|
the centre on any side) or touches the tail, the old snake is sent off the
|
||||||
screen, a new snake of three segments appears in the middle, the score goes back
|
screen, a new snake of three segments appears in the middle, the score goes back
|
||||||
to 0, and the high score is raised if the score was higher. Close the window with
|
to 0, and the high score is raised if the score was higher. Close the window with
|
||||||
its close button when you have had enough. The high score is lost when the game
|
its close button when you have had enough.
|
||||||
is closed until milestone 4 stores it in a file.
|
|
||||||
|
### The high score file
|
||||||
|
|
||||||
|
The high score is kept as text in the file `data.txt`, in the folder you start
|
||||||
|
the game from (`python -m snake_game`). The game reads it when it starts and
|
||||||
|
writes it again each time a new high score is reached, so the high score is
|
||||||
|
still there the next time you play. The file is created holding `0` the first
|
||||||
|
time, and a file that is empty or does not hold a whole number is read as `0`.
|
||||||
|
To start from zero again, close the game and delete `data.txt`. The file is
|
||||||
|
your own state and is ignored by git. Start the game from another folder and it
|
||||||
|
uses the `data.txt` of that folder.
|
||||||
|
|
||||||
## Run the tests
|
## Run the tests
|
||||||
|
|
||||||
@@ -168,10 +176,12 @@ The HTML is written to `build/doxygen/index.html`. A warning fails the build.
|
|||||||
│ ├── __main__.py starts the game: python -m snake_game
|
│ ├── __main__.py starts the game: python -m snake_game
|
||||||
│ ├── constants.py every constant of the game
|
│ ├── constants.py every constant of the game
|
||||||
│ ├── food.py the Food class (inherits from Turtle)
|
│ ├── food.py the Food class (inherits from Turtle)
|
||||||
|
│ ├── high_score_file.py read_high_score and write_high_score (data.txt)
|
||||||
│ ├── main.py screen set-up and the main flow
|
│ ├── main.py screen set-up and the main flow
|
||||||
│ ├── scoreboard.py the Scoreboard class (inherits from Turtle)
|
│ ├── scoreboard.py the Scoreboard class (inherits from Turtle)
|
||||||
│ └── snake.py the Snake class
|
│ └── snake.py the Snake class
|
||||||
├── tests/ pytest tests (fakes.py holds the fake turtle and screen)
|
├── tests/ pytest tests (fakes.py holds the fake turtle and screen;
|
||||||
|
│ conftest.py runs every test in a temporary folder)
|
||||||
├── Doxyfile source documentation settings
|
├── Doxyfile source documentation settings
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
├── pyproject.toml project configuration
|
├── pyproject.toml project configuration
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
|
||||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# RC-012: Code review of MIL-004 (high score file)
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-012 |
|
||||||
|
| CrossReference | [MIL-004], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-13 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [5dcd51a] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: the source and tests that [MIL-004] delivers (high score file)
|
||||||
|
- Checklist used: [QC-PY-001]
|
||||||
|
- Scope: full review of the files changed on the branch of the milestone
|
||||||
|
- Language and domain: n/a
|
||||||
|
- Language reviewer: none
|
||||||
|
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the assistant also wrote the code)
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | `read_high_score`, `write_high_score`, `high_score_file` and the constants `HIGH_SCORE_FILE`, `DEFAULT_HIGH_SCORE` follow PEP 8. |
|
||||||
|
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; `path`, `data` and `text` are the lecture's own and are used in short scopes. |
|
||||||
|
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` exit 0; no `noqa`. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy` strict reports no issues in 16 files; every new def is annotated. |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Only `FileNotFoundError`, `UnicodeDecodeError` and `ValueError` are caught, each with a defined result (create the file with 0 and return 0; read as 0); other `OSError`s propagate; none is swallowed without a defined result. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default argument; no shadowed builtin (`open` is called, not rebound). |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | Pass | Both functions use `with open(...) as data`, as the lecture teaches; a test checks that the file can be replaced afterwards. |
|
||||||
|
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Both functions, the module and the new constants have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. |
|
||||||
|
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging. |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No Design Class Diagram exists (deviation recorded in RC-010); `high_score_file.py` is a function module and `Scoreboard` gains a `path` and the two calls, as the lecture does with `data.txt`. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 169 tests; `test_high_score_file.py` covers stored, newline, missing, empty, non-numeric, negative, undecodable, replace and create; `conftest.py` runs every test in a temporary folder so no test touches a real `data.txt`; no test depends on order or the network. |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `strict = true`; no `Any` was added. |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []` is unchanged. |
|
||||||
|
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The file name, encoding and the fallback value are defined once in `constants.py`; reading and writing the file live only in `high_score_file.py`. |
|
||||||
|
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `high_score_file.py` imports only `constants`; `scoreboard.py` imports it; no cycle; the module needs no `turtle`. |
|
||||||
|
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Reading and writing are separate functions with one reason each; `Scoreboard` receives the path as a parameter, so tests pass a temporary file. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — all Mandatory criteria pass or are not applicable. Assessed on 2026-10-13 by the assistant that wrote the code, as stand-in for S01 in autonomous mode. Mode: autonomous, not independent. The pull request is the second look.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Challenge this `Go` in the run summary or on the pull request if the stand-in decision is not wanted | S01 | 2026-10-20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-004]: ../../milestones/mil-004-high-score-file.md
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
|
[5dcd51a]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/024-snake/commit/5dcd51a47a16f28181a649bff9a9104d524ab278
|
||||||
@@ -128,3 +128,15 @@ HIGH_SCORE_LABEL: Final[str] = "High Score: "
|
|||||||
# The place is far outside the 600 by 600 screen, so the old segments are out
|
# The place is far outside the 600 by 600 screen, so the old segments are out
|
||||||
# of sight, as the day-24 lecture does it.
|
# of sight, as the day-24 lecture does it.
|
||||||
SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000)
|
SEGMENT_PARKING_POSITION: Final[tuple[int, int]] = (1000, 1000)
|
||||||
|
|
||||||
|
## @brief Name of the text file that keeps the high score between games.
|
||||||
|
#
|
||||||
|
# A relative path: the file lies in the working directory the game is started
|
||||||
|
# from. The file is not tracked by git.
|
||||||
|
HIGH_SCORE_FILE: Final[str] = "data.txt"
|
||||||
|
|
||||||
|
## @brief Text encoding used to read and write the high score file.
|
||||||
|
HIGH_SCORE_FILE_ENCODING: Final[str] = "utf-8"
|
||||||
|
|
||||||
|
## @brief The high score of a new game, and of a file that cannot be read.
|
||||||
|
DEFAULT_HIGH_SCORE: Final[int] = 0
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""! @file
|
||||||
|
@brief Reading and writing the high score file: the day-24 file lectures.
|
||||||
|
|
||||||
|
The high score is kept as text in a file, so that it is still there the next time
|
||||||
|
the game is started. Both functions open the file with a `with` statement, which
|
||||||
|
closes it again even when something goes wrong, as the lecture teaches. The module
|
||||||
|
does not import `turtle`, so it needs no display and the tests use a temporary
|
||||||
|
file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from snake_game.constants import (
|
||||||
|
DEFAULT_HIGH_SCORE,
|
||||||
|
HIGH_SCORE_FILE,
|
||||||
|
HIGH_SCORE_FILE_ENCODING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_high_score(high_score: int, path: str = HIGH_SCORE_FILE) -> None:
|
||||||
|
"""! @brief Replace the content of the file with the high score, as text.
|
||||||
|
|
||||||
|
The file is opened in write mode `w`, so the old content is overwritten and a
|
||||||
|
file that does not exist yet is created.
|
||||||
|
|
||||||
|
@param high_score The high score to store.
|
||||||
|
@param path The file to write; defaults to `HIGH_SCORE_FILE`.
|
||||||
|
"""
|
||||||
|
with open(path, mode="w", encoding=HIGH_SCORE_FILE_ENCODING) as data:
|
||||||
|
data.write(str(high_score))
|
||||||
|
|
||||||
|
|
||||||
|
def read_high_score(path: str = HIGH_SCORE_FILE) -> int:
|
||||||
|
"""! @brief Read the high score from the file and turn the text into a number.
|
||||||
|
|
||||||
|
A file that does not exist yet is created holding `DEFAULT_HIGH_SCORE`. A file
|
||||||
|
that does not hold a whole number, or holds a negative one, is read as
|
||||||
|
`DEFAULT_HIGH_SCORE` and is left as it is; the next new high score replaces it.
|
||||||
|
|
||||||
|
@param path The file to read; defaults to `HIGH_SCORE_FILE`.
|
||||||
|
@return The stored high score, 0 or more.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, encoding=HIGH_SCORE_FILE_ENCODING) as data:
|
||||||
|
text = data.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
write_high_score(DEFAULT_HIGH_SCORE, path)
|
||||||
|
return DEFAULT_HIGH_SCORE
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return DEFAULT_HIGH_SCORE
|
||||||
|
try:
|
||||||
|
high_score = int(text)
|
||||||
|
except ValueError:
|
||||||
|
return DEFAULT_HIGH_SCORE
|
||||||
|
return max(high_score, DEFAULT_HIGH_SCORE)
|
||||||
@@ -10,6 +10,7 @@ module first.
|
|||||||
from turtle import Turtle
|
from turtle import Turtle
|
||||||
|
|
||||||
from snake_game.constants import (
|
from snake_game.constants import (
|
||||||
|
HIGH_SCORE_FILE,
|
||||||
HIGH_SCORE_LABEL,
|
HIGH_SCORE_LABEL,
|
||||||
SCORE_LABEL,
|
SCORE_LABEL,
|
||||||
SCOREBOARD_ALIGNMENT,
|
SCOREBOARD_ALIGNMENT,
|
||||||
@@ -17,18 +18,25 @@ from snake_game.constants import (
|
|||||||
SCOREBOARD_FONT,
|
SCOREBOARD_FONT,
|
||||||
SCOREBOARD_POSITION,
|
SCOREBOARD_POSITION,
|
||||||
)
|
)
|
||||||
|
from snake_game.high_score_file import read_high_score, write_high_score
|
||||||
|
|
||||||
|
|
||||||
class Scoreboard(Turtle):
|
class Scoreboard(Turtle):
|
||||||
"""! @brief Shows the score as text at the top centre of the screen."""
|
"""! @brief Shows the score as text at the top centre of the screen."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, path: str = HIGH_SCORE_FILE) -> None:
|
||||||
"""! @brief Set the scoreboard up and write the score 0."""
|
"""! @brief Set the scoreboard up and write the score 0 and the stored best.
|
||||||
|
|
||||||
|
@param path The file that keeps the high score; defaults to
|
||||||
|
`HIGH_SCORE_FILE`.
|
||||||
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
## @brief The file that keeps the high score between games.
|
||||||
|
self.path = path
|
||||||
## @brief The number of foods the snake has eaten since the last reset.
|
## @brief The number of foods the snake has eaten since the last reset.
|
||||||
self.score = 0
|
self.score = 0
|
||||||
## @brief The largest score reached so far.
|
## @brief The largest score reached so far, as stored in the file.
|
||||||
self.high_score = 0
|
self.high_score = read_high_score(path)
|
||||||
self.color(SCOREBOARD_COLOR)
|
self.color(SCOREBOARD_COLOR)
|
||||||
self.penup()
|
self.penup()
|
||||||
self.hideturtle()
|
self.hideturtle()
|
||||||
@@ -53,9 +61,11 @@ class Scoreboard(Turtle):
|
|||||||
"""! @brief Keep the best score, start a new game and write the scoreboard.
|
"""! @brief Keep the best score, start a new game and write the scoreboard.
|
||||||
|
|
||||||
When the score is higher than the high score, it becomes the new high
|
When the score is higher than the high score, it becomes the new high
|
||||||
score. The score then goes back to 0.
|
score and is written to the file; a lower or equal score does not write
|
||||||
|
the file. The score then goes back to 0.
|
||||||
"""
|
"""
|
||||||
if self.score > self.high_score:
|
if self.score > self.high_score:
|
||||||
self.high_score = self.score
|
self.high_score = self.score
|
||||||
|
write_high_score(self.high_score, self.path)
|
||||||
self.score = 0
|
self.score = 0
|
||||||
self.update_scoreboard()
|
self.update_scoreboard()
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Shared pytest configuration for the snake game tests."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Run every test in an empty temporary working directory.
|
||||||
|
|
||||||
|
The high score file has a relative default path (`data.txt`). Without this
|
||||||
|
fixture, a test that uses the default would read and write the real file of
|
||||||
|
the repository or of the directory pytest was started from.
|
||||||
|
"""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
+9
-2
@@ -286,16 +286,23 @@ def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
|
|||||||
return module.Food()
|
return module.Food()
|
||||||
|
|
||||||
|
|
||||||
def make_scoreboard(monkeypatch: pytest.MonkeyPatch) -> Any:
|
def make_scoreboard(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, path: str | Path | None = None
|
||||||
|
) -> Any:
|
||||||
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
|
"""Make a real `Scoreboard` whose base class is the fake `Turtle`.
|
||||||
|
|
||||||
|
When `path` is given it is the high score file; otherwise the default
|
||||||
|
`data.txt` is used, which `conftest.py` has moved into a temporary directory.
|
||||||
|
|
||||||
The result is typed `Any` because it has the methods of `Scoreboard`
|
The result is typed `Any` because it has the methods of `Scoreboard`
|
||||||
(`increase_score`) and the recording methods of the fake base class (`calls`)
|
(`increase_score`) and the recording methods of the fake base class (`calls`)
|
||||||
at once.
|
at once.
|
||||||
"""
|
"""
|
||||||
install_fake_turtle(monkeypatch)
|
install_fake_turtle(monkeypatch)
|
||||||
module = importlib.import_module("snake_game.scoreboard")
|
module = importlib.import_module("snake_game.scoreboard")
|
||||||
return module.Scoreboard()
|
if path is None:
|
||||||
|
return module.Scoreboard()
|
||||||
|
return module.Scoreboard(str(path))
|
||||||
|
|
||||||
|
|
||||||
def imports_turtle_or_tkinter(module_name: str) -> bool:
|
def imports_turtle_or_tkinter(module_name: str) -> bool:
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ def test_high_score_label_follows_the_score_label_as_in_the_lecture() -> None:
|
|||||||
assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL
|
assert constants.HIGH_SCORE_LABEL != constants.SCORE_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_score_is_kept_in_the_text_file_data_txt_as_utf_8() -> None:
|
||||||
|
assert constants.HIGH_SCORE_FILE == "data.txt"
|
||||||
|
assert constants.HIGH_SCORE_FILE_ENCODING == "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_high_score_is_0() -> None:
|
||||||
|
assert constants.DEFAULT_HIGH_SCORE == 0
|
||||||
|
|
||||||
|
|
||||||
def test_old_segments_are_parked_outside_the_screen() -> None:
|
def test_old_segments_are_parked_outside_the_screen() -> None:
|
||||||
x, y = constants.SEGMENT_PARKING_POSITION
|
x, y = constants.SEGMENT_PARKING_POSITION
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Tests of reading and writing the high score file, with temporary files."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from snake_game.high_score_file import read_high_score, write_high_score
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_returns_the_stored_number(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("17", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_accepts_a_trailing_newline(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("5\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_creates_a_missing_file_holding_0(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 0
|
||||||
|
assert path.read_text(encoding="utf-8") == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_gives_0_for_an_empty_file_and_leaves_it_empty(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 0
|
||||||
|
assert path.read_text(encoding="utf-8") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_gives_0_for_text_that_is_not_a_whole_number(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("many", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 0
|
||||||
|
assert path.read_text(encoding="utf-8") == "many"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_gives_0_for_a_negative_number(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("-4", encoding="utf-8")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 0
|
||||||
|
assert path.read_text(encoding="utf-8") == "-4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_gives_0_for_bytes_that_are_not_utf_8(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_bytes(b"\xff\xfe\x00\x80")
|
||||||
|
|
||||||
|
assert read_high_score(str(path)) == 0
|
||||||
|
assert path.read_bytes() == b"\xff\xfe\x00\x80"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_replaces_the_old_content(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
path.write_text("123456", encoding="utf-8")
|
||||||
|
|
||||||
|
write_high_score(9, str(path))
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == "9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_creates_the_file(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
|
||||||
|
write_high_score(3, str(path))
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_file_is_closed_after_reading_and_writing(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "data.txt"
|
||||||
|
|
||||||
|
write_high_score(2, str(path))
|
||||||
|
read_high_score(str(path))
|
||||||
|
path.unlink() # fails on Windows while a handle is still open
|
||||||
|
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_default_path_is_data_txt_in_the_working_directory(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
write_high_score(4)
|
||||||
|
|
||||||
|
assert (tmp_path / "data.txt").read_text(encoding="utf-8") == "4"
|
||||||
|
assert read_high_score() == 4
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Tests of the main flow, with fakes instead of a real screen."""
|
"""Tests of the main flow, with fakes instead of a real screen."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fakes import (
|
from fakes import (
|
||||||
@@ -131,6 +133,16 @@ def test_main_sets_up_the_screen_turns_off_drawing_and_binds_the_keys(
|
|||||||
assert sorted(screens[0].bindings) == sorted(ARROW_KEYS)
|
assert sorted(screens[0].bindings) == sorted(ARROW_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_creates_data_txt_in_the_working_directory_holding_0(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, sleeps: list[float], tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
install_fake_turtle(monkeypatch)
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
assert (tmp_path / constants.HIGH_SCORE_FILE).read_text(encoding="utf-8") == "0"
|
||||||
|
|
||||||
|
|
||||||
def test_register_head_shape_builds_two_components_from_the_constants(
|
def test_register_head_shape_builds_two_components_from_the_constants(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -129,6 +130,85 @@ def test_the_score_text_shows_both_scores_while_playing(
|
|||||||
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 2"
|
assert scoreboard.calls[-1][1][0] == "Score: 1 High Score: 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoreboard_shows_the_stored_high_score_at_start(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
path.write_text("12", encoding="utf-8")
|
||||||
|
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
|
||||||
|
assert scoreboard.high_score == 12
|
||||||
|
assert scoreboard.calls[-1][1][0] == "Score: 0 High Score: 12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_high_score_is_written_to_the_file_on_reset(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
path.write_text("1", encoding="utf-8")
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
for _ in range(2):
|
||||||
|
scoreboard.increase_score()
|
||||||
|
|
||||||
|
scoreboard.reset()
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lower_score_does_not_write_the_file(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
path.write_text("5", encoding="utf-8")
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
scoreboard.increase_score()
|
||||||
|
path.write_text("sentinel", encoding="utf-8")
|
||||||
|
|
||||||
|
scoreboard.reset()
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == "sentinel"
|
||||||
|
assert scoreboard.high_score == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_equal_score_does_not_write_the_file(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
path.write_text("2", encoding="utf-8")
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
scoreboard.increase_score()
|
||||||
|
scoreboard.increase_score()
|
||||||
|
path.write_text("sentinel", encoding="utf-8")
|
||||||
|
|
||||||
|
scoreboard.reset()
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == "sentinel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_file_shows_high_score_0_and_is_created(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
|
||||||
|
assert scoreboard.calls[-1] == START_TEXT
|
||||||
|
assert path.read_text(encoding="utf-8") == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_damaged_file_shows_high_score_0(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "scores.txt"
|
||||||
|
path.write_text("not a number", encoding="utf-8")
|
||||||
|
|
||||||
|
scoreboard = make_scoreboard(monkeypatch, path)
|
||||||
|
|
||||||
|
assert scoreboard.high_score == 0
|
||||||
|
assert scoreboard.calls[-1] == START_TEXT
|
||||||
|
|
||||||
|
|
||||||
def test_scoreboard_has_no_game_over_any_more(
|
def test_scoreboard_has_no_game_over_any_more(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user