MIL-008: Choose level

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

Task: MIL-008#1
Task: MIL-008#2
Task: MIL-008#3
Task: MIL-008#4
Task: MIL-008#5
Task: MIL-008#6
Task: MIL-008#7
Task: MIL-008#8
This commit is contained in:
2026-10-14 23:58:31 +08:00
parent ee9c531ea6
commit f45d6036e4
40 changed files with 1483 additions and 102 deletions
+22
View File
@@ -0,0 +1,22 @@
"""Fixtures shared by the end-to-end tests."""
import tkinter as tk
from collections.abc import Iterator
import pytest
@pytest.fixture(scope="session")
def tk_root() -> Iterator[tk.Tk]:
"""The one Tk root of the process, shared by every e2e module.
A second interpreter right after destroying the first fails to find
init.tcl on some Windows Python installs, so there is only one. Each test
builds its views on it and destroys their widgets afterwards.
"""
try:
root = tk.Tk()
except tk.TclError:
pytest.skip("no display available for Tk")
yield root
root.destroy()
+15 -11
View File
@@ -29,6 +29,7 @@ from flash_card.constants import (
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.domain.value_objects.level import Level
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
@@ -85,18 +86,20 @@ def _shown(view: FlashCardView) -> tuple[str, str]:
)
def _destroy_view(root: tk.Tk, view: FlashCardView) -> None:
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()
@pytest.fixture(scope="module")
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
# One Tk per module: a second interpreter right after destroying the
# first fails to find init.tcl on some Windows Python installs.
try:
root = tk.Tk()
except tk.TclError:
pytest.skip("no display available for Tk")
view = _build_view(root)
root.update_idletasks()
yield root, view
root.destroy()
def window(tk_root: tk.Tk) -> Iterator[tuple[tk.Tk, FlashCardView]]:
view = _build_view(tk_root)
tk_root.update_idletasks()
yield tk_root, view
_destroy_view(tk_root, view)
def test_window_has_title_background_and_padding(
@@ -306,6 +309,7 @@ def files_view(
CsvProgressRepository(progress),
NullSpeech(),
_first,
Level(None),
)
view = FlashCardView(root, FlashCardViewModel(session))
yield view, progress
+209
View File
@@ -0,0 +1,209 @@
"""End-to-end tests of the level screen and the flow to the first card.
@file test_level_window.py
@brief Builds the real level screen with Tk and checks it against the wireframe.
"""
import shutil
import tkinter as tk
from collections.abc import Iterator, Sequence
from pathlib import Path
import pytest
from flash_card.constants import (
BACKGROUND_COLOR,
CARD_WIDTH,
DEFAULT_DATA_DIR,
FINISHED_MESSAGE,
LEVEL_BUTTON_COLOR,
LEVEL_BUTTON_FONT,
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.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.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.level.level_view_model import LevelViewModel
def _last(cards: Sequence[Card]) -> Card:
return cards[-1]
def _label(button: tk.Button) -> str:
return str(button.cget("text"))
def _word(view: FlashCardView) -> str:
return str(view.canvas.itemcget(view.word_text_id, "text"))
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()
elif isinstance(view, LevelView):
view.destroy()
@pytest.fixture
def data_dir(tmp_path: Path) -> Path:
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
return tmp_path
@pytest.fixture
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,
)
yield instance
_clear(tk_root, instance.view)
@pytest.fixture
def level_view(tk_root: tk.Tk) -> Iterator[LevelView]:
chosen: list[Level] = []
view = LevelView(tk_root, LevelViewModel(), chosen.append)
tk_root.update_idletasks()
yield view
view.destroy()
def test_level_screen_shows_title_with_font_on_window_background(
level_view: LevelView,
) -> None:
label = level_view.title_label
assert label.cget("text") == LEVEL_TITLE == "Choose your level"
assert label.cget("bg") == BACKGROUND_COLOR
family, size, style = LEVEL_TITLE_FONT
assert str(label.cget("font")).split() == [family, str(size), style]
assert level_view.frame.cget("bg") == BACKGROUND_COLOR
def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None:
assert level_view.frame.winfo_width() == CARD_WIDTH
assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT
def test_level_screen_shows_three_buttons_in_order(level_view: LevelView) -> None:
labels = [_label(button) for button in level_view.buttons]
assert labels == ["100 words", "1000 words", "All words"]
rows = [int(button.grid_info()["row"]) for button in level_view.buttons]
assert rows == [1, 2, 3]
def test_level_buttons_have_equal_width_font_and_colour(
level_view: LevelView,
) -> None:
widths = {int(str(button.cget("width"))) for button in level_view.buttons}
assert len(widths) == 1
family, size, style = LEVEL_BUTTON_FONT
for button in level_view.buttons:
assert str(button.cget("font")).split() == [family, str(size), style]
assert button.cget("bg") == LEVEL_BUTTON_COLOR
assert len({button.winfo_reqwidth() for button in level_view.buttons}) == 1
def test_clicking_a_button_forwards_its_level(tk_root: tk.Tk) -> None:
chosen: list[Level] = []
view = LevelView(tk_root, LevelViewModel(), chosen.append)
for button in view.buttons:
button.invoke()
view.destroy()
assert chosen == [Level(100), Level(1000), Level(None)]
def test_destroy_removes_the_level_frame(tk_root: tk.Tk) -> None:
view = LevelView(tk_root, LevelViewModel(), lambda level: None)
view.destroy()
assert not view.frame.winfo_exists()
def test_clicking_100_words_replaces_screen_with_first_card_of_first_hundred(
tk_root: tk.Tk, application: app.Application, data_dir: Path
) -> None:
first_hundred = (
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards[:100]
)
application.show_level_screen()
level_view = application.view
assert isinstance(level_view, LevelView)
level_view.buttons[0].invoke()
card_view = application.view
assert isinstance(card_view, FlashCardView)
assert not level_view.frame.winfo_exists()
assert _word(card_view) == first_hundred[-1].front
assert tk_root.title() == WINDOW_TITLE
assert int(str(tk_root.cget("padx"))) == WINDOW_PADDING
def test_level_whose_words_are_all_known_shows_the_finished_message(
application: app.Application, data_dir: Path
) -> 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:]))
application.show_level_screen()
level_view = application.view
assert isinstance(level_view, LevelView)
level_view.buttons[0].invoke()
card_view = application.view
assert isinstance(card_view, FlashCardView)
assert _word(card_view) == FINISHED_MESSAGE
def test_create_application_with_level_skips_the_level_screen(
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
application = app.create_application(data_dir, False, Level(100))
try:
assert isinstance(application.view, FlashCardView)
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
finally:
_clear(tk_root, application.view)
def test_create_application_without_level_shows_the_level_screen(
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
application = app.create_application(data_dir, False)
try:
assert isinstance(application.view, LevelView)
assert application.root is tk_root
finally:
_clear(tk_root, application.view)