Compare commits

...
Author SHA1 Message Date
Tirsvad 9e56a6b787 Resolve pending commit links of MIL-005 2026-10-14 22:30:05 +08:00
Tirsvad 8dbaaa3059 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
2026-10-14 22:29:58 +08:00
Tirsvad da4e9382cf Merge pull request 'MIL-004: Step 3 - Flip the cards' (#41) from mil-004-step3-flip-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
2026-10-14 16:05:58 +02:00
Tirsvad 83fb0b1ae8 Resolve pending commit links of MIL-004 2026-10-14 22:05:52 +08:00
Tirsvad 02e0c2c55e MIL-004: Step 3 - Flip the cards
Review record RC-026 (Go, autonomous mode, not independent).

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
2026-10-14 22:05:48 +08:00
Tirsvad 9ecae3e6b2 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
2026-10-14 15:59:43 +02:00
23 changed files with 1890 additions and 871 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 | | 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 | 026 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 |
| 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-026: Review of PY-MIL-004
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-026 |
| CrossReference | [PY-MIL-004], [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 | [02e0c2c] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-004]
- 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 assignment's (`flip_card`, `flip_timer`, `next_card`) and the IT terms of the dictionary; `Side` states its purpose. |
| 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 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 | No exception handling was added; none is bare. |
| 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 | No file or connection is opened in this change. |
| 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 logging needed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `Side`, `flip_card`, `flip_timer` and the view's timer follow DCD-001 and SD-001 (`flip_card` row of Method Traceability, timer step of SSD-001). |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 76 tests: view model sides, finished state, flip rules; e2e front and back state, recording scheduler, real 50 ms timer; no network, no order dependency (state reset in each test). |
| 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 | The side rule exists once in the view model; image and colour names come from `constants.py`; `_show_card` is the only place that draws a card. |
| 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 view model imports no Tkinter. |
| 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 view model holds the side rule and the view only draws; a new side would extend `Side` and `_show_card`. |
| 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 | Files stay in `presentation/features/flash_card/`; tests in `unit` and `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 | Names follow the rule; no new file contains a space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-004 Go criteria: 1 `flip_card` changes side, language name and text (unit test); 2 `next_card` returns to the front (unit test); 3 `after(3000, flip_card)` is scheduled and the earlier id is cancelled first (recording scheduler e2e test); 4 back image and white text, front image and black text (e2e); 5 `PhotoImage` objects are attributes created in `__init__`, none in a callback (grep); 6 this record, `pytest` 76 passed. A manual run with a 100 ms delay showed `Danish` and white text. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-004]: ../../../src/flash_card/presentation
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[02e0c2c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/02e0c2c55e2a62ca13e2dec9dbf3aa823cf16242
+64
View File
@@ -0,0 +1,64 @@
# 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 | [8dbaaa3] |
---
## 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
[8dbaaa3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8dbaaa30595a3650bc85006a2188f81ab903125e
+6
View File
@@ -48,6 +48,8 @@ updated whenever an artifact instance is created or reviewed.
| 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] | | 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 ## Coverage Notes
@@ -108,3 +110,7 @@ updated whenever an artifact instance is created or reviewed.
[PY-MIL-002]: ../../src/flash_card [PY-MIL-002]: ../../src/flash_card
[RC-025]: ./reviews/rc-025-py-mil-003.md [RC-025]: ./reviews/rc-025-py-mil-003.md
[PY-MIL-003]: ../../src/flash_card [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" target-version = "py313"
src = ["src", "tests"] src = ["src", "tests"]
[tool.ruff.format]
line-ending = "lf"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"] select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"]
ignore = ["D203", "D213"] 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.application.use_cases.study_session import StudySession
from flash_card.domain.entities.card import Card from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck 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 from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
def open_study_session( def open_study_session(
vocabulary_repository: VocabularyRepository, vocabulary_repository: VocabularyRepository,
progress_repository: ProgressRepository,
choose: Callable[[Sequence[Card]], Card], choose: Callable[[Sequence[Card]], Card],
) -> StudySession: ) -> 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. @param choose Picks one card from a non-empty sequence.
@return The new session. @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 = Deck.from_vocabulary(vocabulary_repository.load()) deck = progress_repository.load()
return StudySession(deck, choose) if deck is None:
deck = Deck.from_vocabulary(vocabulary_repository.load())
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.application.dto.card_dto import CardDto
from flash_card.domain.entities.card import Card from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck from flash_card.domain.entities.deck import Deck
from flash_card.domain.repositories.progress_repository import ProgressRepository
class StudySession: class StudySession:
"""Draws cards from a deck, one after another.""" """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. """Create a session.
@param deck The cards still to learn. @param deck The cards still to learn.
@param choose Picks one card from a non-empty sequence. @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. ## @brief The cards still to learn.
self._deck = deck self._deck = deck
## @brief Picks one card from a non-empty sequence. ## @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) self._current = self._deck.draw(self._choose, exclude=self._current)
return self._to_dto(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: def _to_dto(self, card: Card | None) -> CardDto | None:
"""Map a card to its DTO. """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 @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 import csv
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING 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: if pair is None:
raise _format_error(path, 1, "file is empty") raise _format_error(path, 1, "file is empty")
return pair, tuple(cards) 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 typing import NoReturn
from flash_card.application.use_cases.open_study_session import open_study_session 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.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 ( from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository, CsvVocabularyRepository,
) )
@@ -34,11 +41,12 @@ def create_application(
@param data_dir Folder of the data files, or None for the default folder. @param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it. @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 folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) 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) view_model = FlashCardViewModel(session)
root = tk.Tk() root = tk.Tk()
view = FlashCardView(root, view_model) view = FlashCardView(root, view_model)
@@ -8,11 +8,14 @@ import tkinter as tk
from flash_card.constants import ( from flash_card.constants import (
ASSETS_DIR, ASSETS_DIR,
BACK_TEXT_COLOR,
BACKGROUND_COLOR, BACKGROUND_COLOR,
CARD_BACK_IMAGE_NAME,
CARD_CENTER_X, CARD_CENTER_X,
CARD_FRONT_IMAGE_NAME, CARD_FRONT_IMAGE_NAME,
CARD_HEIGHT, CARD_HEIGHT,
CARD_WIDTH, CARD_WIDTH,
FLIP_DELAY_MS,
FRONT_TEXT_COLOR, FRONT_TEXT_COLOR,
LANGUAGE_FONT, LANGUAGE_FONT,
LANGUAGE_TEXT_Y, LANGUAGE_TEXT_Y,
@@ -25,9 +28,25 @@ from flash_card.constants import (
) )
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,
Side,
) )
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: class FlashCardView:
"""The flash card window: a card and the wrong and right buttons.""" """The flash card window: a card and the wrong and right buttons."""
@@ -46,11 +65,13 @@ class FlashCardView:
# Kept as attributes: Tkinter drops an image nothing refers to. # Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Card front image, kept so Tk does not drop it. ## @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 = _load_image(root, CARD_BACK_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it. ## @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. ## @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. ## @brief Canvas that draws the card.
self.canvas = tk.Canvas( self.canvas = tk.Canvas(
@@ -97,17 +118,89 @@ class FlashCardView:
image=self._right_image, image=self._right_image,
highlightthickness=0, highlightthickness=0,
bd=0, bd=0,
command=self.next_card, command=self.is_known,
) )
self.right_button.grid(row=1, column=1) self.right_button.grid(row=1, column=1)
## @brief Id of the pending flip timer, or None when none is pending.
self.flip_timer: str | None = None
self._start_flip_timer()
@property
def card_front_image(self) -> tk.PhotoImage:
"""Return the card front image.
@return The image shown while the front side is up.
"""
return self._card_front_image
@property
def card_back_image(self) -> tk.PhotoImage:
"""Return the card back image.
@return The image shown while the back side is up.
"""
return self._card_back_image
def next_card(self) -> None: def next_card(self) -> None:
"""Show the next card. """Show the next card front and restart the flip timer.
@return Nothing. @return Nothing.
""" """
self._cancel_flip_timer()
self._view_model.next_card() self._view_model.next_card()
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.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.flip_card()
self._show_card()
def _cancel_flip_timer(self) -> None:
"""Cancel the pending flip timer, if any.
@return Nothing.
"""
if self.flip_timer is not None:
self._root.after_cancel(self.flip_timer)
self.flip_timer = None
def _start_flip_timer(self) -> None:
"""Schedule the flip unless no card is left.
@return Nothing.
"""
if not self._view_model.is_finished:
self.flip_timer = self._root.after(FLIP_DELAY_MS, self.flip_card)
def _show_card(self) -> None:
"""Draw image and texts of the side the view model shows.
@return Nothing.
"""
if self._view_model.side is Side.BACK:
image, color = self._card_back_image, BACK_TEXT_COLOR
else:
image, color = self._card_front_image, FRONT_TEXT_COLOR
self.canvas.itemconfig(self.image_id, image=image)
self.canvas.itemconfig( self.canvas.itemconfig(
self.language_text_id, text=self._view_model.language_name self.language_text_id, text=self._view_model.language_name, fill=color
)
self.canvas.itemconfig(
self.word_text_id, text=self._view_model.text, fill=color
) )
self.canvas.itemconfig(self.word_text_id, text=self._view_model.text)
@@ -4,6 +4,7 @@
@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.
""" """
import enum
from collections.abc import Mapping from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto from flash_card.application.dto.card_dto import CardDto
@@ -11,6 +12,15 @@ from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class Side(enum.Enum):
"""The side of a card that is shown."""
## @brief The front: the word to learn.
FRONT = enum.auto()
## @brief The back: the translation.
BACK = enum.auto()
class FlashCardViewModel: class FlashCardViewModel:
"""Data the flash card view shows.""" """Data the flash card view shows."""
@@ -30,6 +40,8 @@ class FlashCardViewModel:
self._language_names = language_names self._language_names = language_names
## @brief The card shown now, or None when none is left. ## @brief The card shown now, or None when none is left.
self._card: CardDto | None = session.next_card() self._card: CardDto | None = session.next_card()
## @brief The side of the card shown.
self._side = Side.FRONT
@property @property
def is_finished(self) -> bool: def is_finished(self) -> bool:
@@ -39,31 +51,69 @@ class FlashCardViewModel:
""" """
return self._card is None return self._card is None
@property
def side(self) -> Side:
"""Return the side of the card shown.
@return FRONT or BACK; FRONT when no card is left.
"""
return self._side
@property @property
def language_name(self) -> str: def language_name(self) -> str:
"""Return the language name shown on the card. """Return the language name shown on the card.
@return The display name of the front language, upper case code when @return The display name of the language of the side shown, upper case
unknown, or an empty string when no card is left. code when unknown, or an empty string when no card is left.
""" """
if self._card is None: if self._card is None:
return "" return ""
code = self._card.front_language if self._side is Side.BACK:
code = self._card.back_language
else:
code = self._card.front_language
return self._language_names.get(code, code.upper()) return self._language_names.get(code, code.upper())
@property @property
def text(self) -> str: def text(self) -> str:
"""Return the word shown on the card. """Return the word shown on the card.
@return The front text, or the finished message when no card is left. @return The text of the side shown, or the finished message when no
card is left.
""" """
if self._card is None: if self._card is None:
return FINISHED_MESSAGE return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text return self._card.front_text
def next_card(self) -> None: def next_card(self) -> None:
"""Draw the next card. """Draw the next card and show its front; does nothing when finished.
@return Nothing. @return Nothing.
""" """
if self._card is None:
return
self._card = self._session.next_card() 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
def flip_card(self) -> None:
"""Show the back of the card; does nothing when finished or flipped.
@return Nothing.
"""
if self._card is not None:
self._side = Side.BACK
+197 -4
View File
@@ -4,16 +4,22 @@
@brief Builds the real window with Tk and checks it against the wireframe. @brief Builds the real window with Tk and checks it against the wireframe.
""" """
import time
import tkinter as tk import tkinter as tk
from collections.abc import Iterator, Sequence from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
import pytest 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.application.use_cases.study_session import StudySession
from flash_card.constants import ( from flash_card.constants import (
BACK_TEXT_COLOR,
BACKGROUND_COLOR, BACKGROUND_COLOR,
CARD_HEIGHT, CARD_HEIGHT,
CARD_WIDTH, CARD_WIDTH,
FINISHED_MESSAGE,
FLIP_DELAY_MS,
FRONT_TEXT_COLOR, FRONT_TEXT_COLOR,
LANGUAGE_FONT, LANGUAGE_FONT,
WINDOW_PADDING, WINDOW_PADDING,
@@ -23,6 +29,13 @@ from flash_card.constants import (
from flash_card.domain.entities.card import Card from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair 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 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,
@@ -35,9 +48,20 @@ def _first(cards: Sequence[Card]) -> Card:
return cards[0] 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: def _build_view(root: tk.Tk) -> FlashCardView:
deck = Deck(LanguagePair("ms", "da"), CARDS) 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]: def _shown(view: FlashCardView) -> tuple[str, str]:
@@ -125,12 +149,181 @@ def test_buttons_sit_in_row_one_with_their_images(
assert str(view.right_button.cget("command")) 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], window: tuple[tk.Tk, FlashCardView],
) -> None: ) -> None:
_root, view = window _root, view = window
assert _shown(view) == ("Malay", "aku") assert _shown(view) == ("Malay", "aku")
view.wrong_button.invoke() view.wrong_button.invoke()
assert _shown(view) == ("Malay", "kau") assert _shown(view) == ("Malay", "kau")
view.right_button.invoke() view.wrong_button.invoke()
assert _shown(view) == ("Malay", "aku") assert _shown(view) == ("Malay", "aku")
def _word(view: FlashCardView) -> str:
return _shown(view)[1]
def _image(view: FlashCardView) -> str:
return str(view.canvas.itemcget(view.image_id, "image"))
def _fill(view: FlashCardView) -> tuple[str, str]:
return (
str(view.canvas.itemcget(view.language_text_id, "fill")),
str(view.canvas.itemcget(view.word_text_id, "fill")),
)
def _reset(root: tk.Tk, view: FlashCardView) -> None:
"""Show a fresh front with no pending timer."""
view.next_card()
if view.flip_timer is not None:
root.after_cancel(view.flip_timer)
view.flip_timer = None
def test_front_state_uses_front_image_black_text_and_front_language(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
assert _word(view) in {card.front for card in CARDS}
def test_flip_card_shows_back_image_white_text_and_translation(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
front = _word(view)
view.flip_card()
translations = {card.front: card.back for card in CARDS}
assert _image(view) == str(view.card_back_image)
assert _image(view) != str(view.card_front_image)
assert _fill(view) == (BACK_TEXT_COLOR, BACK_TEXT_COLOR)
assert _shown(view) == ("Danish", translations[front])
assert view.flip_timer is None
def test_next_card_after_flip_shows_front_again(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
view.flip_card()
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"
_reset(root, view)
def test_next_card_cancels_pending_timer_and_schedules_flip(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
_reset(root, view)
calls: list[tuple[str, object]] = []
def fake_after(delay: int, callback: Callable[[], None]) -> str:
calls.append(("after", (delay, callback)))
return "timer-id"
def fake_cancel(timer_id: str) -> None:
calls.append(("cancel", timer_id))
monkeypatch.setattr(root, "after", fake_after, raising=False)
monkeypatch.setattr(root, "after_cancel", fake_cancel, raising=False)
view.flip_timer = "old-id"
view.next_card()
assert calls == [
("cancel", "old-id"),
("after", (FLIP_DELAY_MS, view.flip_card)),
]
assert view.flip_timer == "timer-id"
view.flip_timer = None
def test_real_timer_flips_card_after_delay(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
monkeypatch.setattr(flash_card_view, "FLIP_DELAY_MS", 50)
view.next_card()
assert _image(view) == str(view.card_front_image)
deadline = time.monotonic() + 2
while view.flip_timer is not None and time.monotonic() < deadline:
root.update()
time.sleep(0.005)
assert view.flip_timer is None
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.""" """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.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.entities.card import Card
from flash_card.domain.value_objects.language_pair import LanguagePair 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 ( from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository, CsvVocabularyRepository,
) )
@@ -19,10 +28,11 @@ def test_read_word_list_reads_real_file() -> None:
assert cards[0] == Card("aku", "mig") 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) repository = CsvVocabularyRepository(PATH)
cards = repository.load().cards 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() card = session.next_card()
@@ -7,6 +7,7 @@ import pytest
from flash_card.application.dto.card_dto import CardDto 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.application.use_cases.open_study_session import open_study_session
from flash_card.domain.entities.card import Card 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.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
from flash_card.domain.value_objects.language_pair import LanguagePair from flash_card.domain.value_objects.language_pair import LanguagePair
@@ -20,6 +21,18 @@ class _FakeRepository:
return self._vocabulary 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: class _MissingRepository:
def load(self) -> Vocabulary: def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing") 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")) cards = (Card("aku", "mig"), Card("kau", "du"))
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards)) 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.remaining == 2
assert session.next_card() == CardDto("aku", "mig", "ms", "da") 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: def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError): 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] return cards[0]
def _session(*cards: Card) -> StudySession: class _RecordingProgress:
return StudySession(Deck(PAIR, cards), _first) 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: 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 = _session(A, B)
session.next_card() session.next_card()
assert session.remaining == 2 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
+97 -2
View File
@@ -9,6 +9,7 @@ from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair 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,
Side,
) )
A = Card("aku", "mig") A = Card("aku", "mig")
@@ -19,8 +20,22 @@ def _first(cards: Sequence[Card]) -> Card:
return cards[0] return cards[0]
def _session(code: str, *cards: Card) -> StudySession: class _RecordingProgress:
return StudySession(Deck(LanguagePair(code, "da"), cards), _first) 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: def test_view_model_draws_first_card_on_construction() -> None:
@@ -51,3 +66,83 @@ def test_view_model_shows_finished_message_when_no_card_is_left() -> None:
assert view_model.is_finished assert view_model.is_finished
assert view_model.language_name == "" assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE assert view_model.text == FINISHED_MESSAGE
def test_view_model_starts_on_front_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
assert view_model.side is Side.FRONT
def test_flip_card_shows_back_language_and_translation() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.language_name == "Danish"
assert view_model.text == "mig"
def test_flip_card_twice_keeps_back_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.text == "mig"
def test_flip_card_does_nothing_when_finished() -> None:
view_model = FlashCardViewModel(_session("ms"))
view_model.flip_card()
assert view_model.side is Side.FRONT
assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE
def test_next_card_resets_side_to_front() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
view_model.flip_card()
view_model.next_card()
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 == []