Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4f2655c4 | ||
|
|
92894268c5 | ||
|
|
b2cc709031 | ||
|
|
bc055e3c05 | ||
|
|
b3c5116110 | ||
|
|
5900fda90a | ||
|
|
9e56a6b787 | ||
|
|
8dbaaa3059 | ||
|
|
da4e9382cf |
@@ -0,0 +1,5 @@
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.png binary
|
||||
@@ -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` |
|
||||
| `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` |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
|
||||
| Need | Why |
|
||||
@@ -69,7 +92,14 @@ python -m pip install -e ".[dev]"
|
||||
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
|
||||
|
||||
@@ -87,6 +117,8 @@ lint-imports
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
[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
|
||||
|
||||
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 027 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 030 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+14
-7
@@ -31,13 +31,13 @@ This plan schedules the seven phases (gateways) that deliver the flash card prog
|
||||
|
||||
| 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 | |
|
||||
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | |
|
||||
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | |
|
||||
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | |
|
||||
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | |
|
||||
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | |
|
||||
| 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 | |
|
||||
| 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 | [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 | [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 | [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 | [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 | [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 | [Milestone MIL-007][MS-007] |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -106,3 +106,10 @@ A No-Go on a phase moves every later date by the days the rework takes; the buff
|
||||
[MIL-006]: ./milestones/mil-006-malay-speech.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
|
||||
[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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -49,6 +49,9 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
||||
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
||||
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -111,3 +114,9 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[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
|
||||
|
||||
@@ -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/`.
|
||||
@@ -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/`.
|
||||
@@ -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/`.
|
||||
@@ -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*.
|
||||
|
||||

|
||||

|
||||
|
||||
| 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>
|
||||
@@ -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*.
|
||||
|
||||

|
||||

|
||||
|
||||
| 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>
|
||||
@@ -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*.
|
||||
|
||||

|
||||

