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:
@@ -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()
|
||||||
|
|||||||
+8
-1
@@ -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")
|
||||||
|
if path is None:
|
||||||
return module.Scoreboard()
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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