From ce4aacb4bc7ce742e3b2397e05a43572497402ef Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 15 Oct 2026 09:06:47 +0800 Subject: [PATCH 1/2] 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 --- README.md | 17 +- docs/artifact-registry.md | 2 +- docs/images/screenshot-level.png | Bin 13001 -> 13000 bytes docs/images/screenshot-menu.png | Bin 0 -> 22408 bytes docs/sqa/reviews/rc-066-py-mil-011.md | 63 ++++ docs/sqa/traceability-matrix.md | 3 + docs/wiki/Usage-da.md | 14 + docs/wiki/Usage-ms.md | 14 + docs/wiki/Usage.md | 14 + src/flash_card/constants.py | 74 +++++ .../repositories/progress_repository.py | 8 + .../persistence/csv_progress_repository.py | 8 + .../persistence/csv_word_list.py | 7 +- .../persistence/progress_file_name.py | 30 ++ src/flash_card/presentation/app.py | 137 +++++++- .../features/flash_card/flash_card_view.py | 9 + .../presentation/features/menu/__init__.py | 1 + .../presentation/features/menu/menu_view.py | 241 ++++++++++++++ .../features/menu/menu_view_model.py | 102 ++++++ tests/e2e/test_level_window.py | 59 +++- tests/e2e/test_menu_bar.py | 306 ++++++++++++++++++ tests/integration/test_word_list_progress.py | 95 ++++++ .../test_csv_progress_repository.py | 15 + .../unit/infrastructure/test_csv_word_list.py | 15 + .../infrastructure/test_progress_file_name.py | 36 +++ tests/unit/test_app.py | 145 ++++++++- tests/unit/test_menu_view_model.py | 44 +++ tools/capture_screenshots.py | 26 +- 28 files changed, 1450 insertions(+), 35 deletions(-) create mode 100644 docs/images/screenshot-menu.png create mode 100644 docs/sqa/reviews/rc-066-py-mil-011.md create mode 100644 src/flash_card/infrastructure/persistence/progress_file_name.py create mode 100644 src/flash_card/presentation/features/menu/__init__.py create mode 100644 src/flash_card/presentation/features/menu/menu_view.py create mode 100644 src/flash_card/presentation/features/menu/menu_view_model.py create mode 100644 tests/e2e/test_menu_bar.py create mode 100644 tests/integration/test_word_list_progress.py create mode 100644 tests/unit/infrastructure/test_progress_file_name.py create mode 100644 tests/unit/test_menu_view_model.py diff --git a/README.md b/README.md index 325ecdb..b638212 100644 --- a/README.md +++ b/README.md @@ -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 | | `--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_.csv`. The opened list is not remembered after a restart. ### Executable (Windows, no Python needed to run it) @@ -166,6 +180,7 @@ The documents a newcomer needs, in reading order. [license]: ./LICENSE [screenshot-level]: ./docs/images/screenshot-level.png [screenshot-front]: ./docs/images/screenshot-front.png +[screenshot-menu]: ./docs/images/screenshot-menu.png [screenshot-back]: ./docs/images/screenshot-back.png [wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home [wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index e6373d5..20203ab 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 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 | ## Languages diff --git a/docs/images/screenshot-level.png b/docs/images/screenshot-level.png index 4d976ada18d9822bd3dbf31f7223a9af985f90da..d7732682a86ddec0d61647edc0209e83c91678ce 100644 GIT binary patch delta 9826 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 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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index cf69ebb..3e51143 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.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-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] | | 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] | ## Coverage Notes @@ -182,3 +183,5 @@ updated whenever an artifact instance is created or reviewed. [RC-065]: ./reviews/rc-065-mil-011.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 +[RC-066]: ./reviews/rc-066-py-mil-011.md +[PY-MIL-011]: ../../src/flash_card diff --git a/docs/wiki/Usage-da.md b/docs/wiki/Usage-da.md index fa9d16d..582d47d 100644 --- a/docs/wiki/Usage-da.md +++ b/docs/wiki/Usage-da.md @@ -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. +## Menulinjen + +![Vinduet med menulinjen](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-menu.png) + +| 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_.csv`. Den åbnede liste huskes ikke efter en genstart. + ## 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. diff --git a/docs/wiki/Usage-ms.md b/docs/wiki/Usage-ms.md index 398cd4a..a7dc2d7 100644 --- a/docs/wiki/Usage-ms.md +++ b/docs/wiki/Usage-ms.md @@ -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. +## Bar menu + +![Tetingkap dengan bar menu](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-menu.png) + +| 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_.csv`. Senarai yang dibuka tidak diingati selepas dimulakan semula. + ## Kemajuan anda - Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu. diff --git a/docs/wiki/Usage.md b/docs/wiki/Usage.md index 735236a..a6bcfd0 100644 --- a/docs/wiki/Usage.md +++ b/docs/wiki/Usage.md @@ -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. +## The menu bar + +![The window with the menu bar](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/raw/branch/main/docs/images/screenshot-menu.png) + +| 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_.csv`. The opened list is not remembered after a restart. + ## 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. diff --git a/src/flash_card/constants.py b/src/flash_card/constants.py index 5232e88..324b291 100644 --- a/src/flash_card/constants.py +++ b/src/flash_card/constants.py @@ -78,6 +78,12 @@ CSV_ENCODING: Final[str] = "utf-8-sig" VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" ## @brief File name of the words still to learn. PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv" +## @brief 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. 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" ## @brief File name of the wrong button image. 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}" diff --git a/src/flash_card/domain/repositories/progress_repository.py b/src/flash_card/domain/repositories/progress_repository.py index 24e2013..b2e3482 100644 --- a/src/flash_card/domain/repositories/progress_repository.py +++ b/src/flash_card/domain/repositories/progress_repository.py @@ -27,3 +27,11 @@ class ProgressRepository(Protocol): @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. + """ + ... diff --git a/src/flash_card/infrastructure/persistence/csv_progress_repository.py b/src/flash_card/infrastructure/persistence/csv_progress_repository.py index f1e87bf..c9e4979 100644 --- a/src/flash_card/infrastructure/persistence/csv_progress_repository.py +++ b/src/flash_card/infrastructure/persistence/csv_progress_repository.py @@ -42,3 +42,11 @@ class CsvProgressRepository: @return Nothing. """ 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) diff --git a/src/flash_card/infrastructure/persistence/csv_word_list.py b/src/flash_card/infrastructure/persistence/csv_word_list.py index cdb62b9..ebb783e 100644 --- a/src/flash_card/infrastructure/persistence/csv_word_list.py +++ b/src/flash_card/infrastructure/persistence/csv_word_list.py @@ -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. @return The language pair and the cards in file order. @throws VocabularyNotFoundError When the file does not exist. - @throws VocabularyFormatError When the file is empty or a row is malformed. + @throws VocabularyFormatError When the file is empty, not UTF-8 text, cannot + be read or a row is malformed. """ pair: LanguagePair | None = None 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)) except FileNotFoundError as 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: raise _format_error(path, 1, "file is empty") return pair, tuple(cards) diff --git a/src/flash_card/infrastructure/persistence/progress_file_name.py b/src/flash_card/infrastructure/persistence/progress_file_name.py new file mode 100644 index 0000000..d3564b9 --- /dev/null +++ b/src/flash_card/infrastructure/persistence/progress_file_name.py @@ -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) diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index ddcc50e..9ad3a3b 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -1,7 +1,7 @@ """Entry point and composition root of the flash card program. @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 @@ -19,7 +19,6 @@ from flash_card.constants import ( BUNDLED_VOCABULARY_PATH, DEFAULT_DATA_DIR, LEVEL_ARGUMENT_VALUES, - PROGRESS_FILE_NAME, VOCABULARY_FILE_NAME, ) 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 ( 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.null_speech import NullSpeech 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_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. _logger = logging.getLogger(__name__) @@ -64,34 +68,42 @@ def build_speech(enabled: bool) -> SpeechPort: 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__( self, root: tk.Tk, - vocabulary_repository: VocabularyRepository, - progress_repository: ProgressRepository, 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: """Create the application on a window; no screen is shown yet. @param root The Tk root window. - @param vocabulary_repository Source of the delivered word list. - @param progress_repository Store of the words still to learn. @param speech Speaks the front text of each drawn card. @param choose Picks one card from a non-empty sequence. + @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. self.root = root - ## @brief Source of the delivered word list. - self._vocabulary_repository = vocabulary_repository - ## @brief Store of the words still to learn. - self._progress_repository = progress_repository ## @brief Speaks the front text of each drawn card. self._speech = speech ## @brief Picks one card from a non-empty sequence. self._choose = choose + ## @brief 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. self._view: LevelView | FlashCardView | None = None ## @brief The exit code of the program. @@ -113,6 +125,22 @@ class Application: """ 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: """Show the level screen; a choice starts the session. @@ -134,9 +162,60 @@ class Application: self._choose, 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): 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: """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 if data_dir is None and not vocabulary.exists(): 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( data_dir: Path | None = None, speech_enabled: bool = True, level: Level | None = None, + word_list: Path | None = None, ) -> 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 with the delivered word list as fallback. @param speech_enabled False to run without spoken words. @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. @throws VocabularyError When a level is given and the word list or saved progress is unusable. @@ -185,10 +286,12 @@ def create_application( root = tk.Tk() application = Application( root, - CsvVocabularyRepository(vocabulary_path), - CsvProgressRepository(progress_path), 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: application.show_level_screen() return application diff --git a/src/flash_card/presentation/features/flash_card/flash_card_view.py b/src/flash_card/presentation/features/flash_card/flash_card_view.py index 6b88921..10ad352 100644 --- a/src/flash_card/presentation/features/flash_card/flash_card_view.py +++ b/src/flash_card/presentation/features/flash_card/flash_card_view.py @@ -146,6 +146,15 @@ class FlashCardView: """ 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: """Show the next card front and restart the flip timer. diff --git a/src/flash_card/presentation/features/menu/__init__.py b/src/flash_card/presentation/features/menu/__init__.py new file mode 100644 index 0000000..de566be --- /dev/null +++ b/src/flash_card/presentation/features/menu/__init__.py @@ -0,0 +1 @@ +"""Menu bar feature.""" diff --git a/src/flash_card/presentation/features/menu/menu_view.py b/src/flash_card/presentation/features/menu/menu_view.py new file mode 100644 index 0000000..b1d4412 --- /dev/null +++ b/src/flash_card/presentation/features/menu/menu_view.py @@ -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, + ) diff --git a/src/flash_card/presentation/features/menu/menu_view_model.py b/src/flash_card/presentation/features/menu/menu_view_model.py new file mode 100644 index 0000000..f901f2e --- /dev/null +++ b/src/flash_card/presentation/features/menu/menu_view_model.py @@ -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 diff --git a/tests/e2e/test_level_window.py b/tests/e2e/test_level_window.py index b7be1cd..d2668a9 100644 --- a/tests/e2e/test_level_window.py +++ b/tests/e2e/test_level_window.py @@ -6,7 +6,7 @@ import shutil import tkinter as tk -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from pathlib import Path import pytest @@ -21,13 +21,14 @@ from flash_card.constants import ( LEVEL_SCREEN_HEIGHT, LEVEL_TITLE, LEVEL_TITLE_FONT, - PROGRESS_FILE_NAME, VOCABULARY_FILE_NAME, WINDOW_PADDING, WINDOW_TITLE, ) from flash_card.domain.entities.card import Card from flash_card.domain.entities.deck import Deck +from flash_card.domain.repositories.progress_repository import ProgressRepository +from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository from flash_card.domain.value_objects.level import Level from flash_card.infrastructure.persistence.csv_progress_repository import ( CsvProgressRepository, @@ -35,6 +36,9 @@ from flash_card.infrastructure.persistence.csv_progress_repository import ( 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 @@ -57,15 +61,25 @@ def _word(view: FlashCardView) -> str: def _clear(root: tk.Tk, view: object) -> None: """Destroy the widgets of whichever screen is shown, and pending timers.""" if isinstance(view, FlashCardView): - if view.flip_timer is not None: - root.after_cancel(view.flip_timer) - view.flip_timer = None - for widget in (view.canvas, view.wrong_button, view.right_button): - widget.destroy() + view.close() elif isinstance(view, LevelView): 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 def data_dir(tmp_path: Path) -> 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]: instance = app.Application( tk_root, - CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME), - CsvProgressRepository(data_dir / PROGRESS_FILE_NAME), NullSpeech(), _last, + repositories_for(data_dir), + data_dir / VOCABULARY_FILE_NAME, ) yield instance _clear(tk_root, instance.view) @@ -185,7 +199,7 @@ def test_level_whose_words_are_all_known_shows_the_finished_message( ) -> None: cards = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().cards pair = CsvVocabularyRepository(data_dir / VOCABULARY_FILE_NAME).load().language_pair - CsvProgressRepository(data_dir / PROGRESS_FILE_NAME).save(Deck(pair, cards[100:])) + CsvProgressRepository(data_dir / "words_to_learn.csv").save(Deck(pair, cards[100:])) application.show_level_screen() level_view = application.view assert isinstance(level_view, LevelView) @@ -207,8 +221,10 @@ def test_create_application_with_level_skips_the_level_screen( try: assert isinstance(application.view, FlashCardView) assert not [c for c in tk_root.winfo_children() if isinstance(c, tk.Frame)] + assert str(tk_root.cget("menu")) finally: _clear(tk_root, application.view) + tk_root.config(menu="") 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: assert isinstance(application.view, LevelView) assert application.root is tk_root + assert str(tk_root.cget("menu")) finally: _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="") diff --git a/tests/e2e/test_menu_bar.py b/tests/e2e/test_menu_bar.py new file mode 100644 index 0000000..ede0a7c --- /dev/null +++ b/tests/e2e/test_menu_bar.py @@ -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 diff --git a/tests/integration/test_word_list_progress.py b/tests/integration/test_word_list_progress.py new file mode 100644 index 0000000..2587b8f --- /dev/null +++ b/tests/integration/test_word_list_progress.py @@ -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" diff --git a/tests/unit/infrastructure/test_csv_progress_repository.py b/tests/unit/infrastructure/test_csv_progress_repository.py index 81e2b81..c830ac4 100644 --- a/tests/unit/infrastructure/test_csv_progress_repository.py +++ b/tests/unit/infrastructure/test_csv_progress_repository.py @@ -110,3 +110,18 @@ def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None: assert deck is not None assert deck.cards == CARDS 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() diff --git a/tests/unit/infrastructure/test_csv_word_list.py b/tests/unit/infrastructure/test_csv_word_list.py index eb53304..6e4e804 100644 --- a/tests/unit/infrastructure/test_csv_word_list.py +++ b/tests/unit/infrastructure/test_csv_word_list.py @@ -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") with pytest.raises(VocabularyFormatError): 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) diff --git a/tests/unit/infrastructure/test_progress_file_name.py b/tests/unit/infrastructure/test_progress_file_name.py new file mode 100644 index 0000000..fa8183d --- /dev/null +++ b/tests/unit/infrastructure/test_progress_file_name.py @@ -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 diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 785a2dc..7b1e9e5 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -63,6 +63,15 @@ class _FakeLevelView: class _FakeFlashCardView: def __init__(self, root: object, view_model: FlashCardViewModel) -> None: 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: @@ -76,12 +85,18 @@ class _Vocabulary: class _Progress: + def __init__(self) -> None: + self.resets = 0 + def load(self) -> Deck | None: return None def save(self, deck: Deck) -> None: return None + def reset(self) -> None: + self.resets += 1 + class _Speech: 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: """Keep every test in this module away from a real Tk root.""" monkeypatch.setattr(app.tk, "Tk", _FakeRoot) + monkeypatch.setattr(app, "MenuView", _FakeMenuView) @pytest.fixture @@ -104,9 +120,31 @@ def fake_views(monkeypatch: pytest.MonkeyPatch) -> None: 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( - 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" view.view_model.is_known() 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 diff --git a/tests/unit/test_menu_view_model.py b/tests/unit/test_menu_view_model.py new file mode 100644 index 0000000..f73716f --- /dev/null +++ b/tests/unit/test_menu_view_model.py @@ -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] diff --git a/tools/capture_screenshots.py b/tools/capture_screenshots.py index da24ff7..bbdcfc1 100644 --- a/tools/capture_screenshots.py +++ b/tools/capture_screenshots.py @@ -13,11 +13,13 @@ from __future__ import annotations import argparse import ctypes +import ctypes.wintypes import random import shutil import sys import tempfile import time +import tkinter as tk from collections.abc import Sequence from pathlib import Path @@ -51,6 +53,21 @@ def _make_dpi_aware() -> None: 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: """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. @param output_dir Folder to write `screenshot-level.png`, - `screenshot-front.png` and `screenshot-back.png` into. - @return The three files written: level, front, back. + `screenshot-front.png`, `screenshot-back.png` and `screenshot-menu.png` + (the whole window with its menu bar) into. + @return The four files written: level, front, back, menu. """ _make_dpi_aware() output_dir.mkdir(parents=True, exist_ok=True) random.seed(SCREENSHOT_SEED) level = output_dir / "screenshot-level.png" + menu = output_dir / "screenshot-menu.png" front = output_dir / "screenshot-front.png" back = output_dir / "screenshot-back.png" with tempfile.TemporaryDirectory() as data_dir: @@ -105,10 +124,11 @@ def capture(output_dir: Path) -> list[Path]: + 20, ) ImageGrab.grab(bbox=box).save(front) + ImageGrab.grab(bbox=_frame_box(root)).save(menu) _pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS) ImageGrab.grab(bbox=box).save(back) root.destroy() - return [level, front, back] + return [level, front, back, menu] def main(argv: Sequence[str] | None = None) -> int: From 6c6fd723bfb0b1c55161d72d5cebdb3258013c94 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 15 Oct 2026 09:07:04 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-011 --- docs/sqa/reviews/rc-066-py-mil-011.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-066-py-mil-011.md b/docs/sqa/reviews/rc-066-py-mil-011.md index cf3aec6..2ff0923 100644 --- a/docs/sqa/reviews/rc-066-py-mil-011.md +++ b/docs/sqa/reviews/rc-066-py-mil-011.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [ce4aacb] | --- @@ -61,3 +61,4 @@ Milestone MIL-011 Go criteria: 1 the menu bar structure and order are read from [PY-MIL-011]: ../../../src/flash_card [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[ce4aacb]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/ce4aacb4bc7ce742e3b2397e05a43572497402ef