|
||||
|
||||
| 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>
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -24,6 +24,8 @@ dependencies = []
|
||||
[project.optional-dependencies]
|
||||
# Spoken Malay word (text to speech). Optional; the program works without it.
|
||||
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"]
|
||||
|
||||
[project.scripts]
|
||||
@@ -45,6 +47,9 @@ line-length = 88
|
||||
target-version = "py313"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.format]
|
||||
line-ending = "lf"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"]
|
||||
ignore = ["D203", "D213"]
|
||||
|
||||
@@ -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 flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
|
||||
|
||||
def open_study_session(
|
||||
vocabulary_repository: VocabularyRepository,
|
||||
progress_repository: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
) -> StudySession:
|
||||
"""Load the word list and build a session over all its cards.
|
||||
"""Build a session over the saved words to learn, else the whole word list.
|
||||
|
||||
@param vocabulary_repository Source of the word list.
|
||||
@param vocabulary_repository Source of the delivered word list.
|
||||
@param progress_repository Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@return The new session.
|
||||
@throws VocabularyError When the word list cannot be loaded.
|
||||
@throws VocabularyError When the saved progress, or the word list when no
|
||||
progress exists, cannot be loaded.
|
||||
"""
|
||||
deck = progress_repository.load()
|
||||
if deck is None:
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose)
|
||||
return StudySession(deck, choose, progress_repository, speech)
|
||||
|
||||
@@ -7,19 +7,33 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
|
||||
|
||||
class StudySession:
|
||||
"""Draws cards from a deck, one after another."""
|
||||
|
||||
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
deck: Deck,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
progress: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param progress Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
"""
|
||||
## @brief Speaks the front text of each drawn card.
|
||||
self._speech = speech
|
||||
## @brief Store of the words still to learn.
|
||||
self._progress = progress
|
||||
## @brief The cards still to learn.
|
||||
self._deck = deck
|
||||
## @brief Picks one card from a non-empty sequence.
|
||||
@@ -51,8 +65,23 @@ class StudySession:
|
||||
if self._deck.is_empty:
|
||||
self._current = None
|
||||
return None
|
||||
self._current = self._deck.draw(self._choose, exclude=self._current)
|
||||
return self._to_dto(self._current)
|
||||
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.
|
||||
|
||||
@@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
|
||||
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
||||
## @brief File name of the words still to learn.
|
||||
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.
|
||||
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Port to the words still to learn.
|
||||
|
||||
@file progress_repository.py
|
||||
@brief The ProgressRepository interface.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
|
||||
|
||||
class ProgressRepository(Protocol):
|
||||
"""Store of the words still to learn."""
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, or None when no progress was saved.
|
||||
@throws VocabularyError When the saved progress is unusable.
|
||||
"""
|
||||
...
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,44 @@
|
||||
"""CSV implementation of the progress port.
|
||||
|
||||
@file csv_progress_repository.py
|
||||
@brief The CsvProgressRepository adapter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.infrastructure.persistence.csv_word_list import (
|
||||
read_word_list,
|
||||
write_word_list,
|
||||
)
|
||||
|
||||
|
||||
class CsvProgressRepository:
|
||||
"""Keeps the words still to learn in a CSV file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create the repository.
|
||||
|
||||
@param path The words to learn file.
|
||||
"""
|
||||
## @brief The words to learn file.
|
||||
self._path = path
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, possibly empty, or None when the file does not exist.
|
||||
@throws VocabularyFormatError When the file is malformed; it is left as is.
|
||||
"""
|
||||
if not self._path.exists():
|
||||
return None
|
||||
pair, cards = read_word_list(self._path)
|
||||
return Deck(pair, cards)
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
write_word_list(self._path, deck.language_pair, deck.cards)
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Reading of the word list CSV file.
|
||||
"""Reading and writing of the word list CSV file.
|
||||
|
||||
@file csv_word_list.py
|
||||
@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 collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
|
||||
@@ -84,3 +86,31 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
|
||||
if pair is None:
|
||||
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
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import logging
|
||||
import random
|
||||
import tkinter as tk
|
||||
@@ -12,12 +13,22 @@ from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
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,
|
||||
@@ -26,19 +37,40 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
## @brief Optional packages the spoken words need.
|
||||
_SPEECH_PACKAGES = ("gtts", "playsound")
|
||||
|
||||
|
||||
def build_speech(enabled: bool) -> SpeechPort:
|
||||
"""Choose the speech adapter.
|
||||
|
||||
@param enabled False when the user switched speech off.
|
||||
@return GttsSpeech when enabled and gtts and playsound are installed,
|
||||
else NullSpeech.
|
||||
"""
|
||||
if enabled and all(
|
||||
importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
|
||||
):
|
||||
return GttsSpeech()
|
||||
return NullSpeech()
|
||||
|
||||
|
||||
def create_application(
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
) -> tuple[tk.Tk, FlashCardView]:
|
||||
"""Create the root window and the flash card view.
|
||||
|
||||
@param data_dir Folder of the data files, or None for the default folder.
|
||||
@param speech_enabled False to run without spoken words.
|
||||
@return The root window and the view built on it.
|
||||
@throws VocabularyError When the word list cannot be loaded.
|
||||
@throws VocabularyError When the word list or saved progress is unusable.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
session = open_study_session(repository, random.choice)
|
||||
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
|
||||
speech = build_speech(speech_enabled)
|
||||
session = open_study_session(repository, progress, speech, random.choice)
|
||||
view_model = FlashCardViewModel(session)
|
||||
root = tk.Tk()
|
||||
view = FlashCardView(root, view_model)
|
||||
@@ -58,9 +90,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
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)
|
||||
root, _view = create_application(args.data_dir, not args.no_speech)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
@@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
)
|
||||
|
||||
|
||||
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
|
||||
"""Load an asset image.
|
||||
|
||||
The bytes are read here and handed to Tk as data, so the image does not
|
||||
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
|
||||
start, and a process that swaps them (pytest fd capture) makes the first
|
||||
Tcl file open fail with an empty error.
|
||||
|
||||
@param root The Tk root window that owns the image.
|
||||
@param name The file name in the assets folder.
|
||||
@return The image; the caller must keep a reference.
|
||||
"""
|
||||
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
|
||||
|
||||
|
||||
class FlashCardView:
|
||||
"""The flash card window: a card and the wrong and right buttons."""
|
||||
|
||||
@@ -50,13 +65,13 @@ class FlashCardView:
|
||||
|
||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||
## @brief Card front image, kept so Tk does not drop it.
|
||||
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
|
||||
self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
|
||||
## @brief Card back image, kept so Tk does not drop it.
|
||||
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
|
||||
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
|
||||
## @brief Wrong button image, kept so Tk does not drop it.
|
||||
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
|
||||
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
|
||||
## @brief Right button image, kept so Tk does not drop it.
|
||||
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
|
||||
self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
|
||||
|
||||
## @brief Canvas that draws the card.
|
||||
self.canvas = tk.Canvas(
|
||||
@@ -92,6 +107,9 @@ class FlashCardView:
|
||||
self.wrong_button = tk.Button(
|
||||
root,
|
||||
image=self._wrong_image,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
@@ -101,9 +119,12 @@ class FlashCardView:
|
||||
self.right_button = tk.Button(
|
||||
root,
|
||||
image=self._right_image,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
command=self.is_known,
|
||||
)
|
||||
self.right_button.grid(row=1, column=1)
|
||||
|
||||
@@ -137,6 +158,16 @@ class FlashCardView:
|
||||
self._show_card()
|
||||
self._start_flip_timer()
|
||||
|
||||
def is_known(self) -> None:
|
||||
"""Mark the card as known, show the next card front, restart the timer.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._cancel_flip_timer()
|
||||
self._view_model.is_known()
|
||||
self._show_card()
|
||||
self._start_flip_timer()
|
||||
|
||||
def flip_card(self) -> None:
|
||||
"""Show the back of the current card.
|
||||
|
||||
|
||||
@@ -88,10 +88,25 @@ class FlashCardViewModel:
|
||||
return self._card.front_text
|
||||
|
||||
def next_card(self) -> None:
|
||||
"""Draw the next card and show its front.
|
||||
"""Draw the next card and show its front; does nothing when finished.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -7,15 +7,18 @@
|
||||
import time
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import (
|
||||
BACK_TEXT_COLOR,
|
||||
BACKGROUND_COLOR,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
FINISHED_MESSAGE,
|
||||
FLIP_DELAY_MS,
|
||||
FRONT_TEXT_COLOR,
|
||||
LANGUAGE_FONT,
|
||||
@@ -26,6 +29,13 @@ from flash_card.constants import (
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation.features.flash_card import flash_card_view
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
@@ -39,9 +49,33 @@ def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
class _MemoryProgress:
|
||||
"""Progress store that keeps nothing: the shared window never deletes cards."""
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _RecordingSpeech:
|
||||
"""Speech port that records what would have been spoken."""
|
||||
|
||||
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))
|
||||
|
||||
|
||||
SPEECH = _RecordingSpeech()
|
||||
|
||||
|
||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
|
||||
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
|
||||
return FlashCardView(root, FlashCardViewModel(session))
|
||||
|
||||
|
||||
def _shown(view: FlashCardView) -> tuple[str, str]:
|
||||
@@ -129,14 +163,22 @@ def test_buttons_sit_in_row_one_with_their_images(
|
||||
assert str(view.right_button.cget("command"))
|
||||
|
||||
|
||||
def test_each_button_press_shows_next_card(
|
||||
def test_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.right_button.invoke()
|
||||
view.wrong_button.invoke()
|
||||
assert _shown(view) == ("Malay", "aku")
|
||||
|
||||
|
||||
@@ -195,7 +237,7 @@ def test_next_card_after_flip_shows_front_again(
|
||||
root, view = window
|
||||
_reset(root, view)
|
||||
view.flip_card()
|
||||
view.right_button.invoke()
|
||||
view.wrong_button.invoke()
|
||||
assert _image(view) == str(view.card_front_image)
|
||||
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
|
||||
assert _shown(view)[0] == "Malay"
|
||||
@@ -245,3 +287,78 @@ def test_real_timer_flips_card_after_delay(
|
||||
assert _image(view) == str(view.card_back_image)
|
||||
assert _shown(view)[0] == "Danish"
|
||||
_reset(root, view)
|
||||
|
||||
|
||||
HEADER_ONLY = "ms;da\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files_view(
|
||||
window: tuple[tk.Tk, FlashCardView], tmp_path: Path
|
||||
) -> Iterator[tuple[FlashCardView, Path]]:
|
||||
"""A second view on the shared root, over real repositories in tmp_path."""
|
||||
root, _shared = window
|
||||
words = tmp_path / "words.csv"
|
||||
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
|
||||
progress = tmp_path / "words_to_learn.csv"
|
||||
session = open_study_session(
|
||||
CsvVocabularyRepository(words),
|
||||
CsvProgressRepository(progress),
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
@@ -1,13 +1,23 @@
|
||||
"""Integration tests of the word list reader on the delivered file."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -19,10 +29,13 @@ def test_read_word_list_reads_real_file() -> None:
|
||||
assert cards[0] == Card("aku", "mig")
|
||||
|
||||
|
||||
def test_open_study_session_yields_card_from_real_file() -> None:
|
||||
def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
|
||||
repository = CsvVocabularyRepository(PATH)
|
||||
cards = repository.load().cards
|
||||
session = open_study_session(repository, lambda candidates: candidates[0])
|
||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(
|
||||
repository, progress, NullSpeech(), lambda candidates: candidates[0]
|
||||
)
|
||||
|
||||
card = session.next_card()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.entities.vocabulary import Vocabulary
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
@@ -20,6 +21,23 @@ class _FakeRepository:
|
||||
return self._vocabulary
|
||||
|
||||
|
||||
class _RecordingProgress:
|
||||
def __init__(self, stored: Deck | None = None) -> None:
|
||||
self.stored = stored
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return self.stored
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _SilentSpeech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _MissingRepository:
|
||||
def load(self) -> Vocabulary:
|
||||
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"))
|
||||
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.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:
|
||||
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 == []
|
||||
|
||||
@@ -17,8 +17,33 @@ def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _session(*cards: Card) -> StudySession:
|
||||
return StudySession(Deck(PAIR, cards), _first)
|
||||
class _RecordingProgress:
|
||||
def __init__(self) -> None:
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
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:
|
||||
@@ -58,3 +83,70 @@ def test_remaining_counts_cards_in_deck() -> None:
|
||||
session = _session(A, B)
|
||||
session.next_card()
|
||||
assert session.remaining == 2
|
||||
|
||||
|
||||
def test_mark_known_removes_current_card_and_saves_remaining_once() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, B, progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert session.remaining == 1
|
||||
assert session.current_card is None
|
||||
assert progress.saved == [(B,)]
|
||||
|
||||
|
||||
def test_mark_known_saves_nothing_when_no_current_card() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, progress=progress)
|
||||
session.mark_known()
|
||||
assert session.remaining == 1
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_mark_known_on_last_card_leaves_session_finished() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert progress.saved == [()]
|
||||
assert session.next_card() is None
|
||||
|
||||
|
||||
def test_next_card_never_saves() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, B, progress=progress)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
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
|
||||
@@ -25,7 +25,9 @@ def test_main_runs_main_loop_and_returns_zero(
|
||||
root = _FakeRoot()
|
||||
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)
|
||||
return root, object()
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -20,8 +20,29 @@ def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _session(code: str, *cards: Card) -> StudySession:
|
||||
return StudySession(Deck(LanguagePair(code, "da"), cards), _first)
|
||||
class _RecordingProgress:
|
||||
def __init__(self) -> None:
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
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:
|
||||
@@ -90,3 +111,45 @@ def test_next_card_resets_side_to_front() -> None:
|
||||
assert view_model.side is Side.FRONT
|
||||
assert view_model.language_name == "Malay"
|
||||
assert view_model.text == "kau"
|
||||
|
||||
|
||||
def test_is_known_saves_remaining_and_shows_next_front() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
|
||||
view_model.flip_card()
|
||||
view_model.is_known()
|
||||
assert progress.saved == [(B,)]
|
||||
assert view_model.side is Side.FRONT
|
||||
assert view_model.text == "kau"
|
||||
assert not view_model.is_finished
|
||||
|
||||
|
||||
def test_is_known_on_last_card_finishes() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, progress=progress))
|
||||
view_model.is_known()
|
||||
assert view_model.is_finished
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
assert progress.saved == [()]
|
||||
|
||||
|
||||
def test_is_known_does_nothing_when_finished() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", progress=progress))
|
||||
view_model.is_known()
|
||||
assert view_model.is_finished
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_next_card_does_nothing_when_finished() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms"))
|
||||
view_model.next_card()
|
||||
assert view_model.is_finished
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_next_card_never_saves() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
|
||||
view_model.next_card()
|
||||
assert progress.saved == []
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user