Merge pull request 'MIL-005: Step 4 - Save progress' (#42) from mil-005-step4-save-progress into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
This commit was merged in pull request #42.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
*.py text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.csv text eol=lf
|
||||||
|
*.png binary
|
||||||
+70
-70
@@ -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 | 027 |
|
| 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.
|
||||||
|
|||||||
@@ -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
-113
@@ -1,113 +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] |
|
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
||||||
|
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||||
## Coverage Notes
|
|
||||||
|
## Coverage Notes
|
||||||
- `-` 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.
|
- `-` 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).
|
||||||
- Source code (`PY`) is added as `src/` is reviewed, one row per milestone.
|
- 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
|
[BC-001]: ../business-case.md
|
||||||
[PP-001]: ../project-plan.md
|
[SA-001]: ../stakeholder-analysis.md
|
||||||
[UCD-001]: ../use-case-diagram.md
|
[PP-001]: ../project-plan.md
|
||||||
[US-001]: ../user-stories.md
|
[UCD-001]: ../use-case-diagram.md
|
||||||
[UC-001]: ../uc-001/uc.md
|
[US-001]: ../user-stories.md
|
||||||
[DM-001]: ../domain-model.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
[SSD-001]: ../uc-001/ssd.md
|
[DM-001]: ../domain-model.md
|
||||||
[OC-001]: ../uc-001/oc.md
|
[SSD-001]: ../uc-001/ssd.md
|
||||||
[SD-001]: ../uc-001/sd.md
|
[OC-001]: ../uc-001/oc.md
|
||||||
[DCD-001]: ../dcd.md
|
[SD-001]: ../uc-001/sd.md
|
||||||
[DICT-001]: ../dictionary.md
|
[DCD-001]: ../dcd.md
|
||||||
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
|
[DICT-001]: ../dictionary.md
|
||||||
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
|
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
|
||||||
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
|
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
|
||||||
[MIL-001]: ../milestones/mil-001-foundation.md
|
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
|
||||||
[MIL-002]: ../milestones/mil-002-step1-user-interface.md
|
[MIL-001]: ../milestones/mil-001-foundation.md
|
||||||
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
|
[MIL-002]: ../milestones/mil-002-step1-user-interface.md
|
||||||
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md
|
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
|
||||||
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
|
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md
|
||||||
[MIL-006]: ../milestones/mil-006-malay-speech.md
|
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
|
||||||
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md
|
[MIL-006]: ../milestones/mil-006-malay-speech.md
|
||||||
[PY-MIL-001]: ../../src/flash_card
|
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md
|
||||||
[RC-001]: ./reviews/rc-001-business-case.md
|
[PY-MIL-001]: ../../src/flash_card
|
||||||
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
[RC-001]: ./reviews/rc-001-business-case.md
|
||||||
[RC-003]: ./reviews/rc-003-project-plan.md
|
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
||||||
[RC-004]: ./reviews/rc-004-use-case-diagram.md
|
[RC-003]: ./reviews/rc-003-project-plan.md
|
||||||
[RC-005]: ./reviews/rc-005-user-stories.md
|
[RC-004]: ./reviews/rc-004-use-case-diagram.md
|
||||||
[RC-006]: ./reviews/rc-006-use-case.md
|
[RC-005]: ./reviews/rc-005-user-stories.md
|
||||||
[RC-007]: ./reviews/rc-007-domain-model.md
|
[RC-006]: ./reviews/rc-006-use-case.md
|
||||||
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md
|
[RC-007]: ./reviews/rc-007-domain-model.md
|
||||||
[RC-009]: ./reviews/rc-009-operation-contracts.md
|
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md
|
||||||
[RC-010]: ./reviews/rc-010-sequence-diagrams.md
|
[RC-009]: ./reviews/rc-009-operation-contracts.md
|
||||||
[RC-011]: ./reviews/rc-011-design-class-diagram.md
|
[RC-010]: ./reviews/rc-010-sequence-diagrams.md
|
||||||
[RC-012]: ./reviews/rc-012-domain-dictionary.md
|
[RC-011]: ./reviews/rc-011-design-class-diagram.md
|
||||||
[RC-013]: ./reviews/rc-013-adr-0001.md
|
[RC-012]: ./reviews/rc-012-domain-dictionary.md
|
||||||
[RC-014]: ./reviews/rc-014-adr-0002.md
|
[RC-013]: ./reviews/rc-013-adr-0001.md
|
||||||
[RC-015]: ./reviews/rc-015-adr-0003.md
|
[RC-014]: ./reviews/rc-014-adr-0002.md
|
||||||
[RC-016]: ./reviews/rc-016-mil-001.md
|
[RC-015]: ./reviews/rc-015-adr-0003.md
|
||||||
[RC-017]: ./reviews/rc-017-mil-002.md
|
[RC-016]: ./reviews/rc-016-mil-001.md
|
||||||
[RC-018]: ./reviews/rc-018-mil-003.md
|
[RC-017]: ./reviews/rc-017-mil-002.md
|
||||||
[RC-019]: ./reviews/rc-019-mil-004.md
|
[RC-018]: ./reviews/rc-018-mil-003.md
|
||||||
[RC-020]: ./reviews/rc-020-mil-005.md
|
[RC-019]: ./reviews/rc-019-mil-004.md
|
||||||
[RC-021]: ./reviews/rc-021-mil-006.md
|
[RC-020]: ./reviews/rc-020-mil-005.md
|
||||||
[RC-022]: ./reviews/rc-022-mil-007.md
|
[RC-021]: ./reviews/rc-021-mil-006.md
|
||||||
[RC-023]: ./reviews/rc-023-py-mil-001.md
|
[RC-022]: ./reviews/rc-022-mil-007.md
|
||||||
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
|
[RC-023]: ./reviews/rc-023-py-mil-001.md
|
||||||
[RC-024]: ./reviews/rc-024-py-mil-002.md
|
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
|
||||||
[PY-MIL-002]: ../../src/flash_card
|
[RC-024]: ./reviews/rc-024-py-mil-002.md
|
||||||
[RC-025]: ./reviews/rc-025-py-mil-003.md
|
[PY-MIL-002]: ../../src/flash_card
|
||||||
[PY-MIL-003]: ../../src/flash_card
|
[RC-025]: ./reviews/rc-025-py-mil-003.md
|
||||||
[RC-026]: ./reviews/rc-026-py-mil-004.md
|
[PY-MIL-003]: ../../src/flash_card
|
||||||
[PY-MIL-004]: ../../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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
|
||||||
|
"""Load an asset image.
|
||||||
|
|
||||||
|
The bytes are read here and handed to Tk as data, so the image does not
|
||||||
|
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
|
||||||
|
start, and a process that swaps them (pytest fd capture) makes the first
|
||||||
|
Tcl file open fail with an empty error.
|
||||||
|
|
||||||
|
@param root The Tk root window that owns the image.
|
||||||
|
@param name The file name in the assets folder.
|
||||||
|
@return The image; the caller must keep a reference.
|
||||||
|
"""
|
||||||
|
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
|
||||||
|
|
||||||
|
|
||||||
class FlashCardView:
|
class FlashCardView:
|
||||||
"""The flash card window: a card and the wrong and right buttons."""
|
"""The flash card window: a card and the wrong and right buttons."""
|
||||||
|
|
||||||
@@ -50,13 +65,13 @@ class FlashCardView:
|
|||||||
|
|
||||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||||
## @brief Card front image, kept so Tk does not drop it.
|
## @brief Card front image, kept so Tk does not drop it.
|
||||||
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
|
self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
|
||||||
## @brief Card back image, kept so Tk does not drop it.
|
## @brief Card back image, kept so Tk does not drop it.
|
||||||
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
|
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
|
||||||
## @brief Wrong button image, kept so Tk does not drop it.
|
## @brief Wrong button image, kept so Tk does not drop it.
|
||||||
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
|
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
|
||||||
## @brief Right button image, kept so Tk does not drop it.
|
## @brief Right button image, kept so Tk does not drop it.
|
||||||
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
|
self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
|
||||||
|
|
||||||
## @brief Canvas that draws the card.
|
## @brief Canvas that draws the card.
|
||||||
self.canvas = tk.Canvas(
|
self.canvas = tk.Canvas(
|
||||||
@@ -103,7 +118,7 @@ class FlashCardView:
|
|||||||
image=self._right_image,
|
image=self._right_image,
|
||||||
highlightthickness=0,
|
highlightthickness=0,
|
||||||
bd=0,
|
bd=0,
|
||||||
command=self.next_card,
|
command=self.is_known,
|
||||||
)
|
)
|
||||||
self.right_button.grid(row=1, column=1)
|
self.right_button.grid(row=1, column=1)
|
||||||
|
|
||||||
@@ -137,6 +152,16 @@ class FlashCardView:
|
|||||||
self._show_card()
|
self._show_card()
|
||||||
self._start_flip_timer()
|
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:
|
def flip_card(self) -> None:
|
||||||
"""Show the back of the current card.
|
"""Show the back of the current card.
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from flash_card.application.dto.card_dto import CardDto
|
from flash_card.application.dto.card_dto import CardDto
|
||||||
from flash_card.application.use_cases.study_session import StudySession
|
from flash_card.application.use_cases.study_session import StudySession
|
||||||
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
|
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
|
||||||
|
|
||||||
|
|
||||||
class Side(enum.Enum):
|
class Side(enum.Enum):
|
||||||
"""The side of a card that is shown."""
|
"""The side of a card that is shown."""
|
||||||
|
|
||||||
## @brief The front: the word to learn.
|
## @brief The front: the word to learn.
|
||||||
FRONT = enum.auto()
|
FRONT = enum.auto()
|
||||||
## @brief The back: the translation.
|
## @brief The back: the translation.
|
||||||
BACK = enum.auto()
|
BACK = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
class FlashCardViewModel:
|
class FlashCardViewModel:
|
||||||
"""Data the flash card view shows."""
|
"""Data the flash card view shows."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session: StudySession,
|
session: StudySession,
|
||||||
language_names: Mapping[str, str] = LANGUAGE_NAMES,
|
language_names: Mapping[str, str] = LANGUAGE_NAMES,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create the view model and draw the first card.
|
"""Create the view model and draw the first card.
|
||||||
|
|
||||||
@param session The study session that supplies the cards.
|
@param session The study session that supplies the cards.
|
||||||
@param language_names Display names by language code.
|
@param language_names Display names by language code.
|
||||||
"""
|
"""
|
||||||
## @brief The study session that supplies the cards.
|
## @brief The study session that supplies the cards.
|
||||||
self._session = session
|
self._session = session
|
||||||
## @brief Display names by language code.
|
## @brief Display names by language code.
|
||||||
self._language_names = language_names
|
self._language_names = language_names
|
||||||
## @brief The card shown now, or None when none is left.
|
## @brief The card shown now, or None when none is left.
|
||||||
self._card: CardDto | None = session.next_card()
|
self._card: CardDto | None = session.next_card()
|
||||||
## @brief The side of the card shown.
|
## @brief The side of the card shown.
|
||||||
self._side = Side.FRONT
|
self._side = Side.FRONT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_finished(self) -> bool:
|
def is_finished(self) -> bool:
|
||||||
"""Tell whether no card is left to show.
|
"""Tell whether no card is left to show.
|
||||||
|
|
||||||
@return True when there is no card.
|
@return True when there is no card.
|
||||||
"""
|
"""
|
||||||
return self._card is None
|
return self._card is None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def side(self) -> Side:
|
def side(self) -> Side:
|
||||||
"""Return the side of the card shown.
|
"""Return the side of the card shown.
|
||||||
|
|
||||||
@return FRONT or BACK; FRONT when no card is left.
|
@return FRONT or BACK; FRONT when no card is left.
|
||||||
"""
|
"""
|
||||||
return self._side
|
return self._side
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def language_name(self) -> str:
|
def language_name(self) -> str:
|
||||||
"""Return the language name shown on the card.
|
"""Return the language name shown on the card.
|
||||||
|
|
||||||
@return The display name of the language of the side shown, upper case
|
@return The display name of the language of the side shown, upper case
|
||||||
code when unknown, or an empty string when no card is left.
|
code when unknown, or an empty string when no card is left.
|
||||||
"""
|
"""
|
||||||
if self._card is None:
|
if self._card is None:
|
||||||
return ""
|
return ""
|
||||||
if self._side is Side.BACK:
|
if self._side is Side.BACK:
|
||||||
code = self._card.back_language
|
code = self._card.back_language
|
||||||
else:
|
else:
|
||||||
code = self._card.front_language
|
code = self._card.front_language
|
||||||
return self._language_names.get(code, code.upper())
|
return self._language_names.get(code, code.upper())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def text(self) -> str:
|
def text(self) -> str:
|
||||||
"""Return the word shown on the card.
|
"""Return the word shown on the card.
|
||||||
|
|
||||||
@return The text of the side shown, or the finished message when no
|
@return The text of the side shown, or the finished message when no
|
||||||
card is left.
|
card is left.
|
||||||
"""
|
"""
|
||||||
if self._card is None:
|
if self._card is None:
|
||||||
return FINISHED_MESSAGE
|
return FINISHED_MESSAGE
|
||||||
if self._side is Side.BACK:
|
if self._side is Side.BACK:
|
||||||
return self._card.back_text
|
return self._card.back_text
|
||||||
return self._card.front_text
|
return self._card.front_text
|
||||||
|
|
||||||
def next_card(self) -> None:
|
def next_card(self) -> None:
|
||||||
"""Draw the next card and show its front.
|
"""Draw the next card and show its front; does nothing when finished.
|
||||||
|
|
||||||
@return Nothing.
|
@return Nothing.
|
||||||
"""
|
"""
|
||||||
self._card = self._session.next_card()
|
if self._card is None:
|
||||||
self._side = Side.FRONT
|
return
|
||||||
|
self._card = self._session.next_card()
|
||||||
def flip_card(self) -> None:
|
self._side = Side.FRONT
|
||||||
"""Show the back of the card; does nothing when finished or flipped.
|
|
||||||
|
def is_known(self) -> None:
|
||||||
@return Nothing.
|
"""Mark the card as known, save the progress and show the next card.
|
||||||
"""
|
|
||||||
if self._card is not None:
|
Does nothing when finished.
|
||||||
self._side = Side.BACK
|
|
||||||
|
@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
|
||||||
|
|||||||
@@ -7,15 +7,18 @@
|
|||||||
import time
|
import time
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Callable, Iterator, Sequence
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||||
from flash_card.application.use_cases.study_session import StudySession
|
from flash_card.application.use_cases.study_session import StudySession
|
||||||
from flash_card.constants import (
|
from flash_card.constants import (
|
||||||
BACK_TEXT_COLOR,
|
BACK_TEXT_COLOR,
|
||||||
BACKGROUND_COLOR,
|
BACKGROUND_COLOR,
|
||||||
CARD_HEIGHT,
|
CARD_HEIGHT,
|
||||||
CARD_WIDTH,
|
CARD_WIDTH,
|
||||||
|
FINISHED_MESSAGE,
|
||||||
FLIP_DELAY_MS,
|
FLIP_DELAY_MS,
|
||||||
FRONT_TEXT_COLOR,
|
FRONT_TEXT_COLOR,
|
||||||
LANGUAGE_FONT,
|
LANGUAGE_FONT,
|
||||||
@@ -26,6 +29,12 @@ from flash_card.constants import (
|
|||||||
from flash_card.domain.entities.card import Card
|
from flash_card.domain.entities.card import Card
|
||||||
from flash_card.domain.entities.deck import Deck
|
from flash_card.domain.entities.deck import Deck
|
||||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||||
|
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||||
|
CsvProgressRepository,
|
||||||
|
)
|
||||||
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
|
CsvVocabularyRepository,
|
||||||
|
)
|
||||||
from flash_card.presentation.features.flash_card import flash_card_view
|
from flash_card.presentation.features.flash_card import flash_card_view
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||||
@@ -39,9 +48,20 @@ def _first(cards: Sequence[Card]) -> Card:
|
|||||||
return cards[0]
|
return cards[0]
|
||||||
|
|
||||||
|
|
||||||
|
class _MemoryProgress:
|
||||||
|
"""Progress store that keeps nothing: the shared window never deletes cards."""
|
||||||
|
|
||||||
|
def load(self) -> Deck | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, deck: Deck) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||||
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
|
session = StudySession(deck, _first, _MemoryProgress())
|
||||||
|
return FlashCardView(root, FlashCardViewModel(session))
|
||||||
|
|
||||||
|
|
||||||
def _shown(view: FlashCardView) -> tuple[str, str]:
|
def _shown(view: FlashCardView) -> tuple[str, str]:
|
||||||
@@ -129,14 +149,14 @@ def test_buttons_sit_in_row_one_with_their_images(
|
|||||||
assert str(view.right_button.cget("command"))
|
assert str(view.right_button.cget("command"))
|
||||||
|
|
||||||
|
|
||||||
def test_each_button_press_shows_next_card(
|
def test_wrong_button_press_shows_next_card(
|
||||||
window: tuple[tk.Tk, FlashCardView],
|
window: tuple[tk.Tk, FlashCardView],
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, view = window
|
_root, view = window
|
||||||
assert _shown(view) == ("Malay", "aku")
|
assert _shown(view) == ("Malay", "aku")
|
||||||
view.wrong_button.invoke()
|
view.wrong_button.invoke()
|
||||||
assert _shown(view) == ("Malay", "kau")
|
assert _shown(view) == ("Malay", "kau")
|
||||||
view.right_button.invoke()
|
view.wrong_button.invoke()
|
||||||
assert _shown(view) == ("Malay", "aku")
|
assert _shown(view) == ("Malay", "aku")
|
||||||
|
|
||||||
|
|
||||||
@@ -195,7 +215,7 @@ def test_next_card_after_flip_shows_front_again(
|
|||||||
root, view = window
|
root, view = window
|
||||||
_reset(root, view)
|
_reset(root, view)
|
||||||
view.flip_card()
|
view.flip_card()
|
||||||
view.right_button.invoke()
|
view.wrong_button.invoke()
|
||||||
assert _image(view) == str(view.card_front_image)
|
assert _image(view) == str(view.card_front_image)
|
||||||
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
|
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
|
||||||
assert _shown(view)[0] == "Malay"
|
assert _shown(view)[0] == "Malay"
|
||||||
@@ -245,3 +265,65 @@ def test_real_timer_flips_card_after_delay(
|
|||||||
assert _image(view) == str(view.card_back_image)
|
assert _image(view) == str(view.card_back_image)
|
||||||
assert _shown(view)[0] == "Danish"
|
assert _shown(view)[0] == "Danish"
|
||||||
_reset(root, view)
|
_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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 == []
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,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:
|
||||||
@@ -90,3 +104,45 @@ def test_next_card_resets_side_to_front() -> None:
|
|||||||
assert view_model.side is Side.FRONT
|
assert view_model.side is Side.FRONT
|
||||||
assert view_model.language_name == "Malay"
|
assert view_model.language_name == "Malay"
|
||||||
assert view_model.text == "kau"
|
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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user