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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user