diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..05ecb58 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.py text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.csv text eol=lf +*.png binary diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 5a400ec..c97babf 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -1,70 +1,70 @@ -# Artifact Registry - -This project's artifact state. Types, short names and `CrossReference -Candidates` come from the framework catalog -(`framework/registry/artifact-catalog.md`); this file only records where -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 -document of a type. `Primary File` may contain a glob (e.g. -`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it. - -| Short Name | Artifact Type | Primary File | Next Available Version | -| --- | --- | --- | --- | -| BC | Business Case | docs/business-case.md | 002 | -| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | -| PP | Project Plan | docs/project-plan.md | 002 | -| MIL | Milestone / Gateway | docs/milestones/*.md | 008 | -| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 | -| US | User Story | docs/user-stories.md | 002 | -| UC | Use Case | docs/uc-*/uc.md | 002 | -| OC | Operation Contract | docs/uc-*/oc.md | 002 | -| SD | Sequence Diagram | docs/uc-*/sd.md | 002 | -| DM | Domain Model | docs/domain-model.md | 002 | -| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 | -| DCD | Design Class Diagram | docs/dcd.md | 002 | -| DICT | Domain Dictionary | docs/dictionary.md | 002 | -| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 027 | -| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | - -## Languages - -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" -exists once, in that language, under its normal name (`business-case.md`); the -`artifact` skill ("One file per artifact") has the rule. - -| Setting | Value | -| --- | --- | -| PO language | en | -| PO domain | it | -| High-level register | IT Executive 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) | - -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 -`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 -reviewer can see at once which professional vocabulary a document uses. - -| Domain | Meaning | -| --- | --- | -| it | Software and IT | -| medical | Healthcare and medical devices | -| construction | Construction and civil engineering | - -| Artifact types | Register | Written in the PO language | -| --- | --- | --- | -| BC, KPI, PP, MIL | IT Executive 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 | - -## Notes - -- "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 - a brand-new document is created, not when an existing document's - `## Version History` gets a row. -- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types. +# Artifact Registry + +This project's artifact state. Types, short names and `CrossReference +Candidates` come from the framework catalog +(`framework/registry/artifact-catalog.md`); this file only records where +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 +document of a type. `Primary File` may contain a glob (e.g. +`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it. + +| Short Name | Artifact Type | Primary File | Next Available Version | +| --- | --- | --- | --- | +| BC | Business Case | docs/business-case.md | 002 | +| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | +| PP | Project Plan | docs/project-plan.md | 002 | +| MIL | Milestone / Gateway | docs/milestones/*.md | 008 | +| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 | +| US | User Story | docs/user-stories.md | 002 | +| UC | Use Case | docs/uc-*/uc.md | 002 | +| OC | Operation Contract | docs/uc-*/oc.md | 002 | +| SD | Sequence Diagram | docs/uc-*/sd.md | 002 | +| DM | Domain Model | docs/domain-model.md | 002 | +| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 | +| DCD | Design Class Diagram | docs/dcd.md | 002 | +| DICT | Domain Dictionary | docs/dictionary.md | 002 | +| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 | +| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | + +## Languages + +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" +exists once, in that language, under its normal name (`business-case.md`); the +`artifact` skill ("One file per artifact") has the rule. + +| Setting | Value | +| --- | --- | +| PO language | en | +| PO domain | it | +| High-level register | IT Executive 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) | + +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 +`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 +reviewer can see at once which professional vocabulary a document uses. + +| Domain | Meaning | +| --- | --- | +| it | Software and IT | +| medical | Healthcare and medical devices | +| construction | Construction and civil engineering | + +| Artifact types | Register | Written in the PO language | +| --- | --- | --- | +| BC, KPI, PP, MIL | IT Executive 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 | + +## Notes + +- "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 + a brand-new document is created, not when an existing document's + `## Version History` gets a row. +- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types. diff --git a/docs/sqa/reviews/rc-027-py-mil-005.md b/docs/sqa/reviews/rc-027-py-mil-005.md new file mode 100644 index 0000000..97d0caf --- /dev/null +++ b/docs/sqa/reviews/rc-027-py-mil-005.md @@ -0,0 +1,63 @@ +# RC-027: Review of PY-MIL-005 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-027 | +| CrossReference | [PY-MIL-005], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-005] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds; ruff rule set N passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the dictionary's IT terms and the assignment's (`is_known`, `mark_known`, `ProgressRepository`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 47 files with no suppressions; `[tool.ruff.format] line-ending = "lf"` and `.gitattributes` fix the line endings. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 33 source files. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | A malformed progress file raises `VocabularyFormatError` and is never overwritten; the temporary file is removed when a write fails (re-raise, no swallow); no bare `except`. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | Pass | Reading uses `with`; the write goes through a temporary file that is replaced atomically. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no secret or personal data is logged. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `ProgressRepository`, `CsvProgressRepository`, `StudySession.mark_known` and `is_known` are in DCD-001 and OC-001 (`markKnown`); `open_study_session` takes no speech port until MIL-006, as DCD-001 shows for the final design. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 107 tests (6 consecutive full runs all green) include the assignment's 3 record example, restart, round trip, finished state and e2e on the real window over `tmp_path`; none uses the network. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Reader and writer share `CSV_DELIMITER`, `CSV_ENCODING` and the row width in `csv_word_list.py`; the removal rule is only in `Deck.remove`. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the `ProgressRepository` Protocol is in `domain`, its adapter in `infrastructure`. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The use case depends on the Protocol and receives the adapter; the view model sequences two session calls only. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | New files are in the layer folders of the clean-architecture skill; tests in `unit`, `integration`, `e2e`. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Milestone MIL-005 Go criteria: 1 the 3 record example leaves the other two cards in order with the header (integration test); 2 a start uses the progress file when it exists and the word list otherwise (integration test); 3 the wrong button never writes (unit and e2e); 4 the last card known gives a finished state (unit); 5 a malformed progress file raises `VocabularyFormatError` and stays untouched (unit); 6 round trip readable as a word list (integration); 7 this record, `pytest` 107 passed. A manual run on a copy of `ms-da.csv` removed the known word and wrote 999 cards. Defect found and fixed in this milestone: a flaky `TclError` caused by pytest's descriptor capture and Tcl file handles; the view now loads image bytes with `Path.read_bytes()` and `PhotoImage(data=...)`. All Go. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-005]: ../../../src/flash_card +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index b5ce66e..9f0858f 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -1,113 +1,116 @@ -# Traceability Matrix: Flash Card - -## Metadata -| Key | Value | -| --- | --- | -| ID | TM-001 | -| CrossReference | [BC-001] | - -## Version History -| Date | Status | Author | Reviewer | Change | Commit | -| --- | --- | --- | --- | --- | --- | -| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] | - ---- - -## Purpose - -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 -updated whenever an artifact instance is created or reviewed. - -## Traceability Table - -| 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] | -| 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] | -| 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] | -| 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] | -| 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] | -| 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] | -| 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-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] | -| 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-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-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-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] | -| 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-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | -| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] | - -## 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. -- Source code (`PY`) is added as `src/` is reviewed, one row per milestone. - ---- - -[BC-001]: ../business-case.md -[SA-001]: ../stakeholder-analysis.md -[PP-001]: ../project-plan.md -[UCD-001]: ../use-case-diagram.md -[US-001]: ../user-stories.md -[UC-001]: ../uc-001/uc.md -[DM-001]: ../domain-model.md -[SSD-001]: ../uc-001/ssd.md -[OC-001]: ../uc-001/oc.md -[SD-001]: ../uc-001/sd.md -[DCD-001]: ../dcd.md -[DICT-001]: ../dictionary.md -[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md -[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md -[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md -[MIL-001]: ../milestones/mil-001-foundation.md -[MIL-002]: ../milestones/mil-002-step1-user-interface.md -[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md -[MIL-004]: ../milestones/mil-004-step3-flip-cards.md -[MIL-005]: ../milestones/mil-005-step4-save-progress.md -[MIL-006]: ../milestones/mil-006-malay-speech.md -[MIL-007]: ../milestones/mil-007-documentation-and-publication.md -[PY-MIL-001]: ../../src/flash_card -[RC-001]: ./reviews/rc-001-business-case.md -[RC-002]: ./reviews/rc-002-stakeholder-analysis.md -[RC-003]: ./reviews/rc-003-project-plan.md -[RC-004]: ./reviews/rc-004-use-case-diagram.md -[RC-005]: ./reviews/rc-005-user-stories.md -[RC-006]: ./reviews/rc-006-use-case.md -[RC-007]: ./reviews/rc-007-domain-model.md -[RC-008]: ./reviews/rc-008-system-sequence-diagram.md -[RC-009]: ./reviews/rc-009-operation-contracts.md -[RC-010]: ./reviews/rc-010-sequence-diagrams.md -[RC-011]: ./reviews/rc-011-design-class-diagram.md -[RC-012]: ./reviews/rc-012-domain-dictionary.md -[RC-013]: ./reviews/rc-013-adr-0001.md -[RC-014]: ./reviews/rc-014-adr-0002.md -[RC-015]: ./reviews/rc-015-adr-0003.md -[RC-016]: ./reviews/rc-016-mil-001.md -[RC-017]: ./reviews/rc-017-mil-002.md -[RC-018]: ./reviews/rc-018-mil-003.md -[RC-019]: ./reviews/rc-019-mil-004.md -[RC-020]: ./reviews/rc-020-mil-005.md -[RC-021]: ./reviews/rc-021-mil-006.md -[RC-022]: ./reviews/rc-022-mil-007.md -[RC-023]: ./reviews/rc-023-py-mil-001.md -[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 -[RC-024]: ./reviews/rc-024-py-mil-002.md -[PY-MIL-002]: ../../src/flash_card -[RC-025]: ./reviews/rc-025-py-mil-003.md -[PY-MIL-003]: ../../src/flash_card -[RC-026]: ./reviews/rc-026-py-mil-004.md -[PY-MIL-004]: ../../src/flash_card +# Traceability Matrix: Flash Card + +## Metadata +| Key | Value | +| --- | --- | +| ID | TM-001 | +| CrossReference | [BC-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] | + +--- + +## Purpose + +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 +updated whenever an artifact instance is created or reviewed. + +## Traceability Table + +| 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] | +| 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] | +| 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] | +| 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] | +| 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] | +| 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] | +| 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-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] | +| 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-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-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-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] | +| 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-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | +| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] | +| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] | + +## Coverage Notes + +- `-` in Upstream means foundational; in Downstream it means nothing is built on it yet; in Last Reviewed it means no `RC-*` exists yet. Every row has a review record from the baseline review (autonomous mode, AI as stand-in for S01). +- Types not created for this project, and why: ADR-0003. +- Source code (`PY`) is added as `src/` is reviewed, one row per milestone. + +--- + +[BC-001]: ../business-case.md +[SA-001]: ../stakeholder-analysis.md +[PP-001]: ../project-plan.md +[UCD-001]: ../use-case-diagram.md +[US-001]: ../user-stories.md +[UC-001]: ../uc-001/uc.md +[DM-001]: ../domain-model.md +[SSD-001]: ../uc-001/ssd.md +[OC-001]: ../uc-001/oc.md +[SD-001]: ../uc-001/sd.md +[DCD-001]: ../dcd.md +[DICT-001]: ../dictionary.md +[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md +[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md +[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md +[MIL-001]: ../milestones/mil-001-foundation.md +[MIL-002]: ../milestones/mil-002-step1-user-interface.md +[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md +[MIL-004]: ../milestones/mil-004-step3-flip-cards.md +[MIL-005]: ../milestones/mil-005-step4-save-progress.md +[MIL-006]: ../milestones/mil-006-malay-speech.md +[MIL-007]: ../milestones/mil-007-documentation-and-publication.md +[PY-MIL-001]: ../../src/flash_card +[RC-001]: ./reviews/rc-001-business-case.md +[RC-002]: ./reviews/rc-002-stakeholder-analysis.md +[RC-003]: ./reviews/rc-003-project-plan.md +[RC-004]: ./reviews/rc-004-use-case-diagram.md +[RC-005]: ./reviews/rc-005-user-stories.md +[RC-006]: ./reviews/rc-006-use-case.md +[RC-007]: ./reviews/rc-007-domain-model.md +[RC-008]: ./reviews/rc-008-system-sequence-diagram.md +[RC-009]: ./reviews/rc-009-operation-contracts.md +[RC-010]: ./reviews/rc-010-sequence-diagrams.md +[RC-011]: ./reviews/rc-011-design-class-diagram.md +[RC-012]: ./reviews/rc-012-domain-dictionary.md +[RC-013]: ./reviews/rc-013-adr-0001.md +[RC-014]: ./reviews/rc-014-adr-0002.md +[RC-015]: ./reviews/rc-015-adr-0003.md +[RC-016]: ./reviews/rc-016-mil-001.md +[RC-017]: ./reviews/rc-017-mil-002.md +[RC-018]: ./reviews/rc-018-mil-003.md +[RC-019]: ./reviews/rc-019-mil-004.md +[RC-020]: ./reviews/rc-020-mil-005.md +[RC-021]: ./reviews/rc-021-mil-006.md +[RC-022]: ./reviews/rc-022-mil-007.md +[RC-023]: ./reviews/rc-023-py-mil-001.md +[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 +[RC-024]: ./reviews/rc-024-py-mil-002.md +[PY-MIL-002]: ../../src/flash_card +[RC-025]: ./reviews/rc-025-py-mil-003.md +[PY-MIL-003]: ../../src/flash_card +[RC-026]: ./reviews/rc-026-py-mil-004.md +[PY-MIL-004]: ../../src/flash_card +[RC-027]: ./reviews/rc-027-py-mil-005.md +[PY-MIL-005]: ../../src/flash_card diff --git a/pyproject.toml b/pyproject.toml index 711827d..5fac880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ line-length = 88 target-version = "py313" src = ["src", "tests"] +[tool.ruff.format] +line-ending = "lf" + [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"] ignore = ["D203", "D213"] diff --git a/src/flash_card/application/use_cases/open_study_session.py b/src/flash_card/application/use_cases/open_study_session.py index 343a694..2edadb1 100644 --- a/src/flash_card/application/use_cases/open_study_session.py +++ b/src/flash_card/application/use_cases/open_study_session.py @@ -9,19 +9,25 @@ from collections.abc import Callable, Sequence from flash_card.application.use_cases.study_session import StudySession from flash_card.domain.entities.card import Card from flash_card.domain.entities.deck import Deck +from flash_card.domain.repositories.progress_repository import ProgressRepository from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository def open_study_session( vocabulary_repository: VocabularyRepository, + progress_repository: ProgressRepository, choose: Callable[[Sequence[Card]], Card], ) -> StudySession: - """Load the word list and build a session over all its cards. + """Build a session over the saved words to learn, else the whole word list. - @param vocabulary_repository Source of the word list. + @param vocabulary_repository Source of the delivered word list. + @param progress_repository Store of the words still to learn. @param choose Picks one card from a non-empty sequence. @return The new session. - @throws VocabularyError When the word list cannot be loaded. + @throws VocabularyError When the saved progress, or the word list when no + progress exists, cannot be loaded. """ - deck = Deck.from_vocabulary(vocabulary_repository.load()) - return StudySession(deck, choose) + deck = progress_repository.load() + if deck is None: + deck = Deck.from_vocabulary(vocabulary_repository.load()) + return StudySession(deck, choose, progress_repository) diff --git a/src/flash_card/application/use_cases/study_session.py b/src/flash_card/application/use_cases/study_session.py index 453d7f7..3d47118 100644 --- a/src/flash_card/application/use_cases/study_session.py +++ b/src/flash_card/application/use_cases/study_session.py @@ -1,66 +1,88 @@ -"""One study session. - -@file study_session.py -@brief The StudySession use case: draws the cards the user studies. -""" - -from collections.abc import Callable, Sequence - -from flash_card.application.dto.card_dto import CardDto -from flash_card.domain.entities.card import Card -from flash_card.domain.entities.deck import Deck - - -class StudySession: - """Draws cards from a deck, one after another.""" - - def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None: - """Create a session. - - @param deck The cards still to learn. - @param choose Picks one card from a non-empty sequence. - """ - ## @brief The cards still to learn. - self._deck = deck - ## @brief Picks one card from a non-empty sequence. - self._choose = choose - ## @brief The card shown now, or None. - self._current: Card | None = None - - @property - def current_card(self) -> CardDto | None: - """Return the card shown now. - - @return The card, or None before the first draw or when none is left. - """ - return self._to_dto(self._current) - - @property - def remaining(self) -> int: - """Return the number of cards still to learn. - - @return The card count. - """ - return len(self._deck) - - def next_card(self) -> CardDto | None: - """Draw the next card, different from the current one when possible. - - @return The new card, or None when the deck is empty. - """ - if self._deck.is_empty: - self._current = None - return None - self._current = self._deck.draw(self._choose, exclude=self._current) - return self._to_dto(self._current) - - def _to_dto(self, card: Card | None) -> CardDto | None: - """Map a card to its DTO. - - @param card The card, or None. - @return The DTO, or None when there is no card. - """ - if card is None: - return None - pair = self._deck.language_pair - return CardDto(card.front, card.back, pair.front_code, pair.back_code) +"""One study session. + +@file study_session.py +@brief The StudySession use case: draws the cards the user studies. +""" + +from collections.abc import Callable, Sequence + +from flash_card.application.dto.card_dto import CardDto +from flash_card.domain.entities.card import Card +from flash_card.domain.entities.deck import Deck +from flash_card.domain.repositories.progress_repository import ProgressRepository + + +class StudySession: + """Draws cards from a deck, one after another.""" + + def __init__( + self, + deck: Deck, + choose: Callable[[Sequence[Card]], Card], + progress: ProgressRepository, + ) -> None: + """Create a session. + + @param deck The cards still to learn. + @param choose Picks one card from a non-empty sequence. + @param progress Store of the words still to learn. + """ + ## @brief Store of the words still to learn. + self._progress = progress + ## @brief The cards still to learn. + self._deck = deck + ## @brief Picks one card from a non-empty sequence. + self._choose = choose + ## @brief The card shown now, or None. + self._current: Card | None = None + + @property + def current_card(self) -> CardDto | None: + """Return the card shown now. + + @return The card, or None before the first draw or when none is left. + """ + return self._to_dto(self._current) + + @property + def remaining(self) -> int: + """Return the number of cards still to learn. + + @return The card count. + """ + return len(self._deck) + + def next_card(self) -> CardDto | None: + """Draw the next card, different from the current one when possible. + + @return The new card, or None when the deck is empty. + """ + if self._deck.is_empty: + self._current = None + return None + self._current = self._deck.draw(self._choose, exclude=self._current) + return self._to_dto(self._current) + + def 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) diff --git a/src/flash_card/domain/entities/deck.py b/src/flash_card/domain/entities/deck.py index 1a22a0b..020149b 100644 --- a/src/flash_card/domain/entities/deck.py +++ b/src/flash_card/domain/entities/deck.py @@ -1,95 +1,95 @@ -"""The cards still to learn. - -@file deck.py -@brief The Deck entity: holds cards, draws one and removes one. -""" - -from collections.abc import Callable, Iterable, Sequence - -from flash_card.domain.entities.card import Card -from flash_card.domain.entities.vocabulary import Vocabulary -from flash_card.domain.errors.vocabulary_errors import EmptyDeckError -from flash_card.domain.value_objects.language_pair import LanguagePair - - -class Deck: - """The cards still to learn, of one language pair.""" - - def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None: - """Create a deck. - - @param language_pair The languages of the cards. - @param cards The cards of the deck. - """ - ## @brief The languages of the cards. - self._language_pair = language_pair - ## @brief The cards still in the deck. - self._cards = list(cards) - - @classmethod - def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck": - """Create a deck holding every card of a vocabulary. - - @param vocabulary The delivered word list. - @return A new deck. - """ - return cls(vocabulary.language_pair, vocabulary.cards) - - @property - def language_pair(self) -> LanguagePair: - """Return the languages of the cards. - - @return The language pair. - """ - return self._language_pair - - @property - def cards(self) -> tuple[Card, ...]: - """Return a copy of the cards. - - @return The cards in deck order. - """ - return tuple(self._cards) - - @property - def is_empty(self) -> bool: - """Tell whether the deck has no card. - - @return True when no card is left. - """ - return not self._cards - - def __len__(self) -> int: - """Return the number of cards. - - @return The card count. - """ - return len(self._cards) - - def draw( - self, - choose: Callable[[Sequence[Card]], Card], - exclude: Card | None = None, - ) -> Card: - """Pick a card without removing it. - - The pick avoids the excluded card when another card exists. - - @param choose Picks one card from a non-empty sequence. - @param exclude A card to avoid, usually the one shown now. - @return The picked card. - @throws EmptyDeckError When the deck has no card. - """ - if not self._cards: - raise EmptyDeckError("cannot draw from an empty deck") - others = [card for card in self._cards if card != exclude] - return choose(others or self._cards) - - def remove(self, card: Card) -> None: - """Remove the first card equal to the given one. - - @param card The card to remove. - @return Nothing. - @throws ValueError When the deck holds no such card. - """ - self._cards.remove(card) +"""The cards still to learn. + +@file deck.py +@brief The Deck entity: holds cards, draws one and removes one. +""" + +from collections.abc import Callable, Iterable, Sequence + +from flash_card.domain.entities.card import Card +from flash_card.domain.entities.vocabulary import Vocabulary +from flash_card.domain.errors.vocabulary_errors import EmptyDeckError +from flash_card.domain.value_objects.language_pair import LanguagePair + + +class Deck: + """The cards still to learn, of one language pair.""" + + def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None: + """Create a deck. + + @param language_pair The languages of the cards. + @param cards The cards of the deck. + """ + ## @brief The languages of the cards. + self._language_pair = language_pair + ## @brief The cards still in the deck. + self._cards = list(cards) + + @classmethod + def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck": + """Create a deck holding every card of a vocabulary. + + @param vocabulary The delivered word list. + @return A new deck. + """ + return cls(vocabulary.language_pair, vocabulary.cards) + + @property + def language_pair(self) -> LanguagePair: + """Return the languages of the cards. + + @return The language pair. + """ + return self._language_pair + + @property + def cards(self) -> tuple[Card, ...]: + """Return a copy of the cards. + + @return The cards in deck order. + """ + return tuple(self._cards) + + @property + def is_empty(self) -> bool: + """Tell whether the deck has no card. + + @return True when no card is left. + """ + return not self._cards + + def __len__(self) -> int: + """Return the number of cards. + + @return The card count. + """ + return len(self._cards) + + def draw( + self, + choose: Callable[[Sequence[Card]], Card], + exclude: Card | None = None, + ) -> Card: + """Pick a card without removing it. + + The pick avoids the excluded card when another card exists. + + @param choose Picks one card from a non-empty sequence. + @param exclude A card to avoid, usually the one shown now. + @return The picked card. + @throws EmptyDeckError When the deck has no card. + """ + if not self._cards: + raise EmptyDeckError("cannot draw from an empty deck") + others = [card for card in self._cards if card != exclude] + return choose(others or self._cards) + + def remove(self, card: Card) -> None: + """Remove the first card equal to the given one. + + @param card The card to remove. + @return Nothing. + @throws ValueError When the deck holds no such card. + """ + self._cards.remove(card) diff --git a/src/flash_card/domain/repositories/progress_repository.py b/src/flash_card/domain/repositories/progress_repository.py new file mode 100644 index 0000000..24e2013 --- /dev/null +++ b/src/flash_card/domain/repositories/progress_repository.py @@ -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. + """ + ... diff --git a/src/flash_card/infrastructure/persistence/csv_progress_repository.py b/src/flash_card/infrastructure/persistence/csv_progress_repository.py new file mode 100644 index 0000000..f1e87bf --- /dev/null +++ b/src/flash_card/infrastructure/persistence/csv_progress_repository.py @@ -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) diff --git a/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py b/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py index f6f243d..4830a77 100644 --- a/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py +++ b/src/flash_card/infrastructure/persistence/csv_vocabulary_repository.py @@ -1,35 +1,35 @@ -"""CSV implementation of the vocabulary port. - -@file csv_vocabulary_repository.py -@brief The CsvVocabularyRepository adapter. -""" - -from pathlib import Path - -from flash_card.domain.entities.vocabulary import Vocabulary -from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError -from flash_card.infrastructure.persistence.csv_word_list import read_word_list - - -class CsvVocabularyRepository: - """Loads the delivered word list from a CSV file.""" - - def __init__(self, path: Path) -> None: - """Create the repository. - - @param path The word list file. - """ - ## @brief The word list file. - self._path = path - - def load(self) -> Vocabulary: - """Load the word list. - - @return The vocabulary. - @throws VocabularyNotFoundError When the file does not exist. - @throws VocabularyFormatError When the file is malformed or has no card. - """ - pair, cards = read_word_list(self._path) - if not cards: - raise VocabularyFormatError(f"{self._path}: word list has no card") - return Vocabulary(pair, cards) +"""CSV implementation of the vocabulary port. + +@file csv_vocabulary_repository.py +@brief The CsvVocabularyRepository adapter. +""" + +from pathlib import Path + +from flash_card.domain.entities.vocabulary import Vocabulary +from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError +from flash_card.infrastructure.persistence.csv_word_list import read_word_list + + +class CsvVocabularyRepository: + """Loads the delivered word list from a CSV file.""" + + def __init__(self, path: Path) -> None: + """Create the repository. + + @param path The word list file. + """ + ## @brief The word list file. + self._path = path + + def load(self) -> Vocabulary: + """Load the word list. + + @return The vocabulary. + @throws VocabularyNotFoundError When the file does not exist. + @throws VocabularyFormatError When the file is malformed or has no card. + """ + pair, cards = read_word_list(self._path) + if not cards: + raise VocabularyFormatError(f"{self._path}: word list has no card") + return Vocabulary(pair, cards) diff --git a/src/flash_card/infrastructure/persistence/csv_word_list.py b/src/flash_card/infrastructure/persistence/csv_word_list.py index 0f98f0f..cdb62b9 100644 --- a/src/flash_card/infrastructure/persistence/csv_word_list.py +++ b/src/flash_card/infrastructure/persistence/csv_word_list.py @@ -1,86 +1,116 @@ -"""Reading of the word list CSV file. - -@file csv_word_list.py -@brief Reads a semicolon separated word list with a language code header. -""" - -import csv -from pathlib import Path - -from flash_card.constants import CSV_DELIMITER, CSV_ENCODING -from flash_card.domain.entities.card import Card -from flash_card.domain.errors.vocabulary_errors import ( - VocabularyFormatError, - VocabularyNotFoundError, -) -from flash_card.domain.value_objects.language_pair import LanguagePair - -## @brief Number of cells in the header and in every card row. -_CELLS_PER_ROW = 2 - - -def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError: - """Build a format error naming the file and line. - - @param path The file read. - @param line The line number of the fault. - @param reason What is wrong. - @return The error to raise. - """ - return VocabularyFormatError(f"{path}, line {line}: {reason}") - - -def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair: - """Parse the header row. - - @param path The file read. - @param line The line number of the row. - @param row The cells of the row. - @return The language pair. - @throws VocabularyFormatError When the header is not two codes. - """ - cells = [cell.strip() for cell in row] - if len(cells) != _CELLS_PER_ROW or not all(cells): - raise _format_error(path, line, "header must hold two language codes") - return LanguagePair(cells[0], cells[1]) - - -def _parse_card(path: Path, line: int, row: list[str]) -> Card: - """Parse a card row. - - @param path The file read. - @param line The line number of the row. - @param row The cells of the row. - @return The card. - @throws VocabularyFormatError When the row is not two cells. - """ - if len(row) != _CELLS_PER_ROW: - raise _format_error(path, line, "row must hold exactly two cells") - return Card(row[0].strip(), row[1].strip()) - - -def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]: - """Read a word list file. - - @param path The CSV file: a header of two language codes, then one card a row. - @return The language pair and the cards in file order. - @throws VocabularyNotFoundError When the file does not exist. - @throws VocabularyFormatError When the file is empty or a row is malformed. - """ - pair: LanguagePair | None = None - cards: list[Card] = [] - try: - with path.open(encoding=CSV_ENCODING, newline="") as file: - reader = csv.reader(file, delimiter=CSV_DELIMITER) - for row in reader: - if not any(cell.strip() for cell in row): - continue - if pair is None: - pair = _parse_header(path, reader.line_num, row) - else: - cards.append(_parse_card(path, reader.line_num, row)) - except FileNotFoundError as error: - raise VocabularyNotFoundError(f"word list not found: {path}") from error - if pair is None: - raise _format_error(path, 1, "file is empty") - return pair, tuple(cards) +"""Reading and writing of the word list CSV file. + +@file csv_word_list.py +@brief Reads and writes a semicolon separated word list with a language code +header; the format is defined once here. +""" + +import csv +from collections.abc import Iterable +from pathlib import Path + +from flash_card.constants import CSV_DELIMITER, CSV_ENCODING +from flash_card.domain.entities.card import Card +from flash_card.domain.errors.vocabulary_errors import ( + VocabularyFormatError, + VocabularyNotFoundError, +) +from flash_card.domain.value_objects.language_pair import LanguagePair + +## @brief Number of cells in the header and in every card row. +_CELLS_PER_ROW = 2 + + +def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError: + """Build a format error naming the file and line. + + @param path The file read. + @param line The line number of the fault. + @param reason What is wrong. + @return The error to raise. + """ + return VocabularyFormatError(f"{path}, line {line}: {reason}") + + +def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair: + """Parse the header row. + + @param path The file read. + @param line The line number of the row. + @param row The cells of the row. + @return The language pair. + @throws VocabularyFormatError When the header is not two codes. + """ + cells = [cell.strip() for cell in row] + if len(cells) != _CELLS_PER_ROW or not all(cells): + raise _format_error(path, line, "header must hold two language codes") + return LanguagePair(cells[0], cells[1]) + + +def _parse_card(path: Path, line: int, row: list[str]) -> Card: + """Parse a card row. + + @param path The file read. + @param line The line number of the row. + @param row The cells of the row. + @return The card. + @throws VocabularyFormatError When the row is not two cells. + """ + if len(row) != _CELLS_PER_ROW: + raise _format_error(path, line, "row must hold exactly two cells") + return Card(row[0].strip(), row[1].strip()) + + +def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]: + """Read a word list file. + + @param path The CSV file: a header of two language codes, then one card a row. + @return The language pair and the cards in file order. + @throws VocabularyNotFoundError When the file does not exist. + @throws VocabularyFormatError When the file is empty or a row is malformed. + """ + pair: LanguagePair | None = None + cards: list[Card] = [] + try: + with path.open(encoding=CSV_ENCODING, newline="") as file: + reader = csv.reader(file, delimiter=CSV_DELIMITER) + for row in reader: + if not any(cell.strip() for cell in row): + continue + if pair is None: + pair = _parse_header(path, reader.line_num, row) + else: + cards.append(_parse_card(path, reader.line_num, row)) + except FileNotFoundError as error: + raise VocabularyNotFoundError(f"word list not found: {path}") from error + if pair is None: + raise _format_error(path, 1, "file is empty") + return pair, tuple(cards) + + +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 diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index fd90b2d..30d032c 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -1,77 +1,85 @@ -"""Entry point and composition root of the flash card program. - -@file app.py -@brief Creates the window, its view model and view, and runs the main loop. -""" - -import argparse -import logging -import random -import tkinter as tk -from collections.abc import Sequence -from pathlib import Path -from typing import NoReturn - -from flash_card.application.use_cases.open_study_session import open_study_session -from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME -from flash_card.domain.errors.vocabulary_errors import VocabularyError -from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( - CsvVocabularyRepository, -) -from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView -from flash_card.presentation.features.flash_card.flash_card_view_model import ( - FlashCardViewModel, -) - -## @brief Logger of the entry point. -_logger = logging.getLogger(__name__) - - -def create_application( - data_dir: Path | None = None, -) -> tuple[tk.Tk, FlashCardView]: - """Create the root window and the flash card view. - - @param data_dir Folder of the data files, or None for the default folder. - @return The root window and the view built on it. - @throws VocabularyError When the word list cannot be loaded. - """ - folder = DEFAULT_DATA_DIR if data_dir is None else data_dir - repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) - session = open_study_session(repository, random.choice) - view_model = FlashCardViewModel(session) - root = tk.Tk() - view = FlashCardView(root, view_model) - return root, view - - -def main(argv: Sequence[str] | None = None) -> int: - """Run the program and return its exit code. - - @param argv Command line arguments, or None for those of the process. - @return The exit code, 0 on success and 1 when the word list is unusable. - """ - parser = argparse.ArgumentParser(description="Flash card program.") - parser.add_argument( - "--data-dir", - type=Path, - default=DEFAULT_DATA_DIR, - help="folder of the data files", - ) - args = parser.parse_args(argv) - try: - root, _view = create_application(args.data_dir) - except VocabularyError as error: - _logger.error("%s", error) - return 1 - root.mainloop() - return 0 - - -def run() -> NoReturn: - """Run the program and exit the interpreter with its exit code. - - @return Never returns; the function always raises SystemExit. - @throws SystemExit Always, with the exit code of main. - """ - raise SystemExit(main()) +"""Entry point and composition root of the flash card program. + +@file app.py +@brief Creates the window, its view model and view, and runs the main loop. +""" + +import argparse +import logging +import random +import tkinter as tk +from collections.abc import Sequence +from pathlib import Path +from typing import NoReturn + +from flash_card.application.use_cases.open_study_session import open_study_session +from flash_card.constants import ( + DEFAULT_DATA_DIR, + PROGRESS_FILE_NAME, + VOCABULARY_FILE_NAME, +) +from flash_card.domain.errors.vocabulary_errors import VocabularyError +from flash_card.infrastructure.persistence.csv_progress_repository import ( + CsvProgressRepository, +) +from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( + CsvVocabularyRepository, +) +from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView +from flash_card.presentation.features.flash_card.flash_card_view_model import ( + FlashCardViewModel, +) + +## @brief Logger of the entry point. +_logger = logging.getLogger(__name__) + + +def create_application( + data_dir: Path | None = None, +) -> tuple[tk.Tk, FlashCardView]: + """Create the root window and the flash card view. + + @param data_dir Folder of the data files, or None for the default folder. + @return The root window and the view built on it. + @throws VocabularyError When the word list or saved progress is unusable. + """ + folder = DEFAULT_DATA_DIR if data_dir is None else data_dir + repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) + progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME) + session = open_study_session(repository, progress, random.choice) + view_model = FlashCardViewModel(session) + root = tk.Tk() + view = FlashCardView(root, view_model) + return root, view + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the program and return its exit code. + + @param argv Command line arguments, or None for those of the process. + @return The exit code, 0 on success and 1 when the word list is unusable. + """ + parser = argparse.ArgumentParser(description="Flash card program.") + parser.add_argument( + "--data-dir", + type=Path, + default=DEFAULT_DATA_DIR, + help="folder of the data files", + ) + args = parser.parse_args(argv) + try: + root, _view = create_application(args.data_dir) + except VocabularyError as error: + _logger.error("%s", error) + return 1 + root.mainloop() + return 0 + + +def run() -> NoReturn: + """Run the program and exit the interpreter with its exit code. + + @return Never returns; the function always raises SystemExit. + @throws SystemExit Always, with the exit code of main. + """ + raise SystemExit(main()) diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view.py b/src/flash_card/presentation/features/flash_card/flash_card_view.py index d648a7f..3ba9cd4 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view.py @@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import ( ) +def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage: + """Load an asset image. + + The bytes are read here and handed to Tk as data, so the image does not + depend on Tcl opening the file: Tcl keeps the standard handles it saw at + start, and a process that swaps them (pytest fd capture) makes the first + Tcl file open fail with an empty error. + + @param root The Tk root window that owns the image. + @param name The file name in the assets folder. + @return The image; the caller must keep a reference. + """ + return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes()) + + class FlashCardView: """The flash card window: a card and the wrong and right buttons.""" @@ -50,13 +65,13 @@ class FlashCardView: # Kept as attributes: Tkinter drops an image nothing refers to. ## @brief Card front image, kept so Tk does not drop it. - self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME) + self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME) ## @brief Card back image, kept so Tk does not drop it. - self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME) + self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME) ## @brief Wrong button image, kept so Tk does not drop it. - self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME) + self._wrong_image = _load_image(root, WRONG_IMAGE_NAME) ## @brief Right button image, kept so Tk does not drop it. - self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME) + self._right_image = _load_image(root, RIGHT_IMAGE_NAME) ## @brief Canvas that draws the card. self.canvas = tk.Canvas( @@ -103,7 +118,7 @@ class FlashCardView: image=self._right_image, highlightthickness=0, bd=0, - command=self.next_card, + command=self.is_known, ) self.right_button.grid(row=1, column=1) @@ -137,6 +152,16 @@ class FlashCardView: self._show_card() self._start_flip_timer() + def is_known(self) -> None: + """Mark the card as known, show the next card front, restart the timer. + + @return Nothing. + """ + self._cancel_flip_timer() + self._view_model.is_known() + self._show_card() + self._start_flip_timer() + def flip_card(self) -> None: """Show the back of the current card. diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py index 2dc2059..cabb922 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view_model.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view_model.py @@ -1,104 +1,119 @@ -"""View model of the flash card screen. - -@file flash_card_view_model.py -@brief Exposes the texts shown on the card; free of any Tkinter import. -""" - -import enum -from collections.abc import Mapping - -from flash_card.application.dto.card_dto import CardDto -from flash_card.application.use_cases.study_session import StudySession -from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES - - -class Side(enum.Enum): - """The side of a card that is shown.""" - - ## @brief The front: the word to learn. - FRONT = enum.auto() - ## @brief The back: the translation. - BACK = enum.auto() - - -class FlashCardViewModel: - """Data the flash card view shows.""" - - def __init__( - self, - session: StudySession, - language_names: Mapping[str, str] = LANGUAGE_NAMES, - ) -> None: - """Create the view model and draw the first card. - - @param session The study session that supplies the cards. - @param language_names Display names by language code. - """ - ## @brief The study session that supplies the cards. - self._session = session - ## @brief Display names by language code. - self._language_names = language_names - ## @brief The card shown now, or None when none is left. - self._card: CardDto | None = session.next_card() - ## @brief The side of the card shown. - self._side = Side.FRONT - - @property - def is_finished(self) -> bool: - """Tell whether no card is left to show. - - @return True when there is no card. - """ - return self._card is None - - @property - def side(self) -> Side: - """Return the side of the card shown. - - @return FRONT or BACK; FRONT when no card is left. - """ - return self._side - - @property - def language_name(self) -> str: - """Return the language name shown on the card. - - @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. - """ - if self._card is None: - return "" - if self._side is Side.BACK: - code = self._card.back_language - else: - code = self._card.front_language - return self._language_names.get(code, code.upper()) - - @property - def text(self) -> str: - """Return the word shown on the card. - - @return The text of the side shown, or the finished message when no - card is left. - """ - if self._card is None: - return FINISHED_MESSAGE - if self._side is Side.BACK: - return self._card.back_text - return self._card.front_text - - def next_card(self) -> None: - """Draw the next card and show its front. - - @return Nothing. - """ - 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 +"""View model of the flash card screen. + +@file flash_card_view_model.py +@brief Exposes the texts shown on the card; free of any Tkinter import. +""" + +import enum +from collections.abc import Mapping + +from flash_card.application.dto.card_dto import CardDto +from flash_card.application.use_cases.study_session import StudySession +from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES + + +class Side(enum.Enum): + """The side of a card that is shown.""" + + ## @brief The front: the word to learn. + FRONT = enum.auto() + ## @brief The back: the translation. + BACK = enum.auto() + + +class FlashCardViewModel: + """Data the flash card view shows.""" + + def __init__( + self, + session: StudySession, + language_names: Mapping[str, str] = LANGUAGE_NAMES, + ) -> None: + """Create the view model and draw the first card. + + @param session The study session that supplies the cards. + @param language_names Display names by language code. + """ + ## @brief The study session that supplies the cards. + self._session = session + ## @brief Display names by language code. + self._language_names = language_names + ## @brief The card shown now, or None when none is left. + self._card: CardDto | None = session.next_card() + ## @brief The side of the card shown. + self._side = Side.FRONT + + @property + def is_finished(self) -> bool: + """Tell whether no card is left to show. + + @return True when there is no card. + """ + return self._card is None + + @property + def side(self) -> Side: + """Return the side of the card shown. + + @return FRONT or BACK; FRONT when no card is left. + """ + return self._side + + @property + def language_name(self) -> str: + """Return the language name shown on the card. + + @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. + """ + if self._card is None: + return "" + if self._side is Side.BACK: + code = self._card.back_language + else: + code = self._card.front_language + return self._language_names.get(code, code.upper()) + + @property + def text(self) -> str: + """Return the word shown on the card. + + @return The text of the side shown, or the finished message when no + card is left. + """ + if self._card is None: + return FINISHED_MESSAGE + if self._side is Side.BACK: + return self._card.back_text + return self._card.front_text + + def next_card(self) -> None: + """Draw the next card and show its front; does nothing when finished. + + @return Nothing. + """ + if self._card is None: + return + self._card = self._session.next_card() + self._side = Side.FRONT + + def is_known(self) -> None: + """Mark the card as known, save the progress and show the next card. + + Does nothing when finished. + + @return Nothing. + """ + if self._card is None: + return + self._session.mark_known() + self._card = self._session.next_card() + self._side = Side.FRONT + + def flip_card(self) -> None: + """Show the back of the card; does nothing when finished or flipped. + + @return Nothing. + """ + if self._card is not None: + self._side = Side.BACK diff --git a/tests/e2e/test_flash_card_window.py b/tests/e2e/test_flash_card_window.py index 52b164f..f5ae4c0 100644 --- a/tests/e2e/test_flash_card_window.py +++ b/tests/e2e/test_flash_card_window.py @@ -7,15 +7,18 @@ import time import tkinter as tk from collections.abc import Callable, Iterator, Sequence +from pathlib import Path import pytest +from flash_card.application.use_cases.open_study_session import open_study_session from flash_card.application.use_cases.study_session import StudySession from flash_card.constants import ( BACK_TEXT_COLOR, BACKGROUND_COLOR, CARD_HEIGHT, CARD_WIDTH, + FINISHED_MESSAGE, FLIP_DELAY_MS, FRONT_TEXT_COLOR, LANGUAGE_FONT, @@ -26,6 +29,12 @@ from flash_card.constants import ( from flash_card.domain.entities.card import Card from flash_card.domain.entities.deck import Deck from flash_card.domain.value_objects.language_pair import LanguagePair +from flash_card.infrastructure.persistence.csv_progress_repository import ( + CsvProgressRepository, +) +from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( + CsvVocabularyRepository, +) from flash_card.presentation.features.flash_card import flash_card_view from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView from flash_card.presentation.features.flash_card.flash_card_view_model import ( @@ -39,9 +48,20 @@ def _first(cards: Sequence[Card]) -> Card: return cards[0] +class _MemoryProgress: + """Progress store that keeps nothing: the shared window never deletes cards.""" + + def load(self) -> Deck | None: + return None + + def save(self, deck: Deck) -> None: + return None + + def _build_view(root: tk.Tk) -> FlashCardView: deck = Deck(LanguagePair("ms", "da"), CARDS) - return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first))) + session = StudySession(deck, _first, _MemoryProgress()) + return FlashCardView(root, FlashCardViewModel(session)) def _shown(view: FlashCardView) -> tuple[str, str]: @@ -129,14 +149,14 @@ def test_buttons_sit_in_row_one_with_their_images( assert str(view.right_button.cget("command")) -def test_each_button_press_shows_next_card( +def test_wrong_button_press_shows_next_card( window: tuple[tk.Tk, FlashCardView], ) -> None: _root, view = window assert _shown(view) == ("Malay", "aku") view.wrong_button.invoke() assert _shown(view) == ("Malay", "kau") - view.right_button.invoke() + view.wrong_button.invoke() assert _shown(view) == ("Malay", "aku") @@ -195,7 +215,7 @@ def test_next_card_after_flip_shows_front_again( root, view = window _reset(root, view) view.flip_card() - view.right_button.invoke() + view.wrong_button.invoke() assert _image(view) == str(view.card_front_image) assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR) assert _shown(view)[0] == "Malay" @@ -245,3 +265,65 @@ def test_real_timer_flips_card_after_delay( assert _image(view) == str(view.card_back_image) assert _shown(view)[0] == "Danish" _reset(root, view) + + +HEADER_ONLY = "ms;da\n" + + +@pytest.fixture +def files_view( + window: tuple[tk.Tk, FlashCardView], tmp_path: Path +) -> Iterator[tuple[FlashCardView, Path]]: + """A second view on the shared root, over real repositories in tmp_path.""" + root, _shared = window + words = tmp_path / "words.csv" + words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8") + progress = tmp_path / "words_to_learn.csv" + session = open_study_session( + CsvVocabularyRepository(words), CsvProgressRepository(progress), _first + ) + view = FlashCardView(root, FlashCardViewModel(session)) + yield view, progress + if view.flip_timer is not None: + root.after_cancel(view.flip_timer) + for widget in (view.canvas, view.wrong_button, view.right_button): + widget.destroy() + + +def _saved(progress: Path) -> str: + return progress.read_bytes().decode("utf-8-sig") + + +def test_right_button_removes_card_and_writes_progress_file( + files_view: tuple[FlashCardView, Path], +) -> None: + view, progress = files_view + assert _shown(view) == ("Malay", "aku") + view.right_button.invoke() + assert _shown(view) == ("Malay", "kau") + assert _saved(progress) == "ms;da\nkau;du\nini;denne\n" + + +def test_wrong_button_shows_next_card_and_leaves_progress_file_absent( + files_view: tuple[FlashCardView, Path], +) -> None: + view, progress = files_view + view.wrong_button.invoke() + assert _shown(view) == ("Malay", "kau") + assert not progress.exists() + + +def test_buttons_do_nothing_visible_when_finished( + files_view: tuple[FlashCardView, Path], +) -> None: + view, progress = files_view + for _ in range(3): + view.right_button.invoke() + assert _shown(view) == ("", FINISHED_MESSAGE) + assert view.flip_timer is None + assert _saved(progress) == HEADER_ONLY + view.right_button.invoke() + view.wrong_button.invoke() + assert _shown(view) == ("", FINISHED_MESSAGE) + assert view.flip_timer is None + assert _saved(progress) == HEADER_ONLY diff --git a/tests/integration/test_progress_file.py b/tests/integration/test_progress_file.py new file mode 100644 index 0000000..2233dcc --- /dev/null +++ b/tests/integration/test_progress_file.py @@ -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() diff --git a/tests/integration/test_vocabulary_file.py b/tests/integration/test_vocabulary_file.py index 8b4584c..a9fae7b 100644 --- a/tests/integration/test_vocabulary_file.py +++ b/tests/integration/test_vocabulary_file.py @@ -1,9 +1,18 @@ """Integration tests of the word list reader on the delivered file.""" +from pathlib import Path + from flash_card.application.use_cases.open_study_session import open_study_session -from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME +from flash_card.constants import ( + DEFAULT_DATA_DIR, + PROGRESS_FILE_NAME, + VOCABULARY_FILE_NAME, +) from flash_card.domain.entities.card import Card from flash_card.domain.value_objects.language_pair import LanguagePair +from flash_card.infrastructure.persistence.csv_progress_repository import ( + CsvProgressRepository, +) from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( CsvVocabularyRepository, ) @@ -19,10 +28,11 @@ def test_read_word_list_reads_real_file() -> None: assert cards[0] == Card("aku", "mig") -def test_open_study_session_yields_card_from_real_file() -> None: +def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None: repository = CsvVocabularyRepository(PATH) cards = repository.load().cards - session = open_study_session(repository, lambda candidates: candidates[0]) + progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME) + session = open_study_session(repository, progress, lambda candidates: candidates[0]) card = session.next_card() diff --git a/tests/unit/application/test_open_study_session.py b/tests/unit/application/test_open_study_session.py index 0219628..3bc84d5 100644 --- a/tests/unit/application/test_open_study_session.py +++ b/tests/unit/application/test_open_study_session.py @@ -7,6 +7,7 @@ import pytest from flash_card.application.dto.card_dto import CardDto from flash_card.application.use_cases.open_study_session import open_study_session from flash_card.domain.entities.card import Card +from flash_card.domain.entities.deck import Deck from flash_card.domain.entities.vocabulary import Vocabulary from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError from flash_card.domain.value_objects.language_pair import LanguagePair @@ -20,6 +21,18 @@ class _FakeRepository: return self._vocabulary +class _RecordingProgress: + def __init__(self, stored: Deck | None = None) -> None: + self.stored = stored + self.saved: list[tuple[Card, ...]] = [] + + def load(self) -> Deck | None: + return self.stored + + def save(self, deck: Deck) -> None: + self.saved.append(deck.cards) + + class _MissingRepository: def load(self) -> Vocabulary: raise VocabularyNotFoundError("missing") @@ -33,7 +46,7 @@ def test_open_study_session_builds_session_over_all_cards() -> None: cards = (Card("aku", "mig"), Card("kau", "du")) repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards)) - session = open_study_session(repository, _first) + session = open_study_session(repository, _RecordingProgress(), _first) assert session.remaining == 2 assert session.next_card() == CardDto("aku", "mig", "ms", "da") @@ -41,4 +54,33 @@ def test_open_study_session_builds_session_over_all_cards() -> None: def test_open_study_session_propagates_repository_error() -> None: with pytest.raises(VocabularyNotFoundError): - open_study_session(_MissingRepository(), _first) + open_study_session(_MissingRepository(), _RecordingProgress(), _first) + + +def test_open_study_session_prefers_saved_progress() -> None: + pair = LanguagePair("ms", "da") + vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du"))) + progress = _RecordingProgress(Deck(pair, [Card("kau", "du")])) + + session = open_study_session(_FakeRepository(vocabulary), progress, _first) + + assert session.remaining == 1 + assert session.next_card() == CardDto("kau", "du", "ms", "da") + + +def test_open_study_session_skips_vocabulary_when_progress_exists() -> None: + progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), [])) + + session = open_study_session(_MissingRepository(), progress, _first) + + assert session.remaining == 0 + + +def test_open_study_session_never_saves_on_start() -> None: + vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),)) + progress = _RecordingProgress() + + session = open_study_session(_FakeRepository(vocabulary), progress, _first) + session.next_card() + + assert progress.saved == [] diff --git a/tests/unit/application/test_study_session.py b/tests/unit/application/test_study_session.py index f4da9ce..22be121 100644 --- a/tests/unit/application/test_study_session.py +++ b/tests/unit/application/test_study_session.py @@ -17,8 +17,20 @@ def _first(cards: Sequence[Card]) -> Card: return cards[0] -def _session(*cards: Card) -> StudySession: - return StudySession(Deck(PAIR, cards), _first) +class _RecordingProgress: + def __init__(self) -> None: + self.saved: list[tuple[Card, ...]] = [] + + def load(self) -> Deck | None: + return None + + def save(self, deck: Deck) -> None: + self.saved.append(deck.cards) + + +def _session(*cards: Card, progress: _RecordingProgress | None = None) -> StudySession: + store = _RecordingProgress() if progress is None else progress + return StudySession(Deck(PAIR, cards), _first, store) def test_current_card_is_none_before_first_draw() -> None: @@ -58,3 +70,38 @@ def test_remaining_counts_cards_in_deck() -> None: session = _session(A, B) session.next_card() assert session.remaining == 2 + + +def test_mark_known_removes_current_card_and_saves_remaining_once() -> None: + progress = _RecordingProgress() + session = _session(A, B, progress=progress) + session.next_card() + session.mark_known() + assert session.remaining == 1 + assert session.current_card is None + assert progress.saved == [(B,)] + + +def test_mark_known_saves_nothing_when_no_current_card() -> None: + progress = _RecordingProgress() + session = _session(A, progress=progress) + session.mark_known() + assert session.remaining == 1 + assert progress.saved == [] + + +def test_mark_known_on_last_card_leaves_session_finished() -> None: + progress = _RecordingProgress() + session = _session(A, progress=progress) + session.next_card() + session.mark_known() + assert progress.saved == [()] + assert session.next_card() is None + + +def test_next_card_never_saves() -> None: + progress = _RecordingProgress() + session = _session(A, B, progress=progress) + session.next_card() + session.next_card() + assert progress.saved == [] diff --git a/tests/unit/infrastructure/test_csv_progress_repository.py b/tests/unit/infrastructure/test_csv_progress_repository.py new file mode 100644 index 0000000..81e2b81 --- /dev/null +++ b/tests/unit/infrastructure/test_csv_progress_repository.py @@ -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 diff --git a/tests/unit/test_flash_card_view_model.py b/tests/unit/test_flash_card_view_model.py index 647e904..3e555c1 100644 --- a/tests/unit/test_flash_card_view_model.py +++ b/tests/unit/test_flash_card_view_model.py @@ -20,8 +20,22 @@ def _first(cards: Sequence[Card]) -> Card: return cards[0] -def _session(code: str, *cards: Card) -> StudySession: - return StudySession(Deck(LanguagePair(code, "da"), cards), _first) +class _RecordingProgress: + def __init__(self) -> None: + self.saved: list[tuple[Card, ...]] = [] + + def load(self) -> Deck | None: + return None + + def save(self, deck: Deck) -> None: + self.saved.append(deck.cards) + + +def _session( + code: str, *cards: Card, progress: _RecordingProgress | None = None +) -> StudySession: + store = _RecordingProgress() if progress is None else progress + return StudySession(Deck(LanguagePair(code, "da"), cards), _first, store) def test_view_model_draws_first_card_on_construction() -> None: @@ -90,3 +104,45 @@ def test_next_card_resets_side_to_front() -> None: assert view_model.side is Side.FRONT assert view_model.language_name == "Malay" assert view_model.text == "kau" + + +def test_is_known_saves_remaining_and_shows_next_front() -> None: + progress = _RecordingProgress() + view_model = FlashCardViewModel(_session("ms", A, B, progress=progress)) + view_model.flip_card() + view_model.is_known() + assert progress.saved == [(B,)] + assert view_model.side is Side.FRONT + assert view_model.text == "kau" + assert not view_model.is_finished + + +def test_is_known_on_last_card_finishes() -> None: + progress = _RecordingProgress() + view_model = FlashCardViewModel(_session("ms", A, progress=progress)) + view_model.is_known() + assert view_model.is_finished + assert view_model.text == FINISHED_MESSAGE + assert progress.saved == [()] + + +def test_is_known_does_nothing_when_finished() -> None: + progress = _RecordingProgress() + view_model = FlashCardViewModel(_session("ms", progress=progress)) + view_model.is_known() + assert view_model.is_finished + assert progress.saved == [] + + +def test_next_card_does_nothing_when_finished() -> None: + view_model = FlashCardViewModel(_session("ms")) + view_model.next_card() + assert view_model.is_finished + assert view_model.text == FINISHED_MESSAGE + + +def test_next_card_never_saves() -> None: + progress = _RecordingProgress() + view_model = FlashCardViewModel(_session("ms", A, B, progress=progress)) + view_model.next_card() + assert progress.saved == []