MIL-003: Step 2 - New flash cards

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

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
This commit is contained in:
2026-10-14 21:59:29 +08:00
parent c6c737ce40
commit 8d66c43eea
36 changed files with 1167 additions and 102 deletions
@@ -0,0 +1,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"}
## @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.
"""
+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.
@file app.py
@brief Creates the window, its view model and view, and runs the main loop.
"""
import tkinter as tk
from collections.abc import Sequence
from typing import NoReturn
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
def create_application() -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@return The root window and the view built on it.
"""
root = tk.Tk()
view = FlashCardView(root, FlashCardViewModel())
return root, view
def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for none.
@return The exit code, 0 on success.
"""
root, _view = create_application()
root.mainloop()
return 0
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, its view model and view, and runs the main loop.
"""
import argparse
import logging
import random
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from typing import NoReturn
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
def create_application(
data_dir: Path | None = None,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it.
@throws VocabularyError When the word list cannot be loaded.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
session = open_study_session(repository, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
return root, view
def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for those of the process.
@return The exit code, 0 on success and 1 when the word list is unusable.
"""
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir)
except VocabularyError as error:
_logger.error("%s", error)
return 1
root.mainloop()
return 0
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
@@ -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)
@@ -1,27 +1,69 @@
"""View model of the flash card screen.
@file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import.
"""
from flash_card.constants import PLACEHOLDER_LANGUAGE_NAME, PLACEHOLDER_WORD
class FlashCardViewModel:
"""Data the flash card view shows."""
@property
def language_name(self) -> str:
"""Return the language name shown on the card.
@return The display name of the language.
"""
return PLACEHOLDER_LANGUAGE_NAME
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The word text.
"""
return PLACEHOLDER_WORD
"""View model of the flash card screen.
@file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import.
"""
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class FlashCardViewModel:
"""Data the flash card view shows."""
def __init__(
self,
session: StudySession,
language_names: Mapping[str, str] = LANGUAGE_NAMES,
) -> None:
"""Create the view model and draw the first card.
@param session The study session that supplies the cards.
@param language_names Display names by language code.
"""
## @brief The study session that supplies the cards.
self._session = session
## @brief Display names by language code.
self._language_names = language_names
## @brief The card shown now, or None when none is left.
self._card: CardDto | None = session.next_card()
@property
def is_finished(self) -> bool:
"""Tell whether no card is left to show.
@return True when there is no card.
"""
return self._card is None
@property
def language_name(self) -> str:
"""Return the language name shown on the card.
@return The display name of the front language, upper case code when
unknown, or an empty string when no card is left.
"""
if self._card is None:
return ""
code = self._card.front_language
return self._language_names.get(code, code.upper())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The front text, or the finished message when no card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card.
@return Nothing.
"""
self._card = self._session.next_card()