Merge pull request 'MIL-003: Step 2 - New flash cards' (#40) from mil-003-step2-new-flash-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s

This commit was merged in pull request #40.
This commit is contained in:
2026-10-14 15:59:43 +02:00
36 changed files with 1168 additions and 102 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| DCD | Design Class Diagram | docs/dcd.md | 002 | | DCD | Design Class Diagram | docs/dcd.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | 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 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
+64
View File
@@ -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
+3
View File
@@ -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] | | 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-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | | 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 ## 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 [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 [RC-024]: ./reviews/rc-024-py-mil-002.md
[PY-MIL-002]: ../../src/flash_card [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)
+2 -6
View File
@@ -42,15 +42,11 @@ BACK_TEXT_COLOR: Final[str] = "white"
LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"} LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"}
## @brief Message shown when no word is left to learn. ## @brief Message shown when no word is left to learn.
FINISHED_MESSAGE: Final[str] = "Nothing 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. ## @brief Delimiter of the CSV files.
CSV_DELIMITER: Final[str] = ";" CSV_DELIMITER: Final[str] = ";"
## @brief Encoding of the CSV files. ## @brief Encoding of the CSV files; drops a leading byte order mark.
CSV_ENCODING: Final[str] = "utf-8" CSV_ENCODING: Final[str] = "utf-8-sig"
## @brief File name of the vocabulary. ## @brief File name of the vocabulary.
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
## @brief File name of the words still to learn. ## @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.
"""
+17
View File
@@ -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
+95
View File
@@ -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, ...]
+5
View File
@@ -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)
+77 -44
View File
@@ -1,44 +1,77 @@
"""Entry point and composition root of the flash card program. """Entry point and composition root of the flash card program.
@file app.py @file app.py
@brief Creates the window, its view model and view, and runs the main loop. @brief Creates the window, its view model and view, and runs the main loop.
""" """
import tkinter as tk import argparse
from collections.abc import Sequence import logging
from typing import NoReturn import random
import tkinter as tk
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView from collections.abc import Sequence
from flash_card.presentation.features.flash_card.flash_card_view_model import ( from pathlib import Path
FlashCardViewModel, 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
def create_application() -> tuple[tk.Tk, FlashCardView]: from flash_card.domain.errors.vocabulary_errors import VocabularyError
"""Create the root window and the flash card view. from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
@return The root window and the view built on it. )
""" from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
root = tk.Tk() from flash_card.presentation.features.flash_card.flash_card_view_model import (
view = FlashCardView(root, FlashCardViewModel()) FlashCardViewModel,
return root, view )
## @brief Logger of the entry point.
def main(argv: Sequence[str] | None = None) -> int: _logger = logging.getLogger(__name__)
"""Run the program and return its exit code.
@param argv Command line arguments, or None for none. def create_application(
@return The exit code, 0 on success. data_dir: Path | None = None,
""" ) -> tuple[tk.Tk, FlashCardView]:
root, _view = create_application() """Create the root window and the flash card view.
root.mainloop()
return 0 @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.
def run() -> NoReturn: """
"""Run the program and exit the interpreter with its exit code. folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
@return Never returns; the function always raises SystemExit. session = open_study_session(repository, random.choice)
@throws SystemExit Always, with the exit code of main. view_model = FlashCardViewModel(session)
""" root = tk.Tk()
raise SystemExit(main()) 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())
@@ -84,11 +84,30 @@ class FlashCardView:
## @brief Button for a word the user does not know. ## @brief Button for a word the user does not know.
self.wrong_button = tk.Button( 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) self.wrong_button.grid(row=1, column=0)
## @brief Button for a word the user knows. ## @brief Button for a word the user knows.
self.right_button = tk.Button( 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) 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)
@@ -1,27 +1,69 @@
"""View model of the flash card screen. """View model of the flash card screen.
@file flash_card_view_model.py @file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import. @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
class FlashCardViewModel: from flash_card.application.use_cases.study_session import StudySession
"""Data the flash card view shows.""" from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
@property
def language_name(self) -> str: class FlashCardViewModel:
"""Return the language name shown on the card. """Data the flash card view shows."""
@return The display name of the language. def __init__(
""" self,
return PLACEHOLDER_LANGUAGE_NAME session: StudySession,
language_names: Mapping[str, str] = LANGUAGE_NAMES,
@property ) -> None:
def text(self) -> str: """Create the view model and draw the first card.
"""Return the word shown on the card.
@param session The study session that supplies the cards.
@return The word text. @param language_names Display names by language code.
""" """
return PLACEHOLDER_WORD ## @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()
+40 -9
View File
@@ -5,27 +5,47 @@
""" """
import tkinter as tk import tkinter as tk
from collections.abc import Iterator from collections.abc import Iterator, Sequence
import pytest import pytest
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import ( from flash_card.constants import (
BACKGROUND_COLOR, BACKGROUND_COLOR,
CARD_HEIGHT, CARD_HEIGHT,
CARD_WIDTH, CARD_WIDTH,
FRONT_TEXT_COLOR, FRONT_TEXT_COLOR,
LANGUAGE_FONT, LANGUAGE_FONT,
PLACEHOLDER_LANGUAGE_NAME,
PLACEHOLDER_WORD,
WINDOW_PADDING, WINDOW_PADDING,
WINDOW_TITLE, WINDOW_TITLE,
WORD_FONT, 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 import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import ( from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel, 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") @pytest.fixture(scope="module")
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]: def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
@@ -35,7 +55,7 @@ def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
root = tk.Tk() root = tk.Tk()
except tk.TclError: except tk.TclError:
pytest.skip("no display available for Tk") pytest.skip("no display available for Tk")
view = FlashCardView(root, FlashCardViewModel()) view = _build_view(root)
root.update_idletasks() root.update_idletasks()
yield root, view yield root, view
root.destroy() root.destroy()
@@ -72,14 +92,14 @@ def test_canvas_holds_one_image_and_two_texts(
assert len(types) == 3 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], window: tuple[tk.Tk, FlashCardView],
) -> None: ) -> None:
_root, view = window _root, view = window
canvas = view.canvas canvas = view.canvas
expected = [ expected = [
(view.language_text_id, PLACEHOLDER_LANGUAGE_NAME, LANGUAGE_FONT), (view.language_text_id, "Malay", LANGUAGE_FONT),
(view.word_text_id, PLACEHOLDER_WORD, WORD_FONT), (view.word_text_id, "aku", WORD_FONT),
] ]
for item, text, font in expected: for item, text, font in expected:
assert canvas.itemcget(item, "text") == text assert canvas.itemcget(item, "text") == text
@@ -101,5 +121,16 @@ def test_buttons_sit_in_row_one_with_their_images(
assert wrong_image assert wrong_image
assert right_image assert right_image
assert wrong_image != right_image assert wrong_image != right_image
assert not str(view.wrong_button.cget("command")) assert str(view.wrong_button.cget("command"))
assert not str(view.right_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")
+31
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
"""Tests of the Card, LanguagePair and Vocabulary value types."""
import dataclasses
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.value_objects.language_pair import LanguagePair
def test_card_equals_card_with_same_texts() -> None:
assert Card("aku", "mig") == Card("aku", "mig")
def test_card_is_frozen() -> None:
card = Card("aku", "mig")
with pytest.raises(dataclasses.FrozenInstanceError):
card.front = "kau" # type: ignore[misc]
def test_language_pair_holds_codes() -> None:
pair = LanguagePair("ms", "da")
assert (pair.front_code, pair.back_code) == ("ms", "da")
def test_language_pair_is_frozen() -> None:
pair = LanguagePair("ms", "da")
with pytest.raises(dataclasses.FrozenInstanceError):
pair.front_code = "en" # type: ignore[misc]
def test_vocabulary_holds_pair_and_cards() -> None:
pair = LanguagePair("ms", "da")
cards = (Card("aku", "mig"),)
vocabulary = Vocabulary(pair, cards)
assert vocabulary.language_pair == pair
assert vocabulary.cards == cards
+73
View File
@@ -0,0 +1,73 @@
"""Tests of the Deck entity."""
from collections.abc import Sequence
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair
PAIR = LanguagePair("ms", "da")
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def test_from_vocabulary_holds_all_cards() -> None:
deck = Deck.from_vocabulary(Vocabulary(PAIR, (A, B)))
assert deck.cards == (A, B)
assert deck.language_pair == PAIR
assert len(deck) == 2
def test_cards_returns_copy_when_deck_changes_later() -> None:
deck = Deck(PAIR, [A])
snapshot = deck.cards
deck.remove(A)
assert snapshot == (A,)
assert deck.is_empty
def test_draw_returns_chosen_card() -> None:
assert Deck(PAIR, [A, B]).draw(_first) == A
def test_draw_avoids_excluded_card_when_another_exists() -> None:
assert Deck(PAIR, [A, B]).draw(_first, exclude=A) == B
def test_draw_returns_excluded_card_when_it_is_the_only_one() -> None:
assert Deck(PAIR, [A]).draw(_first, exclude=A) == A
def test_draw_keeps_card_in_deck() -> None:
deck = Deck(PAIR, [A, B])
deck.draw(_first)
assert len(deck) == 2
def test_draw_raises_when_deck_is_empty() -> None:
with pytest.raises(EmptyDeckError):
Deck(PAIR, []).draw(_first)
def test_remove_deletes_first_equal_card() -> None:
deck = Deck(PAIR, [A, B, A])
deck.remove(A)
assert deck.cards == (B, A)
def test_remove_raises_when_card_is_absent() -> None:
with pytest.raises(ValueError):
Deck(PAIR, [A]).remove(B)
def test_is_empty_reflects_cards() -> None:
assert Deck(PAIR, []).is_empty
assert not Deck(PAIR, [A]).is_empty
@@ -0,0 +1,21 @@
"""Tests of the vocabulary error hierarchy."""
import pytest
from flash_card.domain.errors.vocabulary_errors import (
EmptyDeckError,
VocabularyError,
VocabularyFormatError,
VocabularyNotFoundError,
)
@pytest.mark.parametrize(
"error", [VocabularyFormatError, VocabularyNotFoundError, EmptyDeckError]
)
def test_error_is_a_vocabulary_error(error: type[VocabularyError]) -> None:
assert issubclass(error, VocabularyError)
def test_vocabulary_error_is_an_exception() -> None:
assert issubclass(VocabularyError, Exception)
@@ -0,0 +1,95 @@
"""Tests of read_word_list and CsvVocabularyRepository."""
from pathlib import Path
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import (
VocabularyFormatError,
VocabularyNotFoundError,
)
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
def _write(path: Path, content: str) -> Path:
path.write_bytes(content.encode("utf-8"))
return path
def test_read_word_list_returns_pair_and_cards(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\nkau;du\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_drops_byte_order_mark(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\r\naku;mig\r\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_skips_blank_lines(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "\nms;da\n\naku;mig\n \n;\nkau;du\n\n")
_pair, cards = read_word_list(path)
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_strips_whitespace(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", " ms ; da \n aku ; mig \n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_raises_not_found_when_file_is_missing(
tmp_path: Path,
) -> None:
with pytest.raises(VocabularyNotFoundError) as info:
read_word_list(tmp_path / "missing.csv")
assert isinstance(info.value.__cause__, FileNotFoundError)
def test_read_word_list_raises_format_error_when_file_is_empty(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "\n\n")
with pytest.raises(VocabularyFormatError, match="empty"):
read_word_list(path)
@pytest.mark.parametrize("header", ["ms\n", "ms;da;en\n", "ms;\n"])
def test_read_word_list_raises_format_error_for_bad_header(
tmp_path: Path, header: str
) -> None:
path = _write(tmp_path / "w.csv", header + "aku;mig\n")
with pytest.raises(VocabularyFormatError, match="line 1"):
read_word_list(path)
def test_read_word_list_raises_format_error_with_line_number_for_bad_row(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n\nkau;du;extra\n")
with pytest.raises(VocabularyFormatError, match="line 4") as info:
read_word_list(path)
assert str(path) in str(info.value)
def test_repository_load_returns_vocabulary(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n")
vocabulary = CsvVocabularyRepository(path).load()
assert vocabulary.language_pair == LanguagePair("ms", "da")
assert vocabulary.cards == (Card("aku", "mig"),)
def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\n")
with pytest.raises(VocabularyFormatError):
CsvVocabularyRepository(path).load()
+19 -3
View File
@@ -4,6 +4,8 @@
@brief Checks main of flash_card.presentation.app without opening a window. @brief Checks main of flash_card.presentation.app without opening a window.
""" """
from pathlib import Path
import pytest import pytest
from flash_card.presentation import app from flash_card.presentation import app
@@ -18,10 +20,24 @@ class _FakeRoot:
def test_main_runs_main_loop_and_returns_zero( def test_main_runs_main_loop_and_returns_zero(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> None:
root = _FakeRoot() 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 root.mainloop_calls == 1
assert seen == [tmp_path]
def test_main_returns_one_when_word_list_is_missing(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
assert "word list not found" in caplog.text
+4
View File
@@ -31,3 +31,7 @@ def test_language_names_cover_ms_and_da() -> None:
def test_project_root_contains_pyproject() -> None: def test_project_root_contains_pyproject() -> None:
assert (constants.PROJECT_ROOT / "pyproject.toml").is_file() assert (constants.PROJECT_ROOT / "pyproject.toml").is_file()
def test_csv_encoding_drops_byte_order_mark() -> None:
assert constants.CSV_ENCODING == "utf-8-sig"
+45 -10
View File
@@ -1,18 +1,53 @@
"""Tests of the flash card view model. """Tests of the flash card view model over a real session and in-memory deck."""
@file test_flash_card_view_model.py from collections.abc import Sequence
@brief Checks the placeholder texts of FlashCardViewModel.
"""
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 ( from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel, FlashCardViewModel,
) )
A = Card("aku", "mig")
def test_language_name_returns_placeholder() -> None: B = Card("kau", "du")
assert FlashCardViewModel().language_name == PLACEHOLDER_LANGUAGE_NAME
def test_text_returns_placeholder_word() -> None: def _first(cards: Sequence[Card]) -> Card:
assert FlashCardViewModel().text == PLACEHOLDER_WORD 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