MIL-003: Step 2 - New flash cards

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

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
This commit is contained in:
2026-10-14 21:59:29 +08:00
parent c6c737ce40
commit 8d66c43eea
36 changed files with 1167 additions and 102 deletions
@@ -0,0 +1,44 @@
"""Tests of the open_study_session use case."""
from collections.abc import Sequence
import pytest
from flash_card.application.dto.card_dto import CardDto
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.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
from flash_card.domain.value_objects.language_pair import LanguagePair
class _FakeRepository:
def __init__(self, vocabulary: Vocabulary) -> None:
self._vocabulary = vocabulary
def load(self) -> Vocabulary:
return self._vocabulary
class _MissingRepository:
def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def test_open_study_session_builds_session_over_all_cards() -> None:
cards = (Card("aku", "mig"), Card("kau", "du"))
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
session = open_study_session(repository, _first)
assert session.remaining == 2
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError):
open_study_session(_MissingRepository(), _first)
@@ -0,0 +1,60 @@
"""Tests of the StudySession use case."""
from collections.abc import Sequence
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
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
PAIR = LanguagePair("ms", "da")
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(*cards: Card) -> StudySession:
return StudySession(Deck(PAIR, cards), _first)
def test_current_card_is_none_before_first_draw() -> None:
assert _session(A).current_card is None
def test_next_card_returns_chosen_card_as_dto() -> None:
assert _session(A, B).next_card() == CardDto("aku", "mig", "ms", "da")
def test_next_card_returns_different_card_when_two_or_more() -> None:
session = _session(A, B)
first = session.next_card()
second = session.next_card()
assert first != second
assert second == CardDto("kau", "du", "ms", "da")
def test_next_card_returns_same_card_when_one() -> None:
session = _session(A)
assert session.next_card() == session.next_card()
def test_next_card_returns_none_when_deck_is_empty() -> None:
session = _session()
assert session.next_card() is None
assert session.current_card is None
def test_current_card_follows_last_draw() -> None:
session = _session(A, B)
shown = session.next_card()
assert session.current_card == shown
def test_remaining_counts_cards_in_deck() -> None:
session = _session(A, B)
session.next_card()
assert session.remaining == 2
+38
View File
@@ -0,0 +1,38 @@
"""Tests of the Card, LanguagePair and Vocabulary value types."""
import dataclasses
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.value_objects.language_pair import LanguagePair
def test_card_equals_card_with_same_texts() -> None:
assert Card("aku", "mig") == Card("aku", "mig")
def test_card_is_frozen() -> None:
card = Card("aku", "mig")
with pytest.raises(dataclasses.FrozenInstanceError):
card.front = "kau" # type: ignore[misc]
def test_language_pair_holds_codes() -> None:
pair = LanguagePair("ms", "da")
assert (pair.front_code, pair.back_code) == ("ms", "da")
def test_language_pair_is_frozen() -> None:
pair = LanguagePair("ms", "da")
with pytest.raises(dataclasses.FrozenInstanceError):
pair.front_code = "en" # type: ignore[misc]
def test_vocabulary_holds_pair_and_cards() -> None:
pair = LanguagePair("ms", "da")
cards = (Card("aku", "mig"),)
vocabulary = Vocabulary(pair, cards)
assert vocabulary.language_pair == pair
assert vocabulary.cards == cards
+73
View File
@@ -0,0 +1,73 @@
"""Tests of the Deck entity."""
from collections.abc import Sequence
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 EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair
PAIR = LanguagePair("ms", "da")
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def test_from_vocabulary_holds_all_cards() -> None:
deck = Deck.from_vocabulary(Vocabulary(PAIR, (A, B)))
assert deck.cards == (A, B)
assert deck.language_pair == PAIR
assert len(deck) == 2
def test_cards_returns_copy_when_deck_changes_later() -> None:
deck = Deck(PAIR, [A])
snapshot = deck.cards
deck.remove(A)
assert snapshot == (A,)
assert deck.is_empty
def test_draw_returns_chosen_card() -> None:
assert Deck(PAIR, [A, B]).draw(_first) == A
def test_draw_avoids_excluded_card_when_another_exists() -> None:
assert Deck(PAIR, [A, B]).draw(_first, exclude=A) == B
def test_draw_returns_excluded_card_when_it_is_the_only_one() -> None:
assert Deck(PAIR, [A]).draw(_first, exclude=A) == A
def test_draw_keeps_card_in_deck() -> None:
deck = Deck(PAIR, [A, B])
deck.draw(_first)
assert len(deck) == 2
def test_draw_raises_when_deck_is_empty() -> None:
with pytest.raises(EmptyDeckError):
Deck(PAIR, []).draw(_first)
def test_remove_deletes_first_equal_card() -> None:
deck = Deck(PAIR, [A, B, A])
deck.remove(A)
assert deck.cards == (B, A)
def test_remove_raises_when_card_is_absent() -> None:
with pytest.raises(ValueError):
Deck(PAIR, [A]).remove(B)
def test_is_empty_reflects_cards() -> None:
assert Deck(PAIR, []).is_empty
assert not Deck(PAIR, [A]).is_empty
@@ -0,0 +1,21 @@
"""Tests of the vocabulary error hierarchy."""
import pytest
from flash_card.domain.errors.vocabulary_errors import (
EmptyDeckError,
VocabularyError,
VocabularyFormatError,
VocabularyNotFoundError,
)
@pytest.mark.parametrize(
"error", [VocabularyFormatError, VocabularyNotFoundError, EmptyDeckError]
)
def test_error_is_a_vocabulary_error(error: type[VocabularyError]) -> None:
assert issubclass(error, VocabularyError)
def test_vocabulary_error_is_an_exception() -> None:
assert issubclass(VocabularyError, Exception)
@@ -0,0 +1,95 @@
"""Tests of read_word_list and CsvVocabularyRepository."""
from pathlib import Path
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import (
VocabularyFormatError,
VocabularyNotFoundError,
)
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
def _write(path: Path, content: str) -> Path:
path.write_bytes(content.encode("utf-8"))
return path
def test_read_word_list_returns_pair_and_cards(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\nkau;du\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_drops_byte_order_mark(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\r\naku;mig\r\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_skips_blank_lines(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "\nms;da\n\naku;mig\n \n;\nkau;du\n\n")
_pair, cards = read_word_list(path)
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_strips_whitespace(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", " ms ; da \n aku ; mig \n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_raises_not_found_when_file_is_missing(
tmp_path: Path,
) -> None:
with pytest.raises(VocabularyNotFoundError) as info:
read_word_list(tmp_path / "missing.csv")
assert isinstance(info.value.__cause__, FileNotFoundError)
def test_read_word_list_raises_format_error_when_file_is_empty(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "\n\n")
with pytest.raises(VocabularyFormatError, match="empty"):
read_word_list(path)
@pytest.mark.parametrize("header", ["ms\n", "ms;da;en\n", "ms;\n"])
def test_read_word_list_raises_format_error_for_bad_header(
tmp_path: Path, header: str
) -> None:
path = _write(tmp_path / "w.csv", header + "aku;mig\n")
with pytest.raises(VocabularyFormatError, match="line 1"):
read_word_list(path)
def test_read_word_list_raises_format_error_with_line_number_for_bad_row(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n\nkau;du;extra\n")
with pytest.raises(VocabularyFormatError, match="line 4") as info:
read_word_list(path)
assert str(path) in str(info.value)
def test_repository_load_returns_vocabulary(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n")
vocabulary = CsvVocabularyRepository(path).load()
assert vocabulary.language_pair == LanguagePair("ms", "da")
assert vocabulary.cards == (Card("aku", "mig"),)
def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\n")
with pytest.raises(VocabularyFormatError):
CsvVocabularyRepository(path).load()
+19 -3
View File
@@ -4,6 +4,8 @@
@brief Checks main of flash_card.presentation.app without opening a window.
"""
from pathlib import Path
import pytest
from flash_card.presentation import app
@@ -18,10 +20,24 @@ class _FakeRoot:
def test_main_runs_main_loop_and_returns_zero(
monkeypatch: pytest.MonkeyPatch,
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
root = _FakeRoot()
monkeypatch.setattr(app, "create_application", lambda: (root, object()))
seen: list[Path | None] = []
assert app.main([]) == 0
def fake_create(data_dir: Path | None = None) -> tuple[_FakeRoot, object]:
seen.append(data_dir)
return root, object()
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]
def test_main_returns_one_when_word_list_is_missing(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
assert "word list not found" in caplog.text
+4
View File
@@ -31,3 +31,7 @@ def test_language_names_cover_ms_and_da() -> None:
def test_project_root_contains_pyproject() -> None:
assert (constants.PROJECT_ROOT / "pyproject.toml").is_file()
def test_csv_encoding_drops_byte_order_mark() -> None:
assert constants.CSV_ENCODING == "utf-8-sig"
+45 -10
View File
@@ -1,18 +1,53 @@
"""Tests of the flash card view model.
"""Tests of the flash card view model over a real session and in-memory deck."""
@file test_flash_card_view_model.py
@brief Checks the placeholder texts of FlashCardViewModel.
"""
from collections.abc import Sequence
from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE
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.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
def test_language_name_returns_placeholder() -> None:
assert FlashCardViewModel().language_name == PLACEHOLDER_LANGUAGE_NAME
A = Card("aku", "mig")
B = Card("kau", "du")
def test_text_returns_placeholder_word() -> None:
assert FlashCardViewModel().text == PLACEHOLDER_WORD
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(code: str, *cards: Card) -> StudySession:
return StudySession(Deck(LanguagePair(code, "da"), cards), _first)
def test_view_model_draws_first_card_on_construction() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
assert view_model.text == "aku"
assert view_model.language_name == "Malay"
assert not view_model.is_finished
def test_next_card_shows_next_card() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
view_model.next_card()
assert view_model.text == "kau"
def test_language_name_shows_unknown_code_in_upper_case() -> None:
view_model = FlashCardViewModel(_session("xx", A))
assert view_model.language_name == "XX"
def test_language_name_uses_given_names() -> None:
view_model = FlashCardViewModel(_session("ms", A), {"ms": "Melayu"})
assert view_model.language_name == "Melayu"
def test_view_model_shows_finished_message_when_no_card_is_left() -> None:
view_model = FlashCardViewModel(_session("ms"))
assert view_model.is_finished
assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE