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
+70 -70
View File
@@ -1,70 +1,70 @@
# Artifact Registry # Artifact Registry
This project's artifact state. Types, short names and `CrossReference This project's artifact state. Types, short names and `CrossReference
Candidates` come from the framework catalog Candidates` come from the framework catalog
(`framework/registry/artifact-catalog.md`); this file only records where (`framework/registry/artifact-catalog.md`); this file only records where
each document lives in *this* project and the next version to use. each document lives in *this* project and the next version to use.
Delete rows for types you don't use. Add a row the first time you create a Delete rows for types you don't use. Add a row the first time you create a
document of a type. `Primary File` may contain a glob (e.g. document of a type. `Primary File` may contain a glob (e.g.
`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it. `docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
| Short Name | Artifact Type | Primary File | Next Available Version | | Short Name | Artifact Type | Primary File | Next Available Version |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| BC | Business Case | docs/business-case.md | 002 | | BC | Business Case | docs/business-case.md | 002 |
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 | | MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 | | UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
| US | User Story | docs/user-stories.md | 002 | | US | User Story | docs/user-stories.md | 002 |
| UC | Use Case | docs/uc-*/uc.md | 002 | | UC | Use Case | docs/uc-*/uc.md | 002 |
| OC | Operation Contract | docs/uc-*/oc.md | 002 | | OC | Operation Contract | docs/uc-*/oc.md | 002 |
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 | | SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
| DM | Domain Model | docs/domain-model.md | 002 | | DM | Domain Model | docs/domain-model.md | 002 |
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 | | SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
| 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
Set the PO language when the project starts; `project-planning` asks for it Set the PO language when the project starts; `project-planning` asks for it
if it is missing. An artifact of a type marked "Written in the PO language" if it is missing. An artifact of a type marked "Written in the PO language"
exists once, in that language, under its normal name (`business-case.md`); the exists once, in that language, under its normal name (`business-case.md`); the
`artifact` skill ("One file per artifact") has the rule. `artifact` skill ("One file per artifact") has the rule.
| Setting | Value | | Setting | Value |
| --- | --- | | --- | --- |
| PO language | en | | PO language | en |
| PO domain | it | | PO domain | it |
| High-level register | IT Executive English | | High-level register | IT Executive English |
| Technical register | IT Professional English | | Technical register | IT Professional English |
| Wiki languages | en, da and ms (`framework/process/wiki.md`: en and the PO language at minimum; da and ms added by the Product Owner) | | Wiki languages | en, da and ms (`framework/process/wiki.md`: en and the PO language at minimum; da and ms added by the Product Owner) |
Every artifact of a type marked "Yes" below states its language and domain in Every artifact of a type marked "Yes" below states its language and domain in
its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from
`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`). `PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`).
`Domain` is a value from this list; add a row to introduce a domain, so a `Domain` is a value from this list; add a row to introduce a domain, so a
reviewer can see at once which professional vocabulary a document uses. reviewer can see at once which professional vocabulary a document uses.
| Domain | Meaning | | Domain | Meaning |
| --- | --- | | --- | --- |
| it | Software and IT | | it | Software and IT |
| medical | Healthcare and medical devices | | medical | Healthcare and medical devices |
| construction | Construction and civil engineering | | construction | Construction and civil engineering |
| Artifact types | Register | Written in the PO language | | Artifact types | Register | Written in the PO language |
| --- | --- | --- | | --- | --- | --- |
| BC, KPI, PP, MIL | IT Executive English | Yes | | BC, KPI, PP, MIL | IT Executive English | Yes |
| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes | | SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes |
| OC, SD, DCD, ERD, ADR, TM, RC, QC, source code | IT Professional English | No | | OC, SD, DCD, ERD, ADR, TM, RC, QC, source code | IT Professional English | No |
## Notes ## Notes
- "Next Available Version" is the zero-padded (3-digit) version to use the - "Next Available Version" is the zero-padded (3-digit) version to use the
*next* time a new document of that type is created. Increment it only when *next* time a new document of that type is created. Increment it only when
a brand-new document is created, not when an existing document's a brand-new document is created, not when an existing document's
`## Version History` gets a row. `## Version History` gets a row.
- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types. - `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types.
+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
+116 -110
View File
@@ -1,110 +1,116 @@
# Traceability Matrix: Flash Card # Traceability Matrix: Flash Card
## Metadata ## Metadata
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| ID | TM-001 | | ID | TM-001 |
| CrossReference | [BC-001] | | CrossReference | [BC-001] |
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] | | 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
--- ---
## Purpose ## Purpose
Tracks backward/forward links between artifact instances so that the Business Case's Tracks backward/forward links between artifact instances so that the Business Case's
cross-artifact traceability success criterion is measurable. A row is added or cross-artifact traceability success criterion is measurable. A row is added or
updated whenever an artifact instance is created or reviewed. updated whenever an artifact instance is created or reviewed.
## Traceability Table ## Traceability Table
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | | Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] | | BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] |
| SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] | | SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] |
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] | | PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] |
| UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] | | UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] |
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] | | US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] |
| UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] | | UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] |
| DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] | | DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] |
| SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] | | SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] |
| OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] | | OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] |
| SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] | | SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] |
| DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] | | DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] |
| DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] | | DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] |
| ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] | | ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] |
| ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] | | ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] |
| ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] | | ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] |
| MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] | | MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] |
| MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] | | MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] |
| MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] | | MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] |
| MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] | | MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] |
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] | | MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] | | MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] | | MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] | | PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | | PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | | PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
## Coverage Notes | PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
- `-` in Upstream means foundational; in Downstream it means nothing is built on it yet; in Last Reviewed it means no `RC-*` exists yet. Every row has a review record from the baseline review (autonomous mode, AI as stand-in for S01). ## Coverage Notes
- Types not created for this project, and why: ADR-0003.
- Source code (`PY`) is added as `src/` is reviewed, one row per milestone. - `-` in Upstream means foundational; in Downstream it means nothing is built on it yet; in Last Reviewed it means no `RC-*` exists yet. Every row has a review record from the baseline review (autonomous mode, AI as stand-in for S01).
- Types not created for this project, and why: ADR-0003.
--- - Source code (`PY`) is added as `src/` is reviewed, one row per milestone.
[BC-001]: ../business-case.md ---
[SA-001]: ../stakeholder-analysis.md
[PP-001]: ../project-plan.md [BC-001]: ../business-case.md
[UCD-001]: ../use-case-diagram.md [SA-001]: ../stakeholder-analysis.md
[US-001]: ../user-stories.md [PP-001]: ../project-plan.md
[UC-001]: ../uc-001/uc.md [UCD-001]: ../use-case-diagram.md
[DM-001]: ../domain-model.md [US-001]: ../user-stories.md
[SSD-001]: ../uc-001/ssd.md [UC-001]: ../uc-001/uc.md
[OC-001]: ../uc-001/oc.md [DM-001]: ../domain-model.md
[SD-001]: ../uc-001/sd.md [SSD-001]: ../uc-001/ssd.md
[DCD-001]: ../dcd.md [OC-001]: ../uc-001/oc.md
[DICT-001]: ../dictionary.md [SD-001]: ../uc-001/sd.md
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md [DCD-001]: ../dcd.md
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md [DICT-001]: ../dictionary.md
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md [ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
[MIL-001]: ../milestones/mil-001-foundation.md [ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
[MIL-002]: ../milestones/mil-002-step1-user-interface.md [ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md [MIL-001]: ../milestones/mil-001-foundation.md
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md [MIL-002]: ../milestones/mil-002-step1-user-interface.md
[MIL-005]: ../milestones/mil-005-step4-save-progress.md [MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
[MIL-006]: ../milestones/mil-006-malay-speech.md [MIL-004]: ../milestones/mil-004-step3-flip-cards.md
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md [MIL-005]: ../milestones/mil-005-step4-save-progress.md
[PY-MIL-001]: ../../src/flash_card [MIL-006]: ../milestones/mil-006-malay-speech.md
[RC-001]: ./reviews/rc-001-business-case.md [MIL-007]: ../milestones/mil-007-documentation-and-publication.md
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md [PY-MIL-001]: ../../src/flash_card
[RC-003]: ./reviews/rc-003-project-plan.md [RC-001]: ./reviews/rc-001-business-case.md
[RC-004]: ./reviews/rc-004-use-case-diagram.md [RC-002]: ./reviews/rc-002-stakeholder-analysis.md
[RC-005]: ./reviews/rc-005-user-stories.md [RC-003]: ./reviews/rc-003-project-plan.md
[RC-006]: ./reviews/rc-006-use-case.md [RC-004]: ./reviews/rc-004-use-case-diagram.md
[RC-007]: ./reviews/rc-007-domain-model.md [RC-005]: ./reviews/rc-005-user-stories.md
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md [RC-006]: ./reviews/rc-006-use-case.md
[RC-009]: ./reviews/rc-009-operation-contracts.md [RC-007]: ./reviews/rc-007-domain-model.md
[RC-010]: ./reviews/rc-010-sequence-diagrams.md [RC-008]: ./reviews/rc-008-system-sequence-diagram.md
[RC-011]: ./reviews/rc-011-design-class-diagram.md [RC-009]: ./reviews/rc-009-operation-contracts.md
[RC-012]: ./reviews/rc-012-domain-dictionary.md [RC-010]: ./reviews/rc-010-sequence-diagrams.md
[RC-013]: ./reviews/rc-013-adr-0001.md [RC-011]: ./reviews/rc-011-design-class-diagram.md
[RC-014]: ./reviews/rc-014-adr-0002.md [RC-012]: ./reviews/rc-012-domain-dictionary.md
[RC-015]: ./reviews/rc-015-adr-0003.md [RC-013]: ./reviews/rc-013-adr-0001.md
[RC-016]: ./reviews/rc-016-mil-001.md [RC-014]: ./reviews/rc-014-adr-0002.md
[RC-017]: ./reviews/rc-017-mil-002.md [RC-015]: ./reviews/rc-015-adr-0003.md
[RC-018]: ./reviews/rc-018-mil-003.md [RC-016]: ./reviews/rc-016-mil-001.md
[RC-019]: ./reviews/rc-019-mil-004.md [RC-017]: ./reviews/rc-017-mil-002.md
[RC-020]: ./reviews/rc-020-mil-005.md [RC-018]: ./reviews/rc-018-mil-003.md
[RC-021]: ./reviews/rc-021-mil-006.md [RC-019]: ./reviews/rc-019-mil-004.md
[RC-022]: ./reviews/rc-022-mil-007.md [RC-020]: ./reviews/rc-020-mil-005.md
[RC-023]: ./reviews/rc-023-py-mil-001.md [RC-021]: ./reviews/rc-021-mil-006.md
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 [RC-022]: ./reviews/rc-022-mil-007.md
[RC-024]: ./reviews/rc-024-py-mil-002.md [RC-023]: ./reviews/rc-023-py-mil-001.md
[PY-MIL-002]: ../../src/flash_card [c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
[RC-025]: ./reviews/rc-025-py-mil-003.md [RC-024]: ./reviews/rc-024-py-mil-002.md
[PY-MIL-003]: ../../src/flash_card [PY-MIL-002]: ../../src/flash_card
[RC-025]: ./reviews/rc-025-py-mil-003.md
[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)
@@ -1,66 +1,88 @@
"""One study session. """One study session.
@file study_session.py @file study_session.py
@brief The StudySession use case: draws the cards the user studies. @brief The StudySession use case: draws the cards the user studies.
""" """
from collections.abc import Callable, Sequence 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:
"""Draws cards from a deck, one after another.""" class StudySession:
"""Draws cards from a deck, one after another."""
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None:
"""Create a session. def __init__(
self,
@param deck The cards still to learn. deck: Deck,
@param choose Picks one card from a non-empty sequence. choose: Callable[[Sequence[Card]], Card],
""" progress: ProgressRepository,
## @brief The cards still to learn. ) -> None:
self._deck = deck """Create a session.
## @brief Picks one card from a non-empty sequence.
self._choose = choose @param deck The cards still to learn.
## @brief The card shown now, or None. @param choose Picks one card from a non-empty sequence.
self._current: Card | None = None @param progress Store of the words still to learn.
"""
@property ## @brief Store of the words still to learn.
def current_card(self) -> CardDto | None: self._progress = progress
"""Return the card shown now. ## @brief The cards still to learn.
self._deck = deck
@return The card, or None before the first draw or when none is left. ## @brief Picks one card from a non-empty sequence.
""" self._choose = choose
return self._to_dto(self._current) ## @brief The card shown now, or None.
self._current: Card | None = None
@property
def remaining(self) -> int: @property
"""Return the number of cards still to learn. def current_card(self) -> CardDto | None:
"""Return the card shown now.
@return The card count.
""" @return The card, or None before the first draw or when none is left.
return len(self._deck) """
return self._to_dto(self._current)
def next_card(self) -> CardDto | None:
"""Draw the next card, different from the current one when possible. @property
def remaining(self) -> int:
@return The new card, or None when the deck is empty. """Return the number of cards still to learn.
"""
if self._deck.is_empty: @return The card count.
self._current = None """
return None return len(self._deck)
self._current = self._deck.draw(self._choose, exclude=self._current)
return self._to_dto(self._current) def next_card(self) -> CardDto | None:
"""Draw the next card, different from the current one when possible.
def _to_dto(self, card: Card | None) -> CardDto | None:
"""Map a card to its DTO. @return The new card, or None when the deck is empty.
"""
@param card The card, or None. if self._deck.is_empty:
@return The DTO, or None when there is no card. self._current = None
""" return None
if card is None: self._current = self._deck.draw(self._choose, exclude=self._current)
return None return self._to_dto(self._current)
pair = self._deck.language_pair
return CardDto(card.front, card.back, pair.front_code, pair.back_code) 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.
@param card The card, or None.
@return The DTO, or None when there is no card.
"""
if card is None:
return None
pair = self._deck.language_pair
return CardDto(card.front, card.back, pair.front_code, pair.back_code)
+95 -95
View File
@@ -1,95 +1,95 @@
"""The cards still to learn. """The cards still to learn.
@file deck.py @file deck.py
@brief The Deck entity: holds cards, draws one and removes one. @brief The Deck entity: holds cards, draws one and removes one.
""" """
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
from flash_card.domain.entities.card import Card from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair from flash_card.domain.value_objects.language_pair import LanguagePair
class Deck: class Deck:
"""The cards still to learn, of one language pair.""" """The cards still to learn, of one language pair."""
def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None: def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None:
"""Create a deck. """Create a deck.
@param language_pair The languages of the cards. @param language_pair The languages of the cards.
@param cards The cards of the deck. @param cards The cards of the deck.
""" """
## @brief The languages of the cards. ## @brief The languages of the cards.
self._language_pair = language_pair self._language_pair = language_pair
## @brief The cards still in the deck. ## @brief The cards still in the deck.
self._cards = list(cards) self._cards = list(cards)
@classmethod @classmethod
def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck": def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck":
"""Create a deck holding every card of a vocabulary. """Create a deck holding every card of a vocabulary.
@param vocabulary The delivered word list. @param vocabulary The delivered word list.
@return A new deck. @return A new deck.
""" """
return cls(vocabulary.language_pair, vocabulary.cards) return cls(vocabulary.language_pair, vocabulary.cards)
@property @property
def language_pair(self) -> LanguagePair: def language_pair(self) -> LanguagePair:
"""Return the languages of the cards. """Return the languages of the cards.
@return The language pair. @return The language pair.
""" """
return self._language_pair return self._language_pair
@property @property
def cards(self) -> tuple[Card, ...]: def cards(self) -> tuple[Card, ...]:
"""Return a copy of the cards. """Return a copy of the cards.
@return The cards in deck order. @return The cards in deck order.
""" """
return tuple(self._cards) return tuple(self._cards)
@property @property
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""Tell whether the deck has no card. """Tell whether the deck has no card.
@return True when no card is left. @return True when no card is left.
""" """
return not self._cards return not self._cards
def __len__(self) -> int: def __len__(self) -> int:
"""Return the number of cards. """Return the number of cards.
@return The card count. @return The card count.
""" """
return len(self._cards) return len(self._cards)
def draw( def draw(
self, self,
choose: Callable[[Sequence[Card]], Card], choose: Callable[[Sequence[Card]], Card],
exclude: Card | None = None, exclude: Card | None = None,
) -> Card: ) -> Card:
"""Pick a card without removing it. """Pick a card without removing it.
The pick avoids the excluded card when another card exists. The pick avoids the excluded card when another card exists.
@param choose Picks one card from a non-empty sequence. @param choose Picks one card from a non-empty sequence.
@param exclude A card to avoid, usually the one shown now. @param exclude A card to avoid, usually the one shown now.
@return The picked card. @return The picked card.
@throws EmptyDeckError When the deck has no card. @throws EmptyDeckError When the deck has no card.
""" """
if not self._cards: if not self._cards:
raise EmptyDeckError("cannot draw from an empty deck") raise EmptyDeckError("cannot draw from an empty deck")
others = [card for card in self._cards if card != exclude] others = [card for card in self._cards if card != exclude]
return choose(others or self._cards) return choose(others or self._cards)
def remove(self, card: Card) -> None: def remove(self, card: Card) -> None:
"""Remove the first card equal to the given one. """Remove the first card equal to the given one.
@param card The card to remove. @param card The card to remove.
@return Nothing. @return Nothing.
@throws ValueError When the deck holds no such card. @throws ValueError When the deck holds no such card.
""" """
self._cards.remove(card) self._cards.remove(card)
@@ -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,35 +1,35 @@
"""CSV implementation of the vocabulary port. """CSV implementation of the vocabulary port.
@file csv_vocabulary_repository.py @file csv_vocabulary_repository.py
@brief The CsvVocabularyRepository adapter. @brief The CsvVocabularyRepository adapter.
""" """
from pathlib import Path from pathlib import Path
from flash_card.domain.entities.vocabulary import Vocabulary from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
from flash_card.infrastructure.persistence.csv_word_list import read_word_list from flash_card.infrastructure.persistence.csv_word_list import read_word_list
class CsvVocabularyRepository: class CsvVocabularyRepository:
"""Loads the delivered word list from a CSV file.""" """Loads the delivered word list from a CSV file."""
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
"""Create the repository. """Create the repository.
@param path The word list file. @param path The word list file.
""" """
## @brief The word list file. ## @brief The word list file.
self._path = path self._path = path
def load(self) -> Vocabulary: def load(self) -> Vocabulary:
"""Load the word list. """Load the word list.
@return The vocabulary. @return The vocabulary.
@throws VocabularyNotFoundError When the file does not exist. @throws VocabularyNotFoundError When the file does not exist.
@throws VocabularyFormatError When the file is malformed or has no card. @throws VocabularyFormatError When the file is malformed or has no card.
""" """
pair, cards = read_word_list(self._path) pair, cards = read_word_list(self._path)
if not cards: if not cards:
raise VocabularyFormatError(f"{self._path}: word list has no card") raise VocabularyFormatError(f"{self._path}: word list has no card")
return Vocabulary(pair, cards) return Vocabulary(pair, cards)
@@ -1,86 +1,116 @@
"""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
from pathlib import Path import csv
from collections.abc import Iterable
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING from pathlib import Path
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import ( from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
VocabularyFormatError, from flash_card.domain.entities.card import Card
VocabularyNotFoundError, from flash_card.domain.errors.vocabulary_errors import (
) VocabularyFormatError,
from flash_card.domain.value_objects.language_pair import LanguagePair VocabularyNotFoundError,
)
## @brief Number of cells in the header and in every card row. from flash_card.domain.value_objects.language_pair import LanguagePair
_CELLS_PER_ROW = 2
## @brief Number of cells in the header and in every card row.
_CELLS_PER_ROW = 2
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
"""Build a format error naming the file and line.
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
@param path The file read. """Build a format error naming the file and line.
@param line The line number of the fault.
@param reason What is wrong. @param path The file read.
@return The error to raise. @param line The line number of the fault.
""" @param reason What is wrong.
return VocabularyFormatError(f"{path}, line {line}: {reason}") @return The error to raise.
"""
return VocabularyFormatError(f"{path}, line {line}: {reason}")
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
"""Parse the header row.
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
@param path The file read. """Parse the header row.
@param line The line number of the row.
@param row The cells of the row. @param path The file read.
@return The language pair. @param line The line number of the row.
@throws VocabularyFormatError When the header is not two codes. @param row The cells of the row.
""" @return The language pair.
cells = [cell.strip() for cell in row] @throws VocabularyFormatError When the header is not two codes.
if len(cells) != _CELLS_PER_ROW or not all(cells): """
raise _format_error(path, line, "header must hold two language codes") cells = [cell.strip() for cell in row]
return LanguagePair(cells[0], cells[1]) if len(cells) != _CELLS_PER_ROW or not all(cells):
raise _format_error(path, line, "header must hold two language codes")
return LanguagePair(cells[0], cells[1])
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
"""Parse a card row.
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
@param path The file read. """Parse a card row.
@param line The line number of the row.
@param row The cells of the row. @param path The file read.
@return The card. @param line The line number of the row.
@throws VocabularyFormatError When the row is not two cells. @param row The cells of the row.
""" @return The card.
if len(row) != _CELLS_PER_ROW: @throws VocabularyFormatError When the row is not two cells.
raise _format_error(path, line, "row must hold exactly two cells") """
return Card(row[0].strip(), row[1].strip()) if len(row) != _CELLS_PER_ROW:
raise _format_error(path, line, "row must hold exactly two cells")
return Card(row[0].strip(), row[1].strip())
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
"""Read a word list file.
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
@param path The CSV file: a header of two language codes, then one card a row. """Read a word list file.
@return The language pair and the cards in file order.
@throws VocabularyNotFoundError When the file does not exist. @param path The CSV file: a header of two language codes, then one card a row.
@throws VocabularyFormatError When the file is empty or a row is malformed. @return The language pair and the cards in file order.
""" @throws VocabularyNotFoundError When the file does not exist.
pair: LanguagePair | None = None @throws VocabularyFormatError When the file is empty or a row is malformed.
cards: list[Card] = [] """
try: pair: LanguagePair | None = None
with path.open(encoding=CSV_ENCODING, newline="") as file: cards: list[Card] = []
reader = csv.reader(file, delimiter=CSV_DELIMITER) try:
for row in reader: with path.open(encoding=CSV_ENCODING, newline="") as file:
if not any(cell.strip() for cell in row): reader = csv.reader(file, delimiter=CSV_DELIMITER)
continue for row in reader:
if pair is None: if not any(cell.strip() for cell in row):
pair = _parse_header(path, reader.line_num, row) continue
else: if pair is None:
cards.append(_parse_card(path, reader.line_num, row)) pair = _parse_header(path, reader.line_num, row)
except FileNotFoundError as error: else:
raise VocabularyNotFoundError(f"word list not found: {path}") from error cards.append(_parse_card(path, reader.line_num, row))
if pair is None: except FileNotFoundError as error:
raise _format_error(path, 1, "file is empty") raise VocabularyNotFoundError(f"word list not found: {path}") from error
return pair, tuple(cards) 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
+85 -77
View File
@@ -1,77 +1,85 @@
"""Entry point and composition root of the flash card program. """Entry point and composition root of the flash card program.
@file app.py @file app.py
@brief Creates the window, its view model and view, and runs the main loop. @brief Creates the window, its view model and view, and runs the main loop.
""" """
import argparse import argparse
import logging import logging
import random import random
import tkinter as tk import tkinter as tk
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path 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 (
from flash_card.domain.errors.vocabulary_errors import VocabularyError DEFAULT_DATA_DIR,
from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( PROGRESS_FILE_NAME,
CsvVocabularyRepository, VOCABULARY_FILE_NAME,
) )
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.presentation.features.flash_card.flash_card_view_model import ( from flash_card.infrastructure.persistence.csv_progress_repository import (
FlashCardViewModel, CsvProgressRepository,
) )
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
## @brief Logger of the entry point. CsvVocabularyRepository,
_logger = logging.getLogger(__name__) )
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
def create_application( FlashCardViewModel,
data_dir: Path | None = None, )
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view. ## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
@param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it.
@throws VocabularyError When the word list cannot be loaded. def create_application(
""" data_dir: Path | None = None,
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir ) -> tuple[tk.Tk, FlashCardView]:
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) """Create the root window and the flash card view.
session = open_study_session(repository, random.choice)
view_model = FlashCardViewModel(session) @param data_dir Folder of the data files, or None for the default folder.
root = tk.Tk() @return The root window and the view built on it.
view = FlashCardView(root, view_model) @throws VocabularyError When the word list or saved progress is unusable.
return root, view """
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
def main(argv: Sequence[str] | None = None) -> int: progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
"""Run the program and return its exit code. session = open_study_session(repository, progress, random.choice)
view_model = FlashCardViewModel(session)
@param argv Command line arguments, or None for those of the process. root = tk.Tk()
@return The exit code, 0 on success and 1 when the word list is unusable. view = FlashCardView(root, view_model)
""" return root, view
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir", def main(argv: Sequence[str] | None = None) -> int:
type=Path, """Run the program and return its exit code.
default=DEFAULT_DATA_DIR,
help="folder of the data files", @param argv Command line arguments, or None for those of the process.
) @return The exit code, 0 on success and 1 when the word list is unusable.
args = parser.parse_args(argv) """
try: parser = argparse.ArgumentParser(description="Flash card program.")
root, _view = create_application(args.data_dir) parser.add_argument(
except VocabularyError as error: "--data-dir",
_logger.error("%s", error) type=Path,
return 1 default=DEFAULT_DATA_DIR,
root.mainloop() help="folder of the data files",
return 0 )
args = parser.parse_args(argv)
try:
def run() -> NoReturn: root, _view = create_application(args.data_dir)
"""Run the program and exit the interpreter with its exit code. except VocabularyError as error:
_logger.error("%s", error)
@return Never returns; the function always raises SystemExit. return 1
@throws SystemExit Always, with the exit code of main. root.mainloop()
""" return 0
raise SystemExit(main())
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
@@ -1,113 +1,206 @@
"""View of the flash card screen. """View of the flash card screen.
@file flash_card_view.py @file flash_card_view.py
@brief Builds the window of the wireframe: card canvas and two buttons. @brief Builds the window of the wireframe: card canvas and two buttons.
""" """
import tkinter as tk import tkinter as tk
from flash_card.constants import ( from flash_card.constants import (
ASSETS_DIR, ASSETS_DIR,
BACKGROUND_COLOR, BACK_TEXT_COLOR,
CARD_CENTER_X, BACKGROUND_COLOR,
CARD_FRONT_IMAGE_NAME, CARD_BACK_IMAGE_NAME,
CARD_HEIGHT, CARD_CENTER_X,
CARD_WIDTH, CARD_FRONT_IMAGE_NAME,
FRONT_TEXT_COLOR, CARD_HEIGHT,
LANGUAGE_FONT, CARD_WIDTH,
LANGUAGE_TEXT_Y, FLIP_DELAY_MS,
RIGHT_IMAGE_NAME, FRONT_TEXT_COLOR,
WINDOW_PADDING, LANGUAGE_FONT,
WINDOW_TITLE, LANGUAGE_TEXT_Y,
WORD_FONT, RIGHT_IMAGE_NAME,
WORD_TEXT_Y, WINDOW_PADDING,
WRONG_IMAGE_NAME, WINDOW_TITLE,
) WORD_FONT,
from flash_card.presentation.features.flash_card.flash_card_view_model import ( WORD_TEXT_Y,
FlashCardViewModel, WRONG_IMAGE_NAME,
) )
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
class FlashCardView: Side,
"""The flash card window: a card and the wrong and right buttons.""" )
def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
"""Build the window on a root. def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
"""Load an asset image.
@param root The Tk root window.
@param view_model The data to show. 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
## @brief The Tk root window. start, and a process that swaps them (pytest fd capture) makes the first
self._root = root Tcl file open fail with an empty error.
## @brief The data source of the texts.
self._view_model = view_model @param root The Tk root window that owns the image.
root.title(WINDOW_TITLE) @param name The file name in the assets folder.
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR) @return The image; the caller must keep a reference.
"""
# Kept as attributes: Tkinter drops an image nothing refers to. return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
## @brief Card front image, kept so Tk does not drop it.
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it. class FlashCardView:
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME) """The flash card window: a card and the wrong and right buttons."""
## @brief Right button image, kept so Tk does not drop it.
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME) def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
"""Build the window on a root.
## @brief Canvas that draws the card.
self.canvas = tk.Canvas( @param root The Tk root window.
root, @param view_model The data to show.
width=CARD_WIDTH, """
height=CARD_HEIGHT, ## @brief The Tk root window.
bg=BACKGROUND_COLOR, self._root = root
highlightthickness=0, ## @brief The data source of the texts.
) self._view_model = view_model
## @brief Canvas item id of the card image. root.title(WINDOW_TITLE)
self.image_id = self.canvas.create_image( root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
) # Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Canvas item id of the language name text. ## @brief Card front image, kept so Tk does not drop it.
self.language_text_id = self.canvas.create_text( self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
CARD_CENTER_X, ## @brief Card back image, kept so Tk does not drop it.
LANGUAGE_TEXT_Y, self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
text=view_model.language_name, ## @brief Wrong button image, kept so Tk does not drop it.
font=LANGUAGE_FONT, self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
fill=FRONT_TEXT_COLOR, ## @brief Right button image, kept so Tk does not drop it.
) self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
## @brief Canvas item id of the word text.
self.word_text_id = self.canvas.create_text( ## @brief Canvas that draws the card.
CARD_CENTER_X, self.canvas = tk.Canvas(
WORD_TEXT_Y, root,
text=view_model.text, width=CARD_WIDTH,
font=WORD_FONT, height=CARD_HEIGHT,
fill=FRONT_TEXT_COLOR, bg=BACKGROUND_COLOR,
) highlightthickness=0,
self.canvas.grid(row=0, column=0, columnspan=2) )
## @brief Canvas item id of the card image.
## @brief Button for a word the user does not know. self.image_id = self.canvas.create_image(
self.wrong_button = tk.Button( CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
root, )
image=self._wrong_image, ## @brief Canvas item id of the language name text.
highlightthickness=0, self.language_text_id = self.canvas.create_text(
bd=0, CARD_CENTER_X,
command=self.next_card, LANGUAGE_TEXT_Y,
) text=view_model.language_name,
self.wrong_button.grid(row=1, column=0) font=LANGUAGE_FONT,
## @brief Button for a word the user knows. fill=FRONT_TEXT_COLOR,
self.right_button = tk.Button( )
root, ## @brief Canvas item id of the word text.
image=self._right_image, self.word_text_id = self.canvas.create_text(
highlightthickness=0, CARD_CENTER_X,
bd=0, WORD_TEXT_Y,
command=self.next_card, text=view_model.text,
) font=WORD_FONT,
self.right_button.grid(row=1, column=1) fill=FRONT_TEXT_COLOR,
)
def next_card(self) -> None: self.canvas.grid(row=0, column=0, columnspan=2)
"""Show the next card.
## @brief Button for a word the user does not know.
@return Nothing. self.wrong_button = tk.Button(
""" root,
self._view_model.next_card() image=self._wrong_image,
self.canvas.itemconfig( highlightthickness=0,
self.language_text_id, text=self._view_model.language_name bd=0,
) command=self.next_card,
self.canvas.itemconfig(self.word_text_id, text=self._view_model.text) )
self.wrong_button.grid(row=1, column=0)
## @brief Button for a word the user knows.
self.right_button = tk.Button(
root,
image=self._right_image,
highlightthickness=0,
bd=0,
command=self.is_known,
)
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:
"""Show the next card front and restart the flip timer.
@return Nothing.
"""
self._cancel_flip_timer()
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.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
)
@@ -1,69 +1,119 @@
"""View model of the flash card screen. """View model of the flash card screen.
@file flash_card_view_model.py @file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import. @brief Exposes the texts shown on the card; free of any Tkinter import.
""" """
from collections.abc import Mapping import enum
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession from flash_card.application.dto.card_dto import CardDto
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class FlashCardViewModel:
"""Data the flash card view shows.""" class Side(enum.Enum):
"""The side of a card that is shown."""
def __init__(
self, ## @brief The front: the word to learn.
session: StudySession, FRONT = enum.auto()
language_names: Mapping[str, str] = LANGUAGE_NAMES, ## @brief The back: the translation.
) -> None: BACK = enum.auto()
"""Create the view model and draw the first card.
@param session The study session that supplies the cards. class FlashCardViewModel:
@param language_names Display names by language code. """Data the flash card view shows."""
"""
## @brief The study session that supplies the cards. def __init__(
self._session = session self,
## @brief Display names by language code. session: StudySession,
self._language_names = language_names language_names: Mapping[str, str] = LANGUAGE_NAMES,
## @brief The card shown now, or None when none is left. ) -> None:
self._card: CardDto | None = session.next_card() """Create the view model and draw the first card.
@property @param session The study session that supplies the cards.
def is_finished(self) -> bool: @param language_names Display names by language code.
"""Tell whether no card is left to show. """
## @brief The study session that supplies the cards.
@return True when there is no card. self._session = session
""" ## @brief Display names by language code.
return self._card is None self._language_names = language_names
## @brief The card shown now, or None when none is left.
@property self._card: CardDto | None = session.next_card()
def language_name(self) -> str: ## @brief The side of the card shown.
"""Return the language name shown on the card. self._side = Side.FRONT
@return The display name of the front language, upper case code when @property
unknown, or an empty string when no card is left. def is_finished(self) -> bool:
""" """Tell whether no card is left to show.
if self._card is None:
return "" @return True when there is no card.
code = self._card.front_language """
return self._language_names.get(code, code.upper()) return self._card is None
@property @property
def text(self) -> str: def side(self) -> Side:
"""Return the word shown on the card. """Return the side of the card shown.
@return The front text, or the finished message when no card is left. @return FRONT or BACK; FRONT when no card is left.
""" """
if self._card is None: return self._side
return FINISHED_MESSAGE
return self._card.front_text @property
def language_name(self) -> str:
def next_card(self) -> None: """Return the language name shown on the card.
"""Draw the next card.
@return The display name of the language of the side shown, upper case
@return Nothing. code when unknown, or an empty string when no card is left.
""" """
self._card = self._session.next_card() if self._card is None:
return ""
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())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The text of the side shown, or the finished message when no
card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text
def next_card(self) -> None:
"""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
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
+329 -136
View File
@@ -1,136 +1,329 @@
"""End-to-end tests of the flash card window. """End-to-end tests of the flash card window.
@file test_flash_card_window.py @file test_flash_card_window.py
@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 tkinter as tk import time
from collections.abc import Iterator, Sequence import tkinter as tk
from collections.abc import Callable, Iterator, Sequence
import pytest from pathlib import Path
from flash_card.application.use_cases.study_session import StudySession import pytest
from flash_card.constants import (
BACKGROUND_COLOR, from flash_card.application.use_cases.open_study_session import open_study_session
CARD_HEIGHT, from flash_card.application.use_cases.study_session import StudySession
CARD_WIDTH, from flash_card.constants import (
FRONT_TEXT_COLOR, BACK_TEXT_COLOR,
LANGUAGE_FONT, BACKGROUND_COLOR,
WINDOW_PADDING, CARD_HEIGHT,
WINDOW_TITLE, CARD_WIDTH,
WORD_FONT, FINISHED_MESSAGE,
) FLIP_DELAY_MS,
from flash_card.domain.entities.card import Card FRONT_TEXT_COLOR,
from flash_card.domain.entities.deck import Deck LANGUAGE_FONT,
from flash_card.domain.value_objects.language_pair import LanguagePair WINDOW_PADDING,
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView WINDOW_TITLE,
from flash_card.presentation.features.flash_card.flash_card_view_model import ( WORD_FONT,
FlashCardViewModel, )
) from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne")) from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
def _first(cards: Sequence[Card]) -> Card: )
return cards[0] from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
def _build_view(root: tk.Tk) -> FlashCardView: from flash_card.presentation.features.flash_card import flash_card_view
deck = Deck(LanguagePair("ms", "da"), CARDS) from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first))) from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
def _shown(view: FlashCardView) -> tuple[str, str]:
return ( CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
str(view.canvas.itemcget(view.language_text_id, "text")),
str(view.canvas.itemcget(view.word_text_id, "text")),
) def _first(cards: Sequence[Card]) -> Card:
return cards[0]
@pytest.fixture(scope="module")
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]: class _MemoryProgress:
# One Tk per module: a second interpreter right after destroying the """Progress store that keeps nothing: the shared window never deletes cards."""
# first fails to find init.tcl on some Windows Python installs.
try: def load(self) -> Deck | None:
root = tk.Tk() return None
except tk.TclError:
pytest.skip("no display available for Tk") def save(self, deck: Deck) -> None:
view = _build_view(root) return None
root.update_idletasks()
yield root, view
root.destroy() def _build_view(root: tk.Tk) -> FlashCardView:
deck = Deck(LanguagePair("ms", "da"), CARDS)
session = StudySession(deck, _first, _MemoryProgress())
def test_window_has_title_background_and_padding( return FlashCardView(root, FlashCardViewModel(session))
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, _view = window def _shown(view: FlashCardView) -> tuple[str, str]:
assert root.title() == WINDOW_TITLE return (
assert root.cget("bg") == BACKGROUND_COLOR str(view.canvas.itemcget(view.language_text_id, "text")),
assert int(str(root.cget("padx"))) == WINDOW_PADDING str(view.canvas.itemcget(view.word_text_id, "text")),
assert int(str(root.cget("pady"))) == WINDOW_PADDING )
def test_canvas_has_card_size_and_spans_two_columns( @pytest.fixture(scope="module")
window: tuple[tk.Tk, FlashCardView], def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
) -> None: # One Tk per module: a second interpreter right after destroying the
_root, view = window # first fails to find init.tcl on some Windows Python installs.
assert int(str(view.canvas.cget("width"))) == CARD_WIDTH try:
assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT root = tk.Tk()
info = view.canvas.grid_info() except tk.TclError:
assert (int(info["row"]), int(info["column"])) == (0, 0) pytest.skip("no display available for Tk")
assert int(info["columnspan"]) == 2 view = _build_view(root)
root.update_idletasks()
yield root, view
def test_canvas_holds_one_image_and_two_texts( root.destroy()
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window def test_window_has_title_background_and_padding(
types = [view.canvas.type(item) for item in view.canvas.find_all()] window: tuple[tk.Tk, FlashCardView],
assert types.count("image") == 1 ) -> None:
assert types.count("text") == 2 root, _view = window
assert len(types) == 3 assert root.title() == WINDOW_TITLE
assert root.cget("bg") == BACKGROUND_COLOR
assert int(str(root.cget("padx"))) == WINDOW_PADDING
def test_texts_show_first_card_with_font_and_colour( assert int(str(root.cget("pady"))) == WINDOW_PADDING
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window def test_canvas_has_card_size_and_spans_two_columns(
canvas = view.canvas window: tuple[tk.Tk, FlashCardView],
expected = [ ) -> None:
(view.language_text_id, "Malay", LANGUAGE_FONT), _root, view = window
(view.word_text_id, "aku", WORD_FONT), assert int(str(view.canvas.cget("width"))) == CARD_WIDTH
] assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT
for item, text, font in expected: info = view.canvas.grid_info()
assert canvas.itemcget(item, "text") == text assert (int(info["row"]), int(info["column"])) == (0, 0)
assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR assert int(info["columnspan"]) == 2
family, size, style = font
assert canvas.itemcget(item, "font").split() == [family, str(size), style]
def test_canvas_holds_one_image_and_two_texts(
window: tuple[tk.Tk, FlashCardView],
def test_buttons_sit_in_row_one_with_their_images( ) -> None:
window: tuple[tk.Tk, FlashCardView], _root, view = window
) -> None: types = [view.canvas.type(item) for item in view.canvas.find_all()]
_root, view = window assert types.count("image") == 1
wrong = view.wrong_button.grid_info() assert types.count("text") == 2
right = view.right_button.grid_info() assert len(types) == 3
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0)
assert (int(right["row"]), int(right["column"])) == (1, 1)
wrong_image = str(view.wrong_button.cget("image")) def test_texts_show_first_card_with_font_and_colour(
right_image = str(view.right_button.cget("image")) window: tuple[tk.Tk, FlashCardView],
assert wrong_image ) -> None:
assert right_image _root, view = window
assert wrong_image != right_image canvas = view.canvas
assert str(view.wrong_button.cget("command")) expected = [
assert str(view.right_button.cget("command")) (view.language_text_id, "Malay", LANGUAGE_FONT),
(view.word_text_id, "aku", WORD_FONT),
]
def test_each_button_press_shows_next_card( for item, text, font in expected:
window: tuple[tk.Tk, FlashCardView], assert canvas.itemcget(item, "text") == text
) -> None: assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR
_root, view = window family, size, style = font
assert _shown(view) == ("Malay", "aku") assert canvas.itemcget(item, "font").split() == [family, str(size), style]
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "kau")
view.right_button.invoke() def test_buttons_sit_in_row_one_with_their_images(
assert _shown(view) == ("Malay", "aku") window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
wrong = view.wrong_button.grid_info()
right = view.right_button.grid_info()
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0)
assert (int(right["row"]), int(right["column"])) == (1, 1)
wrong_image = str(view.wrong_button.cget("image"))
right_image = str(view.right_button.cget("image"))
assert wrong_image
assert right_image
assert wrong_image != right_image
assert str(view.wrong_button.cget("command"))
assert str(view.right_button.cget("command"))
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.wrong_button.invoke()
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 == []