MIL-011: Menu bar
Review record RC-066 (Go, autonomous mode, not independent). Task: MIL-011#1 Task: MIL-011#2 Task: MIL-011#3 Task: MIL-011#4 Task: MIL-011#5 Task: MIL-011#6 Task: MIL-011#7 Task: MIL-011#8
This commit is contained in:
@@ -102,7 +102,21 @@ The program is run from the activated `.venv`. The window opens with the level s
|
|||||||
| `--level {100,1000,all}` | Start at that level and skip the level screen |
|
| `--level {100,1000,all}` | Start at that level and skip the level screen |
|
||||||
| `--no-speech` | Do not speak the Malay word |
|
| `--no-speech` | Do not speak the Malay word |
|
||||||
|
|
||||||
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`.
|
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` or use the menu.
|
||||||
|
|
||||||
|
### Menu bar
|
||||||
|
|
||||||
|
![The window with the menu bar][screenshot-menu]
|
||||||
|
|
||||||
|
| Menu | Command | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| File | Open... | Opens another word list from a CSV file (header with two language codes, for example `en;da`). Progress is kept apart for each list |
|
||||||
|
| File | Change level... | Shows the level choice again without a restart; known words stay known |
|
||||||
|
| File | Reset word list... | After a confirmation removes the progress of the open list, so all its words are to learn again; the list file is not changed |
|
||||||
|
| File | Exit | Closes the program |
|
||||||
|
| About | Contact | The author, Jens Tirsvad Nielsen, Software engineer; opens the LinkedIn page in your browser |
|
||||||
|
|
||||||
|
The progress of the delivered `ms-da.csv` is `data/words_to_learn.csv`; another list gets `data/words_to_learn_<name>.csv`. The opened list is not remembered after a restart.
|
||||||
|
|
||||||
### Executable (Windows, no Python needed to run it)
|
### Executable (Windows, no Python needed to run it)
|
||||||
|
|
||||||
@@ -166,6 +180,7 @@ The documents a newcomer needs, in reading order.
|
|||||||
[license]: ./LICENSE
|
[license]: ./LICENSE
|
||||||
[screenshot-level]: ./docs/images/screenshot-level.png
|
[screenshot-level]: ./docs/images/screenshot-level.png
|
||||||
[screenshot-front]: ./docs/images/screenshot-front.png
|
[screenshot-front]: ./docs/images/screenshot-front.png
|
||||||
|
[screenshot-menu]: ./docs/images/screenshot-menu.png
|
||||||
[screenshot-back]: ./docs/images/screenshot-back.png
|
[screenshot-back]: ./docs/images/screenshot-back.png
|
||||||
[wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home
|
[wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home
|
||||||
[wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da
|
[wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0005 |
|
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0005 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 066 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 067 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
# RC-066: Review of PY-MIL-011
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-066 |
|
||||||
|
| CrossReference | [PY-MIL-011], [QC-PY-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [PY-MIL-011]
|
||||||
|
- 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 the new modules (`menu_view.py`, `menu_view_model.py`, `progress_file_name.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 DCD-001 IT terms (`MenuView`, `MenuViewModel`, `Application.open_word_list`, `reset`). |
|
||||||
|
| 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 79 files with no suppressions. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 49 source files and on the three tool scripts. |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Refused files raise `VocabularyFormatError` with `from` (also for bytes that are not UTF-8), a failed reset is logged and shown, a failed browser start is logged; no bare `except`, nothing swallowed silently. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | Pass | Reading uses `with`; the delete uses `unlink(missing_ok=True)`; the Tk root is destroyed through `exit_application`. |
|
||||||
|
| 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 use `logging`; the contact data is public author information given by S01. |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The new classes match the revised DCD-001 and OC-001/SD-001. Deviations: `MenuEntry` has a third field `underline`; `MenuViewModel` has `file_label` and `contact_menu_label`; `Application` has `word_list_folder` and takes a repository factory; `MenuView` depends on a small `MenuApplication` Protocol to avoid an import cycle. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 258 tests (5 consecutive full runs green): unit with fakes only, integration with two word lists, and e2e on the real menu bar with injected dialogs; none uses the network or a real browser. |
|
||||||
|
| 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; `webbrowser`, `tkinter.filedialog` and `tkinter.messagebox` are standard library. |
|
||||||
|
| 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 progress file rule is one function (`progress_file_name`); labels, contact data and dialog texts are constants once. |
|
||||||
|
| 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`, `random` or `webbrowser` import in `domain` or `application`; the menu 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 entries and the view draws and asks dialogs; `Application` coordinates; the repositories own their files; dialogs and the browser are injected. |
|
||||||
|
| 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/menu/` and `infrastructure/persistence/` follow the folder structure; tests stay in `unit`, `integration`, `e2e`. |
|
||||||
|
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||||
|
|
||||||
|
Milestone MIL-011 Go criteria: 1 the menu bar structure and order are read from the real `tk.Menu` (e2e); 2 open valid / invalid / cancelled covered (unit and e2e, including a Latin-1 file and invalid UTF-8 bytes); 3 two lists keep separate progress files, `ms-da.csv` keeps `words_to_learn.csv` (integration); 4 change level shows the level screen and keeps the known words (unit and e2e); 5 reset asks, deletes only the progress file of the open list, never the list file (unit, integration, e2e); 6 About, Contact opens `https://www.linkedin.com/in/tirsvad74` through an injected `webbrowser.open` (unit and e2e); 7 exit cancels the timer and destroys the window (e2e); 8 view model tested without Tkinter; 9 RC-056 to RC-065 `Go`, README and the Usage page of the wiki in three languages describe the menu; 10 this record, `pytest` 258 passed and linters clean. The executable was rebuilt with the menu and passes the smoke test (window, embedded list, list override, progress file). A real browser launch and the native file dialog were not exercised on this machine. Defect found by the reviewer and fixed in this milestone: a file that is not UTF-8 raised `UnicodeDecodeError` instead of a message. All Go.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| none | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[PY-MIL-011]: ../../../src/flash_card
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
@@ -64,6 +64,7 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
||||||
| PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] |
|
| PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] |
|
||||||
| PY-MIL-009 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-048] |
|
| PY-MIL-009 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-048] |
|
||||||
|
| PY-MIL-011 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-066] |
|
||||||
| PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] |
|
| PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] |
|
||||||
|
|
||||||
## Coverage Notes
|
## Coverage Notes
|
||||||
@@ -182,3 +183,5 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[RC-065]: ./reviews/rc-065-mil-011.md
|
[RC-065]: ./reviews/rc-065-mil-011.md
|
||||||
[MIL-011]: ../milestones/mil-011-menu-bar.md
|
[MIL-011]: ../milestones/mil-011-menu-bar.md
|
||||||
[2211d0f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2211d0f4cbd8182314d18cbdefd939ae50c39e6d
|
[2211d0f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2211d0f4cbd8182314d18cbdefd939ae50c39e6d
|
||||||
|
[RC-066]: ./reviews/rc-066-py-mil-011.md
|
||||||
|
[PY-MIL-011]: ../../src/flash_card
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Menulinjen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Menu | Kommando | Hvad den gør |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| File | Open... | Åbner en anden ordliste fra en CSV-fil (overskrift med to sprogkoder, fx `en;da`). Fremgang gemmes adskilt for hver liste |
|
||||||
|
| File | Change level... | Viser niveauvalget igen uden genstart; kendte ord forbliver kendte |
|
||||||
|
| File | Reset word list... | Fjerner efter en bekræftelse fremgangen for den åbne liste, så alle dens ord skal læres igen; listefilen ændres ikke |
|
||||||
|
| File | Exit | Lukker programmet |
|
||||||
|
| About | Contact | Forfatteren, Jens Tirsvad Nielsen, softwareudvikler; åbner LinkedIn-siden i din browser |
|
||||||
|
|
||||||
|
Fremgangen for den medfølgende `ms-da.csv` er `data/words_to_learn.csv`; en anden liste får `data/words_to_learn_<navn>.csv`. Den åbnede liste huskes ikke efter en genstart.
|
||||||
|
|
||||||
## Din fremgang
|
## Din fremgang
|
||||||
|
|
||||||
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
|
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## Bar menu
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Menu | Perintah | Apa yang dilakukan |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| File | Open... | Membuka senarai perkataan lain daripada fail CSV (baris pertama dengan dua kod bahasa, contohnya `en;da`). Kemajuan disimpan berasingan bagi setiap senarai |
|
||||||
|
| File | Change level... | Menunjukkan pilihan tahap semula tanpa memulakan semula; perkataan yang diketahui kekal diketahui |
|
||||||
|
| File | Reset word list... | Selepas pengesahan membuang kemajuan senarai yang dibuka, supaya semua perkataannya perlu dipelajari semula; fail senarai tidak diubah |
|
||||||
|
| File | Exit | Menutup program |
|
||||||
|
| About | Contact | Pengarang, Jens Tirsvad Nielsen, jurutera perisian; membuka halaman LinkedIn dalam pelayar anda |
|
||||||
|
|
||||||
|
Kemajuan bagi `ms-da.csv` yang disertakan ialah `data/words_to_learn.csv`; senarai lain mendapat `data/words_to_learn_<nama>.csv`. Senarai yang dibuka tidak diingati selepas dimulakan semula.
|
||||||
|
|
||||||
## Kemajuan anda
|
## Kemajuan anda
|
||||||
|
|
||||||
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
|
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
|
||||||
|
|||||||
@@ -13,6 +13,20 @@
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
## The menu bar
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Menu | Command | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| File | Open... | Opens another word list from a CSV file (header with two language codes, for example `en;da`). Progress is kept apart for each list |
|
||||||
|
| File | Change level... | Shows the level choice again without a restart; known words stay known |
|
||||||
|
| File | Reset word list... | After a confirmation removes the progress of the open list, so all its words are to learn again; the list file is not changed |
|
||||||
|
| File | Exit | Closes the program |
|
||||||
|
| About | Contact | The author, Jens Tirsvad Nielsen, Software engineer; opens the LinkedIn page in your browser |
|
||||||
|
|
||||||
|
The progress of the delivered `ms-da.csv` is `data/words_to_learn.csv`; another list gets `data/words_to_learn_<name>.csv`. The opened list is not remembered after a restart.
|
||||||
|
|
||||||
## Your progress
|
## Your progress
|
||||||
|
|
||||||
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
|
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
|
|||||||
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
||||||
## @brief File name of the words still to learn.
|
## @brief File name of the words still to learn.
|
||||||
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
||||||
|
## @brief File name of the words to learn of another word list; {stem} is its stem.
|
||||||
|
PROGRESS_FILE_NAME_TEMPLATE: Final[str] = "words_to_learn_{stem}.csv"
|
||||||
|
## @brief Pattern of the characters replaced in a stem (not letter, digit, - or _).
|
||||||
|
PROGRESS_STEM_FORBIDDEN: Final[str] = r"[^\w-]"
|
||||||
|
## @brief Replacement of a forbidden character in the progress file name.
|
||||||
|
PROGRESS_STEM_REPLACEMENT: Final[str] = "_"
|
||||||
|
|
||||||
## @brief Root folder of the project, for a source run.
|
## @brief Root folder of the project, for a source run.
|
||||||
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
||||||
@@ -105,3 +111,71 @@ CARD_BACK_IMAGE_NAME: Final[str] = "card_back.png"
|
|||||||
RIGHT_IMAGE_NAME: Final[str] = "right.png"
|
RIGHT_IMAGE_NAME: Final[str] = "right.png"
|
||||||
## @brief File name of the wrong button image.
|
## @brief File name of the wrong button image.
|
||||||
WRONG_IMAGE_NAME: Final[str] = "wrong.png"
|
WRONG_IMAGE_NAME: Final[str] = "wrong.png"
|
||||||
|
|
||||||
|
## @brief Label of the File menu.
|
||||||
|
MENU_FILE_LABEL: Final[str] = "File"
|
||||||
|
## @brief Label of the File, Open entry.
|
||||||
|
MENU_OPEN_LABEL: Final[str] = "Open..."
|
||||||
|
## @brief Label of the File, Change level entry.
|
||||||
|
MENU_CHANGE_LEVEL_LABEL: Final[str] = "Change level..."
|
||||||
|
## @brief Label of the File, Reset word list entry.
|
||||||
|
MENU_RESET_LABEL: Final[str] = "Reset word list..."
|
||||||
|
## @brief Label of the File, Exit entry.
|
||||||
|
MENU_EXIT_LABEL: Final[str] = "Exit"
|
||||||
|
## @brief Label of the About menu.
|
||||||
|
MENU_ABOUT_LABEL: Final[str] = "About"
|
||||||
|
## @brief Label of the About, Contact submenu.
|
||||||
|
MENU_CONTACT_LABEL: Final[str] = "Contact"
|
||||||
|
## @brief Index of the mnemonic letter of the File menu.
|
||||||
|
MENU_FILE_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Index of the mnemonic letter of the Open entry.
|
||||||
|
MENU_OPEN_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Index of the mnemonic letter of the Change level entry.
|
||||||
|
MENU_CHANGE_LEVEL_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Index of the mnemonic letter of the Reset word list entry.
|
||||||
|
MENU_RESET_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Index of the mnemonic letter of the Exit entry.
|
||||||
|
MENU_EXIT_UNDERLINE: Final[int] = 1
|
||||||
|
## @brief Index of the mnemonic letter of the About menu.
|
||||||
|
MENU_ABOUT_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Index of the mnemonic letter of the Contact submenu.
|
||||||
|
MENU_CONTACT_UNDERLINE: Final[int] = 0
|
||||||
|
## @brief Command name of the Open entry, as the menu view maps it.
|
||||||
|
MENU_COMMAND_OPEN: Final[str] = "open_word_list"
|
||||||
|
## @brief Command name of the Change level entry.
|
||||||
|
MENU_COMMAND_CHANGE_LEVEL: Final[str] = "change_level"
|
||||||
|
## @brief Command name of the Reset word list entry.
|
||||||
|
MENU_COMMAND_RESET: Final[str] = "reset_word_list"
|
||||||
|
## @brief Command name of the Exit entry.
|
||||||
|
MENU_COMMAND_EXIT: Final[str] = "exit_program"
|
||||||
|
|
||||||
|
## @brief Name of the author shown under About, Contact.
|
||||||
|
CONTACT_NAME: Final[str] = "Jens Tirsvad Nielsen"
|
||||||
|
## @brief Title of the author shown under About, Contact.
|
||||||
|
CONTACT_TITLE: Final[str] = "Software engineer"
|
||||||
|
## @brief LinkedIn page of the author.
|
||||||
|
CONTACT_URL: Final[str] = "https://www.linkedin.com/in/tirsvad74"
|
||||||
|
## @brief Text of the contact entry: name and title; {name} and {title}.
|
||||||
|
CONTACT_LABEL_TEMPLATE: Final[str] = "{name}, {title}"
|
||||||
|
|
||||||
|
## @brief Title of the open file dialog.
|
||||||
|
OPEN_DIALOG_TITLE: Final[str] = "Open a word list"
|
||||||
|
## @brief File types of the open file dialog: description and pattern.
|
||||||
|
OPEN_FILE_TYPES: Final[tuple[tuple[str, str], ...]] = (
|
||||||
|
("CSV files", "*.csv"),
|
||||||
|
("All files", "*.*"),
|
||||||
|
)
|
||||||
|
## @brief Title of the reset confirmation.
|
||||||
|
RESET_CONFIRM_TITLE: Final[str] = "Reset word list"
|
||||||
|
## @brief Question of the reset confirmation; {name} is the word list file name.
|
||||||
|
RESET_CONFIRM_TEMPLATE: Final[str] = (
|
||||||
|
"Start {name} from the beginning? All words will be to learn again."
|
||||||
|
)
|
||||||
|
## @brief Title of the message that tells a word list could not be opened.
|
||||||
|
OPEN_ERROR_TITLE: Final[str] = "Cannot open word list"
|
||||||
|
## @brief Message of a word list that could not be opened; {name} and {reason}.
|
||||||
|
OPEN_ERROR_TEMPLATE: Final[str] = "{name} could not be used.\n{reason}"
|
||||||
|
## @brief Title of the message that tells a word list could not be reset.
|
||||||
|
RESET_ERROR_TITLE: Final[str] = "Cannot reset word list"
|
||||||
|
## @brief Message of a word list that could not be reset; {name} and {reason}.
|
||||||
|
RESET_ERROR_TEMPLATE: Final[str] = "{name} could not be reset.\n{reason}"
|
||||||
|
|||||||
@@ -27,3 +27,11 @@ class ProgressRepository(Protocol):
|
|||||||
@return Nothing.
|
@return Nothing.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Forget the saved words to learn, so every word is to learn again.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
@throws OSError When the saved progress cannot be removed.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
@@ -42,3 +42,11 @@ class CsvProgressRepository:
|
|||||||
@return Nothing.
|
@return Nothing.
|
||||||
"""
|
"""
|
||||||
write_word_list(self._path, deck.language_pair, deck.cards)
|
write_word_list(self._path, deck.language_pair, deck.cards)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Delete the words to learn file; nothing happens when it is absent.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
@throws OSError When the file cannot be deleted.
|
||||||
|
"""
|
||||||
|
self._path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
|
|||||||
@param path The CSV file: a header of two language codes, then one card a row.
|
@param path The CSV file: a header of two language codes, then one card a row.
|
||||||
@return The language pair and the cards in file order.
|
@return The language pair and the cards in file order.
|
||||||
@throws VocabularyNotFoundError When the file does not exist.
|
@throws VocabularyNotFoundError When the file does not exist.
|
||||||
@throws VocabularyFormatError When the file is empty or a row is malformed.
|
@throws VocabularyFormatError When the file is empty, not UTF-8 text, cannot
|
||||||
|
be read or a row is malformed.
|
||||||
"""
|
"""
|
||||||
pair: LanguagePair | None = None
|
pair: LanguagePair | None = None
|
||||||
cards: list[Card] = []
|
cards: list[Card] = []
|
||||||
@@ -83,6 +84,10 @@ def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
|
|||||||
cards.append(_parse_card(path, reader.line_num, row))
|
cards.append(_parse_card(path, reader.line_num, row))
|
||||||
except FileNotFoundError as error:
|
except FileNotFoundError as error:
|
||||||
raise VocabularyNotFoundError(f"word list not found: {path}") from error
|
raise VocabularyNotFoundError(f"word list not found: {path}") from error
|
||||||
|
except UnicodeDecodeError as error:
|
||||||
|
raise VocabularyFormatError(f"{path}: file is not UTF-8 text") from error
|
||||||
|
except OSError as error:
|
||||||
|
raise VocabularyFormatError(f"{path}: cannot be read: {error}") from error
|
||||||
if pair is None:
|
if pair is None:
|
||||||
raise _format_error(path, 1, "file is empty")
|
raise _format_error(path, 1, "file is empty")
|
||||||
return pair, tuple(cards)
|
return pair, tuple(cards)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Name of the words to learn file of a word list.
|
||||||
|
|
||||||
|
@file progress_file_name.py
|
||||||
|
@brief Keeps the progress of every word list in a file of its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flash_card.constants import (
|
||||||
|
PROGRESS_FILE_NAME,
|
||||||
|
PROGRESS_FILE_NAME_TEMPLATE,
|
||||||
|
PROGRESS_STEM_FORBIDDEN,
|
||||||
|
PROGRESS_STEM_REPLACEMENT,
|
||||||
|
VOCABULARY_FILE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def progress_file_name(list_path: Path) -> str:
|
||||||
|
"""Name the words to learn file of a word list.
|
||||||
|
|
||||||
|
@param list_path Path of the word list file.
|
||||||
|
@return The default file name for the delivered list, else a name made of
|
||||||
|
the list's file name stem with other characters than letters, digits,
|
||||||
|
hyphen and underscore replaced by an underscore.
|
||||||
|
"""
|
||||||
|
if list_path.name == VOCABULARY_FILE_NAME:
|
||||||
|
return PROGRESS_FILE_NAME
|
||||||
|
stem = re.sub(PROGRESS_STEM_FORBIDDEN, PROGRESS_STEM_REPLACEMENT, list_path.stem)
|
||||||
|
return PROGRESS_FILE_NAME_TEMPLATE.format(stem=stem)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Entry point and composition root of the flash card program.
|
"""Entry point and composition root of the flash card program.
|
||||||
|
|
||||||
@file app.py
|
@file app.py
|
||||||
@brief Creates the window, shows the level screen and then the card screen.
|
@brief Creates the window and menu bar, shows the level screen and then the card.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -19,7 +19,6 @@ from flash_card.constants import (
|
|||||||
BUNDLED_VOCABULARY_PATH,
|
BUNDLED_VOCABULARY_PATH,
|
||||||
DEFAULT_DATA_DIR,
|
DEFAULT_DATA_DIR,
|
||||||
LEVEL_ARGUMENT_VALUES,
|
LEVEL_ARGUMENT_VALUES,
|
||||||
PROGRESS_FILE_NAME,
|
|
||||||
VOCABULARY_FILE_NAME,
|
VOCABULARY_FILE_NAME,
|
||||||
)
|
)
|
||||||
from flash_card.domain.entities.card import Card
|
from flash_card.domain.entities.card import Card
|
||||||
@@ -33,6 +32,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
|||||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
CsvVocabularyRepository,
|
CsvVocabularyRepository,
|
||||||
)
|
)
|
||||||
|
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||||
|
progress_file_name,
|
||||||
|
)
|
||||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||||
@@ -41,6 +43,8 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
|||||||
)
|
)
|
||||||
from flash_card.presentation.features.level.level_view import LevelView
|
from flash_card.presentation.features.level.level_view import LevelView
|
||||||
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
from flash_card.presentation.features.level.level_view_model import LevelViewModel
|
||||||
|
from flash_card.presentation.features.menu.menu_view import MenuView
|
||||||
|
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||||
|
|
||||||
## @brief Logger of the entry point.
|
## @brief Logger of the entry point.
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -64,34 +68,42 @@ def build_speech(enabled: bool) -> SpeechPort:
|
|||||||
|
|
||||||
|
|
||||||
class Application:
|
class Application:
|
||||||
"""The running program: one window that shows the level screen, then the card."""
|
"""The running program: one window with a menu, the level screen and the card."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
root: tk.Tk,
|
root: tk.Tk,
|
||||||
vocabulary_repository: VocabularyRepository,
|
|
||||||
progress_repository: ProgressRepository,
|
|
||||||
speech: SpeechPort,
|
speech: SpeechPort,
|
||||||
choose: Callable[[Sequence[Card]], Card] = random.choice,
|
choose: Callable[[Sequence[Card]], Card],
|
||||||
|
repositories_for: Callable[
|
||||||
|
[Path], tuple[VocabularyRepository, ProgressRepository]
|
||||||
|
],
|
||||||
|
list_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create the application on a window; no screen is shown yet.
|
"""Create the application on a window; no screen is shown yet.
|
||||||
|
|
||||||
@param root The Tk root window.
|
@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 speech Speaks the front text of each drawn card.
|
||||||
@param choose Picks one card from a non-empty sequence.
|
@param choose Picks one card from a non-empty sequence.
|
||||||
|
@param repositories_for Builds the word list and progress repositories of
|
||||||
|
a word list file; the composition root supplies it.
|
||||||
|
@param list_path The word list file in use.
|
||||||
"""
|
"""
|
||||||
## @brief The Tk root window.
|
## @brief The Tk root window.
|
||||||
self.root = root
|
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.
|
## @brief Speaks the front text of each drawn card.
|
||||||
self._speech = speech
|
self._speech = speech
|
||||||
## @brief Picks one card from a non-empty sequence.
|
## @brief Picks one card from a non-empty sequence.
|
||||||
self._choose = choose
|
self._choose = choose
|
||||||
|
## @brief Builds the repositories of a word list file.
|
||||||
|
self._repositories_for = repositories_for
|
||||||
|
## @brief The word list file in use.
|
||||||
|
self._list_path = list_path
|
||||||
|
vocabulary_repository, progress_repository = repositories_for(list_path)
|
||||||
|
## @brief Source of the word list in use.
|
||||||
|
self._vocabulary_repository = vocabulary_repository
|
||||||
|
## @brief Store of the words still to learn of the word list in use.
|
||||||
|
self._progress_repository = progress_repository
|
||||||
## @brief The screen shown now, or None before the first one.
|
## @brief The screen shown now, or None before the first one.
|
||||||
self._view: LevelView | FlashCardView | None = None
|
self._view: LevelView | FlashCardView | None = None
|
||||||
## @brief The exit code of the program.
|
## @brief The exit code of the program.
|
||||||
@@ -113,6 +125,22 @@ class Application:
|
|||||||
"""
|
"""
|
||||||
return self._exit_code
|
return self._exit_code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def word_list_name(self) -> str:
|
||||||
|
"""Return the file name of the word list in use.
|
||||||
|
|
||||||
|
@return The file name.
|
||||||
|
"""
|
||||||
|
return self._list_path.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def word_list_folder(self) -> Path:
|
||||||
|
"""Return the folder of the word list in use.
|
||||||
|
|
||||||
|
@return The folder that holds the word list file.
|
||||||
|
"""
|
||||||
|
return self._list_path.parent
|
||||||
|
|
||||||
def show_level_screen(self) -> None:
|
def show_level_screen(self) -> None:
|
||||||
"""Show the level screen; a choice starts the session.
|
"""Show the level screen; a choice starts the session.
|
||||||
|
|
||||||
@@ -134,9 +162,60 @@ class Application:
|
|||||||
self._choose,
|
self._choose,
|
||||||
level,
|
level,
|
||||||
)
|
)
|
||||||
|
self._close_screen()
|
||||||
|
self._view = FlashCardView(self.root, FlashCardViewModel(session))
|
||||||
|
|
||||||
|
def open_word_list(self, path: Path) -> None:
|
||||||
|
"""Switch to another word list and show the level screen.
|
||||||
|
|
||||||
|
The file is loaded first; nothing changes when it is unusable.
|
||||||
|
|
||||||
|
@param path The CSV word list file.
|
||||||
|
@return Nothing.
|
||||||
|
@throws VocabularyError When the file is missing or malformed.
|
||||||
|
"""
|
||||||
|
vocabulary_repository, progress_repository = self._repositories_for(path)
|
||||||
|
vocabulary_repository.load()
|
||||||
|
self._vocabulary_repository = vocabulary_repository
|
||||||
|
self._progress_repository = progress_repository
|
||||||
|
self._list_path = path
|
||||||
|
self.change_level()
|
||||||
|
|
||||||
|
def change_level(self) -> None:
|
||||||
|
"""Drop the current screen and its timer and show the level screen.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
self._close_screen()
|
||||||
|
self.show_level_screen()
|
||||||
|
|
||||||
|
def reset_word_list(self) -> None:
|
||||||
|
"""Forget the progress of the word list in use and show the level screen.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
@throws OSError When the progress cannot be removed.
|
||||||
|
"""
|
||||||
|
self._progress_repository.reset()
|
||||||
|
self.change_level()
|
||||||
|
|
||||||
|
def exit_application(self) -> None:
|
||||||
|
"""Drop the current screen and its timer and close the window.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
self._close_screen()
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
def _close_screen(self) -> None:
|
||||||
|
"""Remove the screen shown now, if any.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
if isinstance(self._view, LevelView):
|
if isinstance(self._view, LevelView):
|
||||||
self._view.destroy()
|
self._view.destroy()
|
||||||
self._view = FlashCardView(self.root, FlashCardViewModel(session))
|
elif isinstance(self._view, FlashCardView):
|
||||||
|
self._view.close()
|
||||||
|
self._view = None
|
||||||
|
|
||||||
def _choose_level(self, level: Level) -> None:
|
def _choose_level(self, level: Level) -> None:
|
||||||
"""Start the session after a click; close the window when it fails.
|
"""Start the session after a click; close the window when it fails.
|
||||||
@@ -163,20 +242,42 @@ def resolve_data_files(data_dir: Path | None) -> tuple[Path, Path]:
|
|||||||
vocabulary = folder / VOCABULARY_FILE_NAME
|
vocabulary = folder / VOCABULARY_FILE_NAME
|
||||||
if data_dir is None and not vocabulary.exists():
|
if data_dir is None and not vocabulary.exists():
|
||||||
vocabulary = BUNDLED_VOCABULARY_PATH
|
vocabulary = BUNDLED_VOCABULARY_PATH
|
||||||
return vocabulary, folder / PROGRESS_FILE_NAME
|
return vocabulary, folder / progress_file_name(vocabulary)
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_builder(
|
||||||
|
folder: Path,
|
||||||
|
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||||
|
"""Make the function that builds the CSV repositories of a word list.
|
||||||
|
|
||||||
|
@param folder The data folder that holds every progress file.
|
||||||
|
@return A function from a word list path to its repositories; the progress
|
||||||
|
file is in the data folder, named after the list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||||
|
return (
|
||||||
|
CsvVocabularyRepository(list_path),
|
||||||
|
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
def create_application(
|
def create_application(
|
||||||
data_dir: Path | None = None,
|
data_dir: Path | None = None,
|
||||||
speech_enabled: bool = True,
|
speech_enabled: bool = True,
|
||||||
level: Level | None = None,
|
level: Level | None = None,
|
||||||
|
word_list: Path | None = None,
|
||||||
) -> Application:
|
) -> Application:
|
||||||
"""Create the window and show the level screen, or start at the given level.
|
"""Create the window with its menu bar and show the level screen, or start.
|
||||||
|
|
||||||
@param data_dir Folder of the data files, or None for the default folder
|
@param data_dir Folder of the data files, or None for the default folder
|
||||||
with the delivered word list as fallback.
|
with the delivered word list as fallback.
|
||||||
@param speech_enabled False to run without spoken words.
|
@param speech_enabled False to run without spoken words.
|
||||||
@param level The level to start at, or None to show the level screen.
|
@param level The level to start at, or None to show the level screen.
|
||||||
|
@param word_list A word list file to use instead of the one of the data
|
||||||
|
folder, or None; its progress file is still in the data folder.
|
||||||
@return The application, its window in the root attribute.
|
@return The application, its window in the root attribute.
|
||||||
@throws VocabularyError When a level is given and the word list or saved
|
@throws VocabularyError When a level is given and the word list or saved
|
||||||
progress is unusable.
|
progress is unusable.
|
||||||
@@ -185,10 +286,12 @@ def create_application(
|
|||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
application = Application(
|
application = Application(
|
||||||
root,
|
root,
|
||||||
CsvVocabularyRepository(vocabulary_path),
|
|
||||||
CsvProgressRepository(progress_path),
|
|
||||||
build_speech(speech_enabled),
|
build_speech(speech_enabled),
|
||||||
|
random.choice,
|
||||||
|
_repository_builder(progress_path.parent),
|
||||||
|
vocabulary_path if word_list is None else word_list,
|
||||||
)
|
)
|
||||||
|
MenuView(root, MenuViewModel(), application)
|
||||||
if level is None:
|
if level is None:
|
||||||
application.show_level_screen()
|
application.show_level_screen()
|
||||||
return application
|
return application
|
||||||
|
|||||||
@@ -146,6 +146,15 @@ class FlashCardView:
|
|||||||
"""
|
"""
|
||||||
return self._card_back_image
|
return self._card_back_image
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Cancel the flip timer and remove the widgets of the screen.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
self._cancel_flip_timer()
|
||||||
|
for widget in (self.canvas, self.wrong_button, self.right_button):
|
||||||
|
widget.destroy()
|
||||||
|
|
||||||
def next_card(self) -> None:
|
def next_card(self) -> None:
|
||||||
"""Show the next card front and restart the flip timer.
|
"""Show the next card front and restart the flip timer.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Menu bar feature."""
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""View of the menu bar.
|
||||||
|
|
||||||
|
@file menu_view.py
|
||||||
|
@brief Builds the menu bar of the wireframe and asks the dialogs of its commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tkinter as tk
|
||||||
|
import webbrowser
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, messagebox
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from flash_card.constants import (
|
||||||
|
MENU_ABOUT_UNDERLINE,
|
||||||
|
MENU_COMMAND_CHANGE_LEVEL,
|
||||||
|
MENU_COMMAND_EXIT,
|
||||||
|
MENU_COMMAND_OPEN,
|
||||||
|
MENU_COMMAND_RESET,
|
||||||
|
MENU_CONTACT_UNDERLINE,
|
||||||
|
MENU_FILE_UNDERLINE,
|
||||||
|
OPEN_DIALOG_TITLE,
|
||||||
|
OPEN_ERROR_TEMPLATE,
|
||||||
|
OPEN_ERROR_TITLE,
|
||||||
|
OPEN_FILE_TYPES,
|
||||||
|
RESET_CONFIRM_TEMPLATE,
|
||||||
|
RESET_CONFIRM_TITLE,
|
||||||
|
RESET_ERROR_TEMPLATE,
|
||||||
|
RESET_ERROR_TITLE,
|
||||||
|
)
|
||||||
|
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||||
|
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||||
|
|
||||||
|
## @brief Logger of the menu view.
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuApplication(Protocol):
|
||||||
|
"""What the menu view needs from the application."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def word_list_name(self) -> str:
|
||||||
|
"""Return the file name of the word list in use.
|
||||||
|
|
||||||
|
@return The file name.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def word_list_folder(self) -> Path:
|
||||||
|
"""Return the folder of the word list in use.
|
||||||
|
|
||||||
|
@return The folder.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def open_word_list(self, path: Path) -> None:
|
||||||
|
"""Switch to the word list in a file.
|
||||||
|
|
||||||
|
@param path The CSV file.
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def change_level(self) -> None:
|
||||||
|
"""Show the level screen.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def reset_word_list(self) -> None:
|
||||||
|
"""Forget the progress of the word list in use.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def exit_application(self) -> None:
|
||||||
|
"""Close the window.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MenuView:
|
||||||
|
"""The menu bar of the window: File and About."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: tk.Tk,
|
||||||
|
view_model: MenuViewModel,
|
||||||
|
application: MenuApplication,
|
||||||
|
*,
|
||||||
|
ask_open_file: Callable[..., str] = filedialog.askopenfilename,
|
||||||
|
confirm: Callable[..., bool] = messagebox.askyesno,
|
||||||
|
show_error: Callable[..., str] = messagebox.showerror,
|
||||||
|
open_url: Callable[[str], bool] = webbrowser.open,
|
||||||
|
) -> None:
|
||||||
|
"""Build the menu bar on a root.
|
||||||
|
|
||||||
|
@param root The Tk root window.
|
||||||
|
@param view_model The labels and the contact data.
|
||||||
|
@param application The commands the entries run.
|
||||||
|
@param ask_open_file Shows the file dialog and returns the chosen path,
|
||||||
|
or an empty value when cancelled.
|
||||||
|
@param confirm Asks a yes or no question.
|
||||||
|
@param show_error Shows an error message.
|
||||||
|
@param open_url Opens an address in the web browser.
|
||||||
|
"""
|
||||||
|
## @brief The Tk root window.
|
||||||
|
self._root = root
|
||||||
|
## @brief The labels and the contact data.
|
||||||
|
self._view_model = view_model
|
||||||
|
## @brief The commands the entries run.
|
||||||
|
self._application = application
|
||||||
|
## @brief Shows the file dialog.
|
||||||
|
self._ask_open_file = ask_open_file
|
||||||
|
## @brief Asks a yes or no question.
|
||||||
|
self._confirm = confirm
|
||||||
|
## @brief Shows an error message.
|
||||||
|
self._show_error = show_error
|
||||||
|
## @brief Opens an address in the web browser.
|
||||||
|
self._open_url = open_url
|
||||||
|
## @brief The menu bar of the window.
|
||||||
|
self.menubar = tk.Menu(root, tearoff=False)
|
||||||
|
## @brief The File menu.
|
||||||
|
self.file_menu = tk.Menu(self.menubar, tearoff=False)
|
||||||
|
## @brief The About menu.
|
||||||
|
self.about_menu = tk.Menu(self.menubar, tearoff=False)
|
||||||
|
## @brief The Contact submenu of the About menu.
|
||||||
|
self.contact_menu = tk.Menu(self.about_menu, tearoff=False)
|
||||||
|
self._build()
|
||||||
|
root.config(menu=self.menubar)
|
||||||
|
|
||||||
|
def open_word_list(self) -> None:
|
||||||
|
"""Ask for a CSV file and switch to it; a refused file shows a message.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
chosen = self._ask_open_file(
|
||||||
|
parent=self._root,
|
||||||
|
title=OPEN_DIALOG_TITLE,
|
||||||
|
initialdir=str(self._application.word_list_folder),
|
||||||
|
filetypes=OPEN_FILE_TYPES,
|
||||||
|
)
|
||||||
|
if not chosen:
|
||||||
|
return
|
||||||
|
path = Path(chosen)
|
||||||
|
try:
|
||||||
|
self._application.open_word_list(path)
|
||||||
|
except VocabularyError as error:
|
||||||
|
_logger.error("%s", error)
|
||||||
|
self._show_error(
|
||||||
|
OPEN_ERROR_TITLE,
|
||||||
|
OPEN_ERROR_TEMPLATE.format(name=path.name, reason=error),
|
||||||
|
parent=self._root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_level(self) -> None:
|
||||||
|
"""Show the level screen again.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
self._application.change_level()
|
||||||
|
|
||||||
|
def reset_word_list(self) -> None:
|
||||||
|
"""Ask to confirm, then start the word list in use from the beginning.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
name = self._application.word_list_name
|
||||||
|
question = RESET_CONFIRM_TEMPLATE.format(name=name)
|
||||||
|
if not self._confirm(RESET_CONFIRM_TITLE, question, parent=self._root):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._application.reset_word_list()
|
||||||
|
except OSError as error:
|
||||||
|
_logger.error("%s", error)
|
||||||
|
self._show_error(
|
||||||
|
RESET_ERROR_TITLE,
|
||||||
|
RESET_ERROR_TEMPLATE.format(name=name, reason=error),
|
||||||
|
parent=self._root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def show_contact(self) -> None:
|
||||||
|
"""Open the LinkedIn page of the author in the web browser.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._open_url(self._view_model.contact_url)
|
||||||
|
except (webbrowser.Error, OSError) as error:
|
||||||
|
_logger.error("%s", error)
|
||||||
|
|
||||||
|
def exit_program(self) -> None:
|
||||||
|
"""Close the window.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
self._application.exit_application()
|
||||||
|
|
||||||
|
def _build(self) -> None:
|
||||||
|
"""Fill the menus and add them to the menu bar.
|
||||||
|
|
||||||
|
@return Nothing.
|
||||||
|
"""
|
||||||
|
commands = {
|
||||||
|
MENU_COMMAND_OPEN: self.open_word_list,
|
||||||
|
MENU_COMMAND_CHANGE_LEVEL: self.change_level,
|
||||||
|
MENU_COMMAND_RESET: self.reset_word_list,
|
||||||
|
MENU_COMMAND_EXIT: self.exit_program,
|
||||||
|
}
|
||||||
|
for entry in self._view_model.file_entries:
|
||||||
|
if entry.command == MENU_COMMAND_EXIT:
|
||||||
|
self.file_menu.add_separator()
|
||||||
|
self.file_menu.add_command(
|
||||||
|
label=entry.label,
|
||||||
|
underline=entry.underline,
|
||||||
|
command=commands[entry.command],
|
||||||
|
)
|
||||||
|
self.contact_menu.add_command(
|
||||||
|
label=self._view_model.contact_label, command=self.show_contact
|
||||||
|
)
|
||||||
|
self.about_menu.add_cascade(
|
||||||
|
label=self._view_model.contact_menu_label,
|
||||||
|
underline=MENU_CONTACT_UNDERLINE,
|
||||||
|
menu=self.contact_menu,
|
||||||
|
)
|
||||||
|
self.menubar.add_cascade(
|
||||||
|
label=self._view_model.file_label,
|
||||||
|
underline=MENU_FILE_UNDERLINE,
|
||||||
|
menu=self.file_menu,
|
||||||
|
)
|
||||||
|
self.menubar.add_cascade(
|
||||||
|
label=self._view_model.about_label,
|
||||||
|
underline=MENU_ABOUT_UNDERLINE,
|
||||||
|
menu=self.about_menu,
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""View model of the menu bar.
|
||||||
|
|
||||||
|
@file menu_view_model.py
|
||||||
|
@brief Exposes the labels, the order and the contact data; free of Tkinter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from flash_card.constants import (
|
||||||
|
CONTACT_LABEL_TEMPLATE,
|
||||||
|
CONTACT_NAME,
|
||||||
|
CONTACT_TITLE,
|
||||||
|
CONTACT_URL,
|
||||||
|
MENU_ABOUT_LABEL,
|
||||||
|
MENU_CHANGE_LEVEL_LABEL,
|
||||||
|
MENU_CHANGE_LEVEL_UNDERLINE,
|
||||||
|
MENU_COMMAND_CHANGE_LEVEL,
|
||||||
|
MENU_COMMAND_EXIT,
|
||||||
|
MENU_COMMAND_OPEN,
|
||||||
|
MENU_COMMAND_RESET,
|
||||||
|
MENU_CONTACT_LABEL,
|
||||||
|
MENU_EXIT_LABEL,
|
||||||
|
MENU_EXIT_UNDERLINE,
|
||||||
|
MENU_FILE_LABEL,
|
||||||
|
MENU_OPEN_LABEL,
|
||||||
|
MENU_OPEN_UNDERLINE,
|
||||||
|
MENU_RESET_LABEL,
|
||||||
|
MENU_RESET_UNDERLINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MenuEntry:
|
||||||
|
"""One entry of the File menu."""
|
||||||
|
|
||||||
|
## @brief Text of the entry.
|
||||||
|
label: str
|
||||||
|
## @brief Name of the command of the menu view the entry runs.
|
||||||
|
command: str
|
||||||
|
## @brief Index of the mnemonic letter in the label.
|
||||||
|
underline: int
|
||||||
|
|
||||||
|
|
||||||
|
class MenuViewModel:
|
||||||
|
"""Data the menu view shows."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_label(self) -> str:
|
||||||
|
"""Return the label of the File menu.
|
||||||
|
|
||||||
|
@return The label.
|
||||||
|
"""
|
||||||
|
return MENU_FILE_LABEL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_entries(self) -> tuple[MenuEntry, ...]:
|
||||||
|
"""Return the entries of the File menu.
|
||||||
|
|
||||||
|
@return Open, Change level, Reset word list and Exit, in this order.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
MenuEntry(MENU_OPEN_LABEL, MENU_COMMAND_OPEN, MENU_OPEN_UNDERLINE),
|
||||||
|
MenuEntry(
|
||||||
|
MENU_CHANGE_LEVEL_LABEL,
|
||||||
|
MENU_COMMAND_CHANGE_LEVEL,
|
||||||
|
MENU_CHANGE_LEVEL_UNDERLINE,
|
||||||
|
),
|
||||||
|
MenuEntry(MENU_RESET_LABEL, MENU_COMMAND_RESET, MENU_RESET_UNDERLINE),
|
||||||
|
MenuEntry(MENU_EXIT_LABEL, MENU_COMMAND_EXIT, MENU_EXIT_UNDERLINE),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def about_label(self) -> str:
|
||||||
|
"""Return the label of the About menu.
|
||||||
|
|
||||||
|
@return The label.
|
||||||
|
"""
|
||||||
|
return MENU_ABOUT_LABEL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contact_menu_label(self) -> str:
|
||||||
|
"""Return the label of the Contact submenu.
|
||||||
|
|
||||||
|
@return The label.
|
||||||
|
"""
|
||||||
|
return MENU_CONTACT_LABEL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contact_label(self) -> str:
|
||||||
|
"""Return the text of the contact entry.
|
||||||
|
|
||||||
|
@return The name and the title of the author.
|
||||||
|
"""
|
||||||
|
return CONTACT_LABEL_TEMPLATE.format(name=CONTACT_NAME, title=CONTACT_TITLE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contact_url(self) -> str:
|
||||||
|
"""Return the address the contact entry opens.
|
||||||
|
|
||||||
|
@return The LinkedIn page of the author.
|
||||||
|
"""
|
||||||
|
return CONTACT_URL
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from collections.abc import Iterator, Sequence
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -21,13 +21,14 @@ from flash_card.constants import (
|
|||||||
LEVEL_SCREEN_HEIGHT,
|
LEVEL_SCREEN_HEIGHT,
|
||||||
LEVEL_TITLE,
|
LEVEL_TITLE,
|
||||||
LEVEL_TITLE_FONT,
|
LEVEL_TITLE_FONT,
|
||||||
PROGRESS_FILE_NAME,
|
|
||||||
VOCABULARY_FILE_NAME,
|
VOCABULARY_FILE_NAME,
|
||||||
WINDOW_PADDING,
|
WINDOW_PADDING,
|
||||||
WINDOW_TITLE,
|
WINDOW_TITLE,
|
||||||
)
|
)
|
||||||
from flash_card.domain.entities.card import Card
|
from flash_card.domain.entities.card import Card
|
||||||
from flash_card.domain.entities.deck import Deck
|
from flash_card.domain.entities.deck import Deck
|
||||||
|
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||||
|
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||||
from flash_card.domain.value_objects.level import Level
|
from flash_card.domain.value_objects.level import Level
|
||||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||||
CsvProgressRepository,
|
CsvProgressRepository,
|
||||||
@@ -35,6 +36,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
|||||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
CsvVocabularyRepository,
|
CsvVocabularyRepository,
|
||||||
)
|
)
|
||||||
|
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||||
|
progress_file_name,
|
||||||
|
)
|
||||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||||
from flash_card.presentation import app
|
from flash_card.presentation import app
|
||||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||||
@@ -57,15 +61,25 @@ def _word(view: FlashCardView) -> str:
|
|||||||
def _clear(root: tk.Tk, view: object) -> None:
|
def _clear(root: tk.Tk, view: object) -> None:
|
||||||
"""Destroy the widgets of whichever screen is shown, and pending timers."""
|
"""Destroy the widgets of whichever screen is shown, and pending timers."""
|
||||||
if isinstance(view, FlashCardView):
|
if isinstance(view, FlashCardView):
|
||||||
if view.flip_timer is not None:
|
view.close()
|
||||||
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):
|
elif isinstance(view, LevelView):
|
||||||
view.destroy()
|
view.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def repositories_for(
|
||||||
|
folder: Path,
|
||||||
|
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||||
|
"""Build the CSV repositories of a list, its progress file in folder."""
|
||||||
|
|
||||||
|
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||||
|
return (
|
||||||
|
CsvVocabularyRepository(list_path),
|
||||||
|
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def data_dir(tmp_path: Path) -> Path:
|
def data_dir(tmp_path: Path) -> Path:
|
||||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
|
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, tmp_path)
|
||||||
@@ -76,10 +90,10 @@ def data_dir(tmp_path: Path) -> Path:
|
|||||||
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
|
def application(tk_root: tk.Tk, data_dir: Path) -> Iterator[app.Application]:
|
||||||
instance = app.Application(
|
instance = app.Application(
|
||||||
tk_root,
|
tk_root,
|
||||||
CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME),
|
|
||||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME),
|
|
||||||
NullSpeech(),
|
NullSpeech(),
|
||||||
_last,
|
_last,
|
||||||
|
repositories_for(data_dir),
|
||||||
|
data_dir / VOCABULARY_FILE_NAME,
|
||||||
)
|
)
|
||||||
yield instance
|
yield instance
|
||||||
_clear(tk_root, instance.view)
|
_clear(tk_root, instance.view)
|
||||||
@@ -185,7 +199,7 @@ def test_level_whose_words_are_all_known_shows_the_finished_message(
|
|||||||
) -> None:
|
) -> None:
|
||||||
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
|
cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards
|
||||||
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
|
pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair
|
||||||
CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:]))
|
CsvProgressRepository(data_dir / "words_to_learn.csv").save(Deck(pair, cards[100:]))
|
||||||
application.show_level_screen()
|
application.show_level_screen()
|
||||||
level_view = application.view
|
level_view = application.view
|
||||||
assert isinstance(level_view, LevelView)
|
assert isinstance(level_view, LevelView)
|
||||||
@@ -207,8 +221,10 @@ def test_create_application_with_level_skips_the_level_screen(
|
|||||||
try:
|
try:
|
||||||
assert isinstance(application.view, FlashCardView)
|
assert isinstance(application.view, FlashCardView)
|
||||||
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
|
assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)]
|
||||||
|
assert str(tk_root.cget("menu"))
|
||||||
finally:
|
finally:
|
||||||
_clear(tk_root, application.view)
|
_clear(tk_root, application.view)
|
||||||
|
tk_root.config(menu="")
|
||||||
|
|
||||||
|
|
||||||
def test_create_application_without_level_shows_the_level_screen(
|
def test_create_application_without_level_shows_the_level_screen(
|
||||||
@@ -221,5 +237,28 @@ def test_create_application_without_level_shows_the_level_screen(
|
|||||||
try:
|
try:
|
||||||
assert isinstance(application.view, LevelView)
|
assert isinstance(application.view, LevelView)
|
||||||
assert application.root is tk_root
|
assert application.root is tk_root
|
||||||
|
assert str(tk_root.cget("menu"))
|
||||||
finally:
|
finally:
|
||||||
_clear(tk_root, application.view)
|
_clear(tk_root, application.view)
|
||||||
|
tk_root.config(menu="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_application_with_word_list_keeps_progress_in_the_data_folder(
|
||||||
|
tk_root: tk.Tk, data_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(tk, "Tk", lambda: tk_root)
|
||||||
|
other = tmp_path / "other" / "animals.csv"
|
||||||
|
other.parent.mkdir()
|
||||||
|
other.write_text("ms;da\nkucing;kat\n", encoding="utf-8")
|
||||||
|
|
||||||
|
application = app.create_application(data_dir, False, Level(None), other)
|
||||||
|
|
||||||
|
try:
|
||||||
|
view = application.view
|
||||||
|
assert isinstance(view, FlashCardView)
|
||||||
|
assert application.word_list_name == "animals.csv"
|
||||||
|
view.right_button.invoke()
|
||||||
|
assert (data_dir / "words_to_learn_animals.csv").is_file()
|
||||||
|
finally:
|
||||||
|
_clear(tk_root, application.view)
|
||||||
|
tk_root.config(menu="")
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""End-to-end tests of the menu bar and its commands on a real Tk window.
|
||||||
|
|
||||||
|
@file test_menu_bar.py
|
||||||
|
@brief Builds the real menu bar with injected dialogs and checks the commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flash_card.constants import CONTACT_URL, WINDOW_TITLE
|
||||||
|
from flash_card.domain.entities.card import Card
|
||||||
|
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||||
|
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||||
|
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||||
|
CsvProgressRepository,
|
||||||
|
)
|
||||||
|
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||||
|
CsvVocabularyRepository,
|
||||||
|
)
|
||||||
|
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||||
|
progress_file_name,
|
||||||
|
)
|
||||||
|
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.menu.menu_view import MenuView
|
||||||
|
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||||
|
|
||||||
|
DEFAULT_LIST = "ms;da\naku;mig\nkau;du\n"
|
||||||
|
OTHER_LIST = "ms;da\nkucing;kat\n"
|
||||||
|
CONTACT_LABEL = "Jens Tirsvad Nielsen, Software engineer"
|
||||||
|
|
||||||
|
FILE_INDEX = 0
|
||||||
|
ABOUT_INDEX = 1
|
||||||
|
OPEN, CHANGE_LEVEL, RESET, SEPARATOR, EXIT = range(5)
|
||||||
|
|
||||||
|
|
||||||
|
def _last(cards: Sequence[Card]) -> Card:
|
||||||
|
return cards[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class _Dialogs:
|
||||||
|
"""Records and answers the dialogs the menu view asks."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.file = ""
|
||||||
|
self.answer = True
|
||||||
|
self.asked_files: list[dict[str, object]] = []
|
||||||
|
self.questions: list[str] = []
|
||||||
|
self.errors: list[tuple[str, str]] = []
|
||||||
|
self.urls: list[str] = []
|
||||||
|
|
||||||
|
def ask_open_file(self, **options: object) -> str:
|
||||||
|
self.asked_files.append(options)
|
||||||
|
return self.file
|
||||||
|
|
||||||
|
def confirm(self, title: str, message: str, **options: object) -> bool:
|
||||||
|
self.questions.append(message)
|
||||||
|
return self.answer
|
||||||
|
|
||||||
|
def show_error(self, title: str, message: str, **options: object) -> str:
|
||||||
|
self.errors.append((title, message))
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def open_url(self, url: str) -> bool:
|
||||||
|
self.urls.append(url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _Window:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: tk.Tk,
|
||||||
|
application: app.Application,
|
||||||
|
menu: MenuView,
|
||||||
|
dialogs: _Dialogs,
|
||||||
|
folder: Path,
|
||||||
|
) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.application = application
|
||||||
|
self.menu = menu
|
||||||
|
self.dialogs = dialogs
|
||||||
|
self.folder = folder
|
||||||
|
|
||||||
|
|
||||||
|
def _repositories_for(
|
||||||
|
folder: Path,
|
||||||
|
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||||
|
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||||
|
return (
|
||||||
|
CsvVocabularyRepository(list_path),
|
||||||
|
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def _close(window: _Window) -> None:
|
||||||
|
view = window.application.view
|
||||||
|
if isinstance(view, FlashCardView):
|
||||||
|
view.close()
|
||||||
|
elif isinstance(view, LevelView):
|
||||||
|
view.destroy()
|
||||||
|
window.root.config(menu="")
|
||||||
|
window.menu.menubar.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window(tk_root: tk.Tk, tmp_path: Path) -> Iterator[_Window]:
|
||||||
|
default = tmp_path / "ms-da.csv"
|
||||||
|
default.write_bytes(DEFAULT_LIST.encode("utf-8"))
|
||||||
|
application = app.Application(
|
||||||
|
tk_root, NullSpeech(), _last, _repositories_for(tmp_path), default
|
||||||
|
)
|
||||||
|
dialogs = _Dialogs()
|
||||||
|
menu = MenuView(
|
||||||
|
tk_root,
|
||||||
|
MenuViewModel(),
|
||||||
|
application,
|
||||||
|
ask_open_file=dialogs.ask_open_file,
|
||||||
|
confirm=dialogs.confirm,
|
||||||
|
show_error=dialogs.show_error,
|
||||||
|
open_url=dialogs.open_url,
|
||||||
|
)
|
||||||
|
application.show_level_screen()
|
||||||
|
instance = _Window(tk_root, application, menu, dialogs, tmp_path)
|
||||||
|
yield instance
|
||||||
|
_close(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def _labels(menu: tk.Menu, last: int) -> list[str]:
|
||||||
|
return [
|
||||||
|
"-" if menu.type(i) == "separator" else str(menu.entrycget(i, "label"))
|
||||||
|
for i in range(last + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _start_first_level(window: _Window) -> FlashCardView:
|
||||||
|
level_view = window.application.view
|
||||||
|
assert isinstance(level_view, LevelView)
|
||||||
|
level_view.buttons[0].invoke()
|
||||||
|
card_view = window.application.view
|
||||||
|
assert isinstance(card_view, FlashCardView)
|
||||||
|
return card_view
|
||||||
|
|
||||||
|
|
||||||
|
def _word(view: FlashCardView) -> str:
|
||||||
|
return str(view.canvas.itemcget(view.word_text_id, "text"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_bar_has_file_and_about_in_order(window: _Window) -> None:
|
||||||
|
menubar = window.menu.menubar
|
||||||
|
assert str(window.root.cget("menu")) == str(menubar)
|
||||||
|
assert menubar.index("end") == ABOUT_INDEX
|
||||||
|
assert _labels(menubar, ABOUT_INDEX) == ["File", "About"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_menu_has_four_entries_and_a_separator_before_exit(
|
||||||
|
window: _Window,
|
||||||
|
) -> None:
|
||||||
|
file_menu = window.menu.file_menu
|
||||||
|
assert file_menu.index("end") == EXIT
|
||||||
|
assert _labels(file_menu, EXIT) == [
|
||||||
|
"Open...",
|
||||||
|
"Change level...",
|
||||||
|
"Reset word list...",
|
||||||
|
"-",
|
||||||
|
"Exit",
|
||||||
|
]
|
||||||
|
assert file_menu.type(SEPARATOR) == "separator"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_entries_have_mnemonics(window: _Window) -> None:
|
||||||
|
file_menu = window.menu.file_menu
|
||||||
|
assert int(str(file_menu.entrycget(OPEN, "underline"))) == 0
|
||||||
|
assert int(str(file_menu.entrycget(EXIT, "underline"))) == 1
|
||||||
|
assert int(str(window.menu.menubar.entrycget(FILE_INDEX, "underline"))) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_about_menu_leads_to_the_contact_entry(window: _Window) -> None:
|
||||||
|
about = window.menu.about_menu
|
||||||
|
assert _labels(about, 0) == ["Contact"]
|
||||||
|
assert about.type(0) == "cascade"
|
||||||
|
contact = window.menu.contact_menu
|
||||||
|
assert contact.index("end") == 0
|
||||||
|
assert _labels(contact, 0) == [CONTACT_LABEL]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_starts_the_chosen_list_after_a_level_choice(window: _Window) -> None:
|
||||||
|
other = window.folder / "animals.csv"
|
||||||
|
other.write_bytes(OTHER_LIST.encode("utf-8"))
|
||||||
|
window.dialogs.file = str(other)
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(OPEN)
|
||||||
|
|
||||||
|
assert isinstance(window.application.view, LevelView)
|
||||||
|
assert window.application.word_list_name == "animals.csv"
|
||||||
|
assert _word(_start_first_level(window)) == "kucing"
|
||||||
|
asked = window.dialogs.asked_files[0]
|
||||||
|
assert asked["initialdir"] == str(window.folder)
|
||||||
|
assert ("CSV files", "*.csv") in asked["filetypes"] # type: ignore[operator]
|
||||||
|
assert window.root.title() == WINDOW_TITLE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"content",
|
||||||
|
[None, b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
|
||||||
|
ids=["missing", "invalid-utf8", "latin-1"],
|
||||||
|
)
|
||||||
|
def test_open_of_an_invalid_file_shows_a_message_and_keeps_the_list(
|
||||||
|
window: _Window, content: bytes | None
|
||||||
|
) -> None:
|
||||||
|
bad = window.folder / "bad.csv"
|
||||||
|
if content is not None:
|
||||||
|
bad.write_bytes(content)
|
||||||
|
window.dialogs.file = str(bad)
|
||||||
|
level_view = window.application.view
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(OPEN)
|
||||||
|
|
||||||
|
assert len(window.dialogs.errors) == 1
|
||||||
|
assert "bad.csv" in window.dialogs.errors[0][1]
|
||||||
|
assert window.application.view is level_view
|
||||||
|
assert window.application.word_list_name == "ms-da.csv"
|
||||||
|
assert _word(_start_first_level(window)) == "kau"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_cancelled_changes_nothing(window: _Window) -> None:
|
||||||
|
window.dialogs.file = ""
|
||||||
|
level_view = window.application.view
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(OPEN)
|
||||||
|
|
||||||
|
assert window.application.view is level_view
|
||||||
|
assert window.application.word_list_name == "ms-da.csv"
|
||||||
|
assert not window.dialogs.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_level_shows_the_level_screen_and_keeps_known_words(
|
||||||
|
window: _Window,
|
||||||
|
) -> None:
|
||||||
|
card_view = _start_first_level(window)
|
||||||
|
card_view.right_button.invoke()
|
||||||
|
progress = window.folder / "words_to_learn.csv"
|
||||||
|
saved = progress.read_bytes()
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(CHANGE_LEVEL)
|
||||||
|
|
||||||
|
assert isinstance(window.application.view, LevelView)
|
||||||
|
assert card_view.flip_timer is None
|
||||||
|
assert progress.read_bytes() == saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_asks_and_on_yes_removes_progress_and_shows_level_screen(
|
||||||
|
window: _Window,
|
||||||
|
) -> None:
|
||||||
|
card_view = _start_first_level(window)
|
||||||
|
card_view.right_button.invoke()
|
||||||
|
progress = window.folder / "words_to_learn.csv"
|
||||||
|
assert progress.is_file()
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(RESET)
|
||||||
|
|
||||||
|
assert window.dialogs.questions == [
|
||||||
|
"Start ms-da.csv from the beginning? All words will be to learn again."
|
||||||
|
]
|
||||||
|
assert not progress.exists()
|
||||||
|
assert isinstance(window.application.view, LevelView)
|
||||||
|
assert (window.folder / "ms-da.csv").read_bytes() == DEFAULT_LIST.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_answered_no_changes_nothing(window: _Window) -> None:
|
||||||
|
card_view = _start_first_level(window)
|
||||||
|
card_view.right_button.invoke()
|
||||||
|
progress = window.folder / "words_to_learn.csv"
|
||||||
|
window.dialogs.answer = False
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(RESET)
|
||||||
|
|
||||||
|
assert progress.is_file()
|
||||||
|
assert window.application.view is card_view
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_opens_the_linkedin_page(window: _Window) -> None:
|
||||||
|
window.menu.contact_menu.invoke(0)
|
||||||
|
assert window.dialogs.urls == [CONTACT_URL]
|
||||||
|
assert CONTACT_URL == "https://www.linkedin.com/in/tirsvad74"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exit_cancels_the_timer_and_destroys_the_window(
|
||||||
|
window: _Window, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
destroyed: list[bool] = []
|
||||||
|
monkeypatch.setattr(window.root, "destroy", lambda: destroyed.append(True))
|
||||||
|
card_view = _start_first_level(window)
|
||||||
|
assert card_view.flip_timer is not None
|
||||||
|
|
||||||
|
window.menu.file_menu.invoke(EXIT)
|
||||||
|
|
||||||
|
assert destroyed == [True]
|
||||||
|
assert card_view.flip_timer is None
|
||||||
|
assert window.application.exit_code == 0
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Integration tests: every word list keeps its progress in a file of its own."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||||
|
from flash_card.domain.entities.card import Card
|
||||||
|
from flash_card.domain.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.persistence.progress_file_name import (
|
||||||
|
progress_file_name,
|
||||||
|
)
|
||||||
|
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||||
|
|
||||||
|
MS_DA = "ms;da\naku;mig\nkau;du\nini;denne\n"
|
||||||
|
ANIMALS = "ms;da\nkucing;kat\nanjing;hund\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _first(cards: Sequence[Card]) -> Card:
|
||||||
|
return cards[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _lists(folder: Path) -> tuple[Path, Path]:
|
||||||
|
default = folder / "ms-da.csv"
|
||||||
|
other = folder / "animals.csv"
|
||||||
|
default.write_bytes(MS_DA.encode("utf-8"))
|
||||||
|
other.write_bytes(ANIMALS.encode("utf-8"))
|
||||||
|
return default, other
|
||||||
|
|
||||||
|
|
||||||
|
def _progress(folder: Path, list_path: Path) -> CsvProgressRepository:
|
||||||
|
return CsvProgressRepository(folder / progress_file_name(list_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _know_first_word(folder: Path, list_path: Path) -> str:
|
||||||
|
session = open_study_session(
|
||||||
|
CsvVocabularyRepository(list_path),
|
||||||
|
_progress(folder, list_path),
|
||||||
|
NullSpeech(),
|
||||||
|
_first,
|
||||||
|
Level(None),
|
||||||
|
)
|
||||||
|
card = session.next_card()
|
||||||
|
assert card is not None
|
||||||
|
session.mark_known()
|
||||||
|
return card.front_text
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining(folder: Path, list_path: Path) -> list[str]:
|
||||||
|
deck = _progress(folder, list_path).load()
|
||||||
|
assert deck is not None
|
||||||
|
return [card.front for card in deck.cards]
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_words_of_one_list_never_appear_in_the_other(tmp_path: Path) -> None:
|
||||||
|
default, other = _lists(tmp_path)
|
||||||
|
|
||||||
|
assert _know_first_word(tmp_path, default) == "aku"
|
||||||
|
assert _know_first_word(tmp_path, other) == "kucing"
|
||||||
|
|
||||||
|
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||||
|
assert _remaining(tmp_path, other) == ["anjing"]
|
||||||
|
assert (tmp_path / "words_to_learn.csv").is_file()
|
||||||
|
assert (tmp_path / "words_to_learn_animals.csv").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_of_one_list_deletes_only_its_progress_file(tmp_path: Path) -> None:
|
||||||
|
default, other = _lists(tmp_path)
|
||||||
|
_know_first_word(tmp_path, default)
|
||||||
|
_know_first_word(tmp_path, other)
|
||||||
|
|
||||||
|
_progress(tmp_path, other).reset()
|
||||||
|
|
||||||
|
assert not (tmp_path / "words_to_learn_animals.csv").exists()
|
||||||
|
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_leaves_the_word_list_file_byte_identical(tmp_path: Path) -> None:
|
||||||
|
default, other = _lists(tmp_path)
|
||||||
|
_know_first_word(tmp_path, other)
|
||||||
|
|
||||||
|
_progress(tmp_path, other).reset()
|
||||||
|
|
||||||
|
assert other.read_bytes() == ANIMALS.encode("utf-8")
|
||||||
|
assert default.read_bytes() == MS_DA.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivered_list_keeps_words_to_learn_file_name(tmp_path: Path) -> None:
|
||||||
|
default, _other = _lists(tmp_path)
|
||||||
|
assert progress_file_name(default) == "words_to_learn.csv"
|
||||||
@@ -110,3 +110,18 @@ def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
|
|||||||
assert deck is not None
|
assert deck is not None
|
||||||
assert deck.cards == CARDS
|
assert deck.cards == CARDS
|
||||||
assert deck.language_pair == PAIR
|
assert deck.language_pair == PAIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_deletes_the_progress_file(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "words_to_learn.csv"
|
||||||
|
path.write_text("ms;da\naku;mig\n", encoding="utf-8")
|
||||||
|
repository = CsvProgressRepository(path)
|
||||||
|
|
||||||
|
repository.reset()
|
||||||
|
|
||||||
|
assert not path.exists()
|
||||||
|
assert repository.load() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_does_nothing_when_the_file_is_absent(tmp_path: Path) -> None:
|
||||||
|
CsvProgressRepository(tmp_path / "words_to_learn.csv").reset()
|
||||||
|
|||||||
@@ -93,3 +93,18 @@ def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> Non
|
|||||||
path = _write(tmp_path / "w.csv", "ms;da\n")
|
path = _write(tmp_path / "w.csv", "ms;da\n")
|
||||||
with pytest.raises(VocabularyFormatError):
|
with pytest.raises(VocabularyFormatError):
|
||||||
CsvVocabularyRepository(path).load()
|
CsvVocabularyRepository(path).load()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"content",
|
||||||
|
[b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
|
||||||
|
ids=["invalid-utf8", "latin-1"],
|
||||||
|
)
|
||||||
|
def test_read_word_list_raises_format_error_when_not_utf8(
|
||||||
|
tmp_path: Path, content: bytes
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "w.csv"
|
||||||
|
path.write_bytes(content)
|
||||||
|
with pytest.raises(VocabularyFormatError, match=r"w\.csv.*not UTF-8") as info:
|
||||||
|
read_word_list(path)
|
||||||
|
assert isinstance(info.value.__cause__, UnicodeDecodeError)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tests of the name of the words to learn file of a word list."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||||
|
progress_file_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_file_name_is_default_for_the_delivered_list() -> None:
|
||||||
|
assert progress_file_name(Path("data") / "ms-da.csv") == "words_to_learn.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_file_name_uses_the_stem_for_another_list() -> None:
|
||||||
|
assert progress_file_name(Path("x") / "animals.csv") == "words_to_learn_animals.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_file_name_keeps_hyphen_and_underscore() -> None:
|
||||||
|
name = progress_file_name(Path("my_list-2.csv"))
|
||||||
|
assert name == "words_to_learn_my_list-2.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("file_name", "expected"),
|
||||||
|
[
|
||||||
|
("my list.csv", "words_to_learn_my_list.csv"),
|
||||||
|
("a.b;c.csv", "words_to_learn_a_b_c.csv"),
|
||||||
|
("ord (1).csv", "words_to_learn_ord__1_.csv"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_progress_file_name_replaces_other_characters(
|
||||||
|
file_name: str, expected: str
|
||||||
|
) -> None:
|
||||||
|
assert progress_file_name(Path(file_name)) == expected
|
||||||
+143
-2
@@ -63,6 +63,15 @@ class _FakeLevelView:
|
|||||||
class _FakeFlashCardView:
|
class _FakeFlashCardView:
|
||||||
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
|
def __init__(self, root: object, view_model: FlashCardViewModel) -> None:
|
||||||
self.view_model = view_model
|
self.view_model = view_model
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMenuView:
|
||||||
|
def __init__(self, root: object, view_model: object, application: object) -> None:
|
||||||
|
self.application = application
|
||||||
|
|
||||||
|
|
||||||
class _Vocabulary:
|
class _Vocabulary:
|
||||||
@@ -76,12 +85,18 @@ class _Vocabulary:
|
|||||||
|
|
||||||
|
|
||||||
class _Progress:
|
class _Progress:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.resets = 0
|
||||||
|
|
||||||
def load(self) -> Deck | None:
|
def load(self) -> Deck | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save(self, deck: Deck) -> None:
|
def save(self, deck: Deck) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.resets += 1
|
||||||
|
|
||||||
|
|
||||||
class _Speech:
|
class _Speech:
|
||||||
def speak(self, text: str, language_code: str) -> None:
|
def speak(self, text: str, language_code: str) -> None:
|
||||||
@@ -96,6 +111,7 @@ def _first(cards: Sequence[Card]) -> Card:
|
|||||||
def fake_tk_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
def fake_tk_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Keep every test in this module away from a real Tk root."""
|
"""Keep every test in this module away from a real Tk root."""
|
||||||
monkeypatch.setattr(app.tk, "Tk", _FakeRoot)
|
monkeypatch.setattr(app.tk, "Tk", _FakeRoot)
|
||||||
|
monkeypatch.setattr(app, "MenuView", _FakeMenuView)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -104,9 +120,31 @@ def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
|
monkeypatch.setattr(app, "FlashCardView", _FakeFlashCardView)
|
||||||
|
|
||||||
|
|
||||||
def _application(root: _FakeRoot, error: bool = False) -> app.Application:
|
LIST_PATH = Path("lists") / "ms-da.csv"
|
||||||
|
OTHER_PATH = Path("lists") / "animals.csv"
|
||||||
|
|
||||||
|
|
||||||
|
class _Factory:
|
||||||
|
"""Repositories for a path, recorded; the paths in bad are unusable."""
|
||||||
|
|
||||||
|
def __init__(self, error: bool = False, bad: tuple[Path, ...] = ()) -> None:
|
||||||
|
self._error = error
|
||||||
|
self._bad = bad
|
||||||
|
self.progress: dict[Path, _Progress] = {}
|
||||||
|
self.calls: list[Path] = []
|
||||||
|
|
||||||
|
def __call__(self, path: Path) -> tuple[_Vocabulary, _Progress]:
|
||||||
|
self.calls.append(path)
|
||||||
|
progress = self.progress.setdefault(path, _Progress())
|
||||||
|
return _Vocabulary(self._error or path in self._bad), progress
|
||||||
|
|
||||||
|
|
||||||
|
def _application(
|
||||||
|
root: _FakeRoot, error: bool = False, factory: _Factory | None = None
|
||||||
|
) -> app.Application:
|
||||||
|
repositories_for = _Factory(error) if factory is None else factory
|
||||||
return app.Application(
|
return app.Application(
|
||||||
cast(tk.Tk, root), _Vocabulary(error), _Progress(), _Speech(), _first
|
cast(tk.Tk, root), _Speech(), _first, repositories_for, LIST_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,3 +331,106 @@ def test_create_application_draws_first_card_from_bundled_list(
|
|||||||
assert view.view_model.text == "saya"
|
assert view.view_model.text == "saya"
|
||||||
view.view_model.is_known()
|
view.view_model.is_known()
|
||||||
assert (data / "words_to_learn.csv").is_file()
|
assert (data / "words_to_learn.csv").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_list_name_and_folder_are_those_of_the_list_in_use() -> None:
|
||||||
|
application = _application(_FakeRoot())
|
||||||
|
assert application.word_list_name == "ms-da.csv"
|
||||||
|
assert application.word_list_folder == Path("lists")
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_word_list_replaces_repositories_and_shows_level_screen(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
factory = _Factory()
|
||||||
|
application = _application(_FakeRoot(), factory=factory)
|
||||||
|
application.start(Level(None))
|
||||||
|
card_view = application.view
|
||||||
|
assert isinstance(card_view, _FakeFlashCardView)
|
||||||
|
|
||||||
|
application.open_word_list(OTHER_PATH)
|
||||||
|
|
||||||
|
assert card_view.closed
|
||||||
|
assert isinstance(application.view, _FakeLevelView)
|
||||||
|
assert application.word_list_name == "animals.csv"
|
||||||
|
application.start(Level(None))
|
||||||
|
application.reset_word_list()
|
||||||
|
assert factory.progress[OTHER_PATH].resets == 1
|
||||||
|
assert factory.progress[LIST_PATH].resets == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_word_list_raises_and_changes_nothing_when_list_is_invalid(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
factory = _Factory(bad=(OTHER_PATH,))
|
||||||
|
application = _application(_FakeRoot(), factory=factory)
|
||||||
|
application.start(Level(None))
|
||||||
|
card_view = application.view
|
||||||
|
assert isinstance(card_view, _FakeFlashCardView)
|
||||||
|
|
||||||
|
with pytest.raises(VocabularyNotFoundError):
|
||||||
|
application.open_word_list(OTHER_PATH)
|
||||||
|
|
||||||
|
assert application.view is card_view
|
||||||
|
assert not card_view.closed
|
||||||
|
assert application.word_list_name == "ms-da.csv"
|
||||||
|
application.reset_word_list()
|
||||||
|
assert factory.progress[LIST_PATH].resets == 1
|
||||||
|
assert factory.progress[OTHER_PATH].resets == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_level_closes_the_card_screen_and_shows_the_level_screen(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
application = _application(_FakeRoot())
|
||||||
|
application.start(Level(None))
|
||||||
|
card_view = application.view
|
||||||
|
assert isinstance(card_view, _FakeFlashCardView)
|
||||||
|
|
||||||
|
application.change_level()
|
||||||
|
|
||||||
|
assert card_view.closed
|
||||||
|
assert isinstance(application.view, _FakeLevelView)
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_level_destroys_the_level_screen_it_replaces(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
application = _application(_FakeRoot())
|
||||||
|
application.show_level_screen()
|
||||||
|
first = application.view
|
||||||
|
assert isinstance(first, _FakeLevelView)
|
||||||
|
|
||||||
|
application.change_level()
|
||||||
|
|
||||||
|
assert first.destroyed
|
||||||
|
assert application.view is not first
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_word_list_resets_progress_then_shows_the_level_screen(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
factory = _Factory()
|
||||||
|
application = _application(_FakeRoot(), factory=factory)
|
||||||
|
application.start(Level(None))
|
||||||
|
|
||||||
|
application.reset_word_list()
|
||||||
|
|
||||||
|
assert factory.progress[LIST_PATH].resets == 1
|
||||||
|
assert isinstance(application.view, _FakeLevelView)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exit_application_closes_the_screen_and_destroys_the_root(
|
||||||
|
fake_views: None,
|
||||||
|
) -> None:
|
||||||
|
root = _FakeRoot()
|
||||||
|
application = _application(root)
|
||||||
|
application.start(Level(None))
|
||||||
|
card_view = application.view
|
||||||
|
assert isinstance(card_view, _FakeFlashCardView)
|
||||||
|
|
||||||
|
application.exit_application()
|
||||||
|
|
||||||
|
assert card_view.closed
|
||||||
|
assert root.destroyed
|
||||||
|
assert application.exit_code == 0
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests of the menu view model; no Tkinter is involved."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flash_card.presentation.features.menu.menu_view_model import (
|
||||||
|
MenuEntry,
|
||||||
|
MenuViewModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_entries_are_open_change_level_reset_and_exit_in_order() -> None:
|
||||||
|
labels = [entry.label for entry in MenuViewModel().file_entries]
|
||||||
|
assert labels == ["Open...", "Change level...", "Reset word list...", "Exit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_entries_name_distinct_commands() -> None:
|
||||||
|
commands = [entry.command for entry in MenuViewModel().file_entries]
|
||||||
|
assert commands == [
|
||||||
|
"open_word_list",
|
||||||
|
"change_level",
|
||||||
|
"reset_word_list",
|
||||||
|
"exit_program",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_labels_are_file_about_and_contact() -> None:
|
||||||
|
model = MenuViewModel()
|
||||||
|
assert model.file_label == "File"
|
||||||
|
assert model.about_label == "About"
|
||||||
|
assert model.contact_menu_label == "Contact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_shows_name_and_title() -> None:
|
||||||
|
assert MenuViewModel().contact_label == "Jens Tirsvad Nielsen, Software engineer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contact_url_is_the_linkedin_page() -> None:
|
||||||
|
assert MenuViewModel().contact_url == "https://www.linkedin.com/in/tirsvad74"
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_entry_is_immutable() -> None:
|
||||||
|
entry = MenuEntry("Exit", "exit_program", 1)
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
entry.label = "Quit" # type: ignore[misc]
|
||||||
@@ -13,11 +13,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
import random
|
import random
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import tkinter as tk
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -51,6 +53,21 @@ def _make_dpi_aware() -> None:
|
|||||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
|
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_box(root: tk.Tk) -> tuple[int, int, int, int]:
|
||||||
|
"""Return the screen box of the whole window, with title bar and menu bar.
|
||||||
|
|
||||||
|
@param root The Tk root window.
|
||||||
|
@return Left, top, right and bottom in screen pixels.
|
||||||
|
"""
|
||||||
|
handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore]
|
||||||
|
rect = ctypes.wintypes.RECT()
|
||||||
|
ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore]
|
||||||
|
# Windows 11 adds an invisible resize border of about 7 pixels at the left,
|
||||||
|
# the right and the bottom; cut it so no other window is in the picture.
|
||||||
|
border = 7
|
||||||
|
return rect.left + border, rect.top, rect.right - border, rect.bottom - border
|
||||||
|
|
||||||
|
|
||||||
def _pump(root: object, seconds: float) -> None:
|
def _pump(root: object, seconds: float) -> None:
|
||||||
"""Run the Tk event loop for a while.
|
"""Run the Tk event loop for a while.
|
||||||
|
|
||||||
@@ -67,13 +84,15 @@ def capture(output_dir: Path) -> list[Path]:
|
|||||||
"""Capture the level screen, then 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-level.png`,
|
@param output_dir Folder to write `screenshot-level.png`,
|
||||||
`screenshot-front.png` and `screenshot-back.png` into.
|
`screenshot-front.png`, `screenshot-back.png` and `screenshot-menu.png`
|
||||||
@return The three files written: level, front, back.
|
(the whole window with its menu bar) into.
|
||||||
|
@return The four files written: level, front, back, menu.
|
||||||
"""
|
"""
|
||||||
_make_dpi_aware()
|
_make_dpi_aware()
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
random.seed(SCREENSHOT_SEED)
|
random.seed(SCREENSHOT_SEED)
|
||||||
level = output_dir / "screenshot-level.png"
|
level = output_dir / "screenshot-level.png"
|
||||||
|
menu = output_dir / "screenshot-menu.png"
|
||||||
front = output_dir / "screenshot-front.png"
|
front = output_dir / "screenshot-front.png"
|
||||||
back = output_dir / "screenshot-back.png"
|
back = output_dir / "screenshot-back.png"
|
||||||
with tempfile.TemporaryDirectory() as data_dir:
|
with tempfile.TemporaryDirectory() as data_dir:
|
||||||
@@ -105,10 +124,11 @@ def capture(output_dir: Path) -> list[Path]:
|
|||||||
+ 20,
|
+ 20,
|
||||||
)
|
)
|
||||||
ImageGrab.grab(bbox=box).save(front)
|
ImageGrab.grab(bbox=box).save(front)
|
||||||
|
ImageGrab.grab(bbox=_frame_box(root)).save(menu)
|
||||||
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
|
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
|
||||||
ImageGrab.grab(bbox=box).save(back)
|
ImageGrab.grab(bbox=box).save(back)
|
||||||
root.destroy()
|
root.destroy()
|
||||||
return [level, front, back]
|
return [level, front, back, menu]
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
|||||||
Reference in New Issue
Block a user