Merge pull request 'MIL-008: Choose level' (#54) from mil-008-choose-level into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
This commit was merged in pull request #54.
This commit is contained in:
@@ -15,7 +15,7 @@ A flash card program for learning the most frequent Malay words with their Danis
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
The program shows a Malay word on a card. After three seconds the card turns over and shows the Danish translation. You press the green tick when you know the word and the red cross when you do not. A known word is removed from the words to learn, and the words still to learn are saved, so the next start continues where you stopped.
|
||||
The program first asks for a level: the first 100 words, the first 1000 words or all words of the word list, which is ordered from the most to the least frequent word. Then it shows a Malay word on a card. After three seconds the card turns over and shows the Danish translation. You press the green tick when you know the word and the red cross when you do not. A known word is removed from the words to learn, and the words still to learn are saved, so the next start continues where you stopped.
|
||||
|
||||
| Part | What it is for |
|
||||
| --- | --- |
|
||||
@@ -30,9 +30,9 @@ The core program uses only the Python standard library. Spoken Malay words are a
|
||||
|
||||
### 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] |
|
||||
| Choose your level | Front: the Malay word | Back: the Danish translation after 3 seconds |
|
||||
| --- | --- | --- |
|
||||
| ![The level screen][screenshot-level] | ![The front of a card][screenshot-front] | ![The back of a card][screenshot-back] |
|
||||
|
||||
### Where is each assignment step?
|
||||
|
||||
@@ -42,6 +42,7 @@ The core program uses only the Python standard library. Spoken Malay words are a
|
||||
| 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: level choice | `LevelView`, `LevelViewModel`, `Level`, `Deck.within`, `StudySession.next_card`, `open_study_session` |
|
||||
| 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.
|
||||
@@ -92,14 +93,15 @@ python -m pip install -e ".[dev]"
|
||||
python -m flash_card
|
||||
```
|
||||
|
||||
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:
|
||||
The program is run from the activated `.venv`. The window opens with the level screen: choose 100 words, 1000 words or All words. Then a card appears. 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` |
|
||||
| `--level {100,1000,all}` | Start at that level and skip the level screen |
|
||||
| `--no-speech` | Do not speak the Malay word |
|
||||
|
||||
To start again, delete `data/words_to_learn.csv`.
|
||||
Known words stay known at every level: the level only limits which of the words you do not know yet come up. To start again, delete `data/words_to_learn.csv`.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
@@ -149,6 +151,7 @@ The documents a newcomer needs, in reading order.
|
||||
---
|
||||
|
||||
[license]: ./LICENSE
|
||||
[screenshot-level]: ./docs/images/screenshot-level.png
|
||||
[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
|
||||
|
||||
@@ -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 | 041 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 042 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,64 @@
|
||||
# RC-041: Review of PY-MIL-008
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-041 |
|
||||
| CrossReference | [PY-MIL-008], [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 | [f45d603] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-008]
|
||||
- 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 all new modules (`level.py`, `level_view.py`, `window.py`); 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 and are the dictionary's IT terms (`Level`, `Deck.within`, `LevelViewModel`, `LevelChoice`, `Application.start`). |
|
||||
| 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 67 files with no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 44 source files and on `tools/capture_screenshots.py`. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | `Level` raises `ValueError` for a limit below 1; a failed session start after a click is logged, closes the window with exit code 1 and never swallows silently; 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 | No new file handling beyond the existing context managers. |
|
||||
| 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`; failures are logged with `logging`; no personal data is logged. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `Level`, `Deck.within`, `Deck.draw(among)`, `StudySession(level_cards)`, `open_study_session(..., level)`, `LevelChoice`, `LevelViewModel`, `LevelView` and `Application` match the revised DCD-001, OC-001 `chooseLevel` and SD-001. Deviations: `Application` (root, repositories, speech) with `start`, `show_level_screen` and `exit_code` is the composition root detail that DCD-001 shows as `app.main`; `StudySession.remaining_in_level` and `LevelView.frame` are additions. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 191 tests (3 consecutive full runs green) cover `Level`, `Deck.within`, the level filter, the cross-level progress rules on the real word list, the view model, `--level` and the real level window flow; 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; no new package. |
|
||||
| 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 level rule exists only in `Level.first` and `Deck.within`; the choices, labels and sizes exist once in `constants.py`; both views use `configure_window`. |
|
||||
| 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; a grep finds no `tkinter`, `csv` or `random` import in `domain` or `application`; the level view model imports no Tkinter. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The view model owns the choices and the view draws them; the composition root only wires; the session depends on protocols and receives its collaborators. |
|
||||
| 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 | `presentation/features/level/` and `presentation/shared/` follow the clean-architecture folder structure; tests stay in `unit`, `integration`, `e2e` (with a session-scoped `tk_root` in `tests/e2e/conftest.py`). |
|
||||
| 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-008 Go criteria: 1 level 100 draws exactly the first 100 rows over a full run of the real list (integration test); 2 levels 1000 and All use all 1000 words, and a shorter list is used whole (unit and integration tests); 3 words known at level 100 stay known at All and the progress file keeps every unknown word, also outside the level (integration test); 4 a level with nothing left gives the finished message and no failure (unit and e2e tests); 5 the level view model has no Tkinter import and its tests pass without a display; 6 `--level 100|1000|all` skips the screen and an unknown value exits with 2 (unit tests); 7 UC-001, DM-001, DICT-001, SSD-001, OC-001, SD-001, DCD-001, BC-001, US-001, PP-001 and MIL-008 were re-reviewed `Go` (RC-030 to RC-040), the wireframe has Screen 1, and the README and the wiki in en, da and ms describe the level; 8 this record, `pytest` 191 passed, ruff, mypy, lint-imports and doxygen clean. A real screenshot of the level screen was captured and inspected after fixing a too-small window. The Danish and Malay wiki additions were written by the AI and are not read by a native speaker. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-008]: ../../../src/flash_card
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[f45d603]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f45d6036e4210e1fbe52f6a582c540a6e1877056
|
||||
@@ -54,6 +54,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| 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] |
|
||||
| PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -135,3 +136,5 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[RC-040]: ./reviews/rc-040-mil-008.md
|
||||
[MIL-008]: ../milestones/mil-008-choose-level.md
|
||||
[0b983b7]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0b983b7fdadf6958717c1d0227d0f2ebe037f7b1
|
||||
[RC-041]: ./reviews/rc-041-py-mil-008.md
|
||||
[PY-MIL-008]: ../../src/flash_card
|
||||
|
||||
@@ -6,7 +6,7 @@ Programmet er bygget i fire lag under `src/flash_card/`. Importer peger indad; `
|
||||
|
||||
| Lag | Indeholder | Må importere |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, fejlene og repository-grænsefladerne | ingenting |
|
||||
| `domain` | `Card`, `LanguagePair`, `Level`, `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 |
|
||||
@@ -21,6 +21,7 @@ Alle konstanter står i `src/flash_card/constants.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: niveauvalg | `Level`, `Deck.within`, `StudySession.next_card`, `open_study_session`, `LevelView`, `LevelViewModel`, `Application.start` i `presentation/app.py` |
|
||||
| Ekstra: tale | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` i `presentation/app.py` |
|
||||
|
||||
## Hvorfor en lagdelt løsning til en lille opgave?
|
||||
|
||||
@@ -6,7 +6,7 @@ Program dibina dalam empat lapisan di bawah `src/flash_card/`. Import menuju ke
|
||||
|
||||
| Lapisan | Mengandungi | Boleh mengimport |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, ralat dan antara muka repositori | tiada |
|
||||
| `domain` | `Card`, `LanguagePair`, `Level`, `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 |
|
||||
@@ -21,6 +21,7 @@ Semua pemalar terdapat dalam `src/flash_card/constants.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: pilihan tahap | `Level`, `Deck.within`, `StudySession.next_card`, `open_study_session`, `LevelView`, `LevelViewModel`, `Application.start` dalam `presentation/app.py` |
|
||||
| Tambahan: suara | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` dalam `presentation/app.py` |
|
||||
|
||||
## Mengapa program berlapis untuk tugasan kecil?
|
||||
|
||||
@@ -6,7 +6,7 @@ The program is built in four layers under `src/flash_card/`. Imports point inwar
|
||||
|
||||
| Layer | Holds | May import |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, the errors and the repository interfaces | nothing |
|
||||
| `domain` | `Card`, `LanguagePair`, `Level`, `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 |
|
||||
@@ -21,6 +21,7 @@ All constants are in `src/flash_card/constants.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: level choice | `Level`, `Deck.within`, `StudySession.next_card`, `open_study_session`, `LevelView`, `LevelViewModel`, `Application.start` in `presentation/app.py` |
|
||||
| Extra: speech | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
|
||||
|
||||
## Why a layered program for a small assignment?
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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*.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
@@ -22,6 +23,7 @@ Et flashkortprogram til at lære de mest almindelige malajiske ord med danske ov
|
||||
| 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 |
|
||||
| Ekstra | Du vælger først et niveau: de første 100 ord, de første 1000 ord eller alle ord | MIL-008 |
|
||||
|
||||
## Til kursister
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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*.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
@@ -22,6 +23,7 @@ Program kad imbas untuk mempelajari perkataan Melayu yang paling kerap digunakan
|
||||
| 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 |
|
||||
| Tambahan | Anda memilih tahap dahulu: 100 perkataan pertama, 1000 perkataan pertama atau semua perkataan | MIL-008 |
|
||||
|
||||
## Untuk peserta kursus
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
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*.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
@@ -22,6 +23,7 @@ A flash card program for learning the most frequent Malay words with their Danis
|
||||
| 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 |
|
||||
| Extra | You choose a level first: the first 100 words, the first 1000 words or all words | MIL-008 |
|
||||
|
||||
## For course participants
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
## Ø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.
|
||||
2. Vælg et niveau: **100 words**, **1000 words** eller **All words**. Ordlisten er sorteret fra det hyppigste til det sjældneste ord, så niveau 100 er de 100 hyppigste ord.
|
||||
3. Et kort viser et malajisk ord. Prøv at huske den danske oversættelse.
|
||||
4. Efter tre sekunder vender kortet og viser oversættelsen.
|
||||
5. Tryk på det grønne flueben, hvis du kendte ordet, eller på det røde kryds, hvis du ikke gjorde.
|
||||
6. Et nyt tilfældigt ord fra niveauet 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.
|
||||
Et ord, du har markeret som kendt, fjernes og kommer ikke igen. Når der ikke er flere ord på niveauet, står der "Nothing left to learn" på kortet; start programmet igen og vælg et højere niveau.
|
||||
|
||||
## Din fremgang
|
||||
|
||||
@@ -18,6 +19,8 @@ Et ord, du har markeret som kendt, fjernes og kommer ikke igen. Når der ikke er
|
||||
- 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.
|
||||
- Kendte ord forbliver kendte på alle niveauer. Niveauet begrænser kun, hvilke af de ord, du endnu ikke kender, der kommer frem, og filen beholder altid alle ord, du ikke kender, også dem uden for niveauet.
|
||||
- Denne liste har 1000 ord, så "1000 words" og "All words" giver de samme kort.
|
||||
|
||||
## Filformat
|
||||
|
||||
@@ -34,6 +37,7 @@ yang;hvilke
|
||||
| Indstilling | Betydning |
|
||||
| --- | --- |
|
||||
| `--data-dir <mappe>` | Mappen med `ms-da.csv` og `words_to_learn.csv` (standard: `data/` i repositoryet) |
|
||||
| `--level {100,1000,all}` | Start på det niveau og spring niveauskærmen over |
|
||||
| `--no-speech` | Udtal ikke det malajiske ord, selv om tilvalget til tale er installeret |
|
||||
|
||||
## Brug et andet sprogpar
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
## 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.
|
||||
2. Pilih tahap: **100 words**, **1000 words** atau **All words**. Senarai perkataan disusun daripada perkataan paling kerap kepada paling jarang, jadi tahap 100 ialah 100 perkataan paling kerap.
|
||||
3. Kad menunjukkan satu perkataan Melayu. Cuba ingat terjemahan Denmarknya.
|
||||
4. Selepas tiga saat kad terbalik dan menunjukkan terjemahan.
|
||||
5. Tekan tanda semak hijau jika anda tahu perkataan itu, atau pangkah merah jika tidak.
|
||||
6. Satu perkataan rawak baharu daripada tahap itu muncul. Ulang.
|
||||
|
||||
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi, kad menunjukkan "Nothing left to learn".
|
||||
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi pada tahap itu, kad menunjukkan "Nothing left to learn"; mulakan program semula dan pilih tahap yang lebih tinggi.
|
||||
|
||||
## Kemajuan anda
|
||||
|
||||
@@ -18,6 +19,8 @@ Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Ap
|
||||
- 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.
|
||||
- Perkataan yang diketahui kekal diketahui pada semua tahap. Tahap hanya mengehadkan perkataan yang belum anda ketahui yang muncul, dan fail sentiasa menyimpan semua perkataan yang anda belum tahu, termasuk yang di luar tahap.
|
||||
- Senarai ini mempunyai 1000 perkataan, jadi "1000 words" dan "All words" memberikan kad yang sama.
|
||||
|
||||
## Format fail
|
||||
|
||||
@@ -34,6 +37,7 @@ yang;hvilke
|
||||
| Pilihan | Maksud |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder yang mengandungi `ms-da.csv` dan `words_to_learn.csv` (lalai: `data/` dalam repositori) |
|
||||
| `--level {100,1000,all}` | Mulakan pada tahap itu dan langkau skrin tahap |
|
||||
| `--no-speech` | Jangan sebut perkataan Melayu, walaupun tambahan suara dipasang |
|
||||
|
||||
## Gunakan pasangan bahasa lain
|
||||
|
||||
+9
-5
@@ -5,12 +5,13 @@
|
||||
## 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.
|
||||
2. Choose a level: **100 words**, **1000 words** or **All words**. The word list is ordered from the most to the least frequent word, so level 100 is the 100 most frequent words.
|
||||
3. A card shows a Malay word. Try to remember its Danish translation.
|
||||
4. After three seconds the card turns over and shows the translation.
|
||||
5. Press the green tick when you knew the word, or the red cross when you did not.
|
||||
6. A new random word of the level 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".
|
||||
A word you marked as known is removed and does not come up again. When no word of the level is left, the card says "Nothing left to learn"; restart the program and choose a higher level.
|
||||
|
||||
## Your progress
|
||||
|
||||
@@ -18,6 +19,8 @@ A word you marked as known is removed and does not come up again. When no word i
|
||||
- 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.
|
||||
- Known words stay known at every level. The level only limits which of the words you do not know yet come up, and the file always keeps every word you do not know, also those outside the level.
|
||||
- This list has 1000 words, so "1000 words" and "All words" give the same cards.
|
||||
|
||||
## File format
|
||||
|
||||
@@ -34,6 +37,7 @@ yang;hvilke
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder that holds `ms-da.csv` and `words_to_learn.csv` (default: `data/` of the repository) |
|
||||
| `--level {100,1000,all}` | Start at that level and skip the level screen |
|
||||
| `--no-speech` | Do not speak the Malay word, even when the speech extra is installed |
|
||||
|
||||
## Use another language pair
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
|
||||
def open_study_session(
|
||||
@@ -19,18 +20,24 @@ def open_study_session(
|
||||
progress_repository: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
level: Level,
|
||||
) -> StudySession:
|
||||
"""Build a session over the saved words to learn, else the whole word list.
|
||||
|
||||
The word list is always loaded: its order defines which words a level holds.
|
||||
|
||||
@param vocabulary_repository Source of the delivered word list.
|
||||
@param progress_repository Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param level The level: the first words of the list that may be drawn.
|
||||
@return The new session.
|
||||
@throws VocabularyError When the saved progress, or the word list when no
|
||||
progress exists, cannot be loaded.
|
||||
@throws VocabularyError When the word list or the saved progress cannot be
|
||||
loaded.
|
||||
"""
|
||||
vocabulary = vocabulary_repository.load()
|
||||
deck = progress_repository.load()
|
||||
if deck is None:
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose, progress_repository, speech)
|
||||
deck = Deck.from_vocabulary(vocabulary)
|
||||
level_cards = level.first(vocabulary.cards)
|
||||
return StudySession(deck, choose, progress_repository, speech, level_cards)
|
||||
|
||||
@@ -22,6 +22,7 @@ class StudySession:
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
progress: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
level_cards: Sequence[Card] | None = None,
|
||||
) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@@ -29,7 +30,10 @@ class StudySession:
|
||||
@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.
|
||||
@param level_cards The cards of the chosen level, or None for no limit.
|
||||
"""
|
||||
## @brief The cards of the chosen level, or None for no limit.
|
||||
self._level_cards = None if level_cards is None else tuple(level_cards)
|
||||
## @brief Speaks the front text of each drawn card.
|
||||
self._speech = speech
|
||||
## @brief Store of the words still to learn.
|
||||
@@ -51,27 +55,36 @@ class StudySession:
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
"""Return the number of cards still to learn.
|
||||
"""Return the number of cards still to learn, of every level.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._deck)
|
||||
|
||||
@property
|
||||
def remaining_in_level(self) -> int:
|
||||
"""Return the number of cards still to learn in the chosen level.
|
||||
|
||||
@return The card count.
|
||||
"""
|
||||
return len(self._candidates())
|
||||
|
||||
def next_card(self) -> CardDto | None:
|
||||
"""Draw the next card, different from the current one when possible.
|
||||
|
||||
@return The new card, or None when the deck is empty.
|
||||
@return The new card, or None when no card of the level is left.
|
||||
"""
|
||||
if self._deck.is_empty:
|
||||
candidates = self._candidates()
|
||||
if not candidates:
|
||||
self._current = None
|
||||
return None
|
||||
card = self._deck.draw(self._choose, exclude=self._current)
|
||||
card = self._deck.draw(self._choose, self._current, candidates)
|
||||
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.
|
||||
"""Remove the current card from the whole deck and save the whole deck.
|
||||
|
||||
Does nothing and saves nothing when no card is shown.
|
||||
|
||||
@@ -83,6 +96,15 @@ class StudySession:
|
||||
self._progress.save(self._deck)
|
||||
self._current = None
|
||||
|
||||
def _candidates(self) -> tuple[Card, ...]:
|
||||
"""Return the cards that may be drawn.
|
||||
|
||||
@return The deck cards of the level, or all deck cards without a level.
|
||||
"""
|
||||
if self._level_cards is None:
|
||||
return self._deck.cards
|
||||
return self._deck.within(self._level_cards)
|
||||
|
||||
def _to_dto(self, card: Card | None) -> CardDto | None:
|
||||
"""Map a card to its DTO.
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ WINDOW_PADDING: Final[int] = 50
|
||||
CARD_WIDTH: Final[int] = 800
|
||||
## @brief Card height in pixels.
|
||||
CARD_HEIGHT: Final[int] = 526
|
||||
## @brief Level screen height in pixels: card plus the card window button row.
|
||||
LEVEL_SCREEN_HEIGHT: Final[int] = 526 + 100
|
||||
## @brief Horizontal centre of the card in pixels.
|
||||
CARD_CENTER_X: Final[int] = 400
|
||||
## @brief Vertical position of the language text in pixels.
|
||||
@@ -43,6 +45,29 @@ LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"}
|
||||
## @brief Message shown when no word is left to learn.
|
||||
FINISHED_MESSAGE: Final[str] = "Nothing left to learn"
|
||||
|
||||
## @brief Title of the level screen.
|
||||
LEVEL_TITLE: Final[str] = "Choose your level"
|
||||
## @brief Level choices in display order: label and word limit (None = all words).
|
||||
LEVEL_CHOICES: Final[tuple[tuple[str, int | None], ...]] = (
|
||||
("100 words", 100),
|
||||
("1000 words", 1000),
|
||||
("All words", None),
|
||||
)
|
||||
## @brief Values of the --level option and the word limit each stands for.
|
||||
LEVEL_ARGUMENT_VALUES: Final[Mapping[str, int | None]] = {
|
||||
"100": 100,
|
||||
"1000": 1000,
|
||||
"all": None,
|
||||
}
|
||||
## @brief Font of the level screen title: family, size, style.
|
||||
LEVEL_TITLE_FONT: Final[tuple[str, int, str]] = ("Arial", 40, "italic")
|
||||
## @brief Font of the level buttons: family, size, style.
|
||||
LEVEL_BUTTON_FONT: Final[tuple[str, int, str]] = ("Arial", 24, "bold")
|
||||
## @brief Width of every level button in characters.
|
||||
LEVEL_BUTTON_WIDTH: Final[int] = 14
|
||||
## @brief Background colour of the level buttons.
|
||||
LEVEL_BUTTON_COLOR: Final[str] = "white"
|
||||
|
||||
## @brief Delimiter of the CSV files.
|
||||
CSV_DELIMITER: Final[str] = ";"
|
||||
## @brief Encoding of the CSV files; drops a leading byte order mark.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@brief The Deck entity: holds cards, draws one and removes one.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
@@ -66,24 +67,43 @@ class Deck:
|
||||
"""
|
||||
return len(self._cards)
|
||||
|
||||
def within(self, cards: Sequence[Card]) -> tuple[Card, ...]:
|
||||
"""Select the cards of the deck that also appear in the given cards.
|
||||
|
||||
A card counts as often as it appears in both.
|
||||
|
||||
@param cards The cards to match against, for example a level.
|
||||
@return The matching cards in deck order.
|
||||
"""
|
||||
allowed = Counter(cards)
|
||||
selected: list[Card] = []
|
||||
for card in self._cards:
|
||||
if allowed[card] > 0:
|
||||
allowed[card] -= 1
|
||||
selected.append(card)
|
||||
return tuple(selected)
|
||||
|
||||
def draw(
|
||||
self,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
exclude: Card | None = None,
|
||||
among: Sequence[Card] | None = None,
|
||||
) -> Card:
|
||||
"""Pick a card without removing it.
|
||||
|
||||
The pick avoids the excluded card when another card exists.
|
||||
The pick avoids the excluded card when another candidate exists.
|
||||
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param exclude A card to avoid, usually the one shown now.
|
||||
@param among The candidates, or None for every card of the deck.
|
||||
@return The picked card.
|
||||
@throws EmptyDeckError When the deck has no card.
|
||||
@throws EmptyDeckError When there is no candidate.
|
||||
"""
|
||||
if not self._cards:
|
||||
candidates = self._cards if among is None else list(among)
|
||||
if not candidates:
|
||||
raise EmptyDeckError("cannot draw from an empty deck")
|
||||
others = [card for card in self._cards if card != exclude]
|
||||
return choose(others or self._cards)
|
||||
others = [card for card in candidates if card != exclude]
|
||||
return choose(others or candidates)
|
||||
|
||||
def remove(self, card: Card) -> None:
|
||||
"""Remove the first card equal to the given one.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The level of a study session.
|
||||
|
||||
@file level.py
|
||||
@brief The Level value object: how many of the first words a session uses.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Level:
|
||||
"""A limit on the words of the list, counted from the most frequent."""
|
||||
|
||||
## @brief Number of first words used, or None for all words.
|
||||
limit: int | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Check the limit.
|
||||
|
||||
@return Nothing.
|
||||
@throws ValueError When the limit is set and not above zero.
|
||||
"""
|
||||
if self.limit is not None and self.limit <= 0:
|
||||
raise ValueError(f"level limit must be above zero, got {self.limit}")
|
||||
|
||||
def first(self, cards: Sequence[Card]) -> tuple[Card, ...]:
|
||||
"""Select the cards of this level.
|
||||
|
||||
@param cards The word list in order from the most frequent word.
|
||||
@return The first limit cards, or all cards when there is no limit or
|
||||
the list is shorter.
|
||||
"""
|
||||
if self.limit is None:
|
||||
return tuple(cards)
|
||||
return tuple(cards[: self.limit])
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Entry point and composition root of the flash card program.
|
||||
|
||||
@file app.py
|
||||
@brief Creates the window, its view model and view, and runs the main loop.
|
||||
@brief Creates the window, shows the level screen and then the card screen.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -9,7 +9,7 @@ import importlib.util
|
||||
import logging
|
||||
import random
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
@@ -17,10 +17,15 @@ 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,
|
||||
LEVEL_ARGUMENT_VALUES,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -33,6 +38,8 @@ from flash_card.presentation.features.flash_card.flash_card_view import FlashCar
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
from flash_card.presentation.features.level.level_view import LevelView
|
||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -55,26 +62,141 @@ def build_speech(enabled: bool) -> SpeechPort:
|
||||
return NullSpeech()
|
||||
|
||||
|
||||
class Application:
|
||||
"""The running program: one window that shows the level screen, then the card."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
vocabulary_repository: VocabularyRepository,
|
||||
progress_repository: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
choose: Callable[[Sequence[Card]], Card] = random.choice,
|
||||
) -> None:
|
||||
"""Create the application on a window; no screen is shown yet.
|
||||
|
||||
@param root The Tk root window.
|
||||
@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.
|
||||
"""
|
||||
## @brief The Tk root window.
|
||||
self.root = root
|
||||
## @brief Source of the delivered word list.
|
||||
self._vocabulary_repository = vocabulary_repository
|
||||
## @brief Store of the words still to learn.
|
||||
self._progress_repository = progress_repository
|
||||
## @brief Speaks the front text of each drawn card.
|
||||
self._speech = speech
|
||||
## @brief Picks one card from a non-empty sequence.
|
||||
self._choose = choose
|
||||
## @brief The screen shown now, or None before the first one.
|
||||
self._view: LevelView | FlashCardView | None = None
|
||||
## @brief The exit code of the program.
|
||||
self._exit_code = 0
|
||||
|
||||
@property
|
||||
def view(self) -> LevelView | FlashCardView | None:
|
||||
"""Return the screen shown now.
|
||||
|
||||
@return The level screen, the card screen, or None before the first.
|
||||
"""
|
||||
return self._view
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
"""Return the exit code the program ends with.
|
||||
|
||||
@return 0, or 1 when a session could not be started after a choice.
|
||||
"""
|
||||
return self._exit_code
|
||||
|
||||
def show_level_screen(self) -> None:
|
||||
"""Show the level screen; a choice starts the session.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._view = LevelView(self.root, LevelViewModel(), self._choose_level)
|
||||
|
||||
def start(self, level: Level) -> None:
|
||||
"""Open the session of a level and show the card screen on the window.
|
||||
|
||||
@param level The level the user chose.
|
||||
@return Nothing.
|
||||
@throws VocabularyError When the word list or saved progress is unusable.
|
||||
"""
|
||||
session = open_study_session(
|
||||
self._vocabulary_repository,
|
||||
self._progress_repository,
|
||||
self._speech,
|
||||
self._choose,
|
||||
level,
|
||||
)
|
||||
if isinstance(self._view, LevelView):
|
||||
self._view.destroy()
|
||||
self._view = FlashCardView(self.root, FlashCardViewModel(session))
|
||||
|
||||
def _choose_level(self, level: Level) -> None:
|
||||
"""Start the session after a click; close the window when it fails.
|
||||
|
||||
@param level The level the user chose.
|
||||
@return Nothing.
|
||||
"""
|
||||
try:
|
||||
self.start(level)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
self._exit_code = 1
|
||||
self.root.destroy()
|
||||
|
||||
|
||||
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.
|
||||
level: Level | None = None,
|
||||
) -> Application:
|
||||
"""Create the window and show the level screen, or start at the given level.
|
||||
|
||||
@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 or saved progress is unusable.
|
||||
@param level The level to start at, or None to show the level screen.
|
||||
@return The application, its window in the root attribute.
|
||||
@throws VocabularyError When a level is given and the word list or saved
|
||||
progress is unusable.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
|
||||
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)
|
||||
return root, view
|
||||
application = Application(
|
||||
root,
|
||||
CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME),
|
||||
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
|
||||
build_speech(speech_enabled),
|
||||
)
|
||||
if level is None:
|
||||
application.show_level_screen()
|
||||
return application
|
||||
try:
|
||||
application.start(level)
|
||||
except VocabularyError:
|
||||
root.destroy()
|
||||
raise
|
||||
return application
|
||||
|
||||
|
||||
def _level_argument(value: str) -> Level:
|
||||
"""Convert a --level value to a level.
|
||||
|
||||
@param value One of the keys of LEVEL_ARGUMENT_VALUES.
|
||||
@return The level.
|
||||
@throws argparse.ArgumentTypeError When the value is unknown.
|
||||
"""
|
||||
if value not in LEVEL_ARGUMENT_VALUES:
|
||||
choices = ", ".join(LEVEL_ARGUMENT_VALUES)
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"invalid level {value!r} (choose from {choices})"
|
||||
)
|
||||
return Level(LEVEL_ARGUMENT_VALUES[value])
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
@@ -95,14 +217,21 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
action="store_true",
|
||||
help="do not speak the words",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--level",
|
||||
type=_level_argument,
|
||||
default=None,
|
||||
metavar="{" + ",".join(LEVEL_ARGUMENT_VALUES) + "}",
|
||||
help="start at a level (100, 1000 or all words) and skip the level screen",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root, _view = create_application(args.data_dir, not args.no_speech)
|
||||
application = create_application(args.data_dir, not args.no_speech, args.level)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
return 1
|
||||
root.mainloop()
|
||||
return 0
|
||||
application.root.mainloop()
|
||||
return application.exit_code
|
||||
|
||||
|
||||
def run() -> NoReturn:
|
||||
|
||||
@@ -20,8 +20,6 @@ from flash_card.constants import (
|
||||
LANGUAGE_FONT,
|
||||
LANGUAGE_TEXT_Y,
|
||||
RIGHT_IMAGE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
WORD_FONT,
|
||||
WORD_TEXT_Y,
|
||||
WRONG_IMAGE_NAME,
|
||||
@@ -30,6 +28,7 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
Side,
|
||||
)
|
||||
from flash_card.presentation.shared.window import configure_window
|
||||
|
||||
|
||||
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
|
||||
@@ -60,8 +59,7 @@ class FlashCardView:
|
||||
self._root = root
|
||||
## @brief The data source of the texts.
|
||||
self._view_model = view_model
|
||||
root.title(WINDOW_TITLE)
|
||||
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
|
||||
configure_window(root)
|
||||
|
||||
# Kept as attributes: Tkinter drops an image nothing refers to.
|
||||
## @brief Card front image, kept so Tk does not drop it.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""The level feature: its view and view model."""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""View of the level screen.
|
||||
|
||||
@file level_view.py
|
||||
@brief Builds the level screen of the wireframe: a title and one button per level.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable
|
||||
|
||||
from flash_card.constants import (
|
||||
BACKGROUND_COLOR,
|
||||
CARD_WIDTH,
|
||||
LEVEL_BUTTON_COLOR,
|
||||
LEVEL_BUTTON_FONT,
|
||||
LEVEL_BUTTON_WIDTH,
|
||||
LEVEL_SCREEN_HEIGHT,
|
||||
LEVEL_TITLE_FONT,
|
||||
)
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.presentation.features.level.level_view_model import (
|
||||
LevelChoice,
|
||||
LevelViewModel,
|
||||
)
|
||||
|
||||
|
||||
class LevelView:
|
||||
"""The level screen: a title and a button for each level."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
view_model: LevelViewModel,
|
||||
on_choose: Callable[[Level], None],
|
||||
) -> None:
|
||||
"""Build the screen on a root.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param view_model The data to show.
|
||||
@param on_choose Called with the level the user picked.
|
||||
"""
|
||||
## @brief Called with the level the user picked.
|
||||
self._on_choose = on_choose
|
||||
## @brief Frame that holds the whole screen.
|
||||
self.frame = tk.Frame(
|
||||
root, bg=BACKGROUND_COLOR, width=CARD_WIDTH, height=LEVEL_SCREEN_HEIGHT
|
||||
)
|
||||
self.frame.grid(row=0, column=0)
|
||||
self.frame.grid_propagate(False)
|
||||
self.frame.grid_columnconfigure(0, weight=1)
|
||||
for row in range(len(view_model.choices) + 1):
|
||||
self.frame.grid_rowconfigure(row, weight=1)
|
||||
## @brief The title text.
|
||||
self.title_label = tk.Label(
|
||||
self.frame,
|
||||
text=view_model.title,
|
||||
font=LEVEL_TITLE_FONT,
|
||||
bg=BACKGROUND_COLOR,
|
||||
)
|
||||
self.title_label.grid(row=0, column=0)
|
||||
## @brief One button per choice, in display order.
|
||||
self.buttons = tuple(
|
||||
self._build_button(row, choice)
|
||||
for row, choice in enumerate(view_model.choices, start=1)
|
||||
)
|
||||
|
||||
def choose(self, level: Level) -> None:
|
||||
"""Forward the chosen level.
|
||||
|
||||
@param level The level the user picked.
|
||||
@return Nothing.
|
||||
"""
|
||||
self._on_choose(level)
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Remove the screen from the window.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self.frame.destroy()
|
||||
|
||||
def _build_button(self, row: int, choice: LevelChoice) -> tk.Button:
|
||||
"""Create the button of one choice.
|
||||
|
||||
@param row Grid row of the button.
|
||||
@param choice The choice the button stands for.
|
||||
@return The button, placed in the frame.
|
||||
"""
|
||||
button = tk.Button(
|
||||
self.frame,
|
||||
text=choice.label,
|
||||
font=LEVEL_BUTTON_FONT,
|
||||
width=LEVEL_BUTTON_WIDTH,
|
||||
bg=LEVEL_BUTTON_COLOR,
|
||||
command=lambda: self.choose(choice.level),
|
||||
)
|
||||
button.grid(row=row, column=0)
|
||||
return button
|
||||
@@ -0,0 +1,53 @@
|
||||
"""View model of the level screen.
|
||||
|
||||
@file level_view_model.py
|
||||
@brief Exposes the title and the level choices; free of any Tkinter import.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from flash_card.constants import LEVEL_CHOICES, LEVEL_TITLE
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LevelChoice:
|
||||
"""One choice on the level screen."""
|
||||
|
||||
## @brief Text of the choice.
|
||||
label: str
|
||||
## @brief The level the choice stands for.
|
||||
level: Level
|
||||
|
||||
|
||||
class LevelViewModel:
|
||||
"""Data the level view shows."""
|
||||
|
||||
def __init__(self, choices: Sequence[tuple[str, int | None]] | None = None) -> None:
|
||||
"""Create the view model.
|
||||
|
||||
@param choices Label and word limit of each choice, or None for the
|
||||
choices of the program.
|
||||
"""
|
||||
source = LEVEL_CHOICES if choices is None else choices
|
||||
## @brief The choices in display order.
|
||||
self._choices = tuple(
|
||||
LevelChoice(label, Level(limit)) for label, limit in source
|
||||
)
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Return the title of the screen.
|
||||
|
||||
@return The title text.
|
||||
"""
|
||||
return LEVEL_TITLE
|
||||
|
||||
@property
|
||||
def choices(self) -> tuple[LevelChoice, ...]:
|
||||
"""Return the choices.
|
||||
|
||||
@return The choices in display order.
|
||||
"""
|
||||
return self._choices
|
||||
@@ -0,0 +1 @@
|
||||
"""Code shared by the screens of the presentation layer."""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Window set-up shared by the screens.
|
||||
|
||||
@file window.py
|
||||
@brief Gives the root window its title, padding and background.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
from flash_card.constants import BACKGROUND_COLOR, WINDOW_PADDING, WINDOW_TITLE
|
||||
|
||||
|
||||
def configure_window(root: tk.Tk) -> None:
|
||||
"""Set the title, padding and background of the window.
|
||||
|
||||
@param root The Tk root window.
|
||||
@return Nothing.
|
||||
"""
|
||||
root.title(WINDOW_TITLE)
|
||||
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Fixtures shared by the end-to-end tests."""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tk_root() -> Iterator[tk.Tk]:
|
||||
"""The one Tk root of the process, shared by every e2e module.
|
||||
|
||||
A second interpreter right after destroying the first fails to find
|
||||
init.tcl on some Windows Python installs, so there is only one. Each test
|
||||
builds its views on it and destroys their widgets afterwards.
|
||||
"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError:
|
||||
pytest.skip("no display available for Tk")
|
||||
yield root
|
||||
root.destroy()
|
||||
@@ -29,6 +29,7 @@ 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.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -85,18 +86,20 @@ def _shown(view: FlashCardView) -> tuple[str, str]:
|
||||
)
|
||||
|
||||
|
||||
def _destroy_view(root: tk.Tk, view: FlashCardView) -> None:
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
view.flip_timer = None
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
|
||||
# One Tk per module: a second interpreter right after destroying the
|
||||
# first fails to find init.tcl on some Windows Python installs.
|
||||
try:
|
||||
root = tk.Tk()
|
||||
except tk.TclError:
|
||||
pytest.skip("no display available for Tk")
|
||||
view = _build_view(root)
|
||||
root.update_idletasks()
|
||||
yield root, view
|
||||
root.destroy()
|
||||
def window(tk_root: tk.Tk) -> Iterator[tuple[tk.Tk, FlashCardView]]:
|
||||
view = _build_view(tk_root)
|
||||
tk_root.update_idletasks()
|
||||
yield tk_root, view
|
||||
_destroy_view(tk_root, view)
|
||||
|
||||
|
||||
def test_window_has_title_background_and_padding(
|
||||
@@ -306,6 +309,7 @@ def files_view(
|
||||
CsvProgressRepository(progress),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
view = FlashCardView(root, FlashCardViewModel(session))
|
||||
yield view, progress
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""End-to-end tests of the level screen and the flow to the first card.
|
||||
|
||||
@file test_level_window.py
|
||||
@brief Builds the real level screen with Tk and checks it against the wireframe.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tkinter as tk
|
||||
from collections.abc import Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import (
|
||||
BACKGROUND_COLOR,
|
||||
CARD_WIDTH,
|
||||
DEFAULT_DATA_DIR,
|
||||
FINISHED_MESSAGE,
|
||||
LEVEL_BUTTON_COLOR,
|
||||
LEVEL_BUTTON_FONT,
|
||||
LEVEL_SCREEN_HEIGHT,
|
||||
LEVEL_TITLE,
|
||||
LEVEL_TITLE_FONT,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
WINDOW_PADDING,
|
||||
WINDOW_TITLE,
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
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 import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.level.level_view import LevelView
|
||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def _label(button: tk.Button) -> str:
|
||||
return str(button.cget("text"))
|
||||
|
||||
|
||||
def _word(view: FlashCardView) -> str:
|
||||
return str(view.canvas.itemcget(view.word_text_id, "text"))
|
||||
|
||||
|
||||
def _clear(root: tk.Tk, view: object) -> None:
|
||||
"""Destroy the widgets of whichever screen is shown, and pending timers."""
|
||||
if isinstance(view, FlashCardView):
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
view.flip_timer = None
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
elif isinstance(view, LevelView):
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
|
||||
instance = app.Application(
|
||||
tk_root,
|
||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME),
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME),
|
||||
NullSpeech(),
|
||||
_last,
|
||||
)
|
||||
yield instance
|
||||
_clear(tk_root, instance.view)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def level_view(tk_root: tk.Tk) -> Iterator[LevelView]:
|
||||
chosen: list[Level] = []
|
||||
view = LevelView(tk_root, LevelViewModel(), chosen.append)
|
||||
tk_root.update_idletasks()
|
||||
yield view
|
||||
view.destroy()
|
||||
|
||||
|
||||
def test_level_screen_shows_title_with_font_on_window_background(
|
||||
level_view: LevelView,
|
||||
) -> None:
|
||||
label = level_view.title_label
|
||||
assert label.cget("text") == LEVEL_TITLE == "Choose your level"
|
||||
assert label.cget("bg") == BACKGROUND_COLOR
|
||||
family, size, style = LEVEL_TITLE_FONT
|
||||
assert str(label.cget("font")).split() == [family, str(size), style]
|
||||
assert level_view.frame.cget("bg") == BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None:
|
||||
assert level_view.frame.winfo_width() == CARD_WIDTH
|
||||
assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT
|
||||
|
||||
|
||||
def test_level_screen_shows_three_buttons_in_order(level_view: LevelView) -> None:
|
||||
labels = [_label(button) for button in level_view.buttons]
|
||||
assert labels == ["100 words", "1000 words", "All words"]
|
||||
rows = [int(button.grid_info()["row"]) for button in level_view.buttons]
|
||||
assert rows == [1, 2, 3]
|
||||
|
||||
|
||||
def test_level_buttons_have_equal_width_font_and_colour(
|
||||
level_view: LevelView,
|
||||
) -> None:
|
||||
widths = {int(str(button.cget("width"))) for button in level_view.buttons}
|
||||
assert len(widths) == 1
|
||||
family, size, style = LEVEL_BUTTON_FONT
|
||||
for button in level_view.buttons:
|
||||
assert str(button.cget("font")).split() == [family, str(size), style]
|
||||
assert button.cget("bg") == LEVEL_BUTTON_COLOR
|
||||
assert len({button.winfo_reqwidth() for button in level_view.buttons}) == 1
|
||||
|
||||
|
||||
def test_clicking_a_button_forwards_its_level(tk_root: tk.Tk) -> None:
|
||||
chosen: list[Level] = []
|
||||
view = LevelView(tk_root, LevelViewModel(), chosen.append)
|
||||
for button in view.buttons:
|
||||
button.invoke()
|
||||
view.destroy()
|
||||
assert chosen == [Level(100), Level(1000), Level(None)]
|
||||
|
||||
|
||||
def test_destroy_removes_the_level_frame(tk_root: tk.Tk) -> None:
|
||||
view = LevelView(tk_root, LevelViewModel(), lambda level: None)
|
||||
view.destroy()
|
||||
assert not view.frame.winfo_exists()
|
||||
|
||||
|
||||
def test_clicking_100_words_replaces_screen_with_first_card_of_first_hundred(
|
||||
tk_root: tk.Tk, application: app.Application, data_dir: Path
|
||||
) -> None:
|
||||
first_hundred = (
|
||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards[:100]
|
||||
)
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
|
||||
level_view.buttons[0].invoke()
|
||||
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
assert not level_view.frame.winfo_exists()
|
||||
assert _word(card_view) == first_hundred[-1].front
|
||||
assert tk_root.title() == WINDOW_TITLE
|
||||
assert int(str(tk_root.cget("padx"))) == WINDOW_PADDING
|
||||
|
||||
|
||||
def test_level_whose_words_are_all_known_shows_the_finished_message(
|
||||
application: app.Application, data_dir: Path
|
||||
) -> None:
|
||||
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
|
||||
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
|
||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:]))
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
|
||||
level_view.buttons[0].invoke()
|
||||
|
||||
card_view = application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
assert _word(card_view) == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_create_application_with_level_skips_the_level_screen(
|
||||
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||
|
||||
application = app.create_application(data_dir, False, Level(100))
|
||||
|
||||
try:
|
||||
assert isinstance(application.view, FlashCardView)
|
||||
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
|
||||
|
||||
def test_create_application_without_level_shows_the_level_screen(
|
||||
tk_root: tk.Tk, data_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||
|
||||
application = app.create_application(data_dir, False)
|
||||
|
||||
try:
|
||||
assert isinstance(application.view, LevelView)
|
||||
assert application.root is tk_root
|
||||
finally:
|
||||
_clear(tk_root, application.view)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests of the levels over the real word list and a progress file."""
|
||||
|
||||
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.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.level import Level
|
||||
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
|
||||
|
||||
VOCABULARY = CsvVocabularyRepository(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME)
|
||||
ALL_CARDS = VOCABULARY.load().cards
|
||||
FIRST_HUNDRED = ALL_CARDS[:100]
|
||||
|
||||
Choose = Callable[[Sequence[Card]], Card]
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _open(folder: Path, level: Level, choose: Choose = _first) -> StudySession:
|
||||
return open_study_session(
|
||||
VOCABULARY,
|
||||
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
level,
|
||||
)
|
||||
|
||||
|
||||
def _learn_to_the_end(session: StudySession) -> list[Card]:
|
||||
"""Mark every drawn card known until the session is finished."""
|
||||
drawn: list[Card] = []
|
||||
while (card := session.next_card()) is not None:
|
||||
drawn.append(Card(card.front_text, card.back_text))
|
||||
session.mark_known()
|
||||
return drawn
|
||||
|
||||
|
||||
def _saved(folder: Path) -> tuple[Card, ...]:
|
||||
loaded = CsvProgressRepository(folder / PROGRESS_FILE_NAME).load()
|
||||
assert loaded is not None
|
||||
return loaded.cards
|
||||
|
||||
|
||||
def test_word_list_has_one_thousand_distinct_words() -> None:
|
||||
assert len(ALL_CARDS) == 1000
|
||||
assert len(set(ALL_CARDS)) == 1000
|
||||
|
||||
|
||||
def test_level_100_draws_exactly_the_first_hundred_words(tmp_path: Path) -> None:
|
||||
drawn = _learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
assert len(drawn) == 100
|
||||
assert len(set(drawn)) == 100
|
||||
assert set(drawn) == set(FIRST_HUNDRED)
|
||||
|
||||
|
||||
def test_level_100_keeps_the_other_words_in_order_in_the_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
assert _saved(tmp_path) == ALL_CARDS[100:]
|
||||
assert len(_saved(tmp_path)) == 900
|
||||
|
||||
|
||||
def test_level_all_after_level_100_draws_only_the_other_words(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
drawn = _learn_to_the_end(_open(tmp_path, Level(None)))
|
||||
assert len(drawn) == 900
|
||||
assert set(drawn) == set(ALL_CARDS[100:])
|
||||
assert not set(drawn) & set(FIRST_HUNDRED)
|
||||
|
||||
|
||||
def test_level_1000_equals_all_words_for_this_list(tmp_path: Path) -> None:
|
||||
session_1000 = _open(tmp_path, Level(1000))
|
||||
session_all = _open(tmp_path, Level(None))
|
||||
assert session_1000.remaining_in_level == 1000
|
||||
assert session_all.remaining_in_level == 1000
|
||||
|
||||
|
||||
def test_words_known_at_level_100_stay_known_at_level_1000(tmp_path: Path) -> None:
|
||||
first = _open(tmp_path, Level(100))
|
||||
shown = first.next_card()
|
||||
assert shown is not None
|
||||
first.mark_known()
|
||||
session = _open(tmp_path, Level(1000))
|
||||
assert session.remaining == 999
|
||||
assert session.remaining_in_level == 999
|
||||
|
||||
|
||||
def test_marking_known_at_level_100_keeps_words_outside_the_level(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
session = _open(tmp_path, Level(100))
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
saved = _saved(tmp_path)
|
||||
assert len(saved) == 999
|
||||
assert saved[-900:] == ALL_CARDS[100:]
|
||||
|
||||
|
||||
def test_level_whose_words_are_all_known_is_finished_at_once(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_learn_to_the_end(_open(tmp_path, Level(100)))
|
||||
session = _open(tmp_path, Level(100))
|
||||
assert session.remaining_in_level == 0
|
||||
assert session.remaining == 900
|
||||
assert session.next_card() is None
|
||||
@@ -6,6 +6,7 @@ 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.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -36,6 +37,7 @@ def _open(folder: Path, choose: Choose = _first) -> StudySession:
|
||||
CsvProgressRepository(folder / "words_to_learn.csv"),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
Level(None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from flash_card.constants import (
|
||||
)
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
@@ -34,7 +35,11 @@ def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
|
||||
cards = repository.load().cards
|
||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(
|
||||
repository, progress, NullSpeech(), lambda candidates: candidates[0]
|
||||
repository,
|
||||
progress,
|
||||
NullSpeech(),
|
||||
lambda candidates: candidates[0],
|
||||
Level(None),
|
||||
)
|
||||
|
||||
card = session.next_card()
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
|
||||
class _FakeRepository:
|
||||
@@ -52,7 +53,7 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
|
||||
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
|
||||
|
||||
session = open_study_session(
|
||||
repository, _RecordingProgress(), _SilentSpeech(), _first
|
||||
repository, _RecordingProgress(), _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
assert session.remaining == 2
|
||||
@@ -62,7 +63,11 @@ 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(), _RecordingProgress(), _SilentSpeech(), _first
|
||||
_MissingRepository(),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
|
||||
|
||||
@@ -72,21 +77,83 @@ def test_open_study_session_prefers_saved_progress() -> None:
|
||||
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
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"), []))
|
||||
PAIR = LanguagePair("ms", "da")
|
||||
THREE = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def test_open_study_session_propagates_error_when_list_is_missing() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, []))
|
||||
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
|
||||
|
||||
def test_open_study_session_limits_draws_to_first_cards_of_level() -> None:
|
||||
session = open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_last,
|
||||
Level(2),
|
||||
)
|
||||
|
||||
assert session.remaining == 0
|
||||
assert session.remaining == 3
|
||||
assert session.remaining_in_level == 2
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_uses_vocabulary_order_with_saved_progress() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, [THREE[2], THREE[1]]))
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
progress,
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(2),
|
||||
)
|
||||
|
||||
assert session.remaining == 2
|
||||
assert session.remaining_in_level == 1
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_is_finished_when_level_has_nothing_left() -> None:
|
||||
progress = _RecordingProgress(Deck(PAIR, [THREE[1]]))
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
progress,
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(1),
|
||||
)
|
||||
|
||||
assert session.next_card() is None
|
||||
assert session.remaining == 1
|
||||
|
||||
|
||||
def test_open_study_session_uses_all_cards_when_level_exceeds_list() -> None:
|
||||
session = open_study_session(
|
||||
_FakeRepository(Vocabulary(PAIR, THREE)),
|
||||
_RecordingProgress(),
|
||||
_SilentSpeech(),
|
||||
_first,
|
||||
Level(1000),
|
||||
)
|
||||
|
||||
assert session.remaining_in_level == 3
|
||||
|
||||
|
||||
def test_open_study_session_never_saves_on_start() -> None:
|
||||
@@ -94,7 +161,7 @@ def test_open_study_session_never_saves_on_start() -> None:
|
||||
progress = _RecordingProgress()
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first, Level(None)
|
||||
)
|
||||
session.next_card()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests of the StudySession use case."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import itertools
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
@@ -150,3 +151,94 @@ def test_mark_known_does_not_speak_but_following_draw_does() -> None:
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
C = Card("ini", "denne")
|
||||
D = Card("itu", "den")
|
||||
|
||||
|
||||
def _level_session(
|
||||
deck_cards: Sequence[Card],
|
||||
level_cards: Sequence[Card] | None,
|
||||
choose: Callable[[Sequence[Card]], Card] = _first,
|
||||
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, deck_cards), choose, store, voice, level_cards)
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
def test_next_card_draws_only_cards_of_the_level_over_many_draws() -> None:
|
||||
chooser = itertools.cycle([_first, _last])
|
||||
session = _level_session(
|
||||
[A, B, C, D], [A, B], choose=lambda cards: next(chooser)(cards)
|
||||
)
|
||||
drawn = {session.next_card() for _ in range(40)}
|
||||
assert drawn == {
|
||||
CardDto("aku", "mig", "ms", "da"),
|
||||
CardDto("kau", "du", "ms", "da"),
|
||||
}
|
||||
|
||||
|
||||
def test_next_card_never_draws_a_card_outside_the_level() -> None:
|
||||
session = _level_session([A, B, C], [B], choose=_last)
|
||||
for _ in range(5):
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_remaining_counts_whole_deck_and_remaining_in_level_counts_level() -> None:
|
||||
session = _level_session([A, B, C], [A, B])
|
||||
assert session.remaining == 3
|
||||
assert session.remaining_in_level == 2
|
||||
|
||||
|
||||
def test_remaining_in_level_counts_whole_deck_without_level() -> None:
|
||||
assert _level_session([A, B, C], None).remaining_in_level == 3
|
||||
|
||||
|
||||
def test_remaining_in_level_ignores_level_cards_missing_from_deck() -> None:
|
||||
assert _level_session([B], [A, B]).remaining_in_level == 1
|
||||
|
||||
|
||||
def test_mark_known_keeps_cards_outside_the_level_and_saves_whole_deck() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _level_session([A, B, C], [A], progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert progress.saved == [(B, C)]
|
||||
assert session.remaining == 2
|
||||
assert session.remaining_in_level == 0
|
||||
|
||||
|
||||
def test_next_card_is_none_when_nothing_of_the_level_is_left() -> None:
|
||||
session = _level_session([B, C], [A])
|
||||
assert session.next_card() is None
|
||||
assert session.current_card is None
|
||||
|
||||
|
||||
def test_session_finishes_after_last_card_of_level_is_known() -> None:
|
||||
session = _level_session([A, B], [A])
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert session.next_card() is None
|
||||
assert session.remaining == 1
|
||||
|
||||
|
||||
def test_next_card_speaks_only_cards_of_the_level() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _level_session([A, B, C], [B], choose=_last, speech=speech)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert speech.spoken == [("kau", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
def test_next_card_speaks_nothing_when_level_is_finished() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _level_session([B], [A], speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == []
|
||||
|
||||
@@ -71,3 +71,47 @@ def test_remove_raises_when_card_is_absent() -> None:
|
||||
def test_is_empty_reflects_cards() -> None:
|
||||
assert Deck(PAIR, []).is_empty
|
||||
assert not Deck(PAIR, [A]).is_empty
|
||||
|
||||
|
||||
def test_within_returns_deck_cards_found_in_given_cards_in_deck_order() -> None:
|
||||
c = Card("ini", "denne")
|
||||
deck = Deck(PAIR, [A, B, c])
|
||||
assert deck.within([c, A]) == (A, c)
|
||||
|
||||
|
||||
def test_within_respects_multiplicity() -> None:
|
||||
deck = Deck(PAIR, [A, A, B])
|
||||
assert deck.within([A, B]) == (A, B)
|
||||
assert deck.within([A, A, A]) == (A, A)
|
||||
|
||||
|
||||
def test_within_returns_empty_when_no_card_matches() -> None:
|
||||
assert Deck(PAIR, [A]).within([B]) == ()
|
||||
|
||||
|
||||
def test_within_does_not_change_the_deck() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
deck.within([A])
|
||||
assert deck.cards == (A, B)
|
||||
|
||||
|
||||
def test_draw_chooses_among_given_candidates_only() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
assert deck.draw(_first, among=[B]) == B
|
||||
|
||||
|
||||
def test_draw_among_avoids_excluded_when_another_candidate_exists() -> None:
|
||||
c = Card("ini", "denne")
|
||||
deck = Deck(PAIR, [A, B, c])
|
||||
assert deck.draw(_first, exclude=B, among=[B, c]) == c
|
||||
|
||||
|
||||
def test_draw_among_returns_excluded_when_it_is_the_only_candidate() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
assert deck.draw(_first, exclude=B, among=[B]) == B
|
||||
|
||||
|
||||
def test_draw_among_empty_raises_empty_deck_error() -> None:
|
||||
deck = Deck(PAIR, [A, B])
|
||||
with pytest.raises(EmptyDeckError):
|
||||
deck.draw(_first, among=[])
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests of the Level value object."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
|
||||
CARDS = tuple(Card(f"w{i}", f"v{i}") for i in range(1000))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, -1, -100])
|
||||
def test_level_refuses_limit_not_above_zero(limit: int) -> None:
|
||||
with pytest.raises(ValueError, match="above zero"):
|
||||
Level(limit)
|
||||
|
||||
|
||||
def test_level_accepts_no_limit() -> None:
|
||||
assert Level(None).limit is None
|
||||
|
||||
|
||||
def test_level_is_frozen() -> None:
|
||||
level = Level(100)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
level.limit = 5 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_first_returns_first_hundred_cards_when_limit_is_100() -> None:
|
||||
assert Level(100).first(CARDS) == CARDS[:100]
|
||||
|
||||
|
||||
def test_first_returns_all_cards_when_there_is_no_limit() -> None:
|
||||
assert Level(None).first(CARDS) == CARDS
|
||||
|
||||
|
||||
def test_first_returns_all_cards_when_limit_exceeds_the_list() -> None:
|
||||
assert Level(5000).first(CARDS) == CARDS
|
||||
|
||||
|
||||
def test_first_returns_empty_when_list_is_empty() -> None:
|
||||
assert Level(100).first(()) == ()
|
||||
+207
-9
@@ -1,45 +1,243 @@
|
||||
"""Tests of the entry point.
|
||||
|
||||
@file test_app.py
|
||||
@brief Checks main of flash_card.presentation.app without opening a window.
|
||||
@brief Checks main and Application of flash_card.presentation.app without Tk.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||
|
||||
PAIR = LanguagePair("ms", "da")
|
||||
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
|
||||
|
||||
|
||||
class _FakeRoot:
|
||||
def __init__(self) -> None:
|
||||
self.mainloop_calls = 0
|
||||
self.destroyed = False
|
||||
|
||||
def mainloop(self) -> None:
|
||||
self.mainloop_calls += 1
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.destroyed = True
|
||||
|
||||
|
||||
class _FakeApplication:
|
||||
def __init__(self, root: _FakeRoot, exit_code: int = 0) -> None:
|
||||
self.root = root
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
class _FakeLevelView:
|
||||
def __init__(
|
||||
self,
|
||||
root: object,
|
||||
view_model: LevelViewModel,
|
||||
on_choose: Callable[[Level], None],
|
||||
) -> None:
|
||||
self.view_model = view_model
|
||||
self.on_choose = on_choose
|
||||
self.destroyed = False
|
||||
|
||||
def destroy(self) -> None:
|
||||
self.destroyed = True
|
||||
|
||||
|
||||
class _FakeFlashCardView:
|
||||
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
|
||||
self.view_model = view_model
|
||||
|
||||
|
||||
class _Vocabulary:
|
||||
def __init__(self, error: bool = False) -> None:
|
||||
self._error = error
|
||||
|
||||
def load(self) -> Vocabulary:
|
||||
if self._error:
|
||||
raise VocabularyNotFoundError("word list not found")
|
||||
return Vocabulary(PAIR, CARDS)
|
||||
|
||||
|
||||
class _Progress:
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Speech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(app, "LevelView", _FakeLevelView)
|
||||
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
|
||||
|
||||
|
||||
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
|
||||
return app.Application(
|
||||
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
|
||||
)
|
||||
|
||||
|
||||
def _recording_create(
|
||||
seen: list[Level | None], exit_code: int = 0
|
||||
) -> Callable[..., _FakeApplication]:
|
||||
def fake_create(
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append(level)
|
||||
return _FakeApplication(_FakeRoot(), exit_code)
|
||||
|
||||
return fake_create
|
||||
|
||||
|
||||
def test_main_runs_main_loop_and_returns_zero(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
root = _FakeRoot()
|
||||
seen: list[Path | None] = []
|
||||
seen: list[tuple[Path | None, Level | None]] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
seen.append(data_dir)
|
||||
return root, object()
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append((data_dir, level))
|
||||
return _FakeApplication(root)
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 0
|
||||
assert root.mainloop_calls == 1
|
||||
assert seen == [tmp_path]
|
||||
assert seen == [(tmp_path, None)]
|
||||
|
||||
|
||||
def test_main_returns_one_when_word_list_is_missing(
|
||||
def test_main_returns_one_when_word_list_is_missing_with_level(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
|
||||
code = app.main(["--data-dir", str(tmp_path / "nowhere"), "--level", "all"])
|
||||
assert code == 1
|
||||
assert "word list not found" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "limit"), [("100", 100), ("1000", 1000), ("all", None)]
|
||||
)
|
||||
def test_main_passes_level_of_level_option(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str, limit: int | None
|
||||
) -> None:
|
||||
seen: list[Level | None] = []
|
||||
monkeypatch.setattr(app, "create_application", _recording_create(seen))
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path), "--level", value]) == 0
|
||||
assert seen == [Level(limit)]
|
||||
|
||||
|
||||
def test_main_refuses_unknown_level(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as raised:
|
||||
app.main(["--level", "50"])
|
||||
assert raised.value.code == 2
|
||||
assert "invalid level" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_main_returns_exit_code_of_application(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
seen: list[Level | None] = []
|
||||
monkeypatch.setattr(app, "create_application", _recording_create(seen, 1))
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 1
|
||||
|
||||
|
||||
def test_application_shows_no_view_before_a_screen_is_requested(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
assert _application(_FakeRoot()).view is None
|
||||
|
||||
|
||||
def test_show_level_screen_offers_the_three_levels(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.show_level_screen()
|
||||
view = application.view
|
||||
assert isinstance(view, _FakeLevelView)
|
||||
assert [c.level.limit for c in view.view_model.choices] == [100, 1000, None]
|
||||
|
||||
|
||||
def test_choosing_a_level_replaces_level_screen_by_card_screen(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, _FakeLevelView)
|
||||
|
||||
level_view.on_choose(Level(100))
|
||||
|
||||
assert level_view.destroyed
|
||||
assert isinstance(application.view, _FakeFlashCardView)
|
||||
assert application.exit_code == 0
|
||||
|
||||
|
||||
def test_start_draws_the_first_card_of_the_level(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.start(Level(2))
|
||||
view = application.view
|
||||
assert isinstance(view, _FakeFlashCardView)
|
||||
assert view.view_model.text == "aku"
|
||||
|
||||
|
||||
def test_start_without_level_screen_shows_card_screen_only(
|
||||
fake_views: None,
|
||||
) -> None:
|
||||
application = _application(_FakeRoot())
|
||||
application.start(Level(None))
|
||||
assert isinstance(application.view, _FakeFlashCardView)
|
||||
|
||||
|
||||
def test_choosing_a_level_closes_window_with_code_one_on_vocabulary_error(
|
||||
fake_views: None, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
root = _FakeRoot()
|
||||
application = _application(root, error=True)
|
||||
application.show_level_screen()
|
||||
level_view = application.view
|
||||
assert isinstance(level_view, _FakeLevelView)
|
||||
|
||||
level_view.on_choose(Level(100))
|
||||
|
||||
assert root.destroyed
|
||||
assert application.exit_code == 1
|
||||
assert "word list not found" in caplog.text
|
||||
|
||||
|
||||
def test_start_raises_vocabulary_error_for_the_caller(fake_views: None) -> None:
|
||||
application = _application(_FakeRoot(), error=True)
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
application.start(Level(100))
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
@@ -16,6 +17,11 @@ class _FakeRoot:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeApplication:
|
||||
root = _FakeRoot()
|
||||
exit_code = 0
|
||||
|
||||
|
||||
def _found(name: str) -> ModuleSpec:
|
||||
return ModuleSpec(name, None)
|
||||
|
||||
@@ -58,10 +64,12 @@ def test_main_passes_speech_disabled_with_no_speech_option(
|
||||
seen: list[bool] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
level: Level | None = None,
|
||||
) -> _FakeApplication:
|
||||
seen.append(speech_enabled)
|
||||
return _FakeRoot(), object()
|
||||
return _FakeApplication()
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests of the level view model."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import LEVEL_TITLE
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.presentation.features.level import level_view_model
|
||||
from flash_card.presentation.features.level.level_view_model import (
|
||||
LevelChoice,
|
||||
LevelViewModel,
|
||||
)
|
||||
|
||||
|
||||
def test_title_is_choose_your_level() -> None:
|
||||
assert LevelViewModel().title == LEVEL_TITLE == "Choose your level"
|
||||
|
||||
|
||||
def test_choices_are_three_levels_in_order() -> None:
|
||||
choices = LevelViewModel().choices
|
||||
assert [choice.label for choice in choices] == [
|
||||
"100 words",
|
||||
"1000 words",
|
||||
"All words",
|
||||
]
|
||||
assert [choice.level for choice in choices] == [
|
||||
Level(100),
|
||||
Level(1000),
|
||||
Level(None),
|
||||
]
|
||||
|
||||
|
||||
def test_choices_come_from_given_choices() -> None:
|
||||
model = LevelViewModel([("Ten", 10)])
|
||||
assert model.choices == (LevelChoice("Ten", Level(10)),)
|
||||
|
||||
|
||||
def test_choice_is_frozen() -> None:
|
||||
choice = LevelChoice("Ten", Level(10))
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
choice.label = "x" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_view_model_module_does_not_import_tkinter() -> None:
|
||||
assert "tkinter" not in vars(level_view_model)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""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.
|
||||
@brief Starts the program, grabs the level screen, the front of a card and,
|
||||
three seconds later, the back, and saves them 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
|
||||
@@ -31,7 +31,11 @@ from flash_card.constants import ( # noqa: E402
|
||||
PROJECT_ROOT,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.value_objects.level import Level # noqa: E402
|
||||
from flash_card.presentation.app import create_application # noqa: E402
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import ( # noqa: E402
|
||||
FlashCardView,
|
||||
)
|
||||
|
||||
## @brief Default folder of the screenshots.
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
|
||||
@@ -60,21 +64,37 @@ def _pump(root: object, seconds: float) -> None:
|
||||
|
||||
|
||||
def capture(output_dir: Path) -> list[Path]:
|
||||
"""Capture the front and the back of one card.
|
||||
"""Capture the level screen, then 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.
|
||||
@param output_dir Folder to write `screenshot-level.png`,
|
||||
`screenshot-front.png` and `screenshot-back.png` into.
|
||||
@return The three files written: level, front, back.
|
||||
"""
|
||||
_make_dpi_aware()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
random.seed(SCREENSHOT_SEED)
|
||||
level = output_dir / "screenshot-level.png"
|
||||
front = output_dir / "screenshot-front.png"
|
||||
back = output_dir / "screenshot-back.png"
|
||||
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)
|
||||
application = create_application(Path(data_dir), speech_enabled=False)
|
||||
root = application.root
|
||||
root.lift()
|
||||
root.attributes("-topmost", True)
|
||||
_pump(root, SETTLE_SECONDS)
|
||||
window_box = (
|
||||
root.winfo_rootx(),
|
||||
root.winfo_rooty(),
|
||||
root.winfo_rootx() + root.winfo_width(),
|
||||
root.winfo_rooty() + root.winfo_height(),
|
||||
)
|
||||
ImageGrab.grab(bbox=window_box).save(level)
|
||||
application.start(Level(None))
|
||||
view = application.view
|
||||
if not isinstance(view, FlashCardView):
|
||||
raise RuntimeError("the card window did not open")
|
||||
_pump(root, SETTLE_SECONDS)
|
||||
box = (
|
||||
view.canvas.winfo_rootx() - 20,
|
||||
view.canvas.winfo_rooty() - 20,
|
||||
@@ -84,13 +104,11 @@ def capture(output_dir: Path) -> list[Path]:
|
||||
+ 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]
|
||||
return [level, front, back]
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
Reference in New Issue
Block a user