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 == []