MIL-005: Step 4 - Save progress

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

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
Task: MIL-005#5
This commit is contained in:
2026-10-14 22:29:58 +08:00
parent da4e9382cf
commit 8dbaaa3059
22 changed files with 1367 additions and 669 deletions
+5
View File
@@ -0,0 +1,5 @@
*.py text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.csv text eol=lf
*.png binary
+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 |
| 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 | 027 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages
+63
View File
@@ -0,0 +1,63 @@
# RC-027: Review of PY-MIL-005
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-027 |
| CrossReference | [PY-MIL-005], [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-005]
- 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 | PEP 8 casing holds; 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 are the dictionary's IT terms and the assignment's (`is_known`, `mark_known`, `ProgressRepository`). |
| 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 47 files with no suppressions; `[tool.ruff.format] line-ending = "lf"` and `.gitattributes` fix the line endings. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 33 source files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | A malformed progress file raises `VocabularyFormatError` and is never overwritten; the temporary file is removed when a write fails (re-raise, no swallow); no bare `except`. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers | Pass | Reading uses `with`; the write goes through a temporary file that is replaced atomically. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed members 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`; no secret or personal data is logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `ProgressRepository`, `CsvProgressRepository`, `StudySession.mark_known` and `is_known` are in DCD-001 and OC-001 (`markKnown`); `open_study_session` takes no speech port until MIL-006, as DCD-001 shows for the final design. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 107 tests (6 consecutive full runs all green) include the assignment's 3 record example, restart, round trip, finished state and e2e on the real window over `tmp_path`; none uses the network. |
| 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. |
| 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 | Reader and writer share `CSV_DELIMITER`, `CSV_ENCODING` and the row width in `csv_word_list.py`; the removal rule is only in `Deck.remove`. |
| 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; the `ProgressRepository` Protocol is in `domain`, its adapter in `infrastructure`. |
| 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 | The use case depends on the Protocol and receives the adapter; the view model sequences two session calls only. |
| 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 | New files are in the layer 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 follow the rule. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-005 Go criteria: 1 the 3 record example leaves the other two cards in order with the header (integration test); 2 a start uses the progress file when it exists and the word list otherwise (integration test); 3 the wrong button never writes (unit and e2e); 4 the last card known gives a finished state (unit); 5 a malformed progress file raises `VocabularyFormatError` and stays untouched (unit); 6 round trip readable as a word list (integration); 7 this record, `pytest` 107 passed. A manual run on a copy of `ms-da.csv` removed the known word and wrote 999 cards. Defect found and fixed in this milestone: a flaky `TclError` caused by pytest's descriptor capture and Tcl file handles; the view now loads image bytes with `Path.read_bytes()` and `PhotoImage(data=...)`. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-005]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+3
View File
@@ -49,6 +49,7 @@ updated whenever an artifact instance is created or reviewed.
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
## Coverage Notes
@@ -111,3 +112,5 @@ updated whenever an artifact instance is created or reviewed.
[PY-MIL-003]: ../../src/flash_card
[RC-026]: ./reviews/rc-026-py-mil-004.md
[PY-MIL-004]: ../../src/flash_card
[RC-027]: ./reviews/rc-027-py-mil-005.md
[PY-MIL-005]: ../../src/flash_card
+3
View File
@@ -45,6 +45,9 @@ line-length = 88
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.format]
line-ending = "lf"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"]
ignore = ["D203", "D213"]
@@ -9,19 +9,25 @@ 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.progress_repository import ProgressRepository
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
def open_study_session(
vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
choose: Callable[[Sequence[Card]], Card],
) -> StudySession:
"""Load the word list and build a session over all its cards.
"""Build a session over the saved words to learn, else the whole word list.
@param vocabulary_repository Source of the word list.
@param vocabulary_repository Source of the delivered word list.
@param progress_repository Store of the words still to learn.
@param choose Picks one card from a non-empty sequence.
@return The new session.
@throws VocabularyError When the word list cannot be loaded.
@throws VocabularyError When the saved progress, or the word list when no
progress exists, cannot be loaded.
"""
deck = progress_repository.load()
if deck is None:
deck = Deck.from_vocabulary(vocabulary_repository.load())
return StudySession(deck, choose)
return StudySession(deck, choose, progress_repository)
@@ -9,17 +9,26 @@ 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
from flash_card.domain.repositories.progress_repository import ProgressRepository
class StudySession:
"""Draws cards from a deck, one after another."""
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None:
def __init__(
self,
deck: Deck,
choose: Callable[[Sequence[Card]], Card],
progress: ProgressRepository,
) -> None:
"""Create a session.
@param deck The cards still to learn.
@param choose Picks one card from a non-empty sequence.
@param progress Store of the words still to learn.
"""
## @brief Store of the words still to learn.
self._progress = progress
## @brief The cards still to learn.
self._deck = deck
## @brief Picks one card from a non-empty sequence.
@@ -54,6 +63,19 @@ class StudySession:
self._current = self._deck.draw(self._choose, exclude=self._current)
return self._to_dto(self._current)
def mark_known(self) -> None:
"""Remove the current card from the deck and save the remaining cards.
Does nothing and saves nothing when no card is shown.
@return Nothing.
"""
if self._current is None:
return
self._deck.remove(self._current)
self._progress.save(self._deck)
self._current = None
def _to_dto(self, card: Card | None) -> CardDto | None:
"""Map a card to its DTO.
@@ -0,0 +1,29 @@
"""Port to the words still to learn.
@file progress_repository.py
@brief The ProgressRepository interface.
"""
from typing import Protocol
from flash_card.domain.entities.deck import Deck
class ProgressRepository(Protocol):
"""Store of the words still to learn."""
def load(self) -> Deck | None:
"""Load the saved words to learn.
@return The deck, or None when no progress was saved.
@throws VocabularyError When the saved progress is unusable.
"""
...
def save(self, deck: Deck) -> None:
"""Save the words still to learn.
@param deck The cards still to learn.
@return Nothing.
"""
...
@@ -0,0 +1,44 @@
"""CSV implementation of the progress port.
@file csv_progress_repository.py
@brief The CsvProgressRepository adapter.
"""
from pathlib import Path
from flash_card.domain.entities.deck import Deck
from flash_card.infrastructure.persistence.csv_word_list import (
read_word_list,
write_word_list,
)
class CsvProgressRepository:
"""Keeps the words still to learn in a CSV file."""
def __init__(self, path: Path) -> None:
"""Create the repository.
@param path The words to learn file.
"""
## @brief The words to learn file.
self._path = path
def load(self) -> Deck | None:
"""Load the saved words to learn.
@return The deck, possibly empty, or None when the file does not exist.
@throws VocabularyFormatError When the file is malformed; it is left as is.
"""
if not self._path.exists():
return None
pair, cards = read_word_list(self._path)
return Deck(pair, cards)
def save(self, deck: Deck) -> None:
"""Save the words still to learn.
@param deck The cards still to learn.
@return Nothing.
"""
write_word_list(self._path, deck.language_pair, deck.cards)
@@ -1,10 +1,12 @@
"""Reading of the word list CSV file.
"""Reading and writing of the word list CSV file.
@file csv_word_list.py
@brief Reads a semicolon separated word list with a language code header.
@brief Reads and writes a semicolon separated word list with a language code
header; the format is defined once here.
"""
import csv
from collections.abc import Iterable
from pathlib import Path
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
@@ -84,3 +86,31 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
if pair is None:
raise _format_error(path, 1, "file is empty")
return pair, tuple(cards)
def write_word_list(
path: Path, language_pair: LanguagePair, cards: Iterable[Card]
) -> None:
"""Write a word list file atomically.
The rows go to a temporary file in the same folder, which then replaces the
target, so a crash never leaves a half written file.
@param path The CSV file to create or replace.
@param language_pair The languages, written as the header row.
@param cards The cards, written one row each in the given order.
@return Nothing.
@throws OSError When the file cannot be written.
"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
rows = [(language_pair.front_code, language_pair.back_code)]
rows.extend((card.front, card.back) for card in cards)
try:
with temporary.open("w", encoding=CSV_ENCODING, newline="") as file:
writer = csv.writer(file, delimiter=CSV_DELIMITER, lineterminator="\n")
writer.writerows(rows)
temporary.replace(path)
except BaseException:
temporary.unlink(missing_ok=True)
raise
+11 -3
View File
@@ -13,8 +13,15 @@ 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.constants import (
DEFAULT_DATA_DIR,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
@@ -34,11 +41,12 @@ def create_application(
@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.
@throws VocabularyError When the word list or saved progress is unusable.
"""
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)
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
session = open_study_session(repository, progress, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
@@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
)
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
"""Load an asset image.
The bytes are read here and handed to Tk as data, so the image does not
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
start, and a process that swaps them (pytest fd capture) makes the first
Tcl file open fail with an empty error.
@param root The Tk root window that owns the image.
@param name The file name in the assets folder.
@return The image; the caller must keep a reference.
"""
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
class FlashCardView:
"""The flash card window: a card and the wrong and right buttons."""
@@ -50,13 +65,13 @@ class FlashCardView:
# Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Card front image, kept so Tk does not drop it.
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
## @brief Card back image, kept so Tk does not drop it.
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it.
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
## @brief Right button image, kept so Tk does not drop it.
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
## @brief Canvas that draws the card.
self.canvas = tk.Canvas(
@@ -103,7 +118,7 @@ class FlashCardView:
image=self._right_image,
highlightthickness=0,
bd=0,
command=self.next_card,
command=self.is_known,
)
self.right_button.grid(row=1, column=1)
@@ -137,6 +152,16 @@ class FlashCardView:
self._show_card()
self._start_flip_timer()
def is_known(self) -> None:
"""Mark the card as known, show the next card front, restart the timer.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.is_known()
self._show_card()
self._start_flip_timer()
def flip_card(self) -> None:
"""Show the back of the current card.
@@ -88,10 +88,25 @@ class FlashCardViewModel:
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card and show its front.
"""Draw the next card and show its front; does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._card = self._session.next_card()
self._side = Side.FRONT
def is_known(self) -> None:
"""Mark the card as known, save the progress and show the next card.
Does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._session.mark_known()
self._card = self._session.next_card()
self._side = Side.FRONT
+86 -4
View File
@@ -7,15 +7,18 @@
import time
import tkinter as tk
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
import pytest
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import (
BACK_TEXT_COLOR,
BACKGROUND_COLOR,
CARD_HEIGHT,
CARD_WIDTH,
FINISHED_MESSAGE,
FLIP_DELAY_MS,
FRONT_TEXT_COLOR,
LANGUAGE_FONT,
@@ -26,6 +29,12 @@ from flash_card.constants import (
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.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.presentation.features.flash_card import flash_card_view
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
@@ -39,9 +48,20 @@ def _first(cards: Sequence[Card]) -> Card:
return cards[0]
class _MemoryProgress:
"""Progress store that keeps nothing: the shared window never deletes cards."""
def load(self) -> Deck | None:
return None
def save(self, deck: Deck) -> None:
return None
def _build_view(root: tk.Tk) -> FlashCardView:
deck = Deck(LanguagePair("ms", "da"), CARDS)
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
session = StudySession(deck, _first, _MemoryProgress())
return FlashCardView(root, FlashCardViewModel(session))
def _shown(view: FlashCardView) -> tuple[str, str]:
@@ -129,14 +149,14 @@ def test_buttons_sit_in_row_one_with_their_images(
assert str(view.right_button.cget("command"))
def test_each_button_press_shows_next_card(
def test_wrong_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()
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "aku")
@@ -195,7 +215,7 @@ def test_next_card_after_flip_shows_front_again(
root, view = window
_reset(root, view)
view.flip_card()
view.right_button.invoke()
view.wrong_button.invoke()
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
@@ -245,3 +265,65 @@ def test_real_timer_flips_card_after_delay(
assert _image(view) == str(view.card_back_image)
assert _shown(view)[0] == "Danish"
_reset(root, view)
HEADER_ONLY = "ms;da\n"
@pytest.fixture
def files_view(
window: tuple[tk.Tk, FlashCardView], tmp_path: Path
) -> Iterator[tuple[FlashCardView, Path]]:
"""A second view on the shared root, over real repositories in tmp_path."""
root, _shared = window
words = tmp_path / "words.csv"
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
progress = tmp_path / "words_to_learn.csv"
session = open_study_session(
CsvVocabularyRepository(words), CsvProgressRepository(progress), _first
)
view = FlashCardView(root, FlashCardViewModel(session))
yield view, progress
if view.flip_timer is not None:
root.after_cancel(view.flip_timer)
for widget in (view.canvas, view.wrong_button, view.right_button):
widget.destroy()
def _saved(progress: Path) -> str:
return progress.read_bytes().decode("utf-8-sig")
def test_right_button_removes_card_and_writes_progress_file(
files_view: tuple[FlashCardView, Path],
) -> None:
view, progress = files_view
assert _shown(view) == ("Malay", "aku")
view.right_button.invoke()
assert _shown(view) == ("Malay", "kau")
assert _saved(progress) == "ms;da\nkau;du\nini;denne\n"
def test_wrong_button_shows_next_card_and_leaves_progress_file_absent(
files_view: tuple[FlashCardView, Path],
) -> None:
view, progress = files_view
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "kau")
assert not progress.exists()
def test_buttons_do_nothing_visible_when_finished(
files_view: tuple[FlashCardView, Path],
) -> None:
view, progress = files_view
for _ in range(3):
view.right_button.invoke()
assert _shown(view) == ("", FINISHED_MESSAGE)
assert view.flip_timer is None
assert _saved(progress) == HEADER_ONLY
view.right_button.invoke()
view.wrong_button.invoke()
assert _shown(view) == ("", FINISHED_MESSAGE)
assert view.flip_timer is None
assert _saved(progress) == HEADER_ONLY
+96
View File
@@ -0,0 +1,96 @@
"""Integration tests of saving and resuming progress over real CSV files."""
from collections.abc import Callable, Sequence
from pathlib import Path
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.application.use_cases.study_session import StudySession
from flash_card.domain.entities.card import Card
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
WORDS = "fr;en\nchaque;each\nparlé;speak\narrivé;come\n"
Choose = Callable[[Sequence[Card]], Card]
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _choose_front(front: str) -> Choose:
def choose(cards: Sequence[Card]) -> Card:
return next(card for card in cards if card.front == front)
return choose
def _open(folder: Path, choose: Choose = _first) -> StudySession:
return open_study_session(
CsvVocabularyRepository(folder / "words.csv"),
CsvProgressRepository(folder / "words_to_learn.csv"),
choose,
)
def _setup(folder: Path) -> Path:
(folder / "words.csv").write_text(WORDS, encoding="utf-8")
return folder / "words_to_learn.csv"
def _text(path: Path) -> str:
return path.read_bytes().decode("utf-8-sig")
def test_mark_known_writes_remaining_cards_in_order(tmp_path: Path) -> None:
progress = _setup(tmp_path)
session = _open(tmp_path, _choose_front("parlé"))
session.next_card()
session.mark_known()
assert _text(progress) == "fr;en\nchaque;each\narrivé;come\n"
def test_restart_draws_only_from_progress_file(tmp_path: Path) -> None:
_setup(tmp_path)
first = _open(tmp_path, _choose_front("parlé"))
first.next_card()
first.mark_known()
second = _open(tmp_path)
fronts: set[str] = set()
for _ in range(6):
card = second.next_card()
assert card is not None
fronts.add(card.front_text)
assert second.remaining == 2
assert fronts == {"chaque", "arrivé"}
def test_progress_file_is_readable_as_vocabulary(tmp_path: Path) -> None:
progress = _setup(tmp_path)
session = _open(tmp_path, _choose_front("chaque"))
session.next_card()
session.mark_known()
vocabulary = CsvVocabularyRepository(progress).load()
assert vocabulary.cards == (Card("parlé", "speak"), Card("arrivé", "come"))
def test_header_only_progress_file_gives_finished_session(tmp_path: Path) -> None:
progress = _setup(tmp_path)
progress.write_text("fr;en\n", encoding="utf-8")
session = _open(tmp_path)
assert session.remaining == 0
assert session.next_card() is None
def test_unknown_card_never_creates_progress_file(tmp_path: Path) -> None:
progress = _setup(tmp_path)
session = _open(tmp_path)
session.next_card()
session.next_card()
assert not progress.exists()
+13 -3
View File
@@ -1,9 +1,18 @@
"""Integration tests of the word list reader on the delivered file."""
from pathlib import Path
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.constants import (
DEFAULT_DATA_DIR,
PROGRESS_FILE_NAME,
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_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
@@ -19,10 +28,11 @@ def test_read_word_list_reads_real_file() -> None:
assert cards[0] == Card("aku", "mig")
def test_open_study_session_yields_card_from_real_file() -> None:
def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
repository = CsvVocabularyRepository(PATH)
cards = repository.load().cards
session = open_study_session(repository, lambda candidates: candidates[0])
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
session = open_study_session(repository, progress, lambda candidates: candidates[0])
card = session.next_card()
@@ -7,6 +7,7 @@ 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.deck import Deck
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
@@ -20,6 +21,18 @@ class _FakeRepository:
return self._vocabulary
class _RecordingProgress:
def __init__(self, stored: Deck | None = None) -> None:
self.stored = stored
self.saved: list[tuple[Card, ...]] = []
def load(self) -> Deck | None:
return self.stored
def save(self, deck: Deck) -> None:
self.saved.append(deck.cards)
class _MissingRepository:
def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing")
@@ -33,7 +46,7 @@ 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)
session = open_study_session(repository, _RecordingProgress(), _first)
assert session.remaining == 2
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
@@ -41,4 +54,33 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError):
open_study_session(_MissingRepository(), _first)
open_study_session(_MissingRepository(), _RecordingProgress(), _first)
def test_open_study_session_prefers_saved_progress() -> None:
pair = LanguagePair("ms", "da")
vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du")))
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
assert session.remaining == 1
assert session.next_card() == CardDto("kau", "du", "ms", "da")
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
session = open_study_session(_MissingRepository(), progress, _first)
assert session.remaining == 0
def test_open_study_session_never_saves_on_start() -> None:
vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),))
progress = _RecordingProgress()
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
session.next_card()
assert progress.saved == []
+49 -2
View File
@@ -17,8 +17,20 @@ def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(*cards: Card) -> StudySession:
return StudySession(Deck(PAIR, cards), _first)
class _RecordingProgress:
def __init__(self) -> None:
self.saved: list[tuple[Card, ...]] = []
def load(self) -> Deck | None:
return None
def save(self, deck: Deck) -> None:
self.saved.append(deck.cards)
def _session(*cards: Card, progress: _RecordingProgress | None = None) -> StudySession:
store = _RecordingProgress() if progress is None else progress
return StudySession(Deck(PAIR, cards), _first, store)
def test_current_card_is_none_before_first_draw() -> None:
@@ -58,3 +70,38 @@ def test_remaining_counts_cards_in_deck() -> None:
session = _session(A, B)
session.next_card()
assert session.remaining == 2
def test_mark_known_removes_current_card_and_saves_remaining_once() -> None:
progress = _RecordingProgress()
session = _session(A, B, progress=progress)
session.next_card()
session.mark_known()
assert session.remaining == 1
assert session.current_card is None
assert progress.saved == [(B,)]
def test_mark_known_saves_nothing_when_no_current_card() -> None:
progress = _RecordingProgress()
session = _session(A, progress=progress)
session.mark_known()
assert session.remaining == 1
assert progress.saved == []
def test_mark_known_on_last_card_leaves_session_finished() -> None:
progress = _RecordingProgress()
session = _session(A, progress=progress)
session.next_card()
session.mark_known()
assert progress.saved == [()]
assert session.next_card() is None
def test_next_card_never_saves() -> None:
progress = _RecordingProgress()
session = _session(A, B, progress=progress)
session.next_card()
session.next_card()
assert progress.saved == []
@@ -0,0 +1,112 @@
"""Tests of write_word_list and CsvProgressRepository."""
from pathlib import Path
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import (
read_word_list,
write_word_list,
)
PAIR = LanguagePair("ms", "da")
CARDS = (Card("aku", "mig"), Card("kau", "du"))
def test_write_word_list_round_trips_through_read_word_list(tmp_path: Path) -> None:
path = tmp_path / "w.csv"
write_word_list(path, PAIR, CARDS)
assert read_word_list(path) == (PAIR, CARDS)
def test_write_word_list_writes_header_and_rows_with_semicolon(
tmp_path: Path,
) -> None:
path = tmp_path / "w.csv"
write_word_list(path, PAIR, CARDS)
raw = path.read_bytes()
assert raw.decode("utf-8-sig") == "ms;da\naku;mig\nkau;du\n"
assert b"\r" not in raw
def test_write_word_list_keeps_non_ascii_text(tmp_path: Path) -> None:
path = tmp_path / "w.csv"
cards = (Card("chaque", "each"), Card("parlé", "speak"))
write_word_list(path, LanguagePair("fr", "en"), cards)
assert read_word_list(path)[1] == cards
def test_write_word_list_writes_header_only_for_no_cards(tmp_path: Path) -> None:
path = tmp_path / "w.csv"
write_word_list(path, PAIR, ())
assert read_word_list(path) == (PAIR, ())
def test_write_word_list_creates_parent_folder(tmp_path: Path) -> None:
path = tmp_path / "data" / "deep" / "w.csv"
write_word_list(path, PAIR, CARDS)
assert path.is_file()
def test_write_word_list_replaces_file_and_leaves_no_temporary_file(
tmp_path: Path,
) -> None:
path = tmp_path / "w.csv"
write_word_list(path, PAIR, CARDS)
write_word_list(path, PAIR, CARDS[:1])
assert read_word_list(path)[1] == CARDS[:1]
assert [item.name for item in tmp_path.iterdir()] == ["w.csv"]
def test_write_word_list_keeps_old_file_and_no_temporary_when_write_fails(
tmp_path: Path,
) -> None:
path = tmp_path / "w.csv"
write_word_list(path, PAIR, CARDS)
unencodable = (Card("aku", "\ud800"),)
with pytest.raises(UnicodeEncodeError):
write_word_list(path, PAIR, unencodable)
assert read_word_list(path)[1] == CARDS
assert [item.name for item in tmp_path.iterdir()] == ["w.csv"]
def test_progress_load_returns_none_when_file_is_missing(tmp_path: Path) -> None:
assert CsvProgressRepository(tmp_path / "p.csv").load() is None
def test_progress_load_returns_empty_deck_for_header_only_file(
tmp_path: Path,
) -> None:
path = tmp_path / "p.csv"
path.write_text("ms;da\n", encoding="utf-8")
deck = CsvProgressRepository(path).load()
assert deck is not None
assert deck.is_empty
assert deck.language_pair == PAIR
def test_progress_load_raises_format_error_and_keeps_malformed_file(
tmp_path: Path,
) -> None:
path = tmp_path / "p.csv"
content = b"ms;da\naku;mig;extra\n"
path.write_bytes(content)
with pytest.raises(VocabularyFormatError):
CsvProgressRepository(path).load()
assert path.read_bytes() == content
def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
repository = CsvProgressRepository(tmp_path / "p.csv")
repository.save(Deck(PAIR, CARDS))
deck = repository.load()
assert deck is not None
assert deck.cards == CARDS
assert deck.language_pair == PAIR
+58 -2
View File
@@ -20,8 +20,22 @@ def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(code: str, *cards: Card) -> StudySession:
return StudySession(Deck(LanguagePair(code, "da"), cards), _first)
class _RecordingProgress:
def __init__(self) -> None:
self.saved: list[tuple[Card, ...]] = []
def load(self) -> Deck | None:
return None
def save(self, deck: Deck) -> None:
self.saved.append(deck.cards)
def _session(
code: str, *cards: Card, progress: _RecordingProgress | None = None
) -> StudySession:
store = _RecordingProgress() if progress is None else progress
return StudySession(Deck(LanguagePair(code, "da"), cards), _first, store)
def test_view_model_draws_first_card_on_construction() -> None:
@@ -90,3 +104,45 @@ def test_next_card_resets_side_to_front() -> None:
assert view_model.side is Side.FRONT
assert view_model.language_name == "Malay"
assert view_model.text == "kau"
def test_is_known_saves_remaining_and_shows_next_front() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
view_model.flip_card()
view_model.is_known()
assert progress.saved == [(B,)]
assert view_model.side is Side.FRONT
assert view_model.text == "kau"
assert not view_model.is_finished
def test_is_known_on_last_card_finishes() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, progress=progress))
view_model.is_known()
assert view_model.is_finished
assert view_model.text == FINISHED_MESSAGE
assert progress.saved == [()]
def test_is_known_does_nothing_when_finished() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", progress=progress))
view_model.is_known()
assert view_model.is_finished
assert progress.saved == []
def test_next_card_does_nothing_when_finished() -> None:
view_model = FlashCardViewModel(_session("ms"))
view_model.next_card()
assert view_model.is_finished
assert view_model.text == FINISHED_MESSAGE
def test_next_card_never_saves() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
view_model.next_card()
assert progress.saved == []