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 4d976ad..d773268 100644 Binary files a/docs/images/screenshot-level.png and b/docs/images/screenshot-level.png differ diff --git a/docs/images/screenshot-menu.png b/docs/images/screenshot-menu.png new file mode 100644 index 0000000..37f8bbb Binary files /dev/null and b/docs/images/screenshot-menu.png differ diff --git a/docs/sqa/reviews/rc-066-py-mil-011.md b/docs/sqa/reviews/rc-066-py-mil-011.md new file mode 100644 index 0000000..cf3aec6 --- /dev/null +++ b/docs/sqa/reviews/rc-066-py-mil-011.md @@ -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 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: