From 4ef3874bb157bf5b5af79d7767c3ee8634cc20ca Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Tue, 13 Oct 2026 14:38:51 +0800 Subject: [PATCH] Read and write the high score file and show the stored high score Refs #24 Refs #25 Refs #26 Task: MIL-004#2 Task: MIL-004#3 Task: MIL-004#4 --- src/snake_game/high_score_file.py | 53 +++++++++++++++++ src/snake_game/scoreboard.py | 20 +++++-- tests/fakes.py | 11 +++- tests/test_high_score_file.py | 94 +++++++++++++++++++++++++++++++ tests/test_scoreboard.py | 80 ++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 src/snake_game/high_score_file.py create mode 100644 tests/test_high_score_file.py diff --git a/src/snake_game/high_score_file.py b/src/snake_game/high_score_file.py new file mode 100644 index 0000000..5037aee --- /dev/null +++ b/src/snake_game/high_score_file.py @@ -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) diff --git a/src/snake_game/scoreboard.py b/src/snake_game/scoreboard.py index 7513098..11925f8 100644 --- a/src/snake_game/scoreboard.py +++ b/src/snake_game/scoreboard.py @@ -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() diff --git a/tests/fakes.py b/tests/fakes.py index d291f1c..ab97356 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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") - return module.Scoreboard() + if path is None: + return module.Scoreboard() + return module.Scoreboard(str(path)) def imports_turtle_or_tkinter(module_name: str) -> bool: diff --git a/tests/test_high_score_file.py b/tests/test_high_score_file.py new file mode 100644 index 0000000..449f0b8 --- /dev/null +++ b/tests/test_high_score_file.py @@ -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 diff --git a/tests/test_scoreboard.py b/tests/test_scoreboard.py index a7e1c2b..22c64f6 100644 --- a/tests/test_scoreboard.py +++ b/tests/test_scoreboard.py @@ -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: