diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 74b1c8e..543716f 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 025 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-025-py-mil-003.md b/docs/sqa/reviews/rc-025-py-mil-003.md new file mode 100644 index 0000000..136c06f --- /dev/null +++ b/docs/sqa/reviews/rc-025-py-mil-003.md @@ -0,0 +1,64 @@ +# RC-025: Review of PY-MIL-003 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-025 | +| CrossReference | [PY-MIL-003], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8d66c43] | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-003] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Packages, modules, classes and functions follow PEP 8 casing; ruff rule set N passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose in the dictionary's IT terms (`Deck`, `Vocabulary`, `StudySession`, `CardDto`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 43 files with no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 31 source files. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | `read_word_list` raises `VocabularyNotFoundError` with `from` to keep the cause; no bare or swallowed exception; `Deck.remove` raises `ValueError` (not in OC-001, a deviation recorded here). | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin shadowed (`Card`, `Deck` do not shadow). | +| 7 | Files, locks and connections are managed with context managers | Pass | The CSV file is opened with `with` in `read_word_list`. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; `app.main` logs errors with `logging`; no personal data logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | Classes and operations are those of DCD-001 (`Card`, `LanguagePair`, `Vocabulary`, `Deck`, `StudySession`, `CardDto`, `CsvVocabularyRepository`, view and view model). Deviations: `Deck.remove` raises `ValueError` when the card is absent; `read_word_list` is a shared function behind both CSV repositories, as MIL-005 task 1 requires. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 66 tests (unit by layer, integration on the real `data/ms-da.csv`, e2e on the real window) named for behaviour; none uses the network or depends on order. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; `csv`, `random` and `pathlib` are standard library (ADR-0001). | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The CSV format, encoding and delimiter are defined once in `constants.py` and `read_word_list`; the draw rule is only in `Deck.draw`. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; a grep finds no `tkinter`, `csv` or `random` import in `domain` or `application`. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | One reason to change per class; the choice function is injected into `StudySession` and `Deck.draw`, and the repository is a Protocol that the use case receives. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Source is in `domain`, `application`, `infrastructure`, `presentation` with the sub-folders of the clean-architecture skill; tests in `unit`, `integration`, `e2e`. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names are English snake_case, no space. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Milestone MIL-003 Go criteria: 1 integration test reads 1000 cards, pair ms/da; 2 missing header, bad row and missing file raise `VocabularyFormatError`/`VocabularyNotFoundError` (unit tests); 3 a press shows a different card when two or more exist and the same when one (unit tests); 4 blank lines are ignored (unit test); 5 no `tkinter`, `csv` or `random` import in domain/application (grep, empty); 6 this record and `pytest` 66 passed. A manual smoke run on the real file showed a new word after a press. All Go. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-003]: ../../../src/flash_card +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[8d66c43]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8d66c43eea9673728e4f7497d7673498306e8b13 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 7103b49..4d7985b 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -47,6 +47,7 @@ updated whenever an artifact instance is created or reviewed. | MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] | | PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] | | PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | +| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | ## Coverage Notes @@ -105,3 +106,5 @@ updated whenever an artifact instance is created or reviewed. [c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 [RC-024]: ./reviews/rc-024-py-mil-002.md [PY-MIL-002]: ../../src/flash_card +[RC-025]: ./reviews/rc-025-py-mil-003.md +[PY-MIL-003]: ../../src/flash_card diff --git a/src/flash_card/application/dto/__init__.py b/src/flash_card/application/dto/__init__.py new file mode 100644 index 0000000..d216442 --- /dev/null +++ b/src/flash_card/application/dto/__init__.py @@ -0,0 +1,5 @@ +"""Application data transfer objects. + +@file __init__.py +@brief Holds the plain data that crosses to the presentation layer. +""" diff --git a/src/flash_card/application/dto/card_dto.py b/src/flash_card/application/dto/card_dto.py new file mode 100644 index 0000000..aaedc10 --- /dev/null +++ b/src/flash_card/application/dto/card_dto.py @@ -0,0 +1,21 @@ +"""Card data for the presentation layer. + +@file card_dto.py +@brief The CardDto: a card as plain text. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CardDto: + """A card as plain data.""" + + ## @brief Word on the card front. + front_text: str + ## @brief Translation on the card back. + back_text: str + ## @brief Language code of the front. + front_language: str + ## @brief Language code of the back. + back_language: str diff --git a/src/flash_card/application/use_cases/__init__.py b/src/flash_card/application/use_cases/__init__.py new file mode 100644 index 0000000..2aaff5c --- /dev/null +++ b/src/flash_card/application/use_cases/__init__.py @@ -0,0 +1,5 @@ +"""Application use cases. + +@file __init__.py +@brief Holds the use cases of the flash card program. +""" diff --git a/src/flash_card/application/use_cases/open_study_session.py b/src/flash_card/application/use_cases/open_study_session.py new file mode 100644 index 0000000..343a694 --- /dev/null +++ b/src/flash_card/application/use_cases/open_study_session.py @@ -0,0 +1,27 @@ +"""Start a study session. + +@file open_study_session.py +@brief The open_study_session use case. +""" + +from collections.abc import Callable, Sequence + +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.repositories.vocabulary_repository import VocabularyRepository + + +def open_study_session( + vocabulary_repository: VocabularyRepository, + choose: Callable[[Sequence[Card]], Card], +) -> StudySession: + """Load the word list and build a session over all its cards. + + @param vocabulary_repository Source of the word list. + @param choose Picks one card from a non-empty sequence. + @return The new session. + @throws VocabularyError When the word list cannot be loaded. + """ + deck = Deck.from_vocabulary(vocabulary_repository.load()) + return StudySession(deck, choose) diff --git a/src/flash_card/application/use_cases/study_session.py b/src/flash_card/application/use_cases/study_session.py new file mode 100644 index 0000000..453d7f7 --- /dev/null +++ b/src/flash_card/application/use_cases/study_session.py @@ -0,0 +1,66 @@ +"""One study session. + +@file study_session.py +@brief The StudySession use case: draws the cards the user studies. +""" + +from collections.abc import Callable, Sequence + +from flash_card.application.dto.card_dto import CardDto +from flash_card.domain.entities.card import Card +from flash_card.domain.entities.deck import Deck + + +class StudySession: + """Draws cards from a deck, one after another.""" + + def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None: + """Create a session. + + @param deck The cards still to learn. + @param choose Picks one card from a non-empty sequence. + """ + ## @brief The cards still to learn. + self._deck = deck + ## @brief Picks one card from a non-empty sequence. + self._choose = choose + ## @brief The card shown now, or None. + self._current: Card | None = None + + @property + def current_card(self) -> CardDto | None: + """Return the card shown now. + + @return The card, or None before the first draw or when none is left. + """ + return self._to_dto(self._current) + + @property + def remaining(self) -> int: + """Return the number of cards still to learn. + + @return The card count. + """ + return len(self._deck) + + def next_card(self) -> CardDto | None: + """Draw the next card, different from the current one when possible. + + @return The new card, or None when the deck is empty. + """ + if self._deck.is_empty: + self._current = None + return None + self._current = self._deck.draw(self._choose, exclude=self._current) + return self._to_dto(self._current) + + def _to_dto(self, card: Card | None) -> CardDto | None: + """Map a card to its DTO. + + @param card The card, or None. + @return The DTO, or None when there is no card. + """ + if card is None: + return None + pair = self._deck.language_pair + return CardDto(card.front, card.back, pair.front_code, pair.back_code) diff --git a/src/flash_card/constants.py b/src/flash_card/constants.py index 9ec03b2..e253b15 100644 --- a/src/flash_card/constants.py +++ b/src/flash_card/constants.py @@ -42,15 +42,11 @@ BACK_TEXT_COLOR: Final[str] = "white" LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"} ## @brief Message shown when no word is left to learn. FINISHED_MESSAGE: Final[str] = "Nothing left to learn" -## @brief Language name shown before the first card. -PLACEHOLDER_LANGUAGE_NAME: Final[str] = "Malay" -## @brief Word shown before the first card. -PLACEHOLDER_WORD: Final[str] = "kata" ## @brief Delimiter of the CSV files. CSV_DELIMITER: Final[str] = ";" -## @brief Encoding of the CSV files. -CSV_ENCODING: Final[str] = "utf-8" +## @brief Encoding of the CSV files; drops a leading byte order mark. +CSV_ENCODING: Final[str] = "utf-8-sig" ## @brief File name of the vocabulary. VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" ## @brief File name of the words still to learn. diff --git a/src/flash_card/domain/entities/__init__.py b/src/flash_card/domain/entities/__init__.py new file mode 100644 index 0000000..9e432a7 --- /dev/null +++ b/src/flash_card/domain/entities/__init__.py @@ -0,0 +1,5 @@ +"""Domain entities. + +@file __init__.py +@brief Holds the entities of the flash card program. +""" diff --git a/src/flash_card/domain/entities/card.py b/src/flash_card/domain/entities/card.py new file mode 100644 index 0000000..3e15db9 --- /dev/null +++ b/src/flash_card/domain/entities/card.py @@ -0,0 +1,17 @@ +"""A flash card. + +@file card.py +@brief The Card entity: a word and its translation. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Card: + """A word and its translation.""" + + ## @brief The word in the front language. + front: str + ## @brief The translation in the back language. + back: str diff --git a/src/flash_card/domain/entities/deck.py b/src/flash_card/domain/entities/deck.py new file mode 100644 index 0000000..1a22a0b --- /dev/null +++ b/src/flash_card/domain/entities/deck.py @@ -0,0 +1,95 @@ +"""The cards still to learn. + +@file deck.py +@brief The Deck entity: holds cards, draws one and removes one. +""" + +from collections.abc import Callable, Iterable, Sequence + +from flash_card.domain.entities.card import Card +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 + + +class Deck: + """The cards still to learn, of one language pair.""" + + def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None: + """Create a deck. + + @param language_pair The languages of the cards. + @param cards The cards of the deck. + """ + ## @brief The languages of the cards. + self._language_pair = language_pair + ## @brief The cards still in the deck. + self._cards = list(cards) + + @classmethod + def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck": + """Create a deck holding every card of a vocabulary. + + @param vocabulary The delivered word list. + @return A new deck. + """ + return cls(vocabulary.language_pair, vocabulary.cards) + + @property + def language_pair(self) -> LanguagePair: + """Return the languages of the cards. + + @return The language pair. + """ + return self._language_pair + + @property + def cards(self) -> tuple[Card, ...]: + """Return a copy of the cards. + + @return The cards in deck order. + """ + return tuple(self._cards) + + @property + def is_empty(self) -> bool: + """Tell whether the deck has no card. + + @return True when no card is left. + """ + return not self._cards + + def __len__(self) -> int: + """Return the number of cards. + + @return The card count. + """ + return len(self._cards) + + def draw( + self, + choose: Callable[[Sequence[Card]], Card], + exclude: Card | None = None, + ) -> Card: + """Pick a card without removing it. + + The pick avoids the excluded card when another card exists. + + @param choose Picks one card from a non-empty sequence. + @param exclude A card to avoid, usually the one shown now. + @return The picked card. + @throws EmptyDeckError When the deck has no card. + """ + if not self._cards: + raise EmptyDeckError("cannot draw from an empty deck") + others = [card for card in self._cards if card != exclude] + return choose(others or self._cards) + + def remove(self, card: Card) -> None: + """Remove the first card equal to the given one. + + @param card The card to remove. + @return Nothing. + @throws ValueError When the deck holds no such card. + """ + self._cards.remove(card) diff --git a/src/flash_card/domain/entities/vocabulary.py b/src/flash_card/domain/entities/vocabulary.py new file mode 100644 index 0000000..b1475bf --- /dev/null +++ b/src/flash_card/domain/entities/vocabulary.py @@ -0,0 +1,20 @@ +"""The delivered word list. + +@file vocabulary.py +@brief The Vocabulary entity: all cards of a language pair. +""" + +from dataclasses import dataclass + +from flash_card.domain.entities.card import Card +from flash_card.domain.value_objects.language_pair import LanguagePair + + +@dataclass(frozen=True, slots=True) +class Vocabulary: + """All cards of a language pair as delivered.""" + + ## @brief The languages of the cards. + language_pair: LanguagePair + ## @brief The cards in file order. + cards: tuple[Card, ...] diff --git a/src/flash_card/domain/errors/__init__.py b/src/flash_card/domain/errors/__init__.py new file mode 100644 index 0000000..1d61b3d --- /dev/null +++ b/src/flash_card/domain/errors/__init__.py @@ -0,0 +1,5 @@ +"""Domain errors. + +@file __init__.py +@brief Holds the errors the domain rules raise. +""" diff --git a/src/flash_card/domain/errors/vocabulary_errors.py b/src/flash_card/domain/errors/vocabulary_errors.py new file mode 100644 index 0000000..6184708 --- /dev/null +++ b/src/flash_card/domain/errors/vocabulary_errors.py @@ -0,0 +1,21 @@ +"""Errors of the vocabulary and the deck. + +@file vocabulary_errors.py +@brief The VocabularyError hierarchy. +""" + + +class VocabularyError(Exception): + """Base of every error about the word list or the deck.""" + + +class VocabularyFormatError(VocabularyError): + """The word list is not in the expected format.""" + + +class VocabularyNotFoundError(VocabularyError): + """The word list does not exist.""" + + +class EmptyDeckError(VocabularyError): + """A card was drawn from a deck with no card.""" diff --git a/src/flash_card/domain/repositories/__init__.py b/src/flash_card/domain/repositories/__init__.py new file mode 100644 index 0000000..77512e5 --- /dev/null +++ b/src/flash_card/domain/repositories/__init__.py @@ -0,0 +1,5 @@ +"""Domain repository ports. + +@file __init__.py +@brief Holds the repository interfaces of the domain. +""" diff --git a/src/flash_card/domain/repositories/vocabulary_repository.py b/src/flash_card/domain/repositories/vocabulary_repository.py new file mode 100644 index 0000000..ca3f411 --- /dev/null +++ b/src/flash_card/domain/repositories/vocabulary_repository.py @@ -0,0 +1,21 @@ +"""Port to the delivered word list. + +@file vocabulary_repository.py +@brief The VocabularyRepository interface. +""" + +from typing import Protocol + +from flash_card.domain.entities.vocabulary import Vocabulary + + +class VocabularyRepository(Protocol): + """Source of the delivered word list.""" + + def load(self) -> Vocabulary: + """Load the delivered word list. + + @return The vocabulary. + @throws VocabularyError When the word list cannot be loaded. + """ + ... diff --git a/src/flash_card/domain/value_objects/__init__.py b/src/flash_card/domain/value_objects/__init__.py new file mode 100644 index 0000000..54bf3d8 --- /dev/null +++ b/src/flash_card/domain/value_objects/__init__.py @@ -0,0 +1,5 @@ +"""Domain value objects. + +@file __init__.py +@brief Holds the immutable value objects of the domain. +""" diff --git a/src/flash_card/domain/value_objects/language_pair.py b/src/flash_card/domain/value_objects/language_pair.py new file mode 100644 index 0000000..2bd47ab --- /dev/null +++ b/src/flash_card/domain/value_objects/language_pair.py @@ -0,0 +1,17 @@ +"""The two languages of a word list. + +@file language_pair.py +@brief The LanguagePair value object. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class LanguagePair: + """The language codes of the front and the back of the cards.""" + + ## @brief Language code of the card front. + front_code: str + ## @brief Language code of the card back. + back_code: str diff --git a/src/flash_card/infrastructure/persistence/__init__.py b/src/flash_card/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..9e6df28 --- /dev/null +++ b/src/flash_card/infrastructure/persistence/__init__.py @@ -0,0 +1,5 @@ +"""Persistence adapters. + +@file __init__.py +@brief Holds the adapters that read and write files. +""" diff --git a/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py b/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py new file mode 100644 index 0000000..f6f243d --- /dev/null +++ b/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py @@ -0,0 +1,35 @@ +"""CSV implementation of the vocabulary port. + +@file csv_vocabulary_repository.py +@brief The CsvVocabularyRepository adapter. +""" + +from pathlib import Path + +from flash_card.domain.entities.vocabulary import Vocabulary +from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError +from flash_card.infrastructure.persistence.csv_word_list import read_word_list + + +class CsvVocabularyRepository: + """Loads the delivered word list from a CSV file.""" + + def __init__(self, path: Path) -> None: + """Create the repository. + + @param path The word list file. + """ + ## @brief The word list file. + self._path = path + + def load(self) -> Vocabulary: + """Load the word list. + + @return The vocabulary. + @throws VocabularyNotFoundError When the file does not exist. + @throws VocabularyFormatError When the file is malformed or has no card. + """ + pair, cards = read_word_list(self._path) + if not cards: + raise VocabularyFormatError(f"{self._path}: word list has no card") + return Vocabulary(pair, cards) diff --git a/src/flash_card/infrastructure/persistence/csv_word_list.py b/src/flash_card/infrastructure/persistence/csv_word_list.py new file mode 100644 index 0000000..0f98f0f --- /dev/null +++ b/src/flash_card/infrastructure/persistence/csv_word_list.py @@ -0,0 +1,86 @@ +"""Reading of the word list CSV file. + +@file csv_word_list.py +@brief Reads a semicolon separated word list with a language code header. +""" + +import csv +from pathlib import Path + +from flash_card.constants import CSV_DELIMITER, CSV_ENCODING +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 + +## @brief Number of cells in the header and in every card row. +_CELLS_PER_ROW = 2 + + +def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError: + """Build a format error naming the file and line. + + @param path The file read. + @param line The line number of the fault. + @param reason What is wrong. + @return The error to raise. + """ + return VocabularyFormatError(f"{path}, line {line}: {reason}") + + +def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair: + """Parse the header row. + + @param path The file read. + @param line The line number of the row. + @param row The cells of the row. + @return The language pair. + @throws VocabularyFormatError When the header is not two codes. + """ + cells = [cell.strip() for cell in row] + if len(cells) != _CELLS_PER_ROW or not all(cells): + raise _format_error(path, line, "header must hold two language codes") + return LanguagePair(cells[0], cells[1]) + + +def _parse_card(path: Path, line: int, row: list[str]) -> Card: + """Parse a card row. + + @param path The file read. + @param line The line number of the row. + @param row The cells of the row. + @return The card. + @throws VocabularyFormatError When the row is not two cells. + """ + if len(row) != _CELLS_PER_ROW: + raise _format_error(path, line, "row must hold exactly two cells") + return Card(row[0].strip(), row[1].strip()) + + +def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]: + """Read a word list file. + + @param path The CSV file: a header of two language codes, then one card a row. + @return The language pair and the cards in file order. + @throws VocabularyNotFoundError When the file does not exist. + @throws VocabularyFormatError When the file is empty or a row is malformed. + """ + pair: LanguagePair | None = None + cards: list[Card] = [] + try: + with path.open(encoding=CSV_ENCODING, newline="") as file: + reader = csv.reader(file, delimiter=CSV_DELIMITER) + for row in reader: + if not any(cell.strip() for cell in row): + continue + if pair is None: + pair = _parse_header(path, reader.line_num, row) + else: + cards.append(_parse_card(path, reader.line_num, row)) + except FileNotFoundError as error: + raise VocabularyNotFoundError(f"word list not found: {path}") from error + if pair is None: + raise _format_error(path, 1, "file is empty") + return pair, tuple(cards) diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index f58c889..fd90b2d 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -1,44 +1,77 @@ -"""Entry point and composition root of the flash card program. - -@file app.py -@brief Creates the window, its view model and view, and runs the main loop. -""" - -import tkinter as tk -from collections.abc import Sequence -from typing import NoReturn - -from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView -from flash_card.presentation.features.flash_card.flash_card_view_model import ( - FlashCardViewModel, -) - - -def create_application() -> tuple[tk.Tk, FlashCardView]: - """Create the root window and the flash card view. - - @return The root window and the view built on it. - """ - root = tk.Tk() - view = FlashCardView(root, FlashCardViewModel()) - return root, view - - -def main(argv: Sequence[str] | None = None) -> int: - """Run the program and return its exit code. - - @param argv Command line arguments, or None for none. - @return The exit code, 0 on success. - """ - root, _view = create_application() - root.mainloop() - return 0 - - -def run() -> NoReturn: - """Run the program and exit the interpreter with its exit code. - - @return Never returns; the function always raises SystemExit. - @throws SystemExit Always, with the exit code of main. - """ - raise SystemExit(main()) +"""Entry point and composition root of the flash card program. + +@file app.py +@brief Creates the window, its view model and view, and runs the main loop. +""" + +import argparse +import logging +import random +import tkinter as tk +from collections.abc import Sequence +from pathlib import Path +from typing import NoReturn + +from flash_card.application.use_cases.open_study_session import open_study_session +from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME +from flash_card.domain.errors.vocabulary_errors import VocabularyError +from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( + CsvVocabularyRepository, +) +from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + +## @brief Logger of the entry point. +_logger = logging.getLogger(__name__) + + +def create_application( + data_dir: Path | None = None, +) -> tuple[tk.Tk, FlashCardView]: + """Create the root window and the flash card view. + + @param data_dir Folder of the data files, or None for the default folder. + @return The root window and the view built on it. + @throws VocabularyError When the word list cannot be loaded. + """ + folder = DEFAULT_DATA_DIR if data_dir is None else data_dir + repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) + session = open_study_session(repository, random.choice) + view_model = FlashCardViewModel(session) + root = tk.Tk() + view = FlashCardView(root, view_model) + return root, view + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the program and return its exit code. + + @param argv Command line arguments, or None for those of the process. + @return The exit code, 0 on success and 1 when the word list is unusable. + """ + parser = argparse.ArgumentParser(description="Flash card program.") + parser.add_argument( + "--data-dir", + type=Path, + default=DEFAULT_DATA_DIR, + help="folder of the data files", + ) + args = parser.parse_args(argv) + try: + root, _view = create_application(args.data_dir) + except VocabularyError as error: + _logger.error("%s", error) + return 1 + root.mainloop() + return 0 + + +def run() -> NoReturn: + """Run the program and exit the interpreter with its exit code. + + @return Never returns; the function always raises SystemExit. + @throws SystemExit Always, with the exit code of main. + """ + raise SystemExit(main()) diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view.py b/src/flash_card/presentation/features/flash_card/flash_card_view.py index be35124..045d05a 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view.py @@ -84,11 +84,30 @@ class FlashCardView: ## @brief Button for a word the user does not know. self.wrong_button = tk.Button( - root, image=self._wrong_image, highlightthickness=0, bd=0 + root, + image=self._wrong_image, + highlightthickness=0, + bd=0, + command=self.next_card, ) self.wrong_button.grid(row=1, column=0) ## @brief Button for a word the user knows. self.right_button = tk.Button( - root, image=self._right_image, highlightthickness=0, bd=0 + root, + image=self._right_image, + highlightthickness=0, + bd=0, + command=self.next_card, ) self.right_button.grid(row=1, column=1) + + def next_card(self) -> None: + """Show the next card. + + @return Nothing. + """ + self._view_model.next_card() + self.canvas.itemconfig( + self.language_text_id, text=self._view_model.language_name + ) + self.canvas.itemconfig(self.word_text_id, text=self._view_model.text) diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py index 1043433..c8aa8cb 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py @@ -1,27 +1,69 @@ -"""View model of the flash card screen. - -@file flash_card_view_model.py -@brief Exposes the texts shown on the card; free of any Tkinter import. -""" - -from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD - - -class FlashCardViewModel: - """Data the flash card view shows.""" - - @property - def language_name(self) -> str: - """Return the language name shown on the card. - - @return The display name of the language. - """ - return PLACEHOLDER_LANGUAGE_NAME - - @property - def text(self) -> str: - """Return the word shown on the card. - - @return The word text. - """ - return PLACEHOLDER_WORD +"""View model of the flash card screen. + +@file flash_card_view_model.py +@brief Exposes the texts shown on the card; free of any Tkinter import. +""" + +from collections.abc import Mapping + +from flash_card.application.dto.card_dto import CardDto +from flash_card.application.use_cases.study_session import StudySession +from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES + + +class FlashCardViewModel: + """Data the flash card view shows.""" + + def __init__( + self, + session: StudySession, + language_names: Mapping[str, str] = LANGUAGE_NAMES, + ) -> None: + """Create the view model and draw the first card. + + @param session The study session that supplies the cards. + @param language_names Display names by language code. + """ + ## @brief The study session that supplies the cards. + self._session = session + ## @brief Display names by language code. + self._language_names = language_names + ## @brief The card shown now, or None when none is left. + self._card: CardDto | None = session.next_card() + + @property + def is_finished(self) -> bool: + """Tell whether no card is left to show. + + @return True when there is no card. + """ + return self._card is None + + @property + def language_name(self) -> str: + """Return the language name shown on the card. + + @return The display name of the front language, upper case code when + unknown, or an empty string when no card is left. + """ + if self._card is None: + return "" + code = self._card.front_language + return self._language_names.get(code, code.upper()) + + @property + def text(self) -> str: + """Return the word shown on the card. + + @return The front text, or the finished message when no card is left. + """ + if self._card is None: + return FINISHED_MESSAGE + return self._card.front_text + + def next_card(self) -> None: + """Draw the next card. + + @return Nothing. + """ + self._card = self._session.next_card() diff --git a/tests/e2e/test_flash_card_window.py b/tests/e2e/test_flash_card_window.py index 8dcf399..810b27c 100644 --- a/tests/e2e/test_flash_card_window.py +++ b/tests/e2e/test_flash_card_window.py @@ -5,27 +5,47 @@ """ import tkinter as tk -from collections.abc import Iterator +from collections.abc import Iterator, Sequence import pytest +from flash_card.application.use_cases.study_session import StudySession from flash_card.constants import ( BACKGROUND_COLOR, CARD_HEIGHT, CARD_WIDTH, FRONT_TEXT_COLOR, LANGUAGE_FONT, - PLACEHOLDER_LANGUAGE_NAME, - PLACEHOLDER_WORD, WINDOW_PADDING, WINDOW_TITLE, WORD_FONT, ) +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 import FlashCardView from flash_card.presentation.features.flash_card.flash_card_view_model import ( FlashCardViewModel, ) +CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne")) + + +def _first(cards: Sequence[Card]) -> Card: + return cards[0] + + +def _build_view(root: tk.Tk) -> FlashCardView: + deck = Deck(LanguagePair("ms", "da"), CARDS) + return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first))) + + +def _shown(view: FlashCardView) -> tuple[str, str]: + return ( + str(view.canvas.itemcget(view.language_text_id, "text")), + str(view.canvas.itemcget(view.word_text_id, "text")), + ) + @pytest.fixture(scope="module") def window() -> Iterator[tuple[tk.Tk, FlashCardView]]: @@ -35,7 +55,7 @@ def window() -> Iterator[tuple[tk.Tk, FlashCardView]]: root = tk.Tk() except tk.TclError: pytest.skip("no display available for Tk") - view = FlashCardView(root, FlashCardViewModel()) + view = _build_view(root) root.update_idletasks() yield root, view root.destroy() @@ -72,14 +92,14 @@ def test_canvas_holds_one_image_and_two_texts( assert len(types) == 3 -def test_texts_show_placeholders_with_font_and_colour( +def test_texts_show_first_card_with_font_and_colour( window: tuple[tk.Tk, FlashCardView], ) -> None: _root, view = window canvas = view.canvas expected = [ - (view.language_text_id, PLACEHOLDER_LANGUAGE_NAME, LANGUAGE_FONT), - (view.word_text_id, PLACEHOLDER_WORD, WORD_FONT), + (view.language_text_id, "Malay", LANGUAGE_FONT), + (view.word_text_id, "aku", WORD_FONT), ] for item, text, font in expected: assert canvas.itemcget(item, "text") == text @@ -101,5 +121,16 @@ def test_buttons_sit_in_row_one_with_their_images( assert wrong_image assert right_image assert wrong_image != right_image - assert not str(view.wrong_button.cget("command")) - assert not str(view.right_button.cget("command")) + assert str(view.wrong_button.cget("command")) + assert str(view.right_button.cget("command")) + + +def test_each_button_press_shows_next_card( + window: tuple[tk.Tk, FlashCardView], +) -> None: + _root, view = window + assert _shown(view) == ("Malay", "aku") + view.wrong_button.invoke() + assert _shown(view) == ("Malay", "kau") + view.right_button.invoke() + assert _shown(view) == ("Malay", "aku") diff --git a/tests/integration/test_vocabulary_file.py b/tests/integration/test_vocabulary_file.py new file mode 100644 index 0000000..8b4584c --- /dev/null +++ b/tests/integration/test_vocabulary_file.py @@ -0,0 +1,31 @@ +"""Integration tests of the word list reader on the delivered file.""" + +from flash_card.application.use_cases.open_study_session import open_study_session +from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME +from flash_card.domain.entities.card import Card +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 + +PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME + + +def test_read_word_list_reads_real_file() -> None: + pair, cards = read_word_list(PATH) + assert pair == LanguagePair("ms", "da") + assert len(cards) == 1000 + assert cards[0] == Card("aku", "mig") + + +def test_open_study_session_yields_card_from_real_file() -> None: + repository = CsvVocabularyRepository(PATH) + cards = repository.load().cards + session = open_study_session(repository, lambda candidates: candidates[0]) + + card = session.next_card() + + assert card is not None + assert Card(card.front_text, card.back_text) in cards + assert (card.front_language, card.back_language) == ("ms", "da") diff --git a/tests/unit/application/test_open_study_session.py b/tests/unit/application/test_open_study_session.py new file mode 100644 index 0000000..0219628 --- /dev/null +++ b/tests/unit/application/test_open_study_session.py @@ -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) diff --git a/tests/unit/application/test_study_session.py b/tests/unit/application/test_study_session.py new file mode 100644 index 0000000..f4da9ce --- /dev/null +++ b/tests/unit/application/test_study_session.py @@ -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 diff --git a/tests/unit/domain/test_card.py b/tests/unit/domain/test_card.py new file mode 100644 index 0000000..9156d34 --- /dev/null +++ b/tests/unit/domain/test_card.py @@ -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 diff --git a/tests/unit/domain/test_deck.py b/tests/unit/domain/test_deck.py new file mode 100644 index 0000000..5fe33a0 --- /dev/null +++ b/tests/unit/domain/test_deck.py @@ -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 diff --git a/tests/unit/domain/test_vocabulary_errors.py b/tests/unit/domain/test_vocabulary_errors.py new file mode 100644 index 0000000..fdf2557 --- /dev/null +++ b/tests/unit/domain/test_vocabulary_errors.py @@ -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) diff --git a/tests/unit/infrastructure/test_csv_word_list.py b/tests/unit/infrastructure/test_csv_word_list.py new file mode 100644 index 0000000..eb53304 --- /dev/null +++ b/tests/unit/infrastructure/test_csv_word_list.py @@ -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() diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index edb1504..b8f2ff6 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -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 diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index 89fba6c..20a8f01 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -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" diff --git a/tests/unit/test_flash_card_view_model.py b/tests/unit/test_flash_card_view_model.py index 1d3c8d9..8b1c8d9 100644 --- a/tests/unit/test_flash_card_view_model.py +++ b/tests/unit/test_flash_card_view_model.py @@ -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