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)
+124
View File
@@ -0,0 +1,124 @@
"""Integration tests of the levels over the real word list and a progress file."""
from collections.abc import Callable, Sequence
from pathlib import Path
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import (
DEFAULT_DATA_DIR,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
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.speech.null_speech import NullSpeech
VOCABULARY = CsvVocabularyRepository(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME)
ALL_CARDS = VOCABULARY.load().cards
FIRST_HUNDRED = ALL_CARDS[:100]
Choose = Callable[[Sequence[Card]], Card]
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _open(folder: Path, level: Level, choose: Choose = _first) -> StudySession:
return open_study_session(
VOCABULARY,
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
NullSpeech(),
choose,
level,
)
def _learn_to_the_end(session: StudySession) -> list[Card]:
"""Mark every drawn card known until the session is finished."""
drawn: list[Card] = []
while (card := session.next_card()) is not None:
drawn.append(Card(card.front_text, card.back_text))
session.mark_known()
return drawn
def _saved(folder: Path) -> tuple[Card, ...]:
loaded = CsvProgressRepository(folder / PROGRESS_FILE_NAME).load()
assert loaded is not None
return loaded.cards
def test_word_list_has_one_thousand_distinct_words() -> None:
assert len(ALL_CARDS) == 1000
assert len(set(ALL_CARDS)) == 1000
def test_level_100_draws_exactly_the_first_hundred_words(tmp_path: Path) -> None:
drawn = _learn_to_the_end(_open(tmp_path, Level(100)))
assert len(drawn) == 100
assert len(set(drawn)) == 100
assert set(drawn) == set(FIRST_HUNDRED)
def test_level_100_keeps_the_other_words_in_order_in_the_file(
tmp_path: Path,
) -> None:
_learn_to_the_end(_open(tmp_path, Level(100)))
assert _saved(tmp_path) == ALL_CARDS[100:]
assert len(_saved(tmp_path)) == 900
def test_level_all_after_level_100_draws_only_the_other_words(
tmp_path: Path,
) -> None:
_learn_to_the_end(_open(tmp_path, Level(100)))
drawn = _learn_to_the_end(_open(tmp_path, Level(None)))
assert len(drawn) == 900
assert set(drawn) == set(ALL_CARDS[100:])
assert not set(drawn) & set(FIRST_HUNDRED)
def test_level_1000_equals_all_words_for_this_list(tmp_path: Path) -> None:
session_1000 = _open(tmp_path, Level(1000))
session_all = _open(tmp_path, Level(None))
assert session_1000.remaining_in_level == 1000
assert session_all.remaining_in_level == 1000
def test_words_known_at_level_100_stay_known_at_level_1000(tmp_path: Path) -> None:
first = _open(tmp_path, Level(100))
shown = first.next_card()
assert shown is not None
first.mark_known()
session = _open(tmp_path, Level(1000))
assert session.remaining == 999
assert session.remaining_in_level == 999
def test_marking_known_at_level_100_keeps_words_outside_the_level(
tmp_path: Path,
) -> None:
session = _open(tmp_path, Level(100))
session.next_card()
session.mark_known()
saved = _saved(tmp_path)
assert len(saved) == 999
assert saved[-900:] == ALL_CARDS[100:]
def test_level_whose_words_are_all_known_is_finished_at_once(
tmp_path: Path,
) -> None:
_learn_to_the_end(_open(tmp_path, Level(100)))
session = _open(tmp_path, Level(100))
assert session.remaining_in_level == 0
assert session.remaining == 900
assert session.next_card() is None
+2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.application.use_cases.study_session import StudySession
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,
)
@@ -36,6 +37,7 @@ def _open(folder: Path, choose: Choose = _first) -> StudySession:
CsvProgressRepository(folder / "words_to_learn.csv"),
NullSpeech(),
choose,
Level(None),
)
+6 -1
View File
@@ -10,6 +10,7 @@ from flash_card.constants import (
)
from flash_card.domain.entities.card import Card
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,
)
@@ -34,7 +35,11 @@ def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
cards = repository.load().cards
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
session = open_study_session(
repository, progress, NullSpeech(), lambda candidates: candidates[0]
repository,
progress,
NullSpeech(),
lambda candidates: candidates[0],
Level(None),
)
card = session.next_card()
@@ -11,6 +11,7 @@ from flash_card.domain.entities.deck import Deck
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.domain.value_objects.level import Level
class _FakeRepository:
@@ -52,7 +53,7 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
session = open_study_session(
repository, _RecordingProgress(), _SilentSpeech(), _first
repository, _RecordingProgress(), _SilentSpeech(), _first, Level(None)
)
assert session.remaining == 2
@@ -62,7 +63,11 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError):
open_study_session(
_MissingRepository(), _RecordingProgress(), _SilentSpeech(), _first
_MissingRepository(),
_RecordingProgress(),
_SilentSpeech(),
_first,
Level(None),
)
@@ -72,21 +77,83 @@ def test_open_study_session_prefers_saved_progress() -> None:
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
session = open_study_session(
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
)
assert session.remaining == 1
assert session.next_card() == CardDto("kau", "du", "ms", "da")
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
PAIR = LanguagePair("ms", "da")
THREE = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
def _last(cards: Sequence[Card]) -> Card:
return cards[-1]
def test_open_study_session_propagates_error_when_list_is_missing() -> None:
progress = _RecordingProgress(Deck(PAIR, []))
with pytest.raises(VocabularyNotFoundError):
open_study_session(
_MissingRepository(), progress, _SilentSpeech(), _first, Level(None)
)
def test_open_study_session_limits_draws_to_first_cards_of_level() -> None:
session = open_study_session(
_MissingRepository(), progress, _SilentSpeech(), _first
_FakeRepository(Vocabulary(PAIR, THREE)),
_RecordingProgress(),
_SilentSpeech(),
_last,
Level(2),
)
assert session.remaining == 0
assert session.remaining == 3
assert session.remaining_in_level == 2
assert session.next_card() == CardDto("kau", "du", "ms", "da")
def test_open_study_session_uses_vocabulary_order_with_saved_progress() -> None:
progress = _RecordingProgress(Deck(PAIR, [THREE[2], THREE[1]]))
session = open_study_session(
_FakeRepository(Vocabulary(PAIR, THREE)),
progress,
_SilentSpeech(),
_first,
Level(2),
)
assert session.remaining == 2
assert session.remaining_in_level == 1
assert session.next_card() == CardDto("kau", "du", "ms", "da")
def test_open_study_session_is_finished_when_level_has_nothing_left() -> None:
progress = _RecordingProgress(Deck(PAIR, [THREE[1]]))
session = open_study_session(
_FakeRepository(Vocabulary(PAIR, THREE)),
progress,
_SilentSpeech(),
_first,
Level(1),
)
assert session.next_card() is None
assert session.remaining == 1
def test_open_study_session_uses_all_cards_when_level_exceeds_list() -> None:
session = open_study_session(
_FakeRepository(Vocabulary(PAIR, THREE)),
_RecordingProgress(),
_SilentSpeech(),
_first,
Level(1000),
)
assert session.remaining_in_level == 3
def test_open_study_session_never_saves_on_start() -> None:
@@ -94,7 +161,7 @@ def test_open_study_session_never_saves_on_start() -> None:
progress = _RecordingProgress()
session = open_study_session(
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
)
session.next_card()
+93 -1
View File
@@ -1,6 +1,7 @@
"""Tests of the StudySession use case."""
from collections.abc import Sequence
import itertools
from collections.abc import Callable, Sequence
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
@@ -150,3 +151,94 @@ def test_mark_known_does_not_speak_but_following_draw_does() -> None:
assert speech.spoken == [("aku", "ms")]
session.next_card()
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
C = Card("ini", "denne")
D = Card("itu", "den")
def _level_session(
deck_cards: Sequence[Card],
level_cards: Sequence[Card] | None,
choose: Callable[[Sequence[Card]], Card] = _first,
progress: _RecordingProgress | None = None,
speech: _RecordingSpeech | None = None,
) -> StudySession:
store = _RecordingProgress() if progress is None else progress
voice = _RecordingSpeech() if speech is None else speech
return StudySession(Deck(PAIR, deck_cards), choose, store, voice, level_cards)
def _last(cards: Sequence[Card]) -> Card:
return cards[-1]
def test_next_card_draws_only_cards_of_the_level_over_many_draws() -> None:
chooser = itertools.cycle([_first, _last])
session = _level_session(
[A, B, C, D], [A, B], choose=lambda cards: next(chooser)(cards)
)
drawn = {session.next_card() for _ in range(40)}
assert drawn == {
CardDto("aku", "mig", "ms", "da"),
CardDto("kau", "du", "ms", "da"),
}
def test_next_card_never_draws_a_card_outside_the_level() -> None:
session = _level_session([A, B, C], [B], choose=_last)
for _ in range(5):
assert session.next_card() == CardDto("kau", "du", "ms", "da")
def test_remaining_counts_whole_deck_and_remaining_in_level_counts_level() -> None:
session = _level_session([A, B, C], [A, B])
assert session.remaining == 3
assert session.remaining_in_level == 2
def test_remaining_in_level_counts_whole_deck_without_level() -> None:
assert _level_session([A, B, C], None).remaining_in_level == 3
def test_remaining_in_level_ignores_level_cards_missing_from_deck() -> None:
assert _level_session([B], [A, B]).remaining_in_level == 1
def test_mark_known_keeps_cards_outside_the_level_and_saves_whole_deck() -> None:
progress = _RecordingProgress()
session = _level_session([A, B, C], [A], progress=progress)
session.next_card()
session.mark_known()
assert progress.saved == [(B, C)]
assert session.remaining == 2
assert session.remaining_in_level == 0
def test_next_card_is_none_when_nothing_of_the_level_is_left() -> None:
session = _level_session([B, C], [A])
assert session.next_card() is None
assert session.current_card is None
def test_session_finishes_after_last_card_of_level_is_known() -> None:
session = _level_session([A, B], [A])
session.next_card()
session.mark_known()
assert session.next_card() is None
assert session.remaining == 1
def test_next_card_speaks_only_cards_of_the_level() -> None:
speech = _RecordingSpeech()
session = _level_session([A, B, C], [B], choose=_last, speech=speech)
session.next_card()
session.next_card()
assert speech.spoken == [("kau", "ms"), ("kau", "ms")]
def test_next_card_speaks_nothing_when_level_is_finished() -> None:
speech = _RecordingSpeech()
session = _level_session([B], [A], speech=speech)
session.next_card()
assert speech.spoken == []
+44
View File
@@ -71,3 +71,47 @@ def test_remove_raises_when_card_is_absent() -> None:
def test_is_empty_reflects_cards() -> None:
assert Deck(PAIR, []).is_empty
assert not Deck(PAIR, [A]).is_empty
def test_within_returns_deck_cards_found_in_given_cards_in_deck_order() -> None:
c = Card("ini", "denne")
deck = Deck(PAIR, [A, B, c])
assert deck.within([c, A]) == (A, c)
def test_within_respects_multiplicity() -> None:
deck = Deck(PAIR, [A, A, B])
assert deck.within([A, B]) == (A, B)
assert deck.within([A, A, A]) == (A, A)
def test_within_returns_empty_when_no_card_matches() -> None:
assert Deck(PAIR, [A]).within([B]) == ()
def test_within_does_not_change_the_deck() -> None:
deck = Deck(PAIR, [A, B])
deck.within([A])
assert deck.cards == (A, B)
def test_draw_chooses_among_given_candidates_only() -> None:
deck = Deck(PAIR, [A, B])
assert deck.draw(_first, among=[B]) == B
def test_draw_among_avoids_excluded_when_another_candidate_exists() -> None:
c = Card("ini", "denne")
deck = Deck(PAIR, [A, B, c])
assert deck.draw(_first, exclude=B, among=[B, c]) == c
def test_draw_among_returns_excluded_when_it_is_the_only_candidate() -> None:
deck = Deck(PAIR, [A, B])
assert deck.draw(_first, exclude=B, among=[B]) == B
def test_draw_among_empty_raises_empty_deck_error() -> None:
deck = Deck(PAIR, [A, B])
with pytest.raises(EmptyDeckError):
deck.draw(_first, among=[])
+42
View File
@@ -0,0 +1,42 @@
"""Tests of the Level value object."""
import dataclasses
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.value_objects.level import Level
CARDS = tuple(Card(f"w{i}", f"v{i}") for i in range(1000))
@pytest.mark.parametrize("limit", [0, -1, -100])
def test_level_refuses_limit_not_above_zero(limit: int) -> None:
with pytest.raises(ValueError, match="above zero"):
Level(limit)
def test_level_accepts_no_limit() -> None:
assert Level(None).limit is None
def test_level_is_frozen() -> None:
level = Level(100)
with pytest.raises(dataclasses.FrozenInstanceError):
level.limit = 5 # type: ignore[misc]
def test_first_returns_first_hundred_cards_when_limit_is_100() -> None:
assert Level(100).first(CARDS) == CARDS[:100]
def test_first_returns_all_cards_when_there_is_no_limit() -> None:
assert Level(None).first(CARDS) == CARDS
def test_first_returns_all_cards_when_limit_exceeds_the_list() -> None:
assert Level(5000).first(CARDS) == CARDS
def test_first_returns_empty_when_list_is_empty() -> None:
assert Level(100).first(()) == ()
+207 -9
View File
@@ -1,45 +1,243 @@
"""Tests of the entry point.
@file test_app.py
@brief Checks main of flash_card.presentation.app without opening a window.
@brief Checks main and Application of flash_card.presentation.app without Tk.
"""
import tkinter as tk
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import cast
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.domain.value_objects.level import Level
from flash_card.presentation import app
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
from flash_card.presentation.features.level.level_view_model import LevelViewModel
PAIR = LanguagePair("ms", "da")
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
class _FakeRoot:
def __init__(self) -> None:
self.mainloop_calls = 0
self.destroyed = False
def mainloop(self) -> None:
self.mainloop_calls += 1
def destroy(self) -> None:
self.destroyed = True
class _FakeApplication:
def __init__(self, root: _FakeRoot, exit_code: int = 0) -> None:
self.root = root
self.exit_code = exit_code
class _FakeLevelView:
def __init__(
self,
root: object,
view_model: LevelViewModel,
on_choose: Callable[[Level], None],
) -> None:
self.view_model = view_model
self.on_choose = on_choose
self.destroyed = False
def destroy(self) -> None:
self.destroyed = True
class _FakeFlashCardView:
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
self.view_model = view_model
class _Vocabulary:
def __init__(self, error: bool = False) -> None:
self._error = error
def load(self) -> Vocabulary:
if self._error:
raise VocabularyNotFoundError("word list not found")
return Vocabulary(PAIR, CARDS)
class _Progress:
def load(self) -> Deck | None:
return None
def save(self, deck: Deck) -> None:
return None
class _Speech:
def speak(self, text: str, language_code: str) -> None:
return None
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
@pytest.fixture
def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app, "LevelView", _FakeLevelView)
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
return app.Application(
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
)
def _recording_create(
seen: list[Level | None], exit_code: int = 0
) -> Callable[..., _FakeApplication]:
def fake_create(
data_dir: Path | None = None,
speech_enabled: bool = True,
level: Level | None = None,
) -> _FakeApplication:
seen.append(level)
return _FakeApplication(_FakeRoot(), exit_code)
return fake_create
def test_main_runs_main_loop_and_returns_zero(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
root = _FakeRoot()
seen: list[Path | None] = []
seen: list[tuple[Path | None, Level | None]] = []
def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
seen.append(data_dir)
return root, object()
data_dir: Path | None = None,
speech_enabled: bool = True,
level: Level | None = None,
) -> _FakeApplication:
seen.append((data_dir, level))
return _FakeApplication(root)
monkeypatch.setattr(app, "create_application", fake_create)
assert app.main(["--data-dir", str(tmp_path)]) == 0
assert root.mainloop_calls == 1
assert seen == [tmp_path]
assert seen == [(tmp_path, None)]
def test_main_returns_one_when_word_list_is_missing(
def test_main_returns_one_when_word_list_is_missing_with_level(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
code = app.main(["--data-dir", str(tmp_path / "nowhere"), "--level", "all"])
assert code == 1
assert "word list not found" in caplog.text
@pytest.mark.parametrize(
("value", "limit"), [("100", 100), ("1000", 1000), ("all", None)]
)
def test_main_passes_level_of_level_option(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str, limit: int | None
) -> None:
seen: list[Level | None] = []
monkeypatch.setattr(app, "create_application", _recording_create(seen))
assert app.main(["--data-dir", str(tmp_path), "--level", value]) == 0
assert seen == [Level(limit)]
def test_main_refuses_unknown_level(capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as raised:
app.main(["--level", "50"])
assert raised.value.code == 2
assert "invalid level" in capsys.readouterr().err
def test_main_returns_exit_code_of_application(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen: list[Level | None] = []
monkeypatch.setattr(app, "create_application", _recording_create(seen, 1))
assert app.main(["--data-dir", str(tmp_path)]) == 1
def test_application_shows_no_view_before_a_screen_is_requested(
fake_views: None,
) -> None:
assert _application(_FakeRoot()).view is None
def test_show_level_screen_offers_the_three_levels(fake_views: None) -> None:
application = _application(_FakeRoot())
application.show_level_screen()
view = application.view
assert isinstance(view, _FakeLevelView)
assert [c.level.limit for c in view.view_model.choices] == [100, 1000, None]
def test_choosing_a_level_replaces_level_screen_by_card_screen(
fake_views: None,
) -> None:
application = _application(_FakeRoot())
application.show_level_screen()
level_view = application.view
assert isinstance(level_view, _FakeLevelView)
level_view.on_choose(Level(100))
assert level_view.destroyed
assert isinstance(application.view, _FakeFlashCardView)
assert application.exit_code == 0
def test_start_draws_the_first_card_of_the_level(fake_views: None) -> None:
application = _application(_FakeRoot())
application.start(Level(2))
view = application.view
assert isinstance(view, _FakeFlashCardView)
assert view.view_model.text == "aku"
def test_start_without_level_screen_shows_card_screen_only(
fake_views: None,
) -> None:
application = _application(_FakeRoot())
application.start(Level(None))
assert isinstance(application.view, _FakeFlashCardView)
def test_choosing_a_level_closes_window_with_code_one_on_vocabulary_error(
fake_views: None, caplog: pytest.LogCaptureFixture
) -> None:
root = _FakeRoot()
application = _application(root, error=True)
application.show_level_screen()
level_view = application.view
assert isinstance(level_view, _FakeLevelView)
level_view.on_choose(Level(100))
assert root.destroyed
assert application.exit_code == 1
assert "word list not found" in caplog.text
def test_start_raises_vocabulary_error_for_the_caller(fake_views: None) -> None:
application = _application(_FakeRoot(), error=True)
with pytest.raises(VocabularyNotFoundError):
application.start(Level(100))
+11 -3
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from flash_card.domain.value_objects.level import Level
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation import app
@@ -16,6 +17,11 @@ class _FakeRoot:
return None
class _FakeApplication:
root = _FakeRoot()
exit_code = 0
def _found(name: str) -> ModuleSpec:
return ModuleSpec(name, None)
@@ -58,10 +64,12 @@ def test_main_passes_speech_disabled_with_no_speech_option(
seen: list[bool] = []
def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
data_dir: Path | None = None,
speech_enabled: bool = True,
level: Level | None = None,
) -> _FakeApplication:
seen.append(speech_enabled)
return _FakeRoot(), object()
return _FakeApplication()
monkeypatch.setattr(app, "create_application", fake_create)
+46
View File
@@ -0,0 +1,46 @@
"""Tests of the level view model."""
import dataclasses
import pytest
from flash_card.constants import LEVEL_TITLE
from flash_card.domain.value_objects.level import Level
from flash_card.presentation.features.level import level_view_model
from flash_card.presentation.features.level.level_view_model import (
LevelChoice,
LevelViewModel,
)
def test_title_is_choose_your_level() -> None:
assert LevelViewModel().title == LEVEL_TITLE == "Choose your level"
def test_choices_are_three_levels_in_order() -> None:
choices = LevelViewModel().choices
assert [choice.label for choice in choices] == [
"100 words",
"1000 words",
"All words",
]
assert [choice.level for choice in choices] == [
Level(100),
Level(1000),
Level(None),
]
def test_choices_come_from_given_choices() -> None:
model = LevelViewModel([("Ten", 10)])
assert model.choices == (LevelChoice("Ten", Level(10)),)
def test_choice_is_frozen() -> None:
choice = LevelChoice("Ten", Level(10))
with pytest.raises(dataclasses.FrozenInstanceError):
choice.label = "x" # type: ignore[misc]
def test_view_model_module_does_not_import_tkinter() -> None:
assert "tkinter" not in vars(level_view_model)