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
+49 -10
View File
@@ -6,7 +6,7 @@
import shutil
import tkinter as tk
from collections.abc import Iterator, Sequence
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
import pytest
@@ -21,13 +21,14 @@ from flash_card.constants import (
LEVEL_SCREEN_HEIGHT,
LEVEL_TITLE,
LEVEL_TITLE_FONT,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
WINDOW_PADDING,
WINDOW_TITLE,
)
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.repositories.progress_repository import ProgressRepository
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
from flash_card.domain.value_objects.level import Level
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
@@ -35,6 +36,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation import app
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
@@ -57,15 +61,25 @@ def _word(view: FlashCardView) -> str:
def _clear(root: tk.Tk, view: object) -> None:
"""Destroy the widgets of whichever screen is shown, and pending timers."""
if isinstance(view, FlashCardView):
if view.flip_timer is not None:
root.after_cancel(view.flip_timer)
view.flip_timer = None
for widget in (view.canvas, view.wrong_button, view.right_button):
widget.destroy()
view.close()
elif isinstance(view, LevelView):
view.destroy()
def repositories_for(
folder: Path,
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
"""Build the CSV repositories of a list, its progress file in folder."""
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
return (
CsvVocabularyRepository(list_path),
CsvProgressRepository(folder / progress_file_name(list_path)),
)
return build
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
@@ -76,10 +90,10 @@ def data_dir(tmp_path: Path) -> Path:
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
instance = app.Application(
tk_root,
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME),
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME),
NullSpeech(),
_last,
repositories_for(data_dir),
data_dir / VOCABULARY_FILE_NAME,
)
yield instance
_clear(tk_root, instance.view)
@@ -185,7 +199,7 @@ def test_level_whose_words_are_all_known_shows_the_finished_message(
) -> None:
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:]))
CsvProgressRepository(data_dir / "words_to_learn.csv").save(Deck(pair, cards[100:]))
application.show_level_screen()
level_view = application.view
assert isinstance(level_view, LevelView)
@@ -207,8 +221,10 @@ def test_create_application_with_level_skips_the_level_screen(
try:
assert isinstance(application.view, FlashCardView)
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
assert str(tk_root.cget("menu"))
finally:
_clear(tk_root, application.view)
tk_root.config(menu="")
def test_create_application_without_level_shows_the_level_screen(
@@ -221,5 +237,28 @@ def test_create_application_without_level_shows_the_level_screen(
try:
assert isinstance(application.view, LevelView)
assert application.root is tk_root
assert str(tk_root.cget("menu"))
finally:
_clear(tk_root, application.view)
tk_root.config(menu="")
def test_create_application_with_word_list_keeps_progress_in_the_data_folder(
tk_root: tk.Tk, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
other = tmp_path / "other" / "animals.csv"
other.parent.mkdir()
other.write_text("ms;da\nkucing;kat\n", encoding="utf-8")
application = app.create_application(data_dir, False, Level(None), other)
try:
view = application.view
assert isinstance(view, FlashCardView)
assert application.word_list_name == "animals.csv"
view.right_button.invoke()
assert (data_dir / "words_to_learn_animals.csv").is_file()
finally:
_clear(tk_root, application.view)
tk_root.config(menu="")
+306
View File
@@ -0,0 +1,306 @@
"""End-to-end tests of the menu bar and its commands on a real Tk window.
@file test_menu_bar.py
@brief Builds the real menu bar with injected dialogs and checks the commands.
"""
import tkinter as tk
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
import pytest
from flash_card.constants import CONTACT_URL, WINDOW_TITLE
from flash_card.domain.entities.card import Card
from flash_card.domain.repositories.progress_repository import ProgressRepository
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation import app
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.level.level_view import LevelView
from flash_card.presentation.features.menu.menu_view import MenuView
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
DEFAULT_LIST = "ms;da\naku;mig\nkau;du\n"
OTHER_LIST = "ms;da\nkucing;kat\n"
CONTACT_LABEL = "Jens Tirsvad Nielsen, Software engineer"
FILE_INDEX = 0
ABOUT_INDEX = 1
OPEN, CHANGE_LEVEL, RESET, SEPARATOR, EXIT = range(5)
def _last(cards: Sequence[Card]) -> Card:
return cards[-1]
class _Dialogs:
"""Records and answers the dialogs the menu view asks."""
def __init__(self) -> None:
self.file = ""
self.answer = True
self.asked_files: list[dict[str, object]] = []
self.questions: list[str] = []
self.errors: list[tuple[str, str]] = []
self.urls: list[str] = []
def ask_open_file(self, **options: object) -> str:
self.asked_files.append(options)
return self.file
def confirm(self, title: str, message: str, **options: object) -> bool:
self.questions.append(message)
return self.answer
def show_error(self, title: str, message: str, **options: object) -> str:
self.errors.append((title, message))
return "ok"
def open_url(self, url: str) -> bool:
self.urls.append(url)
return True
class _Window:
def __init__(
self,
root: tk.Tk,
application: app.Application,
menu: MenuView,
dialogs: _Dialogs,
folder: Path,
) -> None:
self.root = root
self.application = application
self.menu = menu
self.dialogs = dialogs
self.folder = folder
def _repositories_for(
folder: Path,
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
return (
CsvVocabularyRepository(list_path),
CsvProgressRepository(folder / progress_file_name(list_path)),
)
return build
def _close(window: _Window) -> None:
view = window.application.view
if isinstance(view, FlashCardView):
view.close()
elif isinstance(view, LevelView):
view.destroy()
window.root.config(menu="")
window.menu.menubar.destroy()
@pytest.fixture
def window(tk_root: tk.Tk, tmp_path: Path) -> Iterator[_Window]:
default = tmp_path / "ms-da.csv"
default.write_bytes(DEFAULT_LIST.encode("utf-8"))
application = app.Application(
tk_root, NullSpeech(), _last, _repositories_for(tmp_path), default
)
dialogs = _Dialogs()
menu = MenuView(
tk_root,
MenuViewModel(),
application,
ask_open_file=dialogs.ask_open_file,
confirm=dialogs.confirm,
show_error=dialogs.show_error,
open_url=dialogs.open_url,
)
application.show_level_screen()
instance = _Window(tk_root, application, menu, dialogs, tmp_path)
yield instance
_close(instance)
def _labels(menu: tk.Menu, last: int) -> list[str]:
return [
"-" if menu.type(i) == "separator" else str(menu.entrycget(i, "label"))
for i in range(last + 1)
]
def _start_first_level(window: _Window) -> FlashCardView:
level_view = window.application.view
assert isinstance(level_view, LevelView)
level_view.buttons[0].invoke()
card_view = window.application.view
assert isinstance(card_view, FlashCardView)
return card_view
def _word(view: FlashCardView) -> str:
return str(view.canvas.itemcget(view.word_text_id, "text"))
def test_menu_bar_has_file_and_about_in_order(window: _Window) -> None:
menubar = window.menu.menubar
assert str(window.root.cget("menu")) == str(menubar)
assert menubar.index("end") == ABOUT_INDEX
assert _labels(menubar, ABOUT_INDEX) == ["File", "About"]
def test_file_menu_has_four_entries_and_a_separator_before_exit(
window: _Window,
) -> None:
file_menu = window.menu.file_menu
assert file_menu.index("end") == EXIT
assert _labels(file_menu, EXIT) == [
"Open...",
"Change level...",
"Reset word list...",
"-",
"Exit",
]
assert file_menu.type(SEPARATOR) == "separator"
def test_file_entries_have_mnemonics(window: _Window) -> None:
file_menu = window.menu.file_menu
assert int(str(file_menu.entrycget(OPEN, "underline"))) == 0
assert int(str(file_menu.entrycget(EXIT, "underline"))) == 1
assert int(str(window.menu.menubar.entrycget(FILE_INDEX, "underline"))) == 0
def test_about_menu_leads_to_the_contact_entry(window: _Window) -> None:
about = window.menu.about_menu
assert _labels(about, 0) == ["Contact"]
assert about.type(0) == "cascade"
contact = window.menu.contact_menu
assert contact.index("end") == 0
assert _labels(contact, 0) == [CONTACT_LABEL]
def test_open_starts_the_chosen_list_after_a_level_choice(window: _Window) -> None:
other = window.folder / "animals.csv"
other.write_bytes(OTHER_LIST.encode("utf-8"))
window.dialogs.file = str(other)
window.menu.file_menu.invoke(OPEN)
assert isinstance(window.application.view, LevelView)
assert window.application.word_list_name == "animals.csv"
assert _word(_start_first_level(window)) == "kucing"
asked = window.dialogs.asked_files[0]
assert asked["initialdir"] == str(window.folder)
assert ("CSV files", "*.csv") in asked["filetypes"] # type: ignore[operator]
assert window.root.title() == WINDOW_TITLE
@pytest.mark.parametrize(
"content",
[None, b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
ids=["missing", "invalid-utf8", "latin-1"],
)
def test_open_of_an_invalid_file_shows_a_message_and_keeps_the_list(
window: _Window, content: bytes | None
) -> None:
bad = window.folder / "bad.csv"
if content is not None:
bad.write_bytes(content)
window.dialogs.file = str(bad)
level_view = window.application.view
window.menu.file_menu.invoke(OPEN)
assert len(window.dialogs.errors) == 1
assert "bad.csv" in window.dialogs.errors[0][1]
assert window.application.view is level_view
assert window.application.word_list_name == "ms-da.csv"
assert _word(_start_first_level(window)) == "kau"
def test_open_cancelled_changes_nothing(window: _Window) -> None:
window.dialogs.file = ""
level_view = window.application.view
window.menu.file_menu.invoke(OPEN)
assert window.application.view is level_view
assert window.application.word_list_name == "ms-da.csv"
assert not window.dialogs.errors
def test_change_level_shows_the_level_screen_and_keeps_known_words(
window: _Window,
) -> None:
card_view = _start_first_level(window)
card_view.right_button.invoke()
progress = window.folder / "words_to_learn.csv"
saved = progress.read_bytes()
window.menu.file_menu.invoke(CHANGE_LEVEL)
assert isinstance(window.application.view, LevelView)
assert card_view.flip_timer is None
assert progress.read_bytes() == saved
def test_reset_asks_and_on_yes_removes_progress_and_shows_level_screen(
window: _Window,
) -> None:
card_view = _start_first_level(window)
card_view.right_button.invoke()
progress = window.folder / "words_to_learn.csv"
assert progress.is_file()
window.menu.file_menu.invoke(RESET)
assert window.dialogs.questions == [
"Start ms-da.csv from the beginning? All words will be to learn again."
]
assert not progress.exists()
assert isinstance(window.application.view, LevelView)
assert (window.folder / "ms-da.csv").read_bytes() == DEFAULT_LIST.encode("utf-8")
def test_reset_answered_no_changes_nothing(window: _Window) -> None:
card_view = _start_first_level(window)
card_view.right_button.invoke()
progress = window.folder / "words_to_learn.csv"
window.dialogs.answer = False
window.menu.file_menu.invoke(RESET)
assert progress.is_file()
assert window.application.view is card_view
def test_contact_opens_the_linkedin_page(window: _Window) -> None:
window.menu.contact_menu.invoke(0)
assert window.dialogs.urls == [CONTACT_URL]
assert CONTACT_URL == "https://www.linkedin.com/in/tirsvad74"
def test_exit_cancels_the_timer_and_destroys_the_window(
window: _Window, monkeypatch: pytest.MonkeyPatch
) -> None:
destroyed: list[bool] = []
monkeypatch.setattr(window.root, "destroy", lambda: destroyed.append(True))
card_view = _start_first_level(window)
assert card_view.flip_timer is not None
window.menu.file_menu.invoke(EXIT)
assert destroyed == [True]
assert card_view.flip_timer is None
assert window.application.exit_code == 0
@@ -0,0 +1,95 @@
"""Integration tests: every word list keeps its progress in a file of its own."""
from collections.abc import Sequence
from pathlib import Path
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.domain.entities.card import Card
from flash_card.domain.value_objects.level import Level
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.progress_file_name import (
progress_file_name,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
MS_DA = "ms;da\naku;mig\nkau;du\nini;denne\n"
ANIMALS = "ms;da\nkucing;kat\nanjing;hund\n"
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _lists(folder: Path) -> tuple[Path, Path]:
default = folder / "ms-da.csv"
other = folder / "animals.csv"
default.write_bytes(MS_DA.encode("utf-8"))
other.write_bytes(ANIMALS.encode("utf-8"))
return default, other
def _progress(folder: Path, list_path: Path) -> CsvProgressRepository:
return CsvProgressRepository(folder / progress_file_name(list_path))
def _know_first_word(folder: Path, list_path: Path) -> str:
session = open_study_session(
CsvVocabularyRepository(list_path),
_progress(folder, list_path),
NullSpeech(),
_first,
Level(None),
)
card = session.next_card()
assert card is not None
session.mark_known()
return card.front_text
def _remaining(folder: Path, list_path: Path) -> list[str]:
deck = _progress(folder, list_path).load()
assert deck is not None
return [card.front for card in deck.cards]
def test_known_words_of_one_list_never_appear_in_the_other(tmp_path: Path) -> None:
default, other = _lists(tmp_path)
assert _know_first_word(tmp_path, default) == "aku"
assert _know_first_word(tmp_path, other) == "kucing"
assert _remaining(tmp_path, default) == ["kau", "ini"]
assert _remaining(tmp_path, other) == ["anjing"]
assert (tmp_path / "words_to_learn.csv").is_file()
assert (tmp_path / "words_to_learn_animals.csv").is_file()
def test_reset_of_one_list_deletes_only_its_progress_file(tmp_path: Path) -> None:
default, other = _lists(tmp_path)
_know_first_word(tmp_path, default)
_know_first_word(tmp_path, other)
_progress(tmp_path, other).reset()
assert not (tmp_path / "words_to_learn_animals.csv").exists()
assert _remaining(tmp_path, default) == ["kau", "ini"]
def test_reset_leaves_the_word_list_file_byte_identical(tmp_path: Path) -> None:
default, other = _lists(tmp_path)
_know_first_word(tmp_path, other)
_progress(tmp_path, other).reset()
assert other.read_bytes() == ANIMALS.encode("utf-8")
assert default.read_bytes() == MS_DA.encode("utf-8")
def test_delivered_list_keeps_words_to_learn_file_name(tmp_path: Path) -> None:
default, _other = _lists(tmp_path)
assert progress_file_name(default) == "words_to_learn.csv"
@@ -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]