MIL-011: Menu bar

Review record RC-066 (Go, autonomous mode, not independent).

Task: MIL-011#1
Task: MIL-011#2
Task: MIL-011#3
Task: MIL-011#4
Task: MIL-011#5
Task: MIL-011#6
Task: MIL-011#7
Task: MIL-011#8
This commit is contained in:
2026-10-15 09:06:47 +08:00
parent 5d626dc890
commit ce4aacb4bc
28 changed files with 1450 additions and 35 deletions
@@ -110,3 +110,18 @@ def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
assert deck is not None
assert deck.cards == CARDS
assert deck.language_pair == PAIR
def test_reset_deletes_the_progress_file(tmp_path: Path) -> None:
path = tmp_path / "words_to_learn.csv"
path.write_text("ms;da\naku;mig\n", encoding="utf-8")
repository = CsvProgressRepository(path)
repository.reset()
assert not path.exists()
assert repository.load() is None
def test_reset_does_nothing_when_the_file_is_absent(tmp_path: Path) -> None:
CsvProgressRepository(tmp_path / "words_to_learn.csv").reset()
@@ -93,3 +93,18 @@ def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> Non
path = _write(tmp_path / "w.csv", "ms;da\n")
with pytest.raises(VocabularyFormatError):
CsvVocabularyRepository(path).load()
@pytest.mark.parametrize(
"content",
[b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
ids=["invalid-utf8", "latin-1"],
)
def test_read_word_list_raises_format_error_when_not_utf8(
tmp_path: Path, content: bytes
) -> None:
path = tmp_path / "w.csv"
path.write_bytes(content)
with pytest.raises(VocabularyFormatError, match=r"w\.csv.*not UTF-8") as info:
read_word_list(path)
assert isinstance(info.value.__cause__, UnicodeDecodeError)
@@ -0,0 +1,36 @@
"""Tests of the name of the words to learn file of a word list."""
from pathlib import Path
import pytest
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
def test_progress_file_name_is_default_for_the_delivered_list() -> None:
assert progress_file_name(Path("data") / "ms-da.csv") == "words_to_learn.csv"
def test_progress_file_name_uses_the_stem_for_another_list() -> None:
assert progress_file_name(Path("x") / "animals.csv") == "words_to_learn_animals.csv"
def test_progress_file_name_keeps_hyphen_and_underscore() -> None:
name = progress_file_name(Path("my_list-2.csv"))
assert name == "words_to_learn_my_list-2.csv"
@pytest.mark.parametrize(
("file_name", "expected"),
[
("my list.csv", "words_to_learn_my_list.csv"),
("a.b;c.csv", "words_to_learn_a_b_c.csv"),
("ord (1).csv", "words_to_learn_ord__1_.csv"),
],
)
def test_progress_file_name_replaces_other_characters(
file_name: str, expected: str
) -> None:
assert progress_file_name(Path(file_name)) == expected
+143 -2
View File
@@ -63,6 +63,15 @@ class _FakeLevelView:
class _FakeFlashCardView:
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
self.view_model = view_model
self.closed = False
def close(self) -> None:
self.closed = True
class _FakeMenuView:
def __init__(self, root: object, view_model: object, application: object) -> None:
self.application = application
class _Vocabulary:
@@ -76,12 +85,18 @@ class _Vocabulary:
class _Progress:
def __init__(self) -> None:
self.resets = 0
def load(self) -> Deck | None:
return None
def save(self, deck: Deck) -> None:
return None
def reset(self) -> None:
self.resets += 1
class _Speech:
def speak(self, text: str, language_code: str) -> None:
@@ -96,6 +111,7 @@ def _first(cards: Sequence[Card]) -> Card:
def fake_tk_root(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep every test in this module away from a real Tk root."""
monkeypatch.setattr(app.tk, "Tk", _FakeRoot)
monkeypatch.setattr(app, "MenuView", _FakeMenuView)
@pytest.fixture
@@ -104,9 +120,31 @@ def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
LIST_PATH = Path("lists") / "ms-da.csv"
OTHER_PATH = Path("lists") / "animals.csv"
class _Factory:
"""Repositories for a path, recorded; the paths in bad are unusable."""
def __init__(self, error: bool = False, bad: tuple[Path, ...] = ()) -> None:
self._error = error
self._bad = bad
self.progress: dict[Path, _Progress] = {}
self.calls: list[Path] = []
def __call__(self, path: Path) -> tuple[_Vocabulary, _Progress]:
self.calls.append(path)
progress = self.progress.setdefault(path, _Progress())
return _Vocabulary(self._error or path in self._bad), progress
def _application(
root: _FakeRoot, error: bool = False, factory: _Factory | None = None
) -> app.Application:
repositories_for = _Factory(error) if factory is None else factory
return app.Application(
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
cast(tk.Tk, root), _Speech(), _first, repositories_for, LIST_PATH
)
@@ -293,3 +331,106 @@ def test_create_application_draws_first_card_from_bundled_list(
assert view.view_model.text == "saya"
view.view_model.is_known()
assert (data / "words_to_learn.csv").is_file()
def test_word_list_name_and_folder_are_those_of_the_list_in_use() -> None:
application = _application(_FakeRoot())
assert application.word_list_name == "ms-da.csv"
assert application.word_list_folder == Path("lists")
def test_open_word_list_replaces_repositories_and_shows_level_screen(
fake_views: None,
) -> None:
factory = _Factory()
application = _application(_FakeRoot(), factory=factory)
application.start(Level(None))
card_view = application.view
assert isinstance(card_view, _FakeFlashCardView)
application.open_word_list(OTHER_PATH)
assert card_view.closed
assert isinstance(application.view, _FakeLevelView)
assert application.word_list_name == "animals.csv"
application.start(Level(None))
application.reset_word_list()
assert factory.progress[OTHER_PATH].resets == 1
assert factory.progress[LIST_PATH].resets == 0
def test_open_word_list_raises_and_changes_nothing_when_list_is_invalid(
fake_views: None,
) -> None:
factory = _Factory(bad=(OTHER_PATH,))
application = _application(_FakeRoot(), factory=factory)
application.start(Level(None))
card_view = application.view
assert isinstance(card_view, _FakeFlashCardView)
with pytest.raises(VocabularyNotFoundError):
application.open_word_list(OTHER_PATH)
assert application.view is card_view
assert not card_view.closed
assert application.word_list_name == "ms-da.csv"
application.reset_word_list()
assert factory.progress[LIST_PATH].resets == 1
assert factory.progress[OTHER_PATH].resets == 0
def test_change_level_closes_the_card_screen_and_shows_the_level_screen(
fake_views: None,
) -> None:
application = _application(_FakeRoot())
application.start(Level(None))
card_view = application.view
assert isinstance(card_view, _FakeFlashCardView)
application.change_level()
assert card_view.closed
assert isinstance(application.view, _FakeLevelView)
def test_change_level_destroys_the_level_screen_it_replaces(
fake_views: None,
) -> None:
application = _application(_FakeRoot())
application.show_level_screen()
first = application.view
assert isinstance(first, _FakeLevelView)
application.change_level()
assert first.destroyed
assert application.view is not first
def test_reset_word_list_resets_progress_then_shows_the_level_screen(
fake_views: None,
) -> None:
factory = _Factory()
application = _application(_FakeRoot(), factory=factory)
application.start(Level(None))
application.reset_word_list()
assert factory.progress[LIST_PATH].resets == 1
assert isinstance(application.view, _FakeLevelView)
def test_exit_application_closes_the_screen_and_destroys_the_root(
fake_views: None,
) -> None:
root = _FakeRoot()
application = _application(root)
application.start(Level(None))
card_view = application.view
assert isinstance(card_view, _FakeFlashCardView)
application.exit_application()
assert card_view.closed
assert root.destroyed
assert application.exit_code == 0
+44
View File
@@ -0,0 +1,44 @@
"""Tests of the menu view model; no Tkinter is involved."""
import pytest
from flash_card.presentation.features.menu.menu_view_model import (
MenuEntry,
MenuViewModel,
)
def test_file_entries_are_open_change_level_reset_and_exit_in_order() -> None:
labels = [entry.label for entry in MenuViewModel().file_entries]
assert labels == ["Open...", "Change level...", "Reset word list...", "Exit"]
def test_file_entries_name_distinct_commands() -> None:
commands = [entry.command for entry in MenuViewModel().file_entries]
assert commands == [
"open_word_list",
"change_level",
"reset_word_list",
"exit_program",
]
def test_menu_labels_are_file_about_and_contact() -> None:
model = MenuViewModel()
assert model.file_label == "File"
assert model.about_label == "About"
assert model.contact_menu_label == "Contact"
def test_contact_shows_name_and_title() -> None:
assert MenuViewModel().contact_label == "Jens Tirsvad Nielsen, Software engineer"
def test_contact_url_is_the_linkedin_page() -> None:
assert MenuViewModel().contact_url == "https://www.linkedin.com/in/tirsvad74"
def test_menu_entry_is_immutable() -> None:
entry = MenuEntry("Exit", "exit_program", 1)
with pytest.raises(AttributeError):
entry.label = "Quit" # type: ignore[misc]