Compare commits

..
Author SHA1 Message Date
Tirsvad ff4f2655c4 Resolve pending commit links of MIL-007 2026-10-14 22:42:37 +08:00
Tirsvad 92894268c5 MIL-007: Documentation and publication
Review record RC-029 (Go, autonomous mode, not independent).

Task: MIL-007#1
Task: MIL-007#2
Task: MIL-007#3
Task: MIL-007#4
Task: MIL-007#5
Task: MIL-007#6
Task: MIL-007#7
Task: MIL-007#8
2026-10-14 22:42:26 +08:00
Tirsvad b2cc709031 Merge pull request 'MIL-006: Spoken Malay word' (#43) from mil-006-malay-speech into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
2026-10-14 16:39:51 +02:00
Tirsvad bc055e3c05 Resolve pending commit links of MIL-006 2026-10-14 22:39:44 +08:00
Tirsvad b3c5116110 MIL-006: Spoken Malay word
Review record RC-028 (Go, autonomous mode, not independent).

Task: MIL-006#1
Task: MIL-006#2
Task: MIL-006#3
Task: MIL-006#4
Task: MIL-006#5
2026-10-14 22:39:36 +08:00
Tirsvad 5900fda90a 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
2026-10-14 16:30:13 +02:00
Tirsvad 9e56a6b787 Resolve pending commit links of MIL-005 2026-10-14 22:30:05 +08:00
Tirsvad 8dbaaa3059 MIL-005: Step 4 - Save progress
Review record RC-027 (Go, autonomous mode, not independent).

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
Task: MIL-005#5
2026-10-14 22:29:58 +08:00
Tirsvad da4e9382cf Merge pull request 'MIL-004: Step 3 - Flip the cards' (#41) from mil-004-step3-flip-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
2026-10-14 16:05:58 +02:00
Tirsvad 83fb0b1ae8 Resolve pending commit links of MIL-004 2026-10-14 22:05:52 +08:00
Tirsvad 02e0c2c55e MIL-004: Step 3 - Flip the cards
Review record RC-026 (Go, autonomous mode, not independent).

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
2026-10-14 22:05:48 +08:00
Tirsvad 9ecae3e6b2 Merge pull request 'MIL-003: Step 2 - New flash cards' (#40) from mil-003-step2-new-flash-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:59:43 +02:00
53 changed files with 3353 additions and 984 deletions
+5
View File
@@ -0,0 +1,5 @@
*.py text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.csv text eol=lf
*.png binary
+40 -2
View File
@@ -22,11 +22,34 @@ The program shows a Malay word on a card. After three seconds the card turns ove
| `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` | | `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` |
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` | | `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` | | `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
| `docs/` | Project documents (business case, plan, design, review records) | | `docs/` | Project documents (business case, plan, design, review records), `docs/wiki/` and `docs/images/` |
| `tools/` | Helper scripts: `generate_images.py` and `capture_screenshots.py` |
| `Doxyfile` | Doxygen configuration for the source code documentation | | `Doxyfile` | Doxygen configuration for the source code documentation |
The core program uses only the Python standard library. Spoken Malay words are an optional extra. The core program uses only the Python standard library. Spoken Malay words are an optional extra.
### Screenshots
| Front: the Malay word | Back: the Danish translation after 3 seconds |
| --- | --- |
| ![The front of a card][screenshot-front] | ![The back of a card][screenshot-back] |
### Where is each assignment step?
| Assignment step | Code |
| --- | --- |
| 1. User interface | `FlashCardView.__init__` in `src/flash_card/presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
| 2. New flash cards | `CsvVocabularyRepository` (the standard library `csv` instead of `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
| 3. Flip the cards | `FlashCardView.flip_card` and `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
| 4. Save your progress | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository` |
| Extra: spoken word | `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
The wiki ([English][wiki-en], [Dansk][wiki-da], [Bahasa Malaysia][wiki-ms]) explains the same in three languages.
### Data
`data/ms-da.csv` holds 1000 frequent Malay words with a Danish machine translation; see [data/README.md][data-readme] for the format and origin.
## 📋 Requirements ## 📋 Requirements
| Need | Why | | Need | Why |
@@ -69,7 +92,14 @@ python -m pip install -e ".[dev]"
python -m flash_card python -m flash_card
``` ```
The program is run from the activated `.venv`. The window opens with the first card; the program is built step by step and the pull requests and milestones show each step. The program is run from the activated `.venv`. The window opens with a card. Press the green tick for a word you know and the red cross for a word you do not. Options:
| Option | Meaning |
| --- | --- |
| `--data-dir <folder>` | Folder with `ms-da.csv` and `words_to_learn.csv` |
| `--no-speech` | Do not speak the Malay word |
To start again, delete `data/words_to_learn.csv`.
## 🧪 Tests ## 🧪 Tests
@@ -87,6 +117,8 @@ lint-imports
The window test in `tests/e2e/` is skipped when no display is available. The window test in `tests/e2e/` is skipped when no display is available.
Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only).
## 📄 License ## 📄 License
GNU Affero General Public License v3.0 or later. See [LICENSE][license]. GNU Affero General Public License v3.0 or later. See [LICENSE][license].
@@ -117,6 +149,12 @@ The documents a newcomer needs, in reading order.
--- ---
[license]: ./LICENSE [license]: ./LICENSE
[screenshot-front]: ./docs/images/screenshot-front.png
[screenshot-back]: ./docs/images/screenshot-back.png
[wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home
[wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da
[wiki-ms]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-ms
[data-readme]: ./data/README.md
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card [repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki [documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues [issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
+70 -70
View File
@@ -1,70 +1,70 @@
# Artifact Registry # Artifact Registry
This project's artifact state. Types, short names and `CrossReference This project's artifact state. Types, short names and `CrossReference
Candidates` come from the framework catalog Candidates` come from the framework catalog
(`framework/registry/artifact-catalog.md`); this file only records where (`framework/registry/artifact-catalog.md`); this file only records where
each document lives in *this* project and the next version to use. each document lives in *this* project and the next version to use.
Delete rows for types you don't use. Add a row the first time you create a Delete rows for types you don't use. Add a row the first time you create a
document of a type. `Primary File` may contain a glob (e.g. document of a type. `Primary File` may contain a glob (e.g.
`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it. `docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
| Short Name | Artifact Type | Primary File | Next Available Version | | Short Name | Artifact Type | Primary File | Next Available Version |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| BC | Business Case | docs/business-case.md | 002 | | BC | Business Case | docs/business-case.md | 002 |
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 | | MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 | | UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
| US | User Story | docs/user-stories.md | 002 | | US | User Story | docs/user-stories.md | 002 |
| UC | Use Case | docs/uc-*/uc.md | 002 | | UC | Use Case | docs/uc-*/uc.md | 002 |
| OC | Operation Contract | docs/uc-*/oc.md | 002 | | OC | Operation Contract | docs/uc-*/oc.md | 002 |
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 | | SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
| DM | Domain Model | docs/domain-model.md | 002 | | DM | Domain Model | docs/domain-model.md | 002 |
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 | | SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
| DCD | Design Class Diagram | docs/dcd.md | 002 | | DCD | Design Class Diagram | docs/dcd.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 030 |
| 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+115 -108
View File
@@ -1,108 +1,115 @@
# Project Plan: Flash Card # Project Plan: Flash Card
## Metadata ## Metadata
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| ID | PP-001 | | ID | PP-001 |
| CrossReference | [BC-001], [SA-001], [US-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007] | | CrossReference | [BC-001], [SA-001], [US-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007] |
| Language | en | | Language | en |
| Domain | it | | Domain | it |
## 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
This plan schedules the seven phases (gateways) that deliver the flash card program inside the delivery window of one week that the Business Case sets as a constraint (2026-10-14 to 2026-10-21). Each phase is one milestone and is delivered by one pull request, so the Product Owner (S01) can follow the work step by step. This plan schedules the seven phases (gateways) that deliver the flash card program inside the delivery window of one week that the Business Case sets as a constraint (2026-10-14 to 2026-10-21). Each phase is one milestone and is delivered by one pull request, so the Product Owner (S01) can follow the work step by step.
## Planning Assumptions ## Planning Assumptions
- Week 1 starts 2026-10-14; the plan ends by 2026-10-21, per the Business Case constraint. - Week 1 starts 2026-10-14; the plan ends by 2026-10-21, per the Business Case constraint.
- One phase takes one day; the last phase (documentation and publication) has three days because the wiki exists in three languages and the screenshots need the finished program. - One phase takes one day; the last phase (documentation and publication) has three days because the wiki exists in three languages and the screenshots need the finished program.
- The baseline documents (Business Case, Stakeholder Analysis, Project Plan, use case documents, ADRs) are accepted before MIL-001 starts, in a pull request of their own. - The baseline documents (Business Case, Stakeholder Analysis, Project Plan, use case documents, ADRs) are accepted before MIL-001 starts, in a pull request of their own.
- Phases 2 to 5 follow the four steps of the course assignment in order; phase 6 is the Product Owner's extra. - Phases 2 to 5 follow the four steps of the course assignment in order; phase 6 is the Product Owner's extra.
- S01 is owner and approving reviewer of every phase; in autonomous mode the AI acts as stand-in and the review records say so. - S01 is owner and approving reviewer of every phase; in autonomous mode the AI acts as stand-in and the review records say so.
## Gateway Schedule ## Gateway Schedule
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone | | Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- |
| Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | | | Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | [Milestone MIL-001][MS-001] |
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | | | Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | [Milestone MIL-002][MS-002] |
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | | | Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | [Milestone MIL-003][MS-003] |
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | | | Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | [Milestone MIL-004][MS-004] |
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | | | Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | [Milestone MIL-005][MS-005] |
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | | | Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | [Milestone MIL-006][MS-006] |
| Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | | | Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | [Milestone MIL-007][MS-007] |
```plantuml ```plantuml
@startgantt @startgantt
Project starts 2026-10-14 Project starts 2026-10-14
[Foundation] starts 2026-10-14 and ends 2026-10-14 [Foundation] starts 2026-10-14 and ends 2026-10-14
[Step 1 UI] starts 2026-10-15 and ends 2026-10-15 [Step 1 UI] starts 2026-10-15 and ends 2026-10-15
[Step 2 Cards] starts 2026-10-16 and ends 2026-10-16 [Step 2 Cards] starts 2026-10-16 and ends 2026-10-16
[Step 3 Flip] starts 2026-10-17 and ends 2026-10-17 [Step 3 Flip] starts 2026-10-17 and ends 2026-10-17
[Step 4 Progress] starts 2026-10-18 and ends 2026-10-18 [Step 4 Progress] starts 2026-10-18 and ends 2026-10-18
[Speech] starts 2026-10-19 and ends 2026-10-19 [Speech] starts 2026-10-19 and ends 2026-10-19
[Documentation] starts 2026-10-19 and ends 2026-10-21 [Documentation] starts 2026-10-19 and ends 2026-10-21
[Foundation Go/No-Go] happens 2026-10-14 [Foundation Go/No-Go] happens 2026-10-14
[Step 1 Go/No-Go] happens 2026-10-15 [Step 1 Go/No-Go] happens 2026-10-15
[Step 2 Go/No-Go] happens 2026-10-16 [Step 2 Go/No-Go] happens 2026-10-16
[Step 3 Go/No-Go] happens 2026-10-17 [Step 3 Go/No-Go] happens 2026-10-17
[Step 4 Go/No-Go] happens 2026-10-18 [Step 4 Go/No-Go] happens 2026-10-18
[Speech Go/No-Go] happens 2026-10-19 [Speech Go/No-Go] happens 2026-10-19
[Documentation Go/No-Go] happens 2026-10-21 [Documentation Go/No-Go] happens 2026-10-21
@endgantt @endgantt
``` ```
## Scope Coverage ## Scope Coverage
| Business Case scope item | Gateway | | Business Case scope item | Gateway |
| --- | --- | | --- | --- |
| Tkinter program with the assignment's function names | MIL-002, MIL-003, MIL-004, MIL-005 | | Tkinter program with the assignment's function names | MIL-002, MIL-003, MIL-004, MIL-005 |
| Reading `data/ms-da.csv` | MIL-003 | | Reading `data/ms-da.csv` | MIL-003 |
| Saving and using `words_to_learn.csv` | MIL-005 | | Saving and using `words_to_learn.csv` | MIL-005 |
| Optional spoken Malay word | MIL-006 | | Optional spoken Malay word | MIL-006 |
| `pyproject.toml`, `.gitignore`, `Doxyfile`, `.venv` instructions, `pytest`, `constants.py` | MIL-001 | | `pyproject.toml`, `.gitignore`, `Doxyfile`, `.venv` instructions, `pytest`, `constants.py` | MIL-001 |
| README, screenshots, wiki in three languages | MIL-007 | | README, screenshots, wiki in three languages | MIL-007 |
| Repository description and topics | MIL-007 | | Repository description and topics | MIL-007 |
| Framework documents of the baseline | Baseline pull request before MIL-001 | | Framework documents of the baseline | Baseline pull request before MIL-001 |
## Dependencies ## Dependencies
``` ```
Baseline → MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 Baseline → MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007
``` ```
A No-Go on a phase moves every later date by the days the rework takes; the buffer is the 2026-10-20 working day, which has no phase of its own. A No-Go on a phase moves every later date by the days the rework takes; the buffer is the 2026-10-20 working day, which has no phase of its own.
## Plan Risks ## Plan Risks
| Risk | Impact | Mitigation | | Risk | Impact | Mitigation |
| --- | --- | --- | | --- | --- | --- |
| No display on the machine that runs the review, so the e2e tests skip | Window behaviour is checked by the view model tests only | The review record states whether the e2e tests ran; the screenshots in MIL-007 prove the window on a machine with a display | | No display on the machine that runs the review, so the e2e tests skip | Window behaviour is checked by the view model tests only | The review record states whether the e2e tests ran; the screenshots in MIL-007 prove the window on a machine with a display |
| The git host's CI is not available | The publish workflows cannot run | Milestones are published with `sync-project.sh` and the wiki by SSH push (MIL-007) | | The git host's CI is not available | The publish workflows cannot run | Milestones are published with `sync-project.sh` and the wiki by SSH push (MIL-007) |
| The speech extra cannot be installed on the build machine | MIL-006 cannot be demonstrated end to end | The adapter is tested with fakes; the manual check is recorded as not done | | The speech extra cannot be installed on the build machine | MIL-006 cannot be demonstrated end to end | The adapter is tested with fakes; the manual check is recorded as not done |
## Open Issues ## Open Issues
- No Governance document (GOV): see ADR-0003. S01 is the only approver. - No Governance document (GOV): see ADR-0003. S01 is the only approver.
- The git host's `sync-milestones` workflow is not installed; whether to install it is S01's decision. - The git host's `sync-milestones` workflow is not installed; whether to install it is S01's decision.
--- ---
[BC-001]: ./business-case.md [BC-001]: ./business-case.md
[SA-001]: ./stakeholder-analysis.md [SA-001]: ./stakeholder-analysis.md
[US-001]: ./user-stories.md [US-001]: ./user-stories.md
[MIL-001]: ./milestones/mil-001-foundation.md [MIL-001]: ./milestones/mil-001-foundation.md
[MIL-002]: ./milestones/mil-002-step1-user-interface.md [MIL-002]: ./milestones/mil-002-step1-user-interface.md
[MIL-003]: ./milestones/mil-003-step2-new-flash-cards.md [MIL-003]: ./milestones/mil-003-step2-new-flash-cards.md
[MIL-004]: ./milestones/mil-004-step3-flip-cards.md [MIL-004]: ./milestones/mil-004-step3-flip-cards.md
[MIL-005]: ./milestones/mil-005-step4-save-progress.md [MIL-005]: ./milestones/mil-005-step4-save-progress.md
[MIL-006]: ./milestones/mil-006-malay-speech.md [MIL-006]: ./milestones/mil-006-malay-speech.md
[MIL-007]: ./milestones/mil-007-documentation-and-publication.md [MIL-007]: ./milestones/mil-007-documentation-and-publication.md
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 [c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
[MS-001]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/165
[MS-002]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/166
[MS-003]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/167
[MS-004]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/168
[MS-005]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/169
[MS-006]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/170
[MS-007]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/171
+64
View File
@@ -0,0 +1,64 @@
# RC-026: Review of PY-MIL-004
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-026 |
| CrossReference | [PY-MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [02e0c2c] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-004]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds; ruff rule set N passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the assignment's (`flip_card`, `flip_timer`, `next_card`) and the IT terms of the dictionary; `Side` states its purpose. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass with no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 31 source files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; none is bare. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers | Pass | No file or connection is opened in this change. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no logging needed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `Side`, `flip_card`, `flip_timer` and the view's timer follow DCD-001 and SD-001 (`flip_card` row of Method Traceability, timer step of SSD-001). |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 76 tests: view model sides, finished state, flip rules; e2e front and back state, recording scheduler, real 50 ms timer; no network, no order dependency (state reset in each test). |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The side rule exists once in the view model; image and colour names come from `constants.py`; `_show_card` is the only place that draws a card. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the view model imports no Tkinter. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The view model holds the side rule and the view only draws; a new side would extend `Side` and `_show_card`. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Files stay in `presentation/features/flash_card/`; tests in `unit` and `e2e`. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | Names follow the rule; no new file contains a space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-004 Go criteria: 1 `flip_card` changes side, language name and text (unit test); 2 `next_card` returns to the front (unit test); 3 `after(3000, flip_card)` is scheduled and the earlier id is cancelled first (recording scheduler e2e test); 4 back image and white text, front image and black text (e2e); 5 `PhotoImage` objects are attributes created in `__init__`, none in a callback (grep); 6 this record, `pytest` 76 passed. A manual run with a 100 ms delay showed `Danish` and white text. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-004]: ../../../src/flash_card/presentation
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[02e0c2c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/02e0c2c55e2a62ca13e2dec9dbf3aa823cf16242
+64
View File
@@ -0,0 +1,64 @@
# RC-027: Review of PY-MIL-005
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-027 |
| CrossReference | [PY-MIL-005], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8dbaaa3] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-005]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds; ruff rule set N passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the dictionary's IT terms and the assignment's (`is_known`, `mark_known`, `ProgressRepository`). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 47 files with no suppressions; `[tool.ruff.format] line-ending = "lf"` and `.gitattributes` fix the line endings. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 33 source files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | A malformed progress file raises `VocabularyFormatError` and is never overwritten; the temporary file is removed when a write fails (re-raise, no swallow); no bare `except`. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers | Pass | Reading uses `with`; the write goes through a temporary file that is replaced atomically. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no secret or personal data is logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `ProgressRepository`, `CsvProgressRepository`, `StudySession.mark_known` and `is_known` are in DCD-001 and OC-001 (`markKnown`); `open_study_session` takes no speech port until MIL-006, as DCD-001 shows for the final design. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 107 tests (6 consecutive full runs all green) include the assignment's 3 record example, restart, round trip, finished state and e2e on the real window over `tmp_path`; none uses the network. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Reader and writer share `CSV_DELIMITER`, `CSV_ENCODING` and the row width in `csv_word_list.py`; the removal rule is only in `Deck.remove`. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the `ProgressRepository` Protocol is in `domain`, its adapter in `infrastructure`. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The use case depends on the Protocol and receives the adapter; the view model sequences two session calls only. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | New files are in the layer folders of the clean-architecture skill; tests in `unit`, `integration`, `e2e`. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-005 Go criteria: 1 the 3 record example leaves the other two cards in order with the header (integration test); 2 a start uses the progress file when it exists and the word list otherwise (integration test); 3 the wrong button never writes (unit and e2e); 4 the last card known gives a finished state (unit); 5 a malformed progress file raises `VocabularyFormatError` and stays untouched (unit); 6 round trip readable as a word list (integration); 7 this record, `pytest` 107 passed. A manual run on a copy of `ms-da.csv` removed the known word and wrote 999 cards. Defect found and fixed in this milestone: a flaky `TclError` caused by pytest's descriptor capture and Tcl file handles; the view now loads image bytes with `Path.read_bytes()` and `PhotoImage(data=...)`. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-005]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[8dbaaa3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8dbaaa30595a3650bc85006a2188f81ab903125e
+64
View File
@@ -0,0 +1,64 @@
# RC-028: Review of PY-MIL-006
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-028 |
| CrossReference | [PY-MIL-006], [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 | [b3c5116] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-006]
- 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 DCD-001 IT terms (`SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech`). |
| 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 55 files with no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 38 source files; third-party modules are imported with `importlib.import_module`, so no `type: ignore` is needed. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The one broad `except Exception` in `GttsSpeech` is at the thread boundary, is commented, and logs with `exc_info`; it is not silent. 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 | Audio is written to a `tempfile.TemporaryDirectory` used as a context manager, so the file is always removed. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New 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 | Failures are logged with `logging`, not printed; only the spoken text (a word) is in the log context, no personal data. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `open_study_session(vocabulary_repository, progress_repository, speech, choose)` and `build_speech` match DCD-001 and SD-001 (the `speak` message of `startStudySession` and `markUnknown`). |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 124 tests (3 consecutive runs green) with fakes for the synthesiser and player; no test uses the network or plays a sound. |
| 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` is still empty; the extra `speech` pins `playsound==1.2.2` and `gTTS>=2.5`, and no module imports them outside `GttsSpeech`. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The speak rule is only in `StudySession.next_card`; the unused `SPEECH_LANGUAGE_CODE` constant was removed. |
| 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 port is in `application`, the adapters in `infrastructure`, the choice in the composition root. |
| 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 | `StudySession` depends on the `SpeechPort` Protocol and receives the adapter; `NullSpeech` honours the same contract (Null Object), so no `if speech` test exists. |
| 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 | `application/ports/` and `infrastructure/speech/` follow the folder structure of the clean-architecture skill. |
| 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-006 Go criteria: 1 `speak(text, front_code)` once per drawn card and never when empty (unit tests); 2 `gtts` and `playsound` imported only when speaking, temporary file removed (unit tests with fakes); 3 service or player failure is logged and not raised (unit tests with caplog); 4 `build_speech` returns `NullSpeech` when the extra is missing or `--no-speech` is given (unit tests); 5 `dependencies` empty, extra pins `playsound==1.2.2` (pyproject.toml); 6 `speak` returns before a blocked player is released (unit test with an Event); 7 this record, `pytest` 124 passed. A manual check with the extra installed synthesised an mp3 of `kata` in Malay (7872 bytes) and `build_speech(True)` returned `GttsSpeech`; the audio was not played on this machine. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-006]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[b3c5116]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/b3c51161107b4cdcbc4c5334e28608423a299638
+64
View File
@@ -0,0 +1,64 @@
# RC-029: Review of PY-MIL-007
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-029 |
| CrossReference | [PY-MIL-007], [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 | [9289426] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-007]
- 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 in `tools/capture_screenshots.py` and the changed view; 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 state purpose (`capture`, `_make_dpi_aware`, `SCREENSHOT_SEED`). |
| 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 56 files; the one `type: ignore` is for a Windows-only attribute and says which error it silences. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on `src` (38 files) and on both tool scripts. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added. |
| 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 | The data copy uses `tempfile.TemporaryDirectory` as a context manager; the Tk root is destroyed. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Module, functions and constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | The tool writes the two output paths with `sys.stdout.write`; no `print`, no secret. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The only source change is the button background in `FlashCardView`, which follows `wireframe.md`; the tool is outside the design classes and is not part of the program. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 125 tests, 2 consecutive full runs green; one e2e assertion was added for the button background; the tool is checked by running it and reading both images. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue on the touched files; no `Any`. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; Pillow is the optional extra `tools` and is imported only by the tool. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The seed, output folder and delay come from one definition each; the delay is `FLIP_DELAY_MS`. |
| 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 tool imports the composition root only. |
| 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 tool has one purpose (capture); the view change adds no rule. |
| 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 | `tools/` holds helper scripts outside `src/`; the layers are unchanged. |
| 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 (`docs/images`, `docs/wiki` pages are exceptions for wiki names). |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-007 Go criteria: 1 `check-readme.sh` exit 0 and both screenshots linked from the README; 2 `docs/wiki/` holds Home, Install, Usage and Architecture as `<Page>.md`, `<Page>-da.md`, `<Page>-ms.md` (12 pages) and `_Sidebar.md` listing the three languages; 3 the screenshots are captured from the running program by `tools/capture_screenshots.py` and were inspected; 4 the README Setup and Run need 5 commands (create `.venv`, activate, upgrade pip, install, run); 5 the repository has a description and 10 topics (read back from the API); 6 `doxygen Doxyfile` 0 warnings; 7 `docs/sqa/traceability-matrix.md` has a row for every created artifact with its latest `RC-*`, and the plan links each milestone; 8 `check-languages.sh` exit 0 (`check-plan.sh` is a commit-message hook and was not run). The wiki is pushed to the host's wiki after this merge. The Danish and Malay wiki pages were written by the AI and have not been read by a native speaker; this is stated in the end-of-run summary. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-007]: ../../../tools
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[9289426]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/92894268c51ec77d56cff48d968566e6265795a2
+122 -110
View File
@@ -1,110 +1,122 @@
# Traceability Matrix: Flash Card # Traceability Matrix: Flash Card
## Metadata ## Metadata
| Key | Value | | Key | Value |
| --- | --- | | --- | --- |
| ID | TM-001 | | ID | TM-001 |
| CrossReference | [BC-001] | | CrossReference | [BC-001] |
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] | | 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
--- ---
## Purpose ## Purpose
Tracks backward/forward links between artifact instances so that the Business Case's Tracks backward/forward links between artifact instances so that the Business Case's
cross-artifact traceability success criterion is measurable. A row is added or cross-artifact traceability success criterion is measurable. A row is added or
updated whenever an artifact instance is created or reviewed. updated whenever an artifact instance is created or reviewed.
## Traceability Table ## Traceability Table
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) | | Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] | | BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] |
| SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] | | SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] |
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] | | PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] |
| UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] | | UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] |
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] | | US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] |
| UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] | | UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] |
| DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] | | DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] |
| SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] | | SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] |
| OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] | | OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] |
| SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] | | SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] |
| DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] | | DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] |
| DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] | | DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] |
| ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] | | ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] |
| ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] | | ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] |
| ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] | | ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] |
| MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] | | MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] |
| MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] | | MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] |
| MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] | | MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] |
| MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] | | MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] |
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] | | MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] | | MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] | | MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] | | PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | | PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] | | PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
## Coverage Notes | PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
- `-` 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). | PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
- Types not created for this project, and why: ADR-0003.
- Source code (`PY`) is added as `src/` is reviewed, one row per milestone. ## 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.
[BC-001]: ../business-case.md - Source code (`PY`) is added as `src/` is reviewed, one row per milestone.
[SA-001]: ../stakeholder-analysis.md
[PP-001]: ../project-plan.md ---
[UCD-001]: ../use-case-diagram.md
[US-001]: ../user-stories.md [BC-001]: ../business-case.md
[UC-001]: ../uc-001/uc.md [SA-001]: ../stakeholder-analysis.md
[DM-001]: ../domain-model.md [PP-001]: ../project-plan.md
[SSD-001]: ../uc-001/ssd.md [UCD-001]: ../use-case-diagram.md
[OC-001]: ../uc-001/oc.md [US-001]: ../user-stories.md
[SD-001]: ../uc-001/sd.md [UC-001]: ../uc-001/uc.md
[DCD-001]: ../dcd.md [DM-001]: ../domain-model.md
[DICT-001]: ../dictionary.md [SSD-001]: ../uc-001/ssd.md
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md [OC-001]: ../uc-001/oc.md
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md [SD-001]: ../uc-001/sd.md
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md [DCD-001]: ../dcd.md
[MIL-001]: ../milestones/mil-001-foundation.md [DICT-001]: ../dictionary.md
[MIL-002]: ../milestones/mil-002-step1-user-interface.md [ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md [ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md [ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
[MIL-005]: ../milestones/mil-005-step4-save-progress.md [MIL-001]: ../milestones/mil-001-foundation.md
[MIL-006]: ../milestones/mil-006-malay-speech.md [MIL-002]: ../milestones/mil-002-step1-user-interface.md
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md [MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
[PY-MIL-001]: ../../src/flash_card [MIL-004]: ../milestones/mil-004-step3-flip-cards.md
[RC-001]: ./reviews/rc-001-business-case.md [MIL-005]: ../milestones/mil-005-step4-save-progress.md
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md [MIL-006]: ../milestones/mil-006-malay-speech.md
[RC-003]: ./reviews/rc-003-project-plan.md [MIL-007]: ../milestones/mil-007-documentation-and-publication.md
[RC-004]: ./reviews/rc-004-use-case-diagram.md [PY-MIL-001]: ../../src/flash_card
[RC-005]: ./reviews/rc-005-user-stories.md [RC-001]: ./reviews/rc-001-business-case.md
[RC-006]: ./reviews/rc-006-use-case.md [RC-002]: ./reviews/rc-002-stakeholder-analysis.md
[RC-007]: ./reviews/rc-007-domain-model.md [RC-003]: ./reviews/rc-003-project-plan.md
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md [RC-004]: ./reviews/rc-004-use-case-diagram.md
[RC-009]: ./reviews/rc-009-operation-contracts.md [RC-005]: ./reviews/rc-005-user-stories.md
[RC-010]: ./reviews/rc-010-sequence-diagrams.md [RC-006]: ./reviews/rc-006-use-case.md
[RC-011]: ./reviews/rc-011-design-class-diagram.md [RC-007]: ./reviews/rc-007-domain-model.md
[RC-012]: ./reviews/rc-012-domain-dictionary.md [RC-008]: ./reviews/rc-008-system-sequence-diagram.md
[RC-013]: ./reviews/rc-013-adr-0001.md [RC-009]: ./reviews/rc-009-operation-contracts.md
[RC-014]: ./reviews/rc-014-adr-0002.md [RC-010]: ./reviews/rc-010-sequence-diagrams.md
[RC-015]: ./reviews/rc-015-adr-0003.md [RC-011]: ./reviews/rc-011-design-class-diagram.md
[RC-016]: ./reviews/rc-016-mil-001.md [RC-012]: ./reviews/rc-012-domain-dictionary.md
[RC-017]: ./reviews/rc-017-mil-002.md [RC-013]: ./reviews/rc-013-adr-0001.md
[RC-018]: ./reviews/rc-018-mil-003.md [RC-014]: ./reviews/rc-014-adr-0002.md
[RC-019]: ./reviews/rc-019-mil-004.md [RC-015]: ./reviews/rc-015-adr-0003.md
[RC-020]: ./reviews/rc-020-mil-005.md [RC-016]: ./reviews/rc-016-mil-001.md
[RC-021]: ./reviews/rc-021-mil-006.md [RC-017]: ./reviews/rc-017-mil-002.md
[RC-022]: ./reviews/rc-022-mil-007.md [RC-018]: ./reviews/rc-018-mil-003.md
[RC-023]: ./reviews/rc-023-py-mil-001.md [RC-019]: ./reviews/rc-019-mil-004.md
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 [RC-020]: ./reviews/rc-020-mil-005.md
[RC-024]: ./reviews/rc-024-py-mil-002.md [RC-021]: ./reviews/rc-021-mil-006.md
[PY-MIL-002]: ../../src/flash_card [RC-022]: ./reviews/rc-022-mil-007.md
[RC-025]: ./reviews/rc-025-py-mil-003.md [RC-023]: ./reviews/rc-023-py-mil-001.md
[PY-MIL-003]: ../../src/flash_card [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
[RC-028]: ./reviews/rc-028-py-mil-006.md
[PY-MIL-006]: ../../src/flash_card
[RC-029]: ./reviews/rc-029-py-mil-007.md
[PY-MIL-007]: ../../src/flash_card
+40
View File
@@ -0,0 +1,40 @@
# Arkitektur
🌐 [English](Architecture) · **Dansk** · [Bahasa Malaysia](Architecture-ms)
Programmet er bygget i fire lag under `src/flash_card/`. Importer peger indad; `lint-imports` kontrollerer det.
| Lag | Indeholder | Må importere |
| --- | --- | --- |
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, fejlene og repository-grænsefladerne | ingenting |
| `application` | `StudySession`, `open_study_session`, `CardDto` og `SpeechPort` | `domain` |
| `infrastructure` | CSV-repositories og tale-adaptere | `application`, `domain` |
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) og kompositionsroden `app.py` | alle lag |
Alle konstanter står i `src/flash_card/constants.py`.
## Hvor er hvert opgavetrin?
| Opgave | Kode |
| --- | --- |
| Trin 1: vindue, lærred, billede, tekster, knapper | `FlashCardView.__init__` i `presentation/features/flash_card/flash_card_view.py`; billederne laves af `tools/generate_images.py` |
| Trin 2: læs CSV, tilfældigt ord ved hvert tryk | `CsvVocabularyRepository` og `read_word_list` (erstatter `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
| Trin 3: vend efter 3 sekunder | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
| Trin 4: kendte ord, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
| Ekstra: tale | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` i `presentation/app.py` |
## Hvorfor en lagdelt løsning til en lille opgave?
Reglerne (hvilket kort der kommer næste gang, hvad et kendt ord gør) testes uden vindue og uden filsystem. Vinduet, CSV-filerne og taletjenesten kan udskiftes uden at røre reglerne. Se ADR-0001 og ADR-0002 i `docs/adr/`.
## Test
| Mappe | Hvad den tester |
| --- | --- |
| `tests/unit/` | Hvert lag for sig, med falske objekter til portene |
| `tests/integration/` | Den rigtige ordliste og fremgangsfilen i en midlertidig mappe |
| `tests/e2e/` | Det rigtige Tkinter-vindue (springes over uden skærm) |
## Projektdokumenter
Business case, plan, design og gennemgangsrapporter ligger i `docs/`. Arbejdet blev udført i syv milepæle, én pull request hver: se `docs/milestones/`.
+40
View File
@@ -0,0 +1,40 @@
# Seni bina
🌐 [English](Architecture) · [Dansk](Architecture-da) · **Bahasa Malaysia**
Program dibina dalam empat lapisan di bawah `src/flash_card/`. Import menuju ke dalam; `lint-imports` memeriksanya.
| Lapisan | Mengandungi | Boleh mengimport |
| --- | --- | --- |
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, ralat dan antara muka repositori | tiada |
| `application` | `StudySession`, `open_study_session`, `CardDto` dan `SpeechPort` | `domain` |
| `infrastructure` | Repositori CSV dan penyesuai suara | `application`, `domain` |
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) dan akar komposisi `app.py` | semua lapisan |
Semua pemalar terdapat dalam `src/flash_card/constants.py`.
## Di manakah setiap langkah tugasan?
| Tugasan | Kod |
| --- | --- |
| Langkah 1: tetingkap, kanvas, imej, teks, butang | `FlashCardView.__init__` dalam `presentation/features/flash_card/flash_card_view.py`; imej dibuat oleh `tools/generate_images.py` |
| Langkah 2: baca CSV, perkataan rawak setiap tekanan | `CsvVocabularyRepository` dan `read_word_list` (menggantikan `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
| Langkah 3: terbalik selepas 3 saat | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
| Langkah 4: perkataan diketahui, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
| Tambahan: suara | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` dalam `presentation/app.py` |
## Mengapa program berlapis untuk tugasan kecil?
Peraturan (kad mana yang seterusnya, apa yang berlaku apabila perkataan diketahui) diuji tanpa tetingkap atau sistem fail. Tetingkap, fail CSV dan perkhidmatan suara boleh diganti tanpa menyentuh peraturan. Lihat ADR-0001 dan ADR-0002 dalam `docs/adr/`.
## Ujian
| Folder | Apa yang diuji |
| --- | --- |
| `tests/unit/` | Setiap lapisan sendiri, dengan objek palsu untuk port |
| `tests/integration/` | Senarai perkataan sebenar dan fail kemajuan dalam folder sementara |
| `tests/e2e/` | Tetingkap Tkinter sebenar (dilangkau tanpa paparan) |
## Dokumen projek
Kes perniagaan, pelan, reka bentuk dan rekod semakan terdapat dalam `docs/`. Kerja dilakukan dalam tujuh pencapaian, satu pull request setiap satu: lihat `docs/milestones/`.
+40
View File
@@ -0,0 +1,40 @@
# Architecture
🌐 **English** · [Dansk](Architecture-da) · [Bahasa Malaysia](Architecture-ms)
The program is built in four layers under `src/flash_card/`. Imports point inward; `lint-imports` checks this.
| Layer | Holds | May import |
| --- | --- | --- |
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, the errors and the repository interfaces | nothing |
| `application` | `StudySession`, `open_study_session`, `CardDto` and `SpeechPort` | `domain` |
| `infrastructure` | The CSV repositories and the speech adapters | `application`, `domain` |
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) and the composition root `app.py` | all layers |
All constants are in `src/flash_card/constants.py`.
## Where is each assignment step?
| Assignment | Code |
| --- | --- |
| Step 1: window, canvas, image, texts, buttons | `FlashCardView.__init__` in `presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
| Step 2: read the CSV, random word on each press | `CsvVocabularyRepository` and `read_word_list` (replace `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
| Step 3: flip after 3 seconds | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
| Step 4: known words, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
| Extra: speech | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
## Why a layered program for a small assignment?
The rules (which card comes next, what a known word does) are tested without a window or a file system. The window, the CSV files and the speech service can be replaced without touching the rules. See ADR-0001 and ADR-0002 in `docs/adr/`.
## Tests
| Folder | What it tests |
| --- | --- |
| `tests/unit/` | Each layer alone, with fakes for the ports |
| `tests/integration/` | The real word list and the progress file in a temporary folder |
| `tests/e2e/` | The real Tkinter window (skipped when no display exists) |
## Project documents
The business case, plan, design and review records are in `docs/`. The work was done in seven milestones, one pull request each: see `docs/milestones/`.
+30
View File
@@ -0,0 +1,30 @@
# Flash Card
🌐 [English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
Et flashkortprogram til at lære de mest almindelige malajiske ord med danske oversættelser. Det er løsningen på dag 31 i Udemys kursus *100 Days of Code: The Complete Python Pro Bootcamp*.
![Forsiden af et kort: det malajiske ord](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-front.png)
![Bagsiden af kortet efter tre sekunder: den danske oversættelse](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-back.png)
| Side | Læs den, når du vil |
| --- | --- |
| [Installation](Install-da) | Oprette `.venv`, installere programmet og køre testene |
| [Brug](Usage-da) | Øve kortene, forstå fremgangsfilen og bruge indstillingerne |
| [Arkitektur](Architecture-da) | Se lagene og finde koden til hvert opgavetrin |
## De fire opgavetrin
| Trin | Hvad programmet gør | Milepæl |
| --- | --- | --- |
| 1. Brugerflade | Et vindue med et kort og to knapper | MIL-002 |
| 2. Nye flashkort | Et tilfældigt ord fra ordlisten ved hvert tryk på en knap | MIL-003 |
| 3. Vend kortene | Efter 3 sekunder viser kortet den danske oversættelse | MIL-004 |
| 4. Gem din fremgang | Kendte ord fjernes, og resten gemmes i `words_to_learn.csv` | MIL-005 |
| Ekstra | Det malajiske ord bliver udtalt (valgfrit) | MIL-006 |
## Til kursister
Opgavens funktionsnavne er bevaret: `next_card`, `flip_card`, `is_known` og attributterne `flip_timer` og `current_card`. Data læses med standardbibliotekets `csv`-modul i stedet for `pandas`, så kerneprogrammet ikke har nogen afhængigheder ved kørsel.
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
+30
View File
@@ -0,0 +1,30 @@
# Flash Card
🌐 [English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
Program kad imbas untuk mempelajari perkataan Melayu yang paling kerap digunakan beserta terjemahan Denmark. Ia ialah penyelesaian hari ke-31 kursus Udemy *100 Days of Code: The Complete Python Pro Bootcamp*.
![Bahagian depan kad: perkataan Melayu](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-front.png)
![Bahagian belakang kad selepas tiga saat: terjemahan Denmark](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-back.png)
| Halaman | Baca apabila anda mahu |
| --- | --- |
| [Pemasangan](Install-ms) | Mencipta `.venv`, memasang program dan menjalankan ujian |
| [Penggunaan](Usage-ms) | Berlatih dengan kad, memahami fail kemajuan dan menggunakan pilihan |
| [Seni bina](Architecture-ms) | Melihat lapisan dan mencari kod bagi setiap langkah tugasan |
## Empat langkah tugasan
| Langkah | Apa yang program lakukan | Pencapaian |
| --- | --- | --- |
| 1. Antara muka pengguna | Tetingkap dengan satu kad dan dua butang | MIL-002 |
| 2. Kad imbas baharu | Satu perkataan rawak daripada senarai perkataan setiap kali butang ditekan | MIL-003 |
| 3. Terbalikkan kad | Selepas 3 saat kad menunjukkan terjemahan Denmark | MIL-004 |
| 4. Simpan kemajuan anda | Perkataan yang diketahui dibuang dan selebihnya disimpan dalam `words_to_learn.csv` | MIL-005 |
| Tambahan | Perkataan Melayu disebut (pilihan) | MIL-006 |
## Untuk peserta kursus
Nama fungsi tugasan dikekalkan: `next_card`, `flip_card`, `is_known`, serta atribut `flip_timer` dan `current_card`. Data dibaca dengan modul `csv` pustaka standard dan bukan `pandas`, jadi program teras tidak mempunyai kebergantungan semasa larian.
Repositori: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
+30
View File
@@ -0,0 +1,30 @@
# Flash Card
🌐 **English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
A flash card program for learning the most frequent Malay words with their Danish translations. It is the solution of day 31 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*.
![The front of a card: the Malay word](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-front.png)
![The back of the card after three seconds: the Danish translation](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-back.png)
| Page | Read it when you want to |
| --- | --- |
| [Install](Install) | Create the `.venv`, install the program and run the tests |
| [Usage](Usage) | Study the cards, understand the progress file, use the options |
| [Architecture](Architecture) | See the layers and find the code of each assignment step |
## The four assignment steps
| Step | What the program does | Milestone |
| --- | --- | --- |
| 1. User interface | A window with a card and two buttons | MIL-002 |
| 2. New flash cards | A random word from the word list on every button press | MIL-003 |
| 3. Flip the cards | After 3 seconds the card shows the Danish translation | MIL-004 |
| 4. Save your progress | Known words are removed and the rest is saved in `words_to_learn.csv` | MIL-005 |
| Extra | The Malay word is spoken (optional) | MIL-006 |
## For course participants
The function names of the assignment are kept: `next_card`, `flip_card`, `is_known`, and the attributes `flip_timer` and `current_card`. The data is read with the standard library `csv` module instead of `pandas`, so the core program has no runtime dependency.
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
+65
View File
@@ -0,0 +1,65 @@
# Installation
🌐 [English](Install) · **Dansk** · [Bahasa Malaysia](Install-ms)
## Krav
- Python 3.13 eller nyere med Tkinter (på Debian eller Ubuntu: `sudo apt install python3-tk`).
- `git`.
- Valgfrit: en internetforbindelse og tilvalget `speech`, hvis du vil høre det malajiske ord.
## Hent koden
```bash
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
cd 031-flash-card
```
## Opret og brug et lokalt virtuelt miljø
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux og macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
- `.[dev]` tilføjer `pytest`, `ruff`, `mypy` og `import-linter`. Brug `.`, hvis du kun vil køre programmet.
- Hvis PowerShell nægter at køre `Activate.ps1`, så kør `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` én gang.
- Forlad miljøet med `deactivate`; slet mappen `.venv` for at starte forfra.
## Udtalt malajisk ord (valgfrit)
```bash
python -m pip install -e ".[speech]"
```
Det installerer `gTTS` og `playsound==1.2.2`. Uden tilvalget virker programmet uden lyd.
## Kør og test
```bash
python -m flash_card
python -m pytest
```
Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
## Dokumentation af kildekoden
```bash
doxygen Doxyfile
```
HTML-filerne skrives til `docs/doxygen/html/index.html`.
+65
View File
@@ -0,0 +1,65 @@
# Pemasangan
🌐 [English](Install) · [Dansk](Install-da) · **Bahasa Malaysia**
## Keperluan
- Python 3.13 atau lebih baharu dengan Tkinter (pada Debian atau Ubuntu: `sudo apt install python3-tk`).
- `git`.
- Pilihan: sambungan internet dan tambahan `speech` jika anda mahu mendengar perkataan Melayu.
## Dapatkan kod
```bash
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
cd 031-flash-card
```
## Cipta dan gunakan persekitaran maya setempat
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux dan macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
- `.[dev]` menambah `pytest`, `ruff`, `mypy` dan `import-linter`. Gunakan `.` jika anda hanya mahu menjalankan program.
- Jika PowerShell enggan menjalankan `Activate.ps1`, jalankan `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` sekali.
- Keluar daripada persekitaran dengan `deactivate`; padam folder `.venv` untuk bermula semula.
## Perkataan Melayu bersuara (pilihan)
```bash
python -m pip install -e ".[speech]"
```
Ini memasang `gTTS` dan `playsound==1.2.2`. Tanpa tambahan ini program berfungsi tanpa bunyi.
## Jalankan dan uji
```bash
python -m flash_card
python -m pytest
```
Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
## Dokumentasi kod sumber
```bash
doxygen Doxyfile
```
HTML ditulis ke `docs/doxygen/html/index.html`.
+65
View File
@@ -0,0 +1,65 @@
# Install
🌐 **English** · [Dansk](Install-da) · [Bahasa Malaysia](Install-ms)
## Requirements
- Python 3.13 or later with Tkinter (on Debian or Ubuntu: `sudo apt install python3-tk`).
- `git`.
- Optional: an internet connection and the `speech` extra to hear the Malay word.
## Get the code
```bash
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
cd 031-flash-card
```
## Create and use a local virtual environment
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
- `.[dev]` adds `pytest`, `ruff`, `mypy` and `import-linter`. Use `.` if you only want to run the program.
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once.
- Leave the environment with `deactivate`; delete the `.venv` folder to start over.
## Spoken Malay word (optional)
```bash
python -m pip install -e ".[speech]"
```
This installs `gTTS` and `playsound==1.2.2`. Without it the program works silently.
## Run and test
```bash
python -m flash_card
python -m pytest
```
The window test in `tests/e2e/` is skipped when no display is available.
## Documentation of the source code
```bash
doxygen Doxyfile
```
The HTML is written to `docs/doxygen/html/index.html`.
+49
View File
@@ -0,0 +1,49 @@
# Brug
🌐 [English](Usage) · **Dansk** · [Bahasa Malaysia](Usage-ms)
## Øv kortene
1. Start programmet med `python -m flash_card`.
2. Et kort viser et malajisk ord. Prøv at huske den danske oversættelse.
3. Efter tre sekunder vender kortet og viser oversættelsen.
4. Tryk på det grønne flueben, hvis du kendte ordet, eller på det røde kryds, hvis du ikke gjorde.
5. Et nyt tilfældigt ord vises. Gentag.
Et ord, du har markeret som kendt, fjernes og kommer ikke igen. Når der ikke er flere ord, står der "Nothing left to learn" på kortet.
## Din fremgang
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
- Næste start bruger den fil. Findes den ikke, bruger programmet hele ordlisten `data/ms-da.csv`.
- For at starte forfra skal du slette `data/words_to_learn.csv`.
- Det røde kryds ændrer aldrig filen.
## Filformat
Begge filer er UTF-8-tekst med ét kort pr. linje og cellerne adskilt af semikolon. Første linje har de to sprogkoder.
```text
ms;da
aku;mig
yang;hvilke
```
## Indstillinger
| Indstilling | Betydning |
| --- | --- |
| `--data-dir <mappe>` | Mappen med `ms-da.csv` og `words_to_learn.csv` (standard: `data/` i repositoryet) |
| `--no-speech` | Udtal ikke det malajiske ord, selv om tilvalget til tale er installeret |
## Brug et andet sprogpar
Læg en fil med samme format i datamappen og kald den `ms-da.csv`, eller ret `VOCABULARY_FILE_NAME` og `LANGUAGE_NAMES` i `src/flash_card/constants.py`. Kodene i overskriften vises som sprognavne fra `LANGUAGE_NAMES`; ukendte koder vises med store bogstaver.
## Fejlfinding
| Problem | Hvad du skal gøre |
| --- | --- |
| `No module named tkinter` | Installer Tkinter (`python3-tk` på Debian og Ubuntu) |
| Ingen lyd | Installer tilvalget `speech` og tjek netværket; programmet logger en advarsel, når udtalen mislykkes |
| Programmet stopper med en meddelelse om filen | CSV-filen er forkert opbygget; meddelelsen nævner filen og linjen. Filen bliver ikke overskrevet |
+49
View File
@@ -0,0 +1,49 @@
# Penggunaan
🌐 [English](Usage) · [Dansk](Usage-da) · **Bahasa Malaysia**
## Berlatih dengan kad
1. Mulakan program dengan `python -m flash_card`.
2. Kad menunjukkan satu perkataan Melayu. Cuba ingat terjemahan Denmarknya.
3. Selepas tiga saat kad terbalik dan menunjukkan terjemahan.
4. Tekan tanda semak hijau jika anda tahu perkataan itu, atau pangkah merah jika tidak.
5. Satu perkataan rawak baharu muncul. Ulang.
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi, kad menunjukkan "Nothing left to learn".
## Kemajuan anda
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
- Permulaan seterusnya menggunakan fail itu. Jika ia tiada, program menggunakan senarai penuh `data/ms-da.csv`.
- Untuk bermula semula, padam `data/words_to_learn.csv`.
- Pangkah merah tidak pernah mengubah fail.
## Format fail
Kedua-dua fail ialah teks UTF-8 dengan satu kad setiap baris dan sel dipisahkan oleh koma bertitik. Baris pertama mengandungi dua kod bahasa.
```text
ms;da
aku;mig
yang;hvilke
```
## Pilihan
| Pilihan | Maksud |
| --- | --- |
| `--data-dir <folder>` | Folder yang mengandungi `ms-da.csv` dan `words_to_learn.csv` (lalai: `data/` dalam repositori) |
| `--no-speech` | Jangan sebut perkataan Melayu, walaupun tambahan suara dipasang |
## Gunakan pasangan bahasa lain
Letakkan fail dengan format yang sama dalam folder data dan namakannya `ms-da.csv`, atau ubah `VOCABULARY_FILE_NAME` dan `LANGUAGE_NAMES` dalam `src/flash_card/constants.py`. Kod pada baris pertama dipaparkan sebagai nama bahasa daripada `LANGUAGE_NAMES`; kod yang tidak dikenali dipaparkan dengan huruf besar.
## Penyelesaian masalah
| Masalah | Apa yang perlu dilakukan |
| --- | --- |
| `No module named tkinter` | Pasang Tkinter (`python3-tk` pada Debian dan Ubuntu) |
| Tiada bunyi | Pasang tambahan `speech` dan periksa rangkaian; program mencatat amaran apabila penyebutan gagal |
| Program berhenti dengan mesej tentang fail | Fail CSV tidak betul; mesej menyebut fail dan barisnya. Fail tidak ditulis ganti |
+49
View File
@@ -0,0 +1,49 @@
# Usage
🌐 **English** · [Dansk](Usage-da) · [Bahasa Malaysia](Usage-ms)
## Study the cards
1. Start the program with `python -m flash_card`.
2. A card shows a Malay word. Try to remember its Danish translation.
3. After three seconds the card turns over and shows the translation.
4. Press the green tick when you knew the word, or the red cross when you did not.
5. A new random word appears. Repeat.
A word you marked as known is removed and does not come up again. When no word is left, the card says "Nothing left to learn".
## Your progress
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
- The next start uses that file. If it does not exist, the program uses the full word list `data/ms-da.csv`.
- To start again, delete `data/words_to_learn.csv`.
- The red cross never changes the file.
## File format
Both files are UTF-8 text with one card per line and the cells separated by a semicolon. The first line holds the two language codes.
```text
ms;da
aku;mig
yang;hvilke
```
## Options
| Option | Meaning |
| --- | --- |
| `--data-dir <folder>` | Folder that holds `ms-da.csv` and `words_to_learn.csv` (default: `data/` of the repository) |
| `--no-speech` | Do not speak the Malay word, even when the speech extra is installed |
## Use another language pair
Put a file with the same format in the data folder and name it `ms-da.csv`, or change `VOCABULARY_FILE_NAME` and `LANGUAGE_NAMES` in `src/flash_card/constants.py`. The header codes are shown as language names from `LANGUAGE_NAMES`; unknown codes are shown in upper case.
## Troubleshooting
| Problem | What to do |
| --- | --- |
| `No module named tkinter` | Install Tkinter (`python3-tk` on Debian and Ubuntu) |
| No sound | Install the `speech` extra and check the network; the program logs a warning when speaking fails |
| The program stops with a message about the file | The CSV file is malformed; the message names the file and the line. The file is not overwritten |
+12
View File
@@ -0,0 +1,12 @@
**Flash Card**
- [Home](Home)
- [Install](Install)
- [Usage](Usage)
- [Architecture](Architecture)
**Languages**
- English: [Home](Home) · [Install](Install) · [Usage](Usage) · [Architecture](Architecture)
- Dansk: [Hjem](Home-da) · [Installation](Install-da) · [Brug](Usage-da) · [Arkitektur](Architecture-da)
- Bahasa Malaysia: [Utama](Home-ms) · [Pemasangan](Install-ms) · [Penggunaan](Usage-ms) · [Seni bina](Architecture-ms)
+5
View File
@@ -24,6 +24,8 @@ dependencies = []
[project.optional-dependencies] [project.optional-dependencies]
# Spoken Malay word (text to speech). Optional; the program works without it. # Spoken Malay word (text to speech). Optional; the program works without it.
speech = ["gTTS>=2.5", "playsound==1.2.2"] speech = ["gTTS>=2.5", "playsound==1.2.2"]
# Only for tools/capture_screenshots.py; not needed to run the program.
tools = ["pillow>=10"]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"] dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
[project.scripts] [project.scripts]
@@ -45,6 +47,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"]
@@ -0,0 +1,5 @@
"""Ports of the application layer.
@file __init__.py
@brief Holds the interfaces the use cases need from outside.
"""
@@ -0,0 +1,22 @@
"""Port for spoken words.
@file speech_port.py
@brief The SpeechPort interface the study session uses to say a word aloud.
"""
from typing import Protocol
class SpeechPort(Protocol):
"""Speaks a text aloud in a language."""
def speak(self, text: str, language_code: str) -> None:
"""Speak a text without blocking the caller for long.
An implementation does not raise: a failure is logged and ignored.
@param text The text to speak.
@param language_code Language of the text, for example "ms".
@return Nothing.
"""
...
@@ -6,22 +6,31 @@
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from flash_card.application.ports.speech_port import SpeechPort
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,
speech: SpeechPort,
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 speech Speaks the front text of each drawn card.
@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, speech)
@@ -1,66 +1,95 @@
"""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.application.ports.speech_port import SpeechPort
from flash_card.domain.entities.deck import Deck 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."""
class StudySession:
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None: """Draws cards from a deck, one after another."""
"""Create a session.
def __init__(
@param deck The cards still to learn. self,
@param choose Picks one card from a non-empty sequence. deck: Deck,
""" choose: Callable[[Sequence[Card]], Card],
## @brief The cards still to learn. progress: ProgressRepository,
self._deck = deck speech: SpeechPort,
## @brief Picks one card from a non-empty sequence. ) -> None:
self._choose = choose """Create a session.
## @brief The card shown now, or None.
self._current: Card | None = None @param deck The cards still to learn.
@param choose Picks one card from a non-empty sequence.
@property @param progress Store of the words still to learn.
def current_card(self) -> CardDto | None: @param speech Speaks the front text of each drawn card.
"""Return the card shown now. """
## @brief Speaks the front text of each drawn card.
@return The card, or None before the first draw or when none is left. self._speech = speech
""" ## @brief Store of the words still to learn.
return self._to_dto(self._current) self._progress = progress
## @brief The cards still to learn.
@property self._deck = deck
def remaining(self) -> int: ## @brief Picks one card from a non-empty sequence.
"""Return the number of cards still to learn. self._choose = choose
## @brief The card shown now, or None.
@return The card count. self._current: Card | None = None
"""
return len(self._deck) @property
def current_card(self) -> CardDto | None:
def next_card(self) -> CardDto | None: """Return the card shown now.
"""Draw the next card, different from the current one when possible.
@return The card, or None before the first draw or when none is left.
@return The new card, or None when the deck is empty. """
""" return self._to_dto(self._current)
if self._deck.is_empty:
self._current = None @property
return None def remaining(self) -> int:
self._current = self._deck.draw(self._choose, exclude=self._current) """Return the number of cards still to learn.
return self._to_dto(self._current)
@return The card count.
def _to_dto(self, card: Card | None) -> CardDto | None: """
"""Map a card to its DTO. return len(self._deck)
@param card The card, or None. def next_card(self) -> CardDto | None:
@return The DTO, or None when there is no card. """Draw the next card, different from the current one when possible.
"""
if card is None: @return The new card, or None when the deck is empty.
return None """
pair = self._deck.language_pair if self._deck.is_empty:
return CardDto(card.front, card.back, pair.front_code, pair.back_code) self._current = None
return None
card = self._deck.draw(self._choose, exclude=self._current)
self._current = card
self._speech.speak(card.front, self._deck.language_pair.front_code)
return self._to_dto(card)
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)
-2
View File
@@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
## @brief File name of the words still to learn. ## @brief File name of the words still to learn.
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv" PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
## @brief Language code used for speech.
SPEECH_LANGUAGE_CODE: Final[str] = "ms"
## @brief Root folder of the project. ## @brief Root folder of the project.
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+95 -95
View File
@@ -1,95 +1,95 @@
"""The cards still to learn. """The cards still to learn.
@file deck.py @file deck.py
@brief The Deck entity: holds cards, draws one and removes one. @brief The Deck entity: holds cards, draws one and removes one.
""" """
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
from flash_card.domain.entities.card import Card from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair from flash_card.domain.value_objects.language_pair import LanguagePair
class Deck: class Deck:
"""The cards still to learn, of one language pair.""" """The cards still to learn, of one language pair."""
def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None: def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None:
"""Create a deck. """Create a deck.
@param language_pair The languages of the cards. @param language_pair The languages of the cards.
@param cards The cards of the deck. @param cards The cards of the deck.
""" """
## @brief The languages of the cards. ## @brief The languages of the cards.
self._language_pair = language_pair self._language_pair = language_pair
## @brief The cards still in the deck. ## @brief The cards still in the deck.
self._cards = list(cards) self._cards = list(cards)
@classmethod @classmethod
def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck": def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck":
"""Create a deck holding every card of a vocabulary. """Create a deck holding every card of a vocabulary.
@param vocabulary The delivered word list. @param vocabulary The delivered word list.
@return A new deck. @return A new deck.
""" """
return cls(vocabulary.language_pair, vocabulary.cards) return cls(vocabulary.language_pair, vocabulary.cards)
@property @property
def language_pair(self) -> LanguagePair: def language_pair(self) -> LanguagePair:
"""Return the languages of the cards. """Return the languages of the cards.
@return The language pair. @return The language pair.
""" """
return self._language_pair return self._language_pair
@property @property
def cards(self) -> tuple[Card, ...]: def cards(self) -> tuple[Card, ...]:
"""Return a copy of the cards. """Return a copy of the cards.
@return The cards in deck order. @return The cards in deck order.
""" """
return tuple(self._cards) return tuple(self._cards)
@property @property
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""Tell whether the deck has no card. """Tell whether the deck has no card.
@return True when no card is left. @return True when no card is left.
""" """
return not self._cards return not self._cards
def __len__(self) -> int: def __len__(self) -> int:
"""Return the number of cards. """Return the number of cards.
@return The card count. @return The card count.
""" """
return len(self._cards) return len(self._cards)
def draw( def draw(
self, self,
choose: Callable[[Sequence[Card]], Card], choose: Callable[[Sequence[Card]], Card],
exclude: Card | None = None, exclude: Card | None = None,
) -> Card: ) -> Card:
"""Pick a card without removing it. """Pick a card without removing it.
The pick avoids the excluded card when another card exists. The pick avoids the excluded card when another card exists.
@param choose Picks one card from a non-empty sequence. @param choose Picks one card from a non-empty sequence.
@param exclude A card to avoid, usually the one shown now. @param exclude A card to avoid, usually the one shown now.
@return The picked card. @return The picked card.
@throws EmptyDeckError When the deck has no card. @throws EmptyDeckError When the deck has no card.
""" """
if not self._cards: if not self._cards:
raise EmptyDeckError("cannot draw from an empty deck") raise EmptyDeckError("cannot draw from an empty deck")
others = [card for card in self._cards if card != exclude] others = [card for card in self._cards if card != exclude]
return choose(others or self._cards) return choose(others or self._cards)
def remove(self, card: Card) -> None: def remove(self, card: Card) -> None:
"""Remove the first card equal to the given one. """Remove the first card equal to the given one.
@param card The card to remove. @param card The card to remove.
@return Nothing. @return Nothing.
@throws ValueError When the deck holds no such card. @throws ValueError When the deck holds no such card.
""" """
self._cards.remove(card) self._cards.remove(card)
@@ -0,0 +1,29 @@
"""Port to the words still to learn.
@file progress_repository.py
@brief The ProgressRepository interface.
"""
from typing import Protocol
from flash_card.domain.entities.deck import Deck
class ProgressRepository(Protocol):
"""Store of the words still to learn."""
def load(self) -> Deck | None:
"""Load the saved words to learn.
@return The deck, or None when no progress was saved.
@throws VocabularyError When the saved progress is unusable.
"""
...
def save(self, deck: Deck) -> None:
"""Save the words still to learn.
@param deck The cards still to learn.
@return Nothing.
"""
...
@@ -0,0 +1,44 @@
"""CSV implementation of the progress port.
@file csv_progress_repository.py
@brief The CsvProgressRepository adapter.
"""
from pathlib import Path
from flash_card.domain.entities.deck import Deck
from flash_card.infrastructure.persistence.csv_word_list import (
read_word_list,
write_word_list,
)
class CsvProgressRepository:
"""Keeps the words still to learn in a CSV file."""
def __init__(self, path: Path) -> None:
"""Create the repository.
@param path The words to learn file.
"""
## @brief The words to learn file.
self._path = path
def load(self) -> Deck | None:
"""Load the saved words to learn.
@return The deck, possibly empty, or None when the file does not exist.
@throws VocabularyFormatError When the file is malformed; it is left as is.
"""
if not self._path.exists():
return None
pair, cards = read_word_list(self._path)
return Deck(pair, cards)
def save(self, deck: Deck) -> None:
"""Save the words still to learn.
@param deck The cards still to learn.
@return Nothing.
"""
write_word_list(self._path, deck.language_pair, deck.cards)
@@ -1,35 +1,35 @@
"""CSV implementation of the vocabulary port. """CSV implementation of the vocabulary port.
@file csv_vocabulary_repository.py @file csv_vocabulary_repository.py
@brief The CsvVocabularyRepository adapter. @brief The CsvVocabularyRepository adapter.
""" """
from pathlib import Path from pathlib import Path
from flash_card.domain.entities.vocabulary import Vocabulary from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
from flash_card.infrastructure.persistence.csv_word_list import read_word_list from flash_card.infrastructure.persistence.csv_word_list import read_word_list
class CsvVocabularyRepository: class CsvVocabularyRepository:
"""Loads the delivered word list from a CSV file.""" """Loads the delivered word list from a CSV file."""
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
"""Create the repository. """Create the repository.
@param path The word list file. @param path The word list file.
""" """
## @brief The word list file. ## @brief The word list file.
self._path = path self._path = path
def load(self) -> Vocabulary: def load(self) -> Vocabulary:
"""Load the word list. """Load the word list.
@return The vocabulary. @return The vocabulary.
@throws VocabularyNotFoundError When the file does not exist. @throws VocabularyNotFoundError When the file does not exist.
@throws VocabularyFormatError When the file is malformed or has no card. @throws VocabularyFormatError When the file is malformed or has no card.
""" """
pair, cards = read_word_list(self._path) pair, cards = read_word_list(self._path)
if not cards: if not cards:
raise VocabularyFormatError(f"{self._path}: word list has no card") raise VocabularyFormatError(f"{self._path}: word list has no card")
return Vocabulary(pair, cards) return Vocabulary(pair, cards)
@@ -1,86 +1,116 @@
"""Reading of the word list CSV file. """Reading and writing of the word list CSV file.
@file csv_word_list.py @file csv_word_list.py
@brief Reads a semicolon separated word list with a language code header. @brief Reads and writes a semicolon separated word list with a language code
""" header; the format is defined once here.
"""
import csv
from pathlib import Path import csv
from collections.abc import Iterable
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING from pathlib import Path
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import ( from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
VocabularyFormatError, from flash_card.domain.entities.card import Card
VocabularyNotFoundError, from flash_card.domain.errors.vocabulary_errors import (
) VocabularyFormatError,
from flash_card.domain.value_objects.language_pair import LanguagePair VocabularyNotFoundError,
)
## @brief Number of cells in the header and in every card row. from flash_card.domain.value_objects.language_pair import LanguagePair
_CELLS_PER_ROW = 2
## @brief Number of cells in the header and in every card row.
_CELLS_PER_ROW = 2
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
"""Build a format error naming the file and line.
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
@param path The file read. """Build a format error naming the file and line.
@param line The line number of the fault.
@param reason What is wrong. @param path The file read.
@return The error to raise. @param line The line number of the fault.
""" @param reason What is wrong.
return VocabularyFormatError(f"{path}, line {line}: {reason}") @return The error to raise.
"""
return VocabularyFormatError(f"{path}, line {line}: {reason}")
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
"""Parse the header row.
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
@param path The file read. """Parse the header row.
@param line The line number of the row.
@param row The cells of the row. @param path The file read.
@return The language pair. @param line The line number of the row.
@throws VocabularyFormatError When the header is not two codes. @param row The cells of the row.
""" @return The language pair.
cells = [cell.strip() for cell in row] @throws VocabularyFormatError When the header is not two codes.
if len(cells) != _CELLS_PER_ROW or not all(cells): """
raise _format_error(path, line, "header must hold two language codes") cells = [cell.strip() for cell in row]
return LanguagePair(cells[0], cells[1]) if len(cells) != _CELLS_PER_ROW or not all(cells):
raise _format_error(path, line, "header must hold two language codes")
return LanguagePair(cells[0], cells[1])
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
"""Parse a card row.
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
@param path The file read. """Parse a card row.
@param line The line number of the row.
@param row The cells of the row. @param path The file read.
@return The card. @param line The line number of the row.
@throws VocabularyFormatError When the row is not two cells. @param row The cells of the row.
""" @return The card.
if len(row) != _CELLS_PER_ROW: @throws VocabularyFormatError When the row is not two cells.
raise _format_error(path, line, "row must hold exactly two cells") """
return Card(row[0].strip(), row[1].strip()) if len(row) != _CELLS_PER_ROW:
raise _format_error(path, line, "row must hold exactly two cells")
return Card(row[0].strip(), row[1].strip())
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
"""Read a word list file.
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
@param path The CSV file: a header of two language codes, then one card a row. """Read a word list file.
@return The language pair and the cards in file order.
@throws VocabularyNotFoundError When the file does not exist. @param path The CSV file: a header of two language codes, then one card a row.
@throws VocabularyFormatError When the file is empty or a row is malformed. @return The language pair and the cards in file order.
""" @throws VocabularyNotFoundError When the file does not exist.
pair: LanguagePair | None = None @throws VocabularyFormatError When the file is empty or a row is malformed.
cards: list[Card] = [] """
try: pair: LanguagePair | None = None
with path.open(encoding=CSV_ENCODING, newline="") as file: cards: list[Card] = []
reader = csv.reader(file, delimiter=CSV_DELIMITER) try:
for row in reader: with path.open(encoding=CSV_ENCODING, newline="") as file:
if not any(cell.strip() for cell in row): reader = csv.reader(file, delimiter=CSV_DELIMITER)
continue for row in reader:
if pair is None: if not any(cell.strip() for cell in row):
pair = _parse_header(path, reader.line_num, row) continue
else: if pair is None:
cards.append(_parse_card(path, reader.line_num, row)) pair = _parse_header(path, reader.line_num, row)
except FileNotFoundError as error: else:
raise VocabularyNotFoundError(f"word list not found: {path}") from error cards.append(_parse_card(path, reader.line_num, row))
if pair is None: except FileNotFoundError as error:
raise _format_error(path, 1, "file is empty") raise VocabularyNotFoundError(f"word list not found: {path}") from error
return pair, tuple(cards) if pair is None:
raise _format_error(path, 1, "file is empty")
return pair, tuple(cards)
def write_word_list(
path: Path, language_pair: LanguagePair, cards: Iterable[Card]
) -> None:
"""Write a word list file atomically.
The rows go to a temporary file in the same folder, which then replaces the
target, so a crash never leaves a half written file.
@param path The CSV file to create or replace.
@param language_pair The languages, written as the header row.
@param cards The cards, written one row each in the given order.
@return Nothing.
@throws OSError When the file cannot be written.
"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
rows = [(language_pair.front_code, language_pair.back_code)]
rows.extend((card.front, card.back) for card in cards)
try:
with temporary.open("w", encoding=CSV_ENCODING, newline="") as file:
writer = csv.writer(file, delimiter=CSV_DELIMITER, lineterminator="\n")
writer.writerows(rows)
temporary.replace(path)
except BaseException:
temporary.unlink(missing_ok=True)
raise
@@ -0,0 +1,5 @@
"""Speech adapters.
@file __init__.py
@brief Holds the implementations of the speech port.
"""
@@ -0,0 +1,108 @@
"""Speech adapter over the gTTS service.
@file gtts_speech.py
@brief GttsSpeech, the SpeechPort that synthesises with gTTS and plays the file.
The packages gtts and playsound are optional and imported only when used.
"""
import importlib
import logging
import tempfile
import threading
from collections.abc import Callable
from pathlib import Path
## @brief Logger of the speech adapter.
_logger = logging.getLogger(__name__)
## @brief File name of the synthesised speech inside the temporary folder.
_SPEECH_FILE_NAME = "speech.mp3"
## @brief Writes an mp3 of a text and language to a path.
Synthesize = Callable[[str, str, Path], None]
## @brief Plays an audio file.
Play = Callable[[Path], None]
def _default_synthesize(text: str, language_code: str, path: Path) -> None:
"""Write an mp3 of the text with gTTS.
@param text The text to speak.
@param language_code Language of the text.
@param path File to write.
@return Nothing.
@throws ImportError When the gtts package is not installed.
"""
# Imported by name so the program starts without the optional extra.
module = importlib.import_module("gtts")
module.gTTS(text=text, lang=language_code).save(str(path))
def _default_play(path: Path) -> None:
"""Play an audio file and wait until it has been played.
@param path File to play.
@return Nothing.
@throws ImportError When the playsound package is not installed.
"""
# Imported by name so the program starts without the optional extra.
module = importlib.import_module("playsound")
module.playsound(str(path))
class GttsSpeech:
"""Speaks a text in a background thread; never raises."""
def __init__(
self,
synthesize: Synthesize | None = None,
play: Play | None = None,
) -> None:
"""Create the adapter.
@param synthesize Writes an mp3 of a text and language to a path, or
None for gTTS.
@param play Plays an audio file, or None for playsound.
"""
## @brief Writes the audio file.
self._synthesize = _default_synthesize if synthesize is None else synthesize
## @brief Plays the audio file.
self._play = _default_play if play is None else play
## @brief The thread of the latest speak call, or None.
self._thread: threading.Thread | None = None
def speak(self, text: str, language_code: str) -> None:
"""Start speaking and return at once.
@param text The text to speak.
@param language_code Language of the text.
@return Nothing.
"""
thread = threading.Thread(
target=self._run, args=(text, language_code), daemon=True
)
self._thread = thread
thread.start()
def wait(self) -> None:
"""Block until the latest speech has ended.
@return Nothing.
"""
if self._thread is not None:
self._thread.join()
def _run(self, text: str, language_code: str) -> None:
"""Synthesise and play in a temporary folder, then remove the file.
@param text The text to speak.
@param language_code Language of the text.
@return Nothing.
"""
try:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / _SPEECH_FILE_NAME
self._synthesize(text, language_code, path)
self._play(path)
except Exception: # thread boundary: speech must never crash the program
_logger.warning("Speech failed for %r", text, exc_info=True)
@@ -0,0 +1,18 @@
"""Silent speech adapter.
@file null_speech.py
@brief NullSpeech, the SpeechPort used when speech is off or unavailable.
"""
class NullSpeech:
"""Speaks nothing."""
def speak(self, text: str, language_code: str) -> None:
"""Do nothing.
@param text The text, ignored.
@param language_code The language, ignored.
@return Nothing.
"""
return None
+114 -77
View File
@@ -1,77 +1,114 @@
"""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 importlib.util
import random import logging
import tkinter as tk import random
from collections.abc import Sequence import tkinter as tk
from pathlib import Path from collections.abc import Sequence
from typing import NoReturn 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.application.ports.speech_port import SpeechPort
from flash_card.domain.errors.vocabulary_errors import VocabularyError from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.infrastructure.persistence.csv_vocabulary_repository import ( from flash_card.constants import (
CsvVocabularyRepository, DEFAULT_DATA_DIR,
) PROGRESS_FILE_NAME,
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView VOCABULARY_FILE_NAME,
from flash_card.presentation.features.flash_card.flash_card_view_model import ( )
FlashCardViewModel, from flash_card.domain.errors.vocabulary_errors import VocabularyError
) from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
## @brief Logger of the entry point. )
_logger = logging.getLogger(__name__) from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
def create_application( from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
data_dir: Path | None = None, from flash_card.infrastructure.speech.null_speech import NullSpeech
) -> tuple[tk.Tk, FlashCardView]: from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
"""Create the root window and the flash card view. from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
@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. ## @brief Logger of the entry point.
""" _logger = logging.getLogger(__name__)
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME) ## @brief Optional packages the spoken words need.
session = open_study_session(repository, random.choice) _SPEECH_PACKAGES = ("gtts", "playsound")
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model) def build_speech(enabled: bool) -> SpeechPort:
return root, view """Choose the speech adapter.
@param enabled False when the user switched speech off.
def main(argv: Sequence[str] | None = None) -> int: @return GttsSpeech when enabled and gtts and playsound are installed,
"""Run the program and return its exit code. else NullSpeech.
"""
@param argv Command line arguments, or None for those of the process. if enabled and all(
@return The exit code, 0 on success and 1 when the word list is unusable. importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
""" ):
parser = argparse.ArgumentParser(description="Flash card program.") return GttsSpeech()
parser.add_argument( return NullSpeech()
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR, def create_application(
help="folder of the data files", data_dir: Path | None = None,
) speech_enabled: bool = True,
args = parser.parse_args(argv) ) -> tuple[tk.Tk, FlashCardView]:
try: """Create the root window and the flash card view.
root, _view = create_application(args.data_dir)
except VocabularyError as error: @param data_dir Folder of the data files, or None for the default folder.
_logger.error("%s", error) @param speech_enabled False to run without spoken words.
return 1 @return The root window and the view built on it.
root.mainloop() @throws VocabularyError When the word list or saved progress is unusable.
return 0 """
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
def run() -> NoReturn: progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
"""Run the program and exit the interpreter with its exit code. speech = build_speech(speech_enabled)
session = open_study_session(repository, progress, speech, random.choice)
@return Never returns; the function always raises SystemExit. view_model = FlashCardViewModel(session)
@throws SystemExit Always, with the exit code of main. root = tk.Tk()
""" view = FlashCardView(root, view_model)
raise SystemExit(main()) 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",
)
parser.add_argument(
"--no-speech",
action="store_true",
help="do not speak the words",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir, not args.no_speech)
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())
@@ -1,113 +1,212 @@
"""View of the flash card screen. """View of the flash card screen.
@file flash_card_view.py @file flash_card_view.py
@brief Builds the window of the wireframe: card canvas and two buttons. @brief Builds the window of the wireframe: card canvas and two buttons.
""" """
import tkinter as tk import tkinter as tk
from flash_card.constants import ( from flash_card.constants import (
ASSETS_DIR, ASSETS_DIR,
BACKGROUND_COLOR, BACK_TEXT_COLOR,
CARD_CENTER_X, BACKGROUND_COLOR,
CARD_FRONT_IMAGE_NAME, CARD_BACK_IMAGE_NAME,
CARD_HEIGHT, CARD_CENTER_X,
CARD_WIDTH, CARD_FRONT_IMAGE_NAME,
FRONT_TEXT_COLOR, CARD_HEIGHT,
LANGUAGE_FONT, CARD_WIDTH,
LANGUAGE_TEXT_Y, FLIP_DELAY_MS,
RIGHT_IMAGE_NAME, FRONT_TEXT_COLOR,
WINDOW_PADDING, LANGUAGE_FONT,
WINDOW_TITLE, LANGUAGE_TEXT_Y,
WORD_FONT, RIGHT_IMAGE_NAME,
WORD_TEXT_Y, WINDOW_PADDING,
WRONG_IMAGE_NAME, WINDOW_TITLE,
) WORD_FONT,
from flash_card.presentation.features.flash_card.flash_card_view_model import ( WORD_TEXT_Y,
FlashCardViewModel, WRONG_IMAGE_NAME,
) )
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
class FlashCardView: Side,
"""The flash card window: a card and the wrong and right buttons.""" )
def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
"""Build the window on a root. def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
"""Load an asset image.
@param root The Tk root window.
@param view_model The data to show. The bytes are read here and handed to Tk as data, so the image does not
""" depend on Tcl opening the file: Tcl keeps the standard handles it saw at
## @brief The Tk root window. start, and a process that swaps them (pytest fd capture) makes the first
self._root = root Tcl file open fail with an empty error.
## @brief The data source of the texts.
self._view_model = view_model @param root The Tk root window that owns the image.
root.title(WINDOW_TITLE) @param name The file name in the assets folder.
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR) @return The image; the caller must keep a reference.
"""
# Kept as attributes: Tkinter drops an image nothing refers to. return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
## @brief Card front image, kept so Tk does not drop it.
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it. class FlashCardView:
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME) """The flash card window: a card and the wrong and right buttons."""
## @brief Right button image, kept so Tk does not drop it.
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME) def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
"""Build the window on a root.
## @brief Canvas that draws the card.
self.canvas = tk.Canvas( @param root The Tk root window.
root, @param view_model The data to show.
width=CARD_WIDTH, """
height=CARD_HEIGHT, ## @brief The Tk root window.
bg=BACKGROUND_COLOR, self._root = root
highlightthickness=0, ## @brief The data source of the texts.
) self._view_model = view_model
## @brief Canvas item id of the card image. root.title(WINDOW_TITLE)
self.image_id = self.canvas.create_image( root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
) # Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Canvas item id of the language name text. ## @brief Card front image, kept so Tk does not drop it.
self.language_text_id = self.canvas.create_text( self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
CARD_CENTER_X, ## @brief Card back image, kept so Tk does not drop it.
LANGUAGE_TEXT_Y, self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
text=view_model.language_name, ## @brief Wrong button image, kept so Tk does not drop it.
font=LANGUAGE_FONT, self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
fill=FRONT_TEXT_COLOR, ## @brief Right button image, kept so Tk does not drop it.
) self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
## @brief Canvas item id of the word text.
self.word_text_id = self.canvas.create_text( ## @brief Canvas that draws the card.
CARD_CENTER_X, self.canvas = tk.Canvas(
WORD_TEXT_Y, root,
text=view_model.text, width=CARD_WIDTH,
font=WORD_FONT, height=CARD_HEIGHT,
fill=FRONT_TEXT_COLOR, bg=BACKGROUND_COLOR,
) highlightthickness=0,
self.canvas.grid(row=0, column=0, columnspan=2) )
## @brief Canvas item id of the card image.
## @brief Button for a word the user does not know. self.image_id = self.canvas.create_image(
self.wrong_button = tk.Button( CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
root, )
image=self._wrong_image, ## @brief Canvas item id of the language name text.
highlightthickness=0, self.language_text_id = self.canvas.create_text(
bd=0, CARD_CENTER_X,
command=self.next_card, LANGUAGE_TEXT_Y,
) text=view_model.language_name,
self.wrong_button.grid(row=1, column=0) font=LANGUAGE_FONT,
## @brief Button for a word the user knows. fill=FRONT_TEXT_COLOR,
self.right_button = tk.Button( )
root, ## @brief Canvas item id of the word text.
image=self._right_image, self.word_text_id = self.canvas.create_text(
highlightthickness=0, CARD_CENTER_X,
bd=0, WORD_TEXT_Y,
command=self.next_card, text=view_model.text,
) font=WORD_FONT,
self.right_button.grid(row=1, column=1) fill=FRONT_TEXT_COLOR,
)
def next_card(self) -> None: self.canvas.grid(row=0, column=0, columnspan=2)
"""Show the next card.
## @brief Button for a word the user does not know.
@return Nothing. self.wrong_button = tk.Button(
""" root,
self._view_model.next_card() image=self._wrong_image,
self.canvas.itemconfig( bg=BACKGROUND_COLOR,
self.language_text_id, text=self._view_model.language_name activebackground=BACKGROUND_COLOR,
) relief="flat",
self.canvas.itemconfig(self.word_text_id, text=self._view_model.text) highlightthickness=0,
bd=0,
command=self.next_card,
)
self.wrong_button.grid(row=1, column=0)
## @brief Button for a word the user knows.
self.right_button = tk.Button(
root,
image=self._right_image,
bg=BACKGROUND_COLOR,
activebackground=BACKGROUND_COLOR,
relief="flat",
highlightthickness=0,
bd=0,
command=self.is_known,
)
self.right_button.grid(row=1, column=1)
## @brief Id of the pending flip timer, or None when none is pending.
self.flip_timer: str | None = None
self._start_flip_timer()
@property
def card_front_image(self) -> tk.PhotoImage:
"""Return the card front image.
@return The image shown while the front side is up.
"""
return self._card_front_image
@property
def card_back_image(self) -> tk.PhotoImage:
"""Return the card back image.
@return The image shown while the back side is up.
"""
return self._card_back_image
def next_card(self) -> None:
"""Show the next card front and restart the flip timer.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.next_card()
self._show_card()
self._start_flip_timer()
def is_known(self) -> None:
"""Mark the card as known, show the next card front, restart the timer.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.is_known()
self._show_card()
self._start_flip_timer()
def flip_card(self) -> None:
"""Show the back of the current card.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.flip_card()
self._show_card()
def _cancel_flip_timer(self) -> None:
"""Cancel the pending flip timer, if any.
@return Nothing.
"""
if self.flip_timer is not None:
self._root.after_cancel(self.flip_timer)
self.flip_timer = None
def _start_flip_timer(self) -> None:
"""Schedule the flip unless no card is left.
@return Nothing.
"""
if not self._view_model.is_finished:
self.flip_timer = self._root.after(FLIP_DELAY_MS, self.flip_card)
def _show_card(self) -> None:
"""Draw image and texts of the side the view model shows.
@return Nothing.
"""
if self._view_model.side is Side.BACK:
image, color = self._card_back_image, BACK_TEXT_COLOR
else:
image, color = self._card_front_image, FRONT_TEXT_COLOR
self.canvas.itemconfig(self.image_id, image=image)
self.canvas.itemconfig(
self.language_text_id, text=self._view_model.language_name, fill=color
)
self.canvas.itemconfig(
self.word_text_id, text=self._view_model.text, fill=color
)
@@ -1,69 +1,119 @@
"""View model of the flash card screen. """View model of the flash card screen.
@file flash_card_view_model.py @file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import. @brief Exposes the texts shown on the card; free of any Tkinter import.
""" """
from collections.abc import Mapping import enum
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession from flash_card.application.dto.card_dto import CardDto
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class FlashCardViewModel:
"""Data the flash card view shows.""" class Side(enum.Enum):
"""The side of a card that is shown."""
def __init__(
self, ## @brief The front: the word to learn.
session: StudySession, FRONT = enum.auto()
language_names: Mapping[str, str] = LANGUAGE_NAMES, ## @brief The back: the translation.
) -> None: BACK = enum.auto()
"""Create the view model and draw the first card.
@param session The study session that supplies the cards. class FlashCardViewModel:
@param language_names Display names by language code. """Data the flash card view shows."""
"""
## @brief The study session that supplies the cards. def __init__(
self._session = session self,
## @brief Display names by language code. session: StudySession,
self._language_names = language_names language_names: Mapping[str, str] = LANGUAGE_NAMES,
## @brief The card shown now, or None when none is left. ) -> None:
self._card: CardDto | None = session.next_card() """Create the view model and draw the first card.
@property @param session The study session that supplies the cards.
def is_finished(self) -> bool: @param language_names Display names by language code.
"""Tell whether no card is left to show. """
## @brief The study session that supplies the cards.
@return True when there is no card. self._session = session
""" ## @brief Display names by language code.
return self._card is None self._language_names = language_names
## @brief The card shown now, or None when none is left.
@property self._card: CardDto | None = session.next_card()
def language_name(self) -> str: ## @brief The side of the card shown.
"""Return the language name shown on the card. self._side = Side.FRONT
@return The display name of the front language, upper case code when @property
unknown, or an empty string when no card is left. def is_finished(self) -> bool:
""" """Tell whether no card is left to show.
if self._card is None:
return "" @return True when there is no card.
code = self._card.front_language """
return self._language_names.get(code, code.upper()) return self._card is None
@property @property
def text(self) -> str: def side(self) -> Side:
"""Return the word shown on the card. """Return the side of the card shown.
@return The front text, or the finished message when no card is left. @return FRONT or BACK; FRONT when no card is left.
""" """
if self._card is None: return self._side
return FINISHED_MESSAGE
return self._card.front_text @property
def language_name(self) -> str:
def next_card(self) -> None: """Return the language name shown on the card.
"""Draw the next card.
@return The display name of the language of the side shown, upper case
@return Nothing. code when unknown, or an empty string when no card is left.
""" """
self._card = self._session.next_card() if self._card is None:
return ""
if self._side is Side.BACK:
code = self._card.back_language
else:
code = self._card.front_language
return self._language_names.get(code, code.upper())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The text of the side shown, or the finished message when no
card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card and show its front; does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._card = self._session.next_card()
self._side = Side.FRONT
def is_known(self) -> None:
"""Mark the card as known, save the progress and show the next card.
Does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._session.mark_known()
self._card = self._session.next_card()
self._side = Side.FRONT
def flip_card(self) -> None:
"""Show the back of the card; does nothing when finished or flipped.
@return Nothing.
"""
if self._card is not None:
self._side = Side.BACK
+364 -136
View File
@@ -1,136 +1,364 @@
"""End-to-end tests of the flash card window. """End-to-end tests of the flash card window.
@file test_flash_card_window.py @file test_flash_card_window.py
@brief Builds the real window with Tk and checks it against the wireframe. @brief Builds the real window with Tk and checks it against the wireframe.
""" """
import tkinter as tk import time
from collections.abc import Iterator, Sequence import tkinter as tk
from collections.abc import Callable, Iterator, Sequence
import pytest from pathlib import Path
from flash_card.application.use_cases.study_session import StudySession import pytest
from flash_card.constants import (
BACKGROUND_COLOR, from flash_card.application.use_cases.open_study_session import open_study_session
CARD_HEIGHT, from flash_card.application.use_cases.study_session import StudySession
CARD_WIDTH, from flash_card.constants import (
FRONT_TEXT_COLOR, BACK_TEXT_COLOR,
LANGUAGE_FONT, BACKGROUND_COLOR,
WINDOW_PADDING, CARD_HEIGHT,
WINDOW_TITLE, CARD_WIDTH,
WORD_FONT, FINISHED_MESSAGE,
) FLIP_DELAY_MS,
from flash_card.domain.entities.card import Card FRONT_TEXT_COLOR,
from flash_card.domain.entities.deck import Deck LANGUAGE_FONT,
from flash_card.domain.value_objects.language_pair import LanguagePair WINDOW_PADDING,
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView WINDOW_TITLE,
from flash_card.presentation.features.flash_card.flash_card_view_model import ( WORD_FONT,
FlashCardViewModel, )
) from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne")) from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
def _first(cards: Sequence[Card]) -> Card: )
return cards[0] from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
def _build_view(root: tk.Tk) -> FlashCardView: from flash_card.infrastructure.speech.null_speech import NullSpeech
deck = Deck(LanguagePair("ms", "da"), CARDS) from flash_card.presentation.features.flash_card import flash_card_view
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first))) 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,
def _shown(view: FlashCardView) -> tuple[str, str]: )
return (
str(view.canvas.itemcget(view.language_text_id, "text")), CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
str(view.canvas.itemcget(view.word_text_id, "text")),
)
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
@pytest.fixture(scope="module")
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
# One Tk per module: a second interpreter right after destroying the class _MemoryProgress:
# first fails to find init.tcl on some Windows Python installs. """Progress store that keeps nothing: the shared window never deletes cards."""
try:
root = tk.Tk() def load(self) -> Deck | None:
except tk.TclError: return None
pytest.skip("no display available for Tk")
view = _build_view(root) def save(self, deck: Deck) -> None:
root.update_idletasks() return None
yield root, view
root.destroy()
class _RecordingSpeech:
"""Speech port that records what would have been spoken."""
def test_window_has_title_background_and_padding(
window: tuple[tk.Tk, FlashCardView], def __init__(self) -> None:
) -> None: self.spoken: list[tuple[str, str]] = []
root, _view = window
assert root.title() == WINDOW_TITLE def speak(self, text: str, language_code: str) -> None:
assert root.cget("bg") == BACKGROUND_COLOR self.spoken.append((text, language_code))
assert int(str(root.cget("padx"))) == WINDOW_PADDING
assert int(str(root.cget("pady"))) == WINDOW_PADDING
SPEECH = _RecordingSpeech()
def test_canvas_has_card_size_and_spans_two_columns(
window: tuple[tk.Tk, FlashCardView], def _build_view(root: tk.Tk) -> FlashCardView:
) -> None: deck = Deck(LanguagePair("ms", "da"), CARDS)
_root, view = window session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
assert int(str(view.canvas.cget("width"))) == CARD_WIDTH return FlashCardView(root, FlashCardViewModel(session))
assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT
info = view.canvas.grid_info()
assert (int(info["row"]), int(info["column"])) == (0, 0) def _shown(view: FlashCardView) -> tuple[str, str]:
assert int(info["columnspan"]) == 2 return (
str(view.canvas.itemcget(view.language_text_id, "text")),
str(view.canvas.itemcget(view.word_text_id, "text")),
def test_canvas_holds_one_image_and_two_texts( )
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window @pytest.fixture(scope="module")
types = [view.canvas.type(item) for item in view.canvas.find_all()] def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
assert types.count("image") == 1 # One Tk per module: a second interpreter right after destroying the
assert types.count("text") == 2 # first fails to find init.tcl on some Windows Python installs.
assert len(types) == 3 try:
root = tk.Tk()
except tk.TclError:
def test_texts_show_first_card_with_font_and_colour( pytest.skip("no display available for Tk")
window: tuple[tk.Tk, FlashCardView], view = _build_view(root)
) -> None: root.update_idletasks()
_root, view = window yield root, view
canvas = view.canvas root.destroy()
expected = [
(view.language_text_id, "Malay", LANGUAGE_FONT),
(view.word_text_id, "aku", WORD_FONT), def test_window_has_title_background_and_padding(
] window: tuple[tk.Tk, FlashCardView],
for item, text, font in expected: ) -> None:
assert canvas.itemcget(item, "text") == text root, _view = window
assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR assert root.title() == WINDOW_TITLE
family, size, style = font assert root.cget("bg") == BACKGROUND_COLOR
assert canvas.itemcget(item, "font").split() == [family, str(size), style] assert int(str(root.cget("padx"))) == WINDOW_PADDING
assert int(str(root.cget("pady"))) == WINDOW_PADDING
def test_buttons_sit_in_row_one_with_their_images(
window: tuple[tk.Tk, FlashCardView], def test_canvas_has_card_size_and_spans_two_columns(
) -> None: window: tuple[tk.Tk, FlashCardView],
_root, view = window ) -> None:
wrong = view.wrong_button.grid_info() _root, view = window
right = view.right_button.grid_info() assert int(str(view.canvas.cget("width"))) == CARD_WIDTH
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0) assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT
assert (int(right["row"]), int(right["column"])) == (1, 1) info = view.canvas.grid_info()
wrong_image = str(view.wrong_button.cget("image")) assert (int(info["row"]), int(info["column"])) == (0, 0)
right_image = str(view.right_button.cget("image")) assert int(info["columnspan"]) == 2
assert wrong_image
assert right_image
assert wrong_image != right_image def test_canvas_holds_one_image_and_two_texts(
assert str(view.wrong_button.cget("command")) window: tuple[tk.Tk, FlashCardView],
assert str(view.right_button.cget("command")) ) -> None:
_root, view = window
types = [view.canvas.type(item) for item in view.canvas.find_all()]
def test_each_button_press_shows_next_card( assert types.count("image") == 1
window: tuple[tk.Tk, FlashCardView], assert types.count("text") == 2
) -> None: assert len(types) == 3
_root, view = window
assert _shown(view) == ("Malay", "aku")
view.wrong_button.invoke() def test_texts_show_first_card_with_font_and_colour(
assert _shown(view) == ("Malay", "kau") window: tuple[tk.Tk, FlashCardView],
view.right_button.invoke() ) -> None:
assert _shown(view) == ("Malay", "aku") _root, view = window
canvas = view.canvas
expected = [
(view.language_text_id, "Malay", LANGUAGE_FONT),
(view.word_text_id, "aku", WORD_FONT),
]
for item, text, font in expected:
assert canvas.itemcget(item, "text") == text
assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR
family, size, style = font
assert canvas.itemcget(item, "font").split() == [family, str(size), style]
def test_buttons_sit_in_row_one_with_their_images(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
wrong = view.wrong_button.grid_info()
right = view.right_button.grid_info()
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0)
assert (int(right["row"]), int(right["column"])) == (1, 1)
wrong_image = str(view.wrong_button.cget("image"))
right_image = str(view.right_button.cget("image"))
assert wrong_image
assert right_image
assert wrong_image != right_image
assert str(view.wrong_button.cget("command"))
assert str(view.right_button.cget("command"))
def test_buttons_sit_on_the_window_background(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
assert view.wrong_button.cget("bg") == BACKGROUND_COLOR
assert view.right_button.cget("bg") == BACKGROUND_COLOR
def test_wrong_button_press_shows_next_card(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
assert _shown(view) == ("Malay", "aku")
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "kau")
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "aku")
def _word(view: FlashCardView) -> str:
return _shown(view)[1]
def _image(view: FlashCardView) -> str:
return str(view.canvas.itemcget(view.image_id, "image"))
def _fill(view: FlashCardView) -> tuple[str, str]:
return (
str(view.canvas.itemcget(view.language_text_id, "fill")),
str(view.canvas.itemcget(view.word_text_id, "fill")),
)
def _reset(root: tk.Tk, view: FlashCardView) -> None:
"""Show a fresh front with no pending timer."""
view.next_card()
if view.flip_timer is not None:
root.after_cancel(view.flip_timer)
view.flip_timer = None
def test_front_state_uses_front_image_black_text_and_front_language(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
assert _word(view) in {card.front for card in CARDS}
def test_flip_card_shows_back_image_white_text_and_translation(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
front = _word(view)
view.flip_card()
translations = {card.front: card.back for card in CARDS}
assert _image(view) == str(view.card_back_image)
assert _image(view) != str(view.card_front_image)
assert _fill(view) == (BACK_TEXT_COLOR, BACK_TEXT_COLOR)
assert _shown(view) == ("Danish", translations[front])
assert view.flip_timer is None
def test_next_card_after_flip_shows_front_again(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
view.flip_card()
view.wrong_button.invoke()
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
_reset(root, view)
def test_next_card_cancels_pending_timer_and_schedules_flip(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
_reset(root, view)
calls: list[tuple[str, object]] = []
def fake_after(delay: int, callback: Callable[[], None]) -> str:
calls.append(("after", (delay, callback)))
return "timer-id"
def fake_cancel(timer_id: str) -> None:
calls.append(("cancel", timer_id))
monkeypatch.setattr(root, "after", fake_after, raising=False)
monkeypatch.setattr(root, "after_cancel", fake_cancel, raising=False)
view.flip_timer = "old-id"
view.next_card()
assert calls == [
("cancel", "old-id"),
("after", (FLIP_DELAY_MS, view.flip_card)),
]
assert view.flip_timer == "timer-id"
view.flip_timer = None
def test_real_timer_flips_card_after_delay(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
monkeypatch.setattr(flash_card_view, "FLIP_DELAY_MS", 50)
view.next_card()
assert _image(view) == str(view.card_front_image)
deadline = time.monotonic() + 2
while view.flip_timer is not None and time.monotonic() < deadline:
root.update()
time.sleep(0.005)
assert view.flip_timer is None
assert _image(view) == str(view.card_back_image)
assert _shown(view)[0] == "Danish"
_reset(root, view)
HEADER_ONLY = "ms;da\n"
@pytest.fixture
def files_view(
window: tuple[tk.Tk, FlashCardView], tmp_path: Path
) -> Iterator[tuple[FlashCardView, Path]]:
"""A second view on the shared root, over real repositories in tmp_path."""
root, _shared = window
words = tmp_path / "words.csv"
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
progress = tmp_path / "words_to_learn.csv"
session = open_study_session(
CsvVocabularyRepository(words),
CsvProgressRepository(progress),
NullSpeech(),
_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
def test_button_press_speaks_the_malay_word_of_the_shown_card(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
SPEECH.spoken.clear()
view.wrong_button.invoke()
assert SPEECH.spoken == [(_word(view), "ms")]
_reset(root, view)
+98
View File
@@ -0,0 +1,98 @@
"""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,
)
from flash_card.infrastructure.speech.null_speech import NullSpeech
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"),
NullSpeech(),
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()
+16 -3
View File
@@ -1,13 +1,23 @@
"""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,
) )
from flash_card.infrastructure.persistence.csv_word_list import read_word_list from flash_card.infrastructure.persistence.csv_word_list import read_word_list
from flash_card.infrastructure.speech.null_speech import NullSpeech
PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME
@@ -19,10 +29,13 @@ 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, NullSpeech(), 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,23 @@ 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 _SilentSpeech:
def speak(self, text: str, language_code: str) -> None:
return None
class _MissingRepository: class _MissingRepository:
def load(self) -> Vocabulary: def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing") raise VocabularyNotFoundError("missing")
@@ -33,7 +51,9 @@ 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(), _SilentSpeech(), _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 +61,41 @@ 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(), _SilentSpeech(), _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, _SilentSpeech(), _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, _SilentSpeech(), _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, _SilentSpeech(), _first
)
session.next_card()
assert progress.saved == []
+94 -2
View File
@@ -17,8 +17,33 @@ 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)
class _RecordingSpeech:
def __init__(self) -> None:
self.spoken: list[tuple[str, str]] = []
def speak(self, text: str, language_code: str) -> None:
self.spoken.append((text, language_code))
def _session(
*cards: Card,
progress: _RecordingProgress | None = None,
speech: _RecordingSpeech | None = None,
) -> StudySession:
store = _RecordingProgress() if progress is None else progress
voice = _RecordingSpeech() if speech is None else speech
return StudySession(Deck(PAIR, cards), _first, store, voice)
def test_current_card_is_none_before_first_draw() -> None: def test_current_card_is_none_before_first_draw() -> None:
@@ -58,3 +83,70 @@ 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 == []
def test_next_card_speaks_front_text_once_in_front_language() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
assert speech.spoken == [("aku", "ms")]
def test_next_card_does_not_speak_when_deck_is_empty() -> None:
speech = _RecordingSpeech()
session = _session(speech=speech)
session.next_card()
assert speech.spoken == []
def test_next_card_speaks_again_on_every_draw() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
session.next_card()
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
def test_mark_known_does_not_speak_but_following_draw_does() -> None:
speech = _RecordingSpeech()
session = _session(A, B, speech=speech)
session.next_card()
session.mark_known()
assert speech.spoken == [("aku", "ms")]
session.next_card()
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
@@ -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
@@ -0,0 +1,107 @@
"""Tests of the GttsSpeech adapter with fake synthesizer and player."""
import threading
from pathlib import Path
import pytest
from flash_card.infrastructure.speech import gtts_speech
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
class _Recorder:
def __init__(self) -> None:
self.synthesized: list[tuple[str, str]] = []
self.played: list[Path] = []
self.existed_when_played = False
def synthesize(self, text: str, language_code: str, path: Path) -> None:
self.synthesized.append((text, language_code))
path.write_bytes(b"mp3")
def play(self, path: Path) -> None:
self.played.append(path)
self.existed_when_played = path.exists()
def test_speak_synthesizes_and_plays_then_removes_file() -> None:
recorder = _Recorder()
speech = GttsSpeech(recorder.synthesize, recorder.play)
speech.speak("aku", "ms")
speech.wait()
assert recorder.synthesized == [("aku", "ms")]
assert len(recorder.played) == 1
assert recorder.existed_when_played
assert not recorder.played[0].exists()
def test_speak_returns_before_player_has_finished() -> None:
started = threading.Event()
release = threading.Event()
def blocked_play(path: Path) -> None:
started.set()
release.wait(timeout=5)
speech = GttsSpeech(lambda text, code, path: None, blocked_play)
speech.speak("aku", "ms")
assert started.wait(timeout=5)
assert speech._thread is not None
assert speech._thread.is_alive()
release.set()
speech.wait()
def test_speak_logs_and_does_not_raise_when_synthesize_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
played: list[Path] = []
def failing(text: str, language_code: str, path: Path) -> None:
raise OSError("no network")
speech = GttsSpeech(failing, played.append)
speech.speak("aku", "ms")
speech.wait()
assert played == []
assert "Speech failed" in caplog.text
assert "no network" in caplog.text
def test_speak_logs_and_does_not_raise_when_play_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
def failing(path: Path) -> None:
raise RuntimeError("no audio device")
speech = GttsSpeech(lambda text, code, path: None, failing)
speech.speak("aku", "ms")
speech.wait()
assert "no audio device" in caplog.text
def test_speak_logs_missing_package_when_default_synthesizer_raises_import_error(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
def missing(name: str) -> object:
raise ImportError(f"No module named {name!r}")
monkeypatch.setattr(gtts_speech.importlib, "import_module", missing)
speech = GttsSpeech()
speech.speak("aku", "ms")
speech.wait()
assert "No module named 'gtts'" in caplog.text
def test_wait_returns_when_nothing_was_spoken() -> None:
GttsSpeech().wait()
@@ -0,0 +1,7 @@
"""Tests of the NullSpeech adapter."""
from flash_card.infrastructure.speech.null_speech import NullSpeech
def test_speak_does_nothing_and_returns_none() -> None:
assert NullSpeech().speak("aku", "ms") is None
+3 -1
View File
@@ -25,7 +25,9 @@ def test_main_runs_main_loop_and_returns_zero(
root = _FakeRoot() root = _FakeRoot()
seen: list[Path | None] = [] seen: list[Path | None] = []
def fake_create(data_dir: Path | None = None) -> tuple[_FakeRoot, object]: def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
seen.append(data_dir) seen.append(data_dir)
return root, object() return root, object()
+70
View File
@@ -0,0 +1,70 @@
"""Tests of the speech wiring in the entry point."""
import importlib.util
from importlib.machinery import ModuleSpec
from pathlib import Path
import pytest
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
from flash_card.infrastructure.speech.null_speech import NullSpeech
from flash_card.presentation import app
class _FakeRoot:
def mainloop(self) -> None:
return None
def _found(name: str) -> ModuleSpec:
return ModuleSpec(name, None)
def test_build_speech_returns_null_speech_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", _found)
assert isinstance(app.build_speech(False), NullSpeech)
def test_build_speech_returns_null_speech_when_packages_are_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
assert isinstance(app.build_speech(True), NullSpeech)
def test_build_speech_returns_null_speech_when_one_package_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
importlib.util,
"find_spec",
lambda name: None if name == "playsound" else _found(name),
)
assert isinstance(app.build_speech(True), NullSpeech)
def test_build_speech_returns_gtts_speech_when_both_packages_are_found(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(importlib.util, "find_spec", _found)
assert isinstance(app.build_speech(True), GttsSpeech)
def test_main_passes_speech_disabled_with_no_speech_option(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
seen: list[bool] = []
def fake_create(
data_dir: Path | None = None, speech_enabled: bool = True
) -> tuple[_FakeRoot, object]:
seen.append(speech_enabled)
return _FakeRoot(), object()
monkeypatch.setattr(app, "create_application", fake_create)
assert app.main(["--data-dir", str(tmp_path), "--no-speech"]) == 0
assert app.main(["--data-dir", str(tmp_path)]) == 0
assert seen == [False, True]
+104 -2
View File
@@ -9,6 +9,7 @@ from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.presentation.features.flash_card.flash_card_view_model import ( from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel, FlashCardViewModel,
Side,
) )
A = Card("aku", "mig") A = Card("aku", "mig")
@@ -19,8 +20,29 @@ 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)
class _SilentSpeech:
def speak(self, text: str, language_code: str) -> None:
return None
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, _SilentSpeech()
)
def test_view_model_draws_first_card_on_construction() -> None: def test_view_model_draws_first_card_on_construction() -> None:
@@ -51,3 +73,83 @@ def test_view_model_shows_finished_message_when_no_card_is_left() -> None:
assert view_model.is_finished assert view_model.is_finished
assert view_model.language_name == "" assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE assert view_model.text == FINISHED_MESSAGE
def test_view_model_starts_on_front_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
assert view_model.side is Side.FRONT
def test_flip_card_shows_back_language_and_translation() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.language_name == "Danish"
assert view_model.text == "mig"
def test_flip_card_twice_keeps_back_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.text == "mig"
def test_flip_card_does_nothing_when_finished() -> None:
view_model = FlashCardViewModel(_session("ms"))
view_model.flip_card()
assert view_model.side is Side.FRONT
assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE
def test_next_card_resets_side_to_front() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
view_model.flip_card()
view_model.next_card()
assert view_model.side is Side.FRONT
assert view_model.language_name == "Malay"
assert view_model.text == "kau"
def test_is_known_saves_remaining_and_shows_next_front() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
view_model.flip_card()
view_model.is_known()
assert progress.saved == [(B,)]
assert view_model.side is Side.FRONT
assert view_model.text == "kau"
assert not view_model.is_finished
def test_is_known_on_last_card_finishes() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, progress=progress))
view_model.is_known()
assert view_model.is_finished
assert view_model.text == FINISHED_MESSAGE
assert progress.saved == [()]
def test_is_known_does_nothing_when_finished() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", progress=progress))
view_model.is_known()
assert view_model.is_finished
assert progress.saved == []
def test_next_card_does_nothing_when_finished() -> None:
view_model = FlashCardViewModel(_session("ms"))
view_model.next_card()
assert view_model.is_finished
assert view_model.text == FINISHED_MESSAGE
def test_next_card_never_saves() -> None:
progress = _RecordingProgress()
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
view_model.next_card()
assert progress.saved == []
+111
View File
@@ -0,0 +1,111 @@
"""Capture screenshots of the running flash card window.
@file capture_screenshots.py
@brief Starts the program, grabs the front of a card and, three seconds later,
the back, and saves both as PNG files.
The tool needs Pillow (`python -m pip install -e ".[tools]"`), which is a tool
dependency only and not a dependency of the program. It grabs the screen area
of the window, so it needs a visible desktop.
"""
from __future__ import annotations
import argparse
import ctypes
import random
import shutil
import sys
import tempfile
import time
from collections.abc import Sequence
from pathlib import Path
from PIL import ImageGrab
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from flash_card.constants import ( # noqa: E402
DEFAULT_DATA_DIR,
FLIP_DELAY_MS,
PROJECT_ROOT,
VOCABULARY_FILE_NAME,
)
from flash_card.presentation.app import create_application # noqa: E402
## @brief Default folder of the screenshots.
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
## @brief Seed that makes the first card the same on every run.
SCREENSHOT_SEED = 7
## @brief Extra time in seconds to let the window settle before a grab.
SETTLE_SECONDS = 0.5
def _make_dpi_aware() -> None:
"""Make Windows report real pixel coordinates (no effect elsewhere)."""
if sys.platform == "win32":
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
def _pump(root: object, seconds: float) -> None:
"""Run the Tk event loop for a while.
@param root The Tk root window.
@param seconds How long to run.
"""
end = time.monotonic() + seconds
while time.monotonic() < end:
root.update() # type: ignore[attr-defined]
time.sleep(0.01)
def capture(output_dir: Path) -> list[Path]:
"""Capture the front and the back of one card.
@param output_dir Folder to write `screenshot-front.png` and
`screenshot-back.png` into.
@return The two files written, front first.
"""
_make_dpi_aware()
output_dir.mkdir(parents=True, exist_ok=True)
random.seed(SCREENSHOT_SEED)
with tempfile.TemporaryDirectory() as data_dir:
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, data_dir)
root, view = create_application(Path(data_dir), speech_enabled=False)
root.lift()
root.attributes("-topmost", True)
_pump(root, SETTLE_SECONDS)
box = (
view.canvas.winfo_rootx() - 20,
view.canvas.winfo_rooty() - 20,
view.canvas.winfo_rootx() + view.canvas.winfo_width() + 20,
view.canvas.winfo_rooty()
+ view.canvas.winfo_height()
+ view.right_button.winfo_height()
+ 20,
)
front = output_dir / "screenshot-front.png"
back = output_dir / "screenshot-back.png"
ImageGrab.grab(bbox=box).save(front)
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
ImageGrab.grab(bbox=box).save(back)
root.destroy()
return [front, back]
def main(argv: Sequence[str] | None = None) -> int:
"""Run the tool.
@param argv Command line arguments, or None for `sys.argv`.
@return The exit code, 0 on success.
"""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
args = parser.parse_args(argv)
for path in capture(args.output_dir):
sys.stdout.write(f"{path}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())