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
@@ -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)