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
This commit is contained in:
2026-10-13 14:38:51 +08:00
parent c20ea6c616
commit 4ef3874bb1
5 changed files with 251 additions and 7 deletions
+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()
+9 -2
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")
return module.Scoreboard()
if path is None:
return module.Scoreboard()
return module.Scoreboard(str(path))
def imports_turtle_or_tkinter(module_name: str) -> bool:
+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
+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: