MIL-004: High score file #38

Merged
Tirsvad merged 6 commits from mil-004-high-score-file into main 2026-10-13 08:41:38 +02:00
12 changed files with 380 additions and 17 deletions
+19 -9
View File
@@ -19,12 +19,10 @@ This repository continues
and starts from its finished game; `docs/project-plan.md` tells how the work is
split into milestones.
> **Status:** in progress. The game of day 21 is adopted and now follows the
> day-24 lectures on continuous play: the scoreboard shows the score and the high
> score, and a collision resets the snake and the score instead of ending the
> game. The high score is kept in memory only; the high score file (milestone 4)
> and the wiki (milestone 5) follow. The sections below describe the game as it
> is now; they are updated in each milestone.
> **Status:** in progress. The game of day 21 is adopted, follows the day-24
> lectures on continuous play and the high score, and now keeps the high score in
> the file `data.txt`. The wiki in three languages (milestone 5) follows. The
> sections below describe the game as it is now.
## 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
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
its close button when you have had enough. The high score is lost when the game
is closed until milestone 4 stores it in a file.
its close button when you have had enough.
### 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
@@ -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
│ ├── constants.py every constant of the game
│ ├── 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
│ ├── scoreboard.py the Scoreboard class (inherits from Turtle)
│ └── 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
├── LICENSE
├── pyproject.toml project configuration
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 006 |
| 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
+60
View File
@@ -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
+12
View File
@@ -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
# of sight, as the day-24 lecture does it.
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
+53
View File
@@ -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)
+15 -5
View File
@@ -10,6 +10,7 @@ module first.
from turtle import Turtle
from snake_game.constants import (
HIGH_SCORE_FILE,
HIGH_SCORE_LABEL,
SCORE_LABEL,
SCOREBOARD_ALIGNMENT,
@@ -17,18 +18,25 @@ from snake_game.constants import (
SCOREBOARD_FONT,
SCOREBOARD_POSITION,
)
from snake_game.high_score_file import read_high_score, write_high_score
class Scoreboard(Turtle):
"""! @brief Shows the score as text at the top centre of the screen."""
def __init__(self) -> None:
"""! @brief Set the scoreboard up and write the score 0."""
def __init__(self, path: str = HIGH_SCORE_FILE) -> None:
"""! @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__()
## @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.
self.score = 0
## @brief The largest score reached so far.
self.high_score = 0
## @brief The largest score reached so far, as stored in the file.
self.high_score = read_high_score(path)
self.color(SCOREBOARD_COLOR)
self.penup()
self.hideturtle()
@@ -53,9 +61,11 @@ class Scoreboard(Turtle):
"""! @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
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:
self.high_score = self.score
write_high_score(self.high_score, self.path)
self.score = 0
self.update_scoreboard()
+16
View File
@@ -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)
+8 -1
View File
@@ -286,16 +286,23 @@ def make_food(monkeypatch: pytest.MonkeyPatch) -> Any:
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`.
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`
(`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")
if path is None:
return module.Scoreboard()
return module.Scoreboard(str(path))
def imports_turtle_or_tkinter(module_name: str) -> bool:
+9
View File
@@ -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
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:
x, y = constants.SEGMENT_PARKING_POSITION
+94
View File
@@ -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
+12
View File
@@ -1,5 +1,7 @@
"""Tests of the main flow, with fakes instead of a real screen."""
from pathlib import Path
import pytest
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)
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(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+80
View File
@@ -2,6 +2,7 @@
import importlib
import sys
from pathlib import Path
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"
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(
monkeypatch: pytest.MonkeyPatch,
) -> None: