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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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 | pending |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application data transfer objects.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the plain data that crosses to the presentation layer.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application use cases.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the use cases of the flash card program.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the entities of the flash card program.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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, ...]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain errors.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the errors the domain rules raise.
|
||||
"""
|
||||
@@ -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."""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain repository ports.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the repository interfaces of the domain.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain value objects.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the immutable value objects of the domain.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Persistence adapters.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the adapters that read and write files.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -4,33 +4,66 @@
|
||||
@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() -> tuple[tk.Tk, FlashCardView]:
|
||||
|
||||
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, FlashCardViewModel())
|
||||
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 none.
|
||||
@return The exit code, 0 on success.
|
||||
@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.
|
||||
"""
|
||||
root, _view = create_application()
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,24 +4,66 @@
|
||||
@brief Exposes the texts shown on the card; free of any Tkinter import.
|
||||
"""
|
||||
|
||||
from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD
|
||||
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 language.
|
||||
@return The display name of the front language, upper case code when
|
||||
unknown, or an empty string when no card is left.
|
||||
"""
|
||||
return PLACEHOLDER_LANGUAGE_NAME
|
||||
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 word text.
|
||||
@return The front text, or the finished message when no card is left.
|
||||
"""
|
||||
return PLACEHOLDER_WORD
|
||||
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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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