Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9c5ea3c1 | ||
|
|
f311e71bde | ||
|
|
061b4f48b5 | ||
|
|
7bf6d5804b | ||
|
|
4ae4ece10f | ||
|
|
bc513810ec | ||
|
|
5934c396c2 |
@@ -75,3 +75,6 @@ dmypy.json
|
|||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# PyInstaller spec files
|
||||||
|
*.spec
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ The program first asks for a level: the first 100 words, the first 1000 words or
|
|||||||
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
|
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
|
||||||
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
|
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
|
||||||
| `docs/` | Project documents (business case, plan, design, review records), `docs/wiki/` and `docs/images/` |
|
| `docs/` | Project documents (business case, plan, design, review records), `docs/wiki/` and `docs/images/` |
|
||||||
| `tools/` | Helper scripts: `generate_images.py` and `capture_screenshots.py` |
|
| `tools/` | Helper scripts: `generate_images.py`, `capture_screenshots.py` and `build_executable.py` |
|
||||||
| `Doxyfile` | Doxygen configuration for the source code documentation |
|
| `Doxyfile` | Doxygen configuration for the source code documentation |
|
||||||
|
|
||||||
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
|
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
|
||||||
@@ -58,6 +58,7 @@ The wiki ([English][wiki-en], [Dansk][wiki-da], [Bahasa Malaysia][wiki-ms]) expl
|
|||||||
| Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) |
|
| Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) |
|
||||||
| `git` | To clone the repository |
|
| `git` | To clone the repository |
|
||||||
| Doxygen (optional) | To build the source code documentation |
|
| Doxygen (optional) | To build the source code documentation |
|
||||||
|
| `pyinstaller` (optional, extra `build`) | To build a single executable file (see Run) |
|
||||||
| `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection |
|
| `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection |
|
||||||
|
|
||||||
## 🛠️ Setup
|
## 🛠️ Setup
|
||||||
@@ -103,6 +104,15 @@ The program is run from the activated `.venv`. The window opens with the level s
|
|||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
### Executable (Windows, no Python needed to run it)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e ".[build,speech]"
|
||||||
|
python tools/build_executable.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds `dist/flash-card.exe`: one file, no console window, with the images, the word list and the spoken word inside. Open it by double-clicking. It keeps your progress in a `data/` folder next to the executable (`data/words_to_learn.csv`); a `data/ms-da.csv` next to it replaces the word list inside. Add `--no-speech` to the build script to leave the speech packages out. The executable is a build output and is not committed; build it on the system you want to run it on (Windows, Linux or macOS).
|
||||||
|
|
||||||
## 🧪 Tests
|
## 🧪 Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0005 |
|
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0005 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 048 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 051 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -11,7 +11,8 @@
|
|||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [768ca34] |
|
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [768ca34] |
|
||||||
|
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added task 9 (fix a flaky unit test found after the merge) | [061b4f4] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ Decide whether the Learner can start the program from one executable file withou
|
|||||||
| 6 | Build and smoke-test the executable | Build it, start it with the virtual environment not active, check the window, the images, the level choice, the progress file next to it, the word list override and the speech import; record the result in the review record. | Yes | US-001.07 |
|
| 6 | Build and smoke-test the executable | Build it, start it with the virtual environment not active, check the window, the images, the level choice, the progress file next to it, the word list override and the speech import; record the result in the review record. | Yes | US-001.07 |
|
||||||
| 7 | Test the path rules and the build arguments | Unit tests for the frozen and source path rules and for the argument list the build script gives PyInstaller (without running PyInstaller). | No | |
|
| 7 | Test the path rules and the build arguments | Unit tests for the frozen and source path rules and for the argument list the build script gives PyInstaller (without running PyInstaller). | No | |
|
||||||
| 8 | Update README and wiki | README and the Install page of the wiki in English, Danish and Bahasa Malaysia: how to build, where the executable and the data folder are, that Python is not needed to run it. The wiki is republished after the merge. | No | |
|
| 8 | Update README and wiki | README and the Install page of the wiki in English, Danish and Bahasa Malaysia: how to build, where the executable and the data folder are, that Python is not needed to run it. The wiki is republished after the merge. | No | |
|
||||||
|
| 9 | Fix a flaky unit test | After the merge a unit test of `app.main` failed in about 1 of 6 full runs because it created a real Tk root; it uses the module's fake root instead, so the unit tests never need a display. Found by running the suite repeatedly. | No | RC-049 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,3 +83,4 @@ Decide whether the Learner can start the program from one executable file withou
|
|||||||
[UC-001]: ../uc-001/uc.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
[DCD-001]: ../dcd.md
|
[DCD-001]: ../dcd.md
|
||||||
[768ca34]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/768ca344c95d4f21e565e37ac96661c2f8dc8f54
|
[768ca34]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/768ca344c95d4f21e565e37ac96661c2f8dc8f54
|
||||||
|
[061b4f4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/061b4f48b5d49e6800656c95d79df707e07ec0f3
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# RC-048: Review of PY-MIL-009
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-048 |
|
||||||
|
| CrossReference | [PY-MIL-009], [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 | [bc51381] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [PY-MIL-009]
|
||||||
|
- 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 `runtime.py`, `build_executable.py` and the changed modules; 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 (`resource_root`, `executable_dir`, `build_arguments`, `resolve_data_files`). |
|
||||||
|
| 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 71 files with no suppressions. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 45 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 | `main` of the build tool returns 2 with a message when PyInstaller is not installed; no bare `except`; no swallowed exception. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | Pass | No new file handling beyond the existing context managers; the ICO file is written with `Path.write_bytes`. |
|
||||||
|
| 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` in `src`; the tools write results with `sys.stdout.write`; no secret. |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The program classes are unchanged (DCD-001); `runtime.py` and `tools/` are packaging support outside the design classes, as ADR-0004 and the Scope note of DCD-001 state; `resolve_data_files` is a function of the composition root. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 216 tests (3 consecutive runs green): pure runtime helpers for the frozen and source cases, the argument list of the build, the ICO file, the bundled word list fallback, and the level screen window title; none starts PyInstaller or uses the network. The real executable was built and smoke-tested separately (note below). |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; `sys._MEIPASS` is read with `getattr` and no `Any` is used. |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is still empty; PyInstaller is only in the extra `build` and Pillow only in `tools`. |
|
||||||
|
| 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 frozen rule is one definition (`runtime.py`) used by `constants.py`; file names and sizes stay in `constants.py`. |
|
||||||
|
| 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; `runtime.py` imports only the standard library. |
|
||||||
|
| 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 build tool has one purpose and a pure `build_arguments` that is tested; the composition root chooses the word list file. |
|
||||||
|
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `tools/build_executable.py` and `runtime.py` sit outside the four layers as packaging support; layers and test folders are unchanged. |
|
||||||
|
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||||
|
|
||||||
|
Milestone MIL-009 Go criteria: 1 `python tools/build_executable.py` ended with exit code 0 and wrote `dist/flash-card.exe` (25.8 MB) with PyInstaller 6.22.3 on the Windows Store Python 3.13.14; 2 started from a temporary folder without the virtual environment, the executable showed a window titled `Flashy` after about 2 seconds and has no console (windowed bootloader); 3 with `data/ms-da.csv` beside it (3 words) one tick left 2 cards in the progress file, without it the embedded list was used (1000 words); 4 one tick wrote `data/words_to_learn.csv` next to the executable with 999 cards at level 100; 5 a captured screenshot of the executable shows the card and both button images; 6 unit tests of the frozen rule pass; 7 `dependencies` empty, extra `build`; 8 BC-001, DCD-001, ADR-0004, PP-001, US-001 and MIL-009 were reviewed Go (RC-042 to RC-047), README and the Install page of the wiki in three languages describe the build; 9 this record, `pytest` 216 passed, ruff, mypy, lint-imports and doxygen clean. Defects found by the smoke test and fixed in this milestone: the level screen window had the default title `tk` and no padding because `LevelView` did not call `configure_window`. Notes: the one-file build starts a second process (the bootloader), so a tool that watches a window must not match the first process id; the first start after a build took over 20 seconds on this machine (unpacking and a virus scan) and later starts about 2 seconds; the spoken word was not tested in the executable (the speech packages are collected into it, the network call was not exercised). All Go.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| none | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[PY-MIL-009]: ../../../src/flash_card
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
|
[bc51381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/bc513810ece2506d5ab63c1d4c603803c2ad415e
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# RC-049: Review of PY-FIX-001
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-049 |
|
||||||
|
| CrossReference | [PY-FIX-001], [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 | [061b4f4] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [PY-FIX-001]
|
||||||
|
- 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 | The fixture and names follow PEP 8 (`fake_tk_root`). |
|
||||||
|
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose. |
|
||||||
|
| 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 with no suppressions. |
|
||||||
|
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | The fixture is annotated `-> None`; `mypy --strict` passes on 45 source files. |
|
||||||
|
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling in the change. |
|
||||||
|
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults. |
|
||||||
|
| 7 | Files, locks and connections are managed with context managers | N-A | No file handling in the change. |
|
||||||
|
| 8 | Public modules, classes and functions have docstrings that say what, not how | N-A | Tests are excluded from the docstring rules; the fixture has a one-line docstring. |
|
||||||
|
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`, no logging in the change. |
|
||||||
|
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No class or operation changed; the change is in a test of `app.main`. |
|
||||||
|
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | The change makes the test independent of a real Tk root: 20 consecutive full runs (216 tests) were green after 2 failures in 12 runs before; real Tk stays covered by `tests/e2e`. |
|
||||||
|
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue. |
|
||||||
|
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No dependency change. |
|
||||||
|
| 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 fake root is the module's existing `_FakeRoot`, used once by an autouse fixture instead of per test. |
|
||||||
|
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | No source import changed; `lint-imports` kept. |
|
||||||
|
| 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 | One fixture with one responsibility. |
|
||||||
|
| 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 | Test file stays in `tests/unit`. |
|
||||||
|
| 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 names unchanged. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||||
|
|
||||||
|
Defect fixed: an unpatched `app.main` call created a real `tk.Tk()` inside a unit test, which failed in about 1 of 6 full runs (the only Tk process limit and Tcl handle problem seen in MIL-005). Found after the MIL-009 merge, outside any milestone task, so no issue is closed. Go.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| none | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[PY-FIX-001]: ../../../tests/unit/test_app.py
|
||||||
|
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||||
|
[061b4f4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/061b4f48b5d49e6800656c95d79df707e07ec0f3
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# RC-050: Review of MIL-009
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
| Key | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | RC-050 |
|
||||||
|
| CrossReference | [MIL-009], [QC-MIL-001], [QC-LANG-001] |
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [061b4f4] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artifact Under Review
|
||||||
|
|
||||||
|
- Instance reviewed: [MIL-009]
|
||||||
|
- Checklist used: [QC-MIL-001], and [QC-LANG-001] (language and domain)
|
||||||
|
- Scope: full review
|
||||||
|
- Language and domain: en / it
|
||||||
|
- Language reviewer: none (the stand-in for S01 reads the language and knows the domain)
|
||||||
|
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | A concrete deliverable is defined for every gate | Pass | A concrete Deliverable section names the output evaluated at the gate. |
|
||||||
|
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | The Go / No-Go table has numbered criteria, each with an observable Go and No-Go. |
|
||||||
|
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the milestone or documents it needs. |
|
||||||
|
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to Business Case objectives and the user story. |
|
||||||
|
| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership table names S01 as owner and approving reviewer. |
|
||||||
|
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the 2026-10-14 to 2026-10-21 window of the Business Case. |
|
||||||
|
|
||||||
|
## Language and Domain Results
|
||||||
|
|
||||||
|
| # | Criterion | Status | Evidence/Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata of MIL-009 has `Language | en` and `Domain | it`; neither is a placeholder. |
|
||||||
|
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is a BCP 47 code and `it` is a value of the registry's domain list. |
|
||||||
|
| 3 | The content (prose and table cells) is written in the stated language | Pass | Prose and table cells are English; Malay and Danish words and file names appear only as quoted data. |
|
||||||
|
| 4 | The register matches the one the registry gives for the artifact type | Pass | The registry gives `IT Executive English` for this type; the text is written for that reader. |
|
||||||
|
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The PO terms of DICT-001 (flash card, word list, words to learn, study session) are used and no synonym replaces them. |
|
||||||
|
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||||
|
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists beside the document (checked in `docs/`). |
|
||||||
|
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no change of language or domain since an earlier accepted version. |
|
||||||
|
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The reviewer is the stand-in for S01, the Product Owner, who reads English and knows the IT domain; no second language reviewer is needed (review-checklist-process step 2). Open to challenge by S01 in the end-of-run summary. |
|
||||||
|
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | CSV, MVVM, UI, PO and similar abbreviations are not all spelled out at first use; Optional, deferred. |
|
||||||
|
|
||||||
|
## Overall Verdict
|
||||||
|
|
||||||
|
Go — every Mandatory criterion passes; the failed Optional criteria are deferred with an action item. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||||
|
|
||||||
|
Re-review of MIL-009 after task 9 was added (a flaky unit test fix); Go/No-Go criteria, deliverable and dates are unchanged.
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
| Action | Owner | Due |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Criterion LANG 10 (Optional) failed: Abbreviations are spelled out on first use, in the stated language; fix or accept the deferral | S01 | 2026-10-21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[MIL-009]: ../../../docs/milestones/mil-009-executable.md
|
||||||
|
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||||
|
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||||
|
[061b4f4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/061b4f48b5d49e6800656c95d79df707e07ec0f3
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
|
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
|
||||||
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added MIL-008 and the review records of the level choice (no checklist exists for this type) | [0b983b7] |
|
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added MIL-008 and the review records of the level choice (no checklist exists for this type) | [0b983b7] |
|
||||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added the rows and review records of the executable request (no checklist exists for this type) | [768ca34] |
|
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added the rows and review records of the executable request (no checklist exists for this type) | [768ca34] |
|
||||||
|
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added the rows and review records of the executable request (no checklist exists for this type) | [061b4f4] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
|
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
|
||||||
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
|
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
|
||||||
| MIL-008 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-008 | [RC-040] |
|
| MIL-008 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-008 | [RC-040] |
|
||||||
| MIL-009 | Milestone / Gateway | en | it | BC-001 | - | [RC-047] |
|
| MIL-009 | Milestone / Gateway | en | it | BC-001 | - | [RC-050] |
|
||||||
| ADR-0004 | Architecture Decision Record | - | - | BC-001 | - | [RC-046] |
|
| ADR-0004 | Architecture Decision Record | - | - | BC-001 | - | [RC-046] |
|
||||||
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
|
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
|
||||||
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
||||||
@@ -58,6 +59,8 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||||
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
||||||
| PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] |
|
| PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] |
|
||||||
|
| PY-MIL-009 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-048] |
|
||||||
|
| PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] |
|
||||||
|
|
||||||
## Coverage Notes
|
## Coverage Notes
|
||||||
|
|
||||||
@@ -150,3 +153,9 @@ updated whenever an artifact instance is created or reviewed.
|
|||||||
[MIL-009]: ../milestones/mil-009-executable.md
|
[MIL-009]: ../milestones/mil-009-executable.md
|
||||||
[ADR-0004]: ../adr/adr-0004-pyinstaller-executable.md
|
[ADR-0004]: ../adr/adr-0004-pyinstaller-executable.md
|
||||||
[768ca34]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/768ca344c95d4f21e565e37ac96661c2f8dc8f54
|
[768ca34]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/768ca344c95d4f21e565e37ac96661c2f8dc8f54
|
||||||
|
[RC-048]: ./reviews/rc-048-py-mil-009.md
|
||||||
|
[PY-MIL-009]: ../../src/flash_card
|
||||||
|
[RC-049]: ./reviews/rc-049-py-fix-001.md
|
||||||
|
[PY-FIX-001]: ../../tests/unit/test_app.py
|
||||||
|
[RC-050]: ./reviews/rc-050-mil-009-task9.md
|
||||||
|
[061b4f4]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/061b4f48b5d49e6800656c95d79df707e07ec0f3
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ python -m pytest
|
|||||||
|
|
||||||
Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
|
Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
|
||||||
|
|
||||||
|
## Byg en programfil (Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e ".[build,speech]"
|
||||||
|
python tools/build_executable.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Scriptet bruger PyInstaller og skriver `dist/flash-card.exe`: én fil uden konsolvindue, med billeder, ordliste og udtalen indeni. Dobbeltklik for at starte; Python er ikke nødvendigt for at køre den.
|
||||||
|
|
||||||
|
- Din fremgang gemmes i en `data/`-mappe ved siden af programfilen (`data/words_to_learn.csv`).
|
||||||
|
- En `data/ms-da.csv` ved siden af programfilen erstatter ordlisten indeni.
|
||||||
|
- `python tools/build_executable.py --no-speech` udelader talepakkerne; `--clean` bygger forfra.
|
||||||
|
- Programfilen er ikke i repositoryet. Byg den på det system, hvor den skal køre; Windows kan vise en SmartScreen-advarsel, fordi filen ikke er signeret.
|
||||||
|
|
||||||
## Dokumentation af kildekoden
|
## Dokumentation af kildekoden
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ python -m pytest
|
|||||||
|
|
||||||
Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
|
Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
|
||||||
|
|
||||||
|
## Bina fail boleh laku (Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e ".[build,speech]"
|
||||||
|
python tools/build_executable.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Skrip menggunakan PyInstaller dan menulis `dist/flash-card.exe`: satu fail tanpa tetingkap konsol, dengan imej, senarai perkataan dan suara di dalamnya. Klik dua kali untuk memulakan; Python tidak diperlukan untuk menjalankannya.
|
||||||
|
|
||||||
|
- Kemajuan anda disimpan dalam folder `data/` di sebelah fail boleh laku (`data/words_to_learn.csv`).
|
||||||
|
- `data/ms-da.csv` di sebelah fail boleh laku menggantikan senarai perkataan di dalamnya.
|
||||||
|
- `python tools/build_executable.py --no-speech` meninggalkan pakej suara; `--clean` membina semula dari awal.
|
||||||
|
- Fail boleh laku tidak disimpan dalam repositori. Bina di sistem tempat ia akan dijalankan; Windows mungkin menunjukkan amaran SmartScreen kerana fail tidak ditandatangani.
|
||||||
|
|
||||||
## Dokumentasi kod sumber
|
## Dokumentasi kod sumber
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ python -m pytest
|
|||||||
|
|
||||||
The window test in `tests/e2e/` is skipped when no display is available.
|
The window test in `tests/e2e/` is skipped when no display is available.
|
||||||
|
|
||||||
|
## Build an executable (Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -e ".[build,speech]"
|
||||||
|
python tools/build_executable.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses PyInstaller and writes `dist/flash-card.exe`: one file, no console window, with the images, the word list and the spoken word inside. Double-click it to start; Python is not needed to run it.
|
||||||
|
|
||||||
|
- Your progress is kept in a `data/` folder next to the executable (`data/words_to_learn.csv`).
|
||||||
|
- A `data/ms-da.csv` next to the executable replaces the word list inside it.
|
||||||
|
- `python tools/build_executable.py --no-speech` leaves the speech packages out; `--clean` rebuilds from scratch.
|
||||||
|
- The executable is not committed to the repository. Build it on the system you want to run it on; Windows may show a SmartScreen warning because the file is not signed.
|
||||||
|
|
||||||
## Documentation of the source code
|
## Documentation of the source code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+3
-1
@@ -26,6 +26,8 @@ dependencies = []
|
|||||||
speech = ["gTTS>=2.5", "playsound==1.2.2"]
|
speech = ["gTTS>=2.5", "playsound==1.2.2"]
|
||||||
# Only for tools/capture_screenshots.py; not needed to run the program.
|
# Only for tools/capture_screenshots.py; not needed to run the program.
|
||||||
tools = ["pillow>=10"]
|
tools = ["pillow>=10"]
|
||||||
|
# Only for tools/build_executable.py; not needed to run the program.
|
||||||
|
build = ["pyinstaller>=6"]
|
||||||
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
|
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -35,7 +37,7 @@ flash-card = "flash_card.presentation.app:run"
|
|||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
flash_card = ["presentation/assets/*.png"]
|
flash_card = ["presentation/assets/*.png", "presentation/assets/*.ico"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from collections.abc import Mapping
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
from flash_card.runtime import executable_dir, is_frozen, resource_root
|
||||||
|
|
||||||
## @brief Window background colour.
|
## @brief Window background colour.
|
||||||
BACKGROUND_COLOR: Final[str] = "#B1DDC6"
|
BACKGROUND_COLOR: Final[str] = "#B1DDC6"
|
||||||
## @brief Window title.
|
## @brief Window title.
|
||||||
@@ -77,12 +79,24 @@ VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
|||||||
## @brief File name of the words still to learn.
|
## @brief File name of the words still to learn.
|
||||||
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
||||||
|
|
||||||
## @brief Root folder of the project.
|
## @brief Root folder of the project, for a source run.
|
||||||
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
||||||
## @brief Default folder of the data files.
|
## @brief Folder of the image assets, inside the bundle when frozen.
|
||||||
DEFAULT_DATA_DIR: Final[Path] = PROJECT_ROOT / "data"
|
ASSETS_DIR: Final[Path] = (
|
||||||
## @brief Folder of the image assets.
|
resource_root() / "flash_card" / "presentation" / "assets"
|
||||||
ASSETS_DIR: Final[Path] = Path(__file__).resolve().parent / "presentation" / "assets"
|
if is_frozen()
|
||||||
|
else Path(__file__).resolve().parent / "presentation" / "assets"
|
||||||
|
)
|
||||||
|
## @brief Folder of the word list delivered with the program.
|
||||||
|
BUNDLED_DATA_DIR: Final[Path] = resource_root() / "data"
|
||||||
|
## @brief The word list delivered with the program.
|
||||||
|
BUNDLED_VOCABULARY_PATH: Final[Path] = BUNDLED_DATA_DIR / VOCABULARY_FILE_NAME
|
||||||
|
## @brief Default folder of the data files, next to the executable when frozen.
|
||||||
|
DEFAULT_DATA_DIR: Final[Path] = executable_dir() / "data"
|
||||||
|
## @brief File name of the program icon.
|
||||||
|
ICON_FILE_NAME: Final[str] = "flash_card.ico"
|
||||||
|
## @brief Name of the executable without extension.
|
||||||
|
EXECUTABLE_NAME: Final[str] = "flash-card"
|
||||||
## @brief File name of the card front image.
|
## @brief File name of the card front image.
|
||||||
CARD_FRONT_IMAGE_NAME: Final[str] = "card_front.png"
|
CARD_FRONT_IMAGE_NAME: Final[str] = "card_front.png"
|
||||||
## @brief File name of the card back image.
|
## @brief File name of the card back image.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import NoReturn
|
|||||||
from flash_card.application.ports.speech_port import SpeechPort
|
from flash_card.application.ports.speech_port import SpeechPort
|
||||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||||
from flash_card.constants import (
|
from flash_card.constants import (
|
||||||
|
BUNDLED_VOCABULARY_PATH,
|
||||||
DEFAULT_DATA_DIR,
|
DEFAULT_DATA_DIR,
|
||||||
LEVEL_ARGUMENT_VALUES,
|
LEVEL_ARGUMENT_VALUES,
|
||||||
PROGRESS_FILE_NAME,
|
PROGRESS_FILE_NAME,
|
||||||
@@ -151,6 +152,20 @@ class Application:
|
|||||||
self.root.destroy()
|
self.root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_data_files(data_dir: Path | None) -> tuple[Path, Path]:
|
||||||
|
"""Find the word list and the progress file.
|
||||||
|
|
||||||
|
@param data_dir Folder given by the user, or None for the default folder.
|
||||||
|
@return The vocabulary path and the progress path. Without a given folder,
|
||||||
|
the delivered word list is used when the default folder has none.
|
||||||
|
"""
|
||||||
|
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||||
|
vocabulary = folder / VOCABULARY_FILE_NAME
|
||||||
|
if data_dir is None and not vocabulary.exists():
|
||||||
|
vocabulary = BUNDLED_VOCABULARY_PATH
|
||||||
|
return vocabulary, folder / PROGRESS_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
def create_application(
|
def create_application(
|
||||||
data_dir: Path | None = None,
|
data_dir: Path | None = None,
|
||||||
speech_enabled: bool = True,
|
speech_enabled: bool = True,
|
||||||
@@ -158,19 +173,20 @@ def create_application(
|
|||||||
) -> Application:
|
) -> Application:
|
||||||
"""Create the window and show the level screen, or start at the given level.
|
"""Create the window and show the level screen, or start at the given level.
|
||||||
|
|
||||||
@param data_dir Folder of the data files, or None for the default folder.
|
@param 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 speech_enabled False to run without spoken words.
|
||||||
@param level The level to start at, or None to show the level screen.
|
@param level The level to start at, or None to show the level screen.
|
||||||
@return The application, its window in the root attribute.
|
@return The application, its window in the root attribute.
|
||||||
@throws VocabularyError When a level is given and the word list or saved
|
@throws VocabularyError When a level is given and the word list or saved
|
||||||
progress is unusable.
|
progress is unusable.
|
||||||
"""
|
"""
|
||||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
vocabulary_path, progress_path = resolve_data_files(data_dir)
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
application = Application(
|
application = Application(
|
||||||
root,
|
root,
|
||||||
CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME),
|
CsvVocabularyRepository(vocabulary_path),
|
||||||
CsvProgressRepository(folder / PROGRESS_FILE_NAME),
|
CsvProgressRepository(progress_path),
|
||||||
build_speech(speech_enabled),
|
build_speech(speech_enabled),
|
||||||
)
|
)
|
||||||
if level is None:
|
if level is None:
|
||||||
@@ -209,8 +225,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--data-dir",
|
"--data-dir",
|
||||||
type=Path,
|
type=Path,
|
||||||
default=DEFAULT_DATA_DIR,
|
default=None,
|
||||||
help="folder of the data files",
|
help="folder of the data files (default: data next to the program)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-speech",
|
"--no-speech",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -21,6 +21,7 @@ from flash_card.presentation.features.level.level_view_model import (
|
|||||||
LevelChoice,
|
LevelChoice,
|
||||||
LevelViewModel,
|
LevelViewModel,
|
||||||
)
|
)
|
||||||
|
from flash_card.presentation.shared.window import configure_window
|
||||||
|
|
||||||
|
|
||||||
class LevelView:
|
class LevelView:
|
||||||
@@ -40,6 +41,7 @@ class LevelView:
|
|||||||
"""
|
"""
|
||||||
## @brief Called with the level the user picked.
|
## @brief Called with the level the user picked.
|
||||||
self._on_choose = on_choose
|
self._on_choose = on_choose
|
||||||
|
configure_window(root)
|
||||||
## @brief Frame that holds the whole screen.
|
## @brief Frame that holds the whole screen.
|
||||||
self.frame = tk.Frame(
|
self.frame = tk.Frame(
|
||||||
root, bg=BACKGROUND_COLOR, width=CARD_WIDTH, height=LEVEL_SCREEN_HEIGHT
|
root, bg=BACKGROUND_COLOR, width=CARD_WIDTH, height=LEVEL_SCREEN_HEIGHT
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Locate resources and the executable folder, frozen or from source.
|
||||||
|
|
||||||
|
@file runtime.py
|
||||||
|
@brief Packaging support: tells where bundled files and the executable are.
|
||||||
|
Imports only the standard library; it is not one of the layers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
## @brief Number of folders from this file up to the project root.
|
||||||
|
_ROOT_LEVELS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def is_frozen() -> bool:
|
||||||
|
"""Tell whether the program runs as a PyInstaller executable.
|
||||||
|
|
||||||
|
@return True when frozen.
|
||||||
|
"""
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def source_root() -> Path:
|
||||||
|
"""Return the project root of a source run.
|
||||||
|
|
||||||
|
@return The folder that holds src.
|
||||||
|
"""
|
||||||
|
return Path(__file__).resolve().parents[_ROOT_LEVELS]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_resource_root(frozen: bool, bundle_dir: Path | None, root: Path) -> Path:
|
||||||
|
"""Choose the folder that holds the bundled resources.
|
||||||
|
|
||||||
|
@param frozen True when running as an executable.
|
||||||
|
@param bundle_dir The unpack folder of the executable, when frozen.
|
||||||
|
@param root The project root of a source run.
|
||||||
|
@return The bundle folder when frozen and known, else the project root.
|
||||||
|
"""
|
||||||
|
if frozen and bundle_dir is not None:
|
||||||
|
return bundle_dir
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def pick_executable_dir(frozen: bool, executable: Path, root: Path) -> Path:
|
||||||
|
"""Choose the folder next to the executable.
|
||||||
|
|
||||||
|
@param frozen True when running as an executable.
|
||||||
|
@param executable Path of the running executable.
|
||||||
|
@param root The project root of a source run.
|
||||||
|
@return The folder of the executable when frozen, else the project root.
|
||||||
|
"""
|
||||||
|
if frozen:
|
||||||
|
return executable.parent
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def resource_root() -> Path:
|
||||||
|
"""Return the folder that holds the bundled resources.
|
||||||
|
|
||||||
|
@return The PyInstaller unpack folder when frozen, else the project root.
|
||||||
|
"""
|
||||||
|
bundle = getattr(sys, "_MEIPASS", None)
|
||||||
|
bundle_dir = Path(bundle) if bundle else None
|
||||||
|
return pick_resource_root(is_frozen(), bundle_dir, source_root())
|
||||||
|
|
||||||
|
|
||||||
|
def executable_dir() -> Path:
|
||||||
|
"""Return the folder of the running executable.
|
||||||
|
|
||||||
|
@return The folder of the executable when frozen, else the project root.
|
||||||
|
"""
|
||||||
|
return pick_executable_dir(is_frozen(), Path(sys.executable), source_root())
|
||||||
@@ -105,6 +105,22 @@ def test_level_screen_shows_title_with_font_on_window_background(
|
|||||||
assert level_view.frame.cget("bg") == BACKGROUND_COLOR
|
assert level_view.frame.cget("bg") == BACKGROUND_COLOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_screen_sets_window_title_background_and_padding(
|
||||||
|
tk_root: tk.Tk,
|
||||||
|
) -> None:
|
||||||
|
tk_root.title("tk")
|
||||||
|
tk_root.config(padx=0, pady=0, bg="white")
|
||||||
|
|
||||||
|
view = LevelView(tk_root, LevelViewModel(), lambda level: None)
|
||||||
|
try:
|
||||||
|
assert tk_root.title() == WINDOW_TITLE
|
||||||
|
assert tk_root.cget("bg") == BACKGROUND_COLOR
|
||||||
|
assert int(str(tk_root.cget("padx"))) == WINDOW_PADDING
|
||||||
|
assert int(str(tk_root.cget("pady"))) == WINDOW_PADDING
|
||||||
|
finally:
|
||||||
|
view.destroy()
|
||||||
|
|
||||||
|
|
||||||
def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None:
|
def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None:
|
||||||
assert level_view.frame.winfo_width() == CARD_WIDTH
|
assert level_view.frame.winfo_width() == CARD_WIDTH
|
||||||
assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT
|
assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ def _first(cards: Sequence[Card]) -> Card:
|
|||||||
return cards[0]
|
return cards[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
def fake_views(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(app, "LevelView", _FakeLevelView)
|
monkeypatch.setattr(app, "LevelView", _FakeLevelView)
|
||||||
@@ -241,3 +247,49 @@ def test_start_raises_vocabulary_error_for_the_caller(fake_views: None) -> None:
|
|||||||
application = _application(_FakeRoot(), error=True)
|
application = _application(_FakeRoot(), error=True)
|
||||||
with pytest.raises(VocabularyNotFoundError):
|
with pytest.raises(VocabularyNotFoundError):
|
||||||
application.start(Level(100))
|
application.start(Level(100))
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_data_files_uses_bundled_list_when_default_folder_has_none(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
bundled = tmp_path / "bundle" / "ms-da.csv"
|
||||||
|
monkeypatch.setattr(app, "DEFAULT_DATA_DIR", tmp_path / "data")
|
||||||
|
monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", bundled)
|
||||||
|
vocabulary, progress = app.resolve_data_files(None)
|
||||||
|
assert vocabulary == bundled
|
||||||
|
assert progress == tmp_path / "data" / "words_to_learn.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_data_files_prefers_the_default_folder_list(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
(tmp_path / "ms-da.csv").write_text("x", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(app, "DEFAULT_DATA_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", tmp_path / "other.csv")
|
||||||
|
vocabulary, _ = app.resolve_data_files(None)
|
||||||
|
assert vocabulary == tmp_path / "ms-da.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_data_files_uses_a_given_folder_alone(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", tmp_path / "other.csv")
|
||||||
|
vocabulary, progress = app.resolve_data_files(tmp_path / "given")
|
||||||
|
assert vocabulary == tmp_path / "given" / "ms-da.csv"
|
||||||
|
assert progress == tmp_path / "given" / "words_to_learn.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_application_draws_first_card_from_bundled_list(
|
||||||
|
fake_views: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
bundled = tmp_path / "bundle" / "ms-da.csv"
|
||||||
|
bundled.parent.mkdir()
|
||||||
|
bundled.write_text("ms;da\nsaya;jeg\n", encoding="utf-8")
|
||||||
|
data = tmp_path / "data"
|
||||||
|
monkeypatch.setattr(app, "DEFAULT_DATA_DIR", data)
|
||||||
|
monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", bundled)
|
||||||
|
application = app.create_application(None, False, Level(None))
|
||||||
|
view = cast(_FakeFlashCardView, application.view)
|
||||||
|
assert view.view_model.text == "saya"
|
||||||
|
view.view_model.is_known()
|
||||||
|
assert (data / "words_to_learn.csv").is_file()
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Tests of the executable build tool.
|
||||||
|
|
||||||
|
@file test_build_executable.py
|
||||||
|
@brief Checks the PyInstaller arguments and the missing PyInstaller message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flash_card.constants import EXECUTABLE_NAME, ICON_FILE_NAME
|
||||||
|
|
||||||
|
_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "build_executable.py"
|
||||||
|
ROOT = Path("project")
|
||||||
|
ASSETS = ROOT / "src" / "flash_card" / "presentation" / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tool() -> ModuleType:
|
||||||
|
spec = importlib.util.spec_from_file_location("build_executable", _TOOL_PATH)
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _option(arguments: list[str], name: str) -> str:
|
||||||
|
return arguments[arguments.index(name) + 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_start_with_the_packaging_flags() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT)
|
||||||
|
assert arguments[:3] == ["--onefile", "--windowed", "--noconfirm"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_name_icon_and_folders() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT)
|
||||||
|
assert _option(arguments, "--name") == EXECUTABLE_NAME == "flash-card"
|
||||||
|
assert _option(arguments, "--icon") == str(ASSETS / ICON_FILE_NAME)
|
||||||
|
assert _option(arguments, "--paths") == str(ROOT / "src")
|
||||||
|
assert _option(arguments, "--distpath") == str(ROOT / "dist")
|
||||||
|
assert _option(arguments, "--workpath") == str(ROOT / "build")
|
||||||
|
assert _option(arguments, "--specpath") == str(ROOT / "build")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_add_assets_and_word_list() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT)
|
||||||
|
pairs = [
|
||||||
|
arguments[i + 1] for i, value in enumerate(arguments) if value == "--add-data"
|
||||||
|
]
|
||||||
|
assert pairs == [
|
||||||
|
f"{ASSETS}{os.pathsep}flash_card/presentation/assets",
|
||||||
|
f"{ROOT / 'data' / 'ms-da.csv'}{os.pathsep}data",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_end_with_the_entry_script() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT, clean=True)
|
||||||
|
assert arguments[-1] == str(ROOT / "src" / "flash_card" / "__main__.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_include_speech_packages_by_default() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT)
|
||||||
|
assert arguments.count("--collect-all") == 2
|
||||||
|
assert "gtts" in arguments
|
||||||
|
assert "playsound" in arguments
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_leave_out_speech_packages_without_speech() -> None:
|
||||||
|
arguments = _load_tool().build_arguments(ROOT, speech=False)
|
||||||
|
assert "--collect-all" not in arguments
|
||||||
|
assert "--hidden-import" not in arguments
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_arguments_clean_only_when_asked() -> None:
|
||||||
|
tool = _load_tool()
|
||||||
|
assert "--clean" not in tool.build_arguments(ROOT)
|
||||||
|
assert "--clean" in tool.build_arguments(ROOT, clean=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_returns_two_and_explains_when_pyinstaller_is_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
def refuse(name: str, package: str | None = None) -> ModuleType:
|
||||||
|
raise ImportError(name)
|
||||||
|
|
||||||
|
tool = _load_tool()
|
||||||
|
monkeypatch.setattr(importlib, "import_module", refuse)
|
||||||
|
assert tool.main([]) == 2
|
||||||
|
assert 'pip install -e ".[build]"' in capsys.readouterr().err
|
||||||
@@ -35,3 +35,27 @@ def test_project_root_contains_pyproject() -> None:
|
|||||||
|
|
||||||
def test_csv_encoding_drops_byte_order_mark() -> None:
|
def test_csv_encoding_drops_byte_order_mark() -> None:
|
||||||
assert constants.CSV_ENCODING == "utf-8-sig"
|
assert constants.CSV_ENCODING == "utf-8-sig"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_dir_holds_the_four_images_and_the_icon() -> None:
|
||||||
|
names = [
|
||||||
|
constants.CARD_FRONT_IMAGE_NAME,
|
||||||
|
constants.CARD_BACK_IMAGE_NAME,
|
||||||
|
constants.RIGHT_IMAGE_NAME,
|
||||||
|
constants.WRONG_IMAGE_NAME,
|
||||||
|
constants.ICON_FILE_NAME,
|
||||||
|
]
|
||||||
|
assert len(set(names)) == 5
|
||||||
|
assert all((constants.ASSETS_DIR / name).is_file() for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bundled_vocabulary_is_the_word_list_of_the_bundled_data_dir() -> None:
|
||||||
|
assert constants.BUNDLED_VOCABULARY_PATH == (
|
||||||
|
constants.BUNDLED_DATA_DIR / constants.VOCABULARY_FILE_NAME
|
||||||
|
)
|
||||||
|
assert constants.BUNDLED_VOCABULARY_PATH.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_executable_and_icon_names() -> None:
|
||||||
|
assert constants.EXECUTABLE_NAME == "flash-card"
|
||||||
|
assert constants.ICON_FILE_NAME == "flash_card.ico"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from flash_card.constants import (
|
|||||||
CARD_FRONT_IMAGE_NAME,
|
CARD_FRONT_IMAGE_NAME,
|
||||||
CARD_HEIGHT,
|
CARD_HEIGHT,
|
||||||
CARD_WIDTH,
|
CARD_WIDTH,
|
||||||
|
ICON_FILE_NAME,
|
||||||
RIGHT_IMAGE_NAME,
|
RIGHT_IMAGE_NAME,
|
||||||
WRONG_IMAGE_NAME,
|
WRONG_IMAGE_NAME,
|
||||||
)
|
)
|
||||||
@@ -56,3 +57,18 @@ def test_generate_images_writes_png_with_expected_size(
|
|||||||
assert data.startswith(_SIGNATURE)
|
assert data.startswith(_SIGNATURE)
|
||||||
assert data[12:16] == b"IHDR"
|
assert data[12:16] == b"IHDR"
|
||||||
assert struct.unpack(">II", data[16:24]) == (width, height)
|
assert struct.unpack(">II", data[16:24]) == (width, height)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_images_writes_icon_with_one_256_pixel_png(generated: Path) -> None:
|
||||||
|
data = (generated / ICON_FILE_NAME).read_bytes()
|
||||||
|
reserved, kind, count = struct.unpack("<HHH", data[:6])
|
||||||
|
width, height, _colors, _zero, planes, bpp, size, offset = struct.unpack(
|
||||||
|
"<BBBBHHII", data[6:22]
|
||||||
|
)
|
||||||
|
assert (reserved, kind, count) == (0, 1, 1)
|
||||||
|
assert (width, height, planes, bpp) == (0, 0, 1, 32)
|
||||||
|
assert offset == 22
|
||||||
|
assert size == len(data) - 22
|
||||||
|
png = data[offset:]
|
||||||
|
assert png.startswith(_SIGNATURE)
|
||||||
|
assert struct.unpack(">II", png[16:24]) == (256, 256)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests of the runtime helpers.
|
||||||
|
|
||||||
|
@file test_runtime.py
|
||||||
|
@brief Checks where resources and the executable are, frozen or from source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flash_card import runtime
|
||||||
|
|
||||||
|
BUNDLE = Path("bundle")
|
||||||
|
EXECUTABLE = Path("apps") / "flash-card.exe"
|
||||||
|
ROOT = Path("project")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_resource_root_returns_bundle_when_frozen() -> None:
|
||||||
|
assert runtime.pick_resource_root(True, BUNDLE, ROOT) == BUNDLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_resource_root_returns_root_when_not_frozen() -> None:
|
||||||
|
assert runtime.pick_resource_root(False, BUNDLE, ROOT) == ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_resource_root_returns_root_when_bundle_is_unknown() -> None:
|
||||||
|
assert runtime.pick_resource_root(True, None, ROOT) == ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_executable_dir_returns_executable_folder_when_frozen() -> None:
|
||||||
|
assert runtime.pick_executable_dir(True, EXECUTABLE, ROOT) == EXECUTABLE.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_executable_dir_returns_root_when_not_frozen() -> None:
|
||||||
|
assert runtime.pick_executable_dir(False, EXECUTABLE, ROOT) == ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_frozen_is_false_in_a_test_run() -> None:
|
||||||
|
assert runtime.is_frozen() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_root_is_project_root_in_a_source_run() -> None:
|
||||||
|
assert (runtime.resource_root() / "pyproject.toml").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_executable_dir_is_project_root_in_a_source_run() -> None:
|
||||||
|
assert runtime.executable_dir() == runtime.source_root()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Build the single file Windows executable with PyInstaller.
|
||||||
|
|
||||||
|
@file build_executable.py
|
||||||
|
@brief Runs PyInstaller with the arguments of the flash card program.
|
||||||
|
Uses the standard library; PyInstaller is imported only when a build runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SRC_DIR = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
if str(_SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SRC_DIR))
|
||||||
|
|
||||||
|
from flash_card.constants import ( # noqa: E402
|
||||||
|
EXECUTABLE_NAME,
|
||||||
|
ICON_FILE_NAME,
|
||||||
|
VOCABULARY_FILE_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ASSETS_RELATIVE = Path("src") / "flash_card" / "presentation" / "assets"
|
||||||
|
_ASSETS_TARGET = "flash_card/presentation/assets"
|
||||||
|
_DATA_RELATIVE = Path("data")
|
||||||
|
_SPEECH_PACKAGES = ("gtts", "playsound")
|
||||||
|
_INSTALL_HINT = 'python -m pip install -e ".[build]"'
|
||||||
|
_EXIT_MISSING_PYINSTALLER = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _add_data(source: Path, target: str) -> list[str]:
|
||||||
|
"""Return the --add-data option of one source and target.
|
||||||
|
|
||||||
|
@param source File or folder to bundle.
|
||||||
|
@param target Folder inside the bundle.
|
||||||
|
@return The option and its value.
|
||||||
|
"""
|
||||||
|
return ["--add-data", f"{source}{os.pathsep}{target}"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_arguments(
|
||||||
|
root: Path, *, speech: bool = True, clean: bool = False
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return the PyInstaller argument list.
|
||||||
|
|
||||||
|
@param root Project root folder.
|
||||||
|
@param speech True to bundle the optional gtts and playsound packages.
|
||||||
|
@param clean True to clear the PyInstaller cache first.
|
||||||
|
@return The arguments, the entry script last.
|
||||||
|
"""
|
||||||
|
arguments = [
|
||||||
|
"--onefile",
|
||||||
|
"--windowed",
|
||||||
|
"--noconfirm",
|
||||||
|
"--name",
|
||||||
|
EXECUTABLE_NAME,
|
||||||
|
"--icon",
|
||||||
|
str(root / _ASSETS_RELATIVE / ICON_FILE_NAME),
|
||||||
|
"--paths",
|
||||||
|
str(root / "src"),
|
||||||
|
"--distpath",
|
||||||
|
str(root / "dist"),
|
||||||
|
"--workpath",
|
||||||
|
str(root / "build"),
|
||||||
|
"--specpath",
|
||||||
|
str(root / "build"),
|
||||||
|
]
|
||||||
|
arguments += _add_data(root / _ASSETS_RELATIVE, _ASSETS_TARGET)
|
||||||
|
arguments += _add_data(root / _DATA_RELATIVE / VOCABULARY_FILE_NAME, "data")
|
||||||
|
if speech:
|
||||||
|
for package in _SPEECH_PACKAGES:
|
||||||
|
arguments += ["--hidden-import", package, "--collect-all", package]
|
||||||
|
if clean:
|
||||||
|
arguments.append("--clean")
|
||||||
|
arguments.append(str(root / "src" / "flash_card" / "__main__.py"))
|
||||||
|
return arguments
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Build the executable from the command line.
|
||||||
|
|
||||||
|
@param argv Command line arguments, or None for sys.argv.
|
||||||
|
@return 0 on success, 2 when PyInstaller is not installed.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(description="Build the flash card executable.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-speech", action="store_true", help="leave out gtts and playsound"
|
||||||
|
)
|
||||||
|
parser.add_argument("--clean", action="store_true", help="clear the cache first")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
pyinstaller = importlib.import_module("PyInstaller.__main__")
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"PyInstaller is not installed; run {_INSTALL_HINT} and try again\n"
|
||||||
|
)
|
||||||
|
return _EXIT_MISSING_PYINSTALLER
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
pyinstaller.run(build_arguments(root, speech=not args.no_speech, clean=args.clean))
|
||||||
|
sys.stdout.write(f"built {root / 'dist' / (EXECUTABLE_NAME + '.exe')}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Generate the PNG assets of the flash card program.
|
"""Generate the PNG assets of the flash card program.
|
||||||
|
|
||||||
@file generate_images.py
|
@file generate_images.py
|
||||||
@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs.
|
@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs,
|
||||||
|
plus the program icon flash_card.ico.
|
||||||
Uses the standard library only; run it to regenerate the assets.
|
Uses the standard library only; run it to regenerate the assets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ from flash_card.constants import ( # noqa: E402
|
|||||||
CARD_FRONT_IMAGE_NAME,
|
CARD_FRONT_IMAGE_NAME,
|
||||||
CARD_HEIGHT,
|
CARD_HEIGHT,
|
||||||
CARD_WIDTH,
|
CARD_WIDTH,
|
||||||
|
ICON_FILE_NAME,
|
||||||
RIGHT_IMAGE_NAME,
|
RIGHT_IMAGE_NAME,
|
||||||
WRONG_IMAGE_NAME,
|
WRONG_IMAGE_NAME,
|
||||||
)
|
)
|
||||||
@@ -57,6 +59,10 @@ _CROSS_SEGMENTS: tuple[Segment, ...] = (
|
|||||||
((68.0, 32.0), (32.0, 68.0)),
|
((68.0, 32.0), (32.0, 68.0)),
|
||||||
)
|
)
|
||||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||||
|
_ICON_SIZE = 256
|
||||||
|
_ICON_HEADER_SIZE = 6
|
||||||
|
_ICON_ENTRY_SIZE = 16
|
||||||
|
_ICON_BITS_PER_PIXEL = 32
|
||||||
|
|
||||||
|
|
||||||
class _Canvas:
|
class _Canvas:
|
||||||
@@ -309,20 +315,27 @@ def _render_card(fill: Color) -> _Canvas:
|
|||||||
|
|
||||||
|
|
||||||
def _render_button(
|
def _render_button(
|
||||||
gradient: tuple[Color, Color], segments: Sequence[Segment]
|
gradient: tuple[Color, Color],
|
||||||
|
segments: Sequence[Segment],
|
||||||
|
size: int = _BUTTON_SIZE,
|
||||||
) -> _Canvas:
|
) -> _Canvas:
|
||||||
"""Draw a rounded gradient square with a white symbol.
|
"""Draw a rounded gradient square with a white symbol.
|
||||||
|
|
||||||
@param gradient Top and bottom colour.
|
@param gradient Top and bottom colour.
|
||||||
@param segments Line segments of the symbol.
|
@param segments Line segments of the symbol, drawn for a 100 px button.
|
||||||
|
@param size Side of the square in pixels; the design is scaled to it.
|
||||||
@return The canvas.
|
@return The canvas.
|
||||||
"""
|
"""
|
||||||
size = _BUTTON_SIZE
|
scale = size / _BUTTON_SIZE
|
||||||
|
scaled = [
|
||||||
|
((a[0] * scale, a[1] * scale), (b[0] * scale, b[1] * scale))
|
||||||
|
for a, b in segments
|
||||||
|
]
|
||||||
canvas = _Canvas(size, size)
|
canvas = _Canvas(size, size)
|
||||||
box: Box = (0.0, 0.0, float(size), float(size))
|
box: Box = (0.0, 0.0, float(size), float(size))
|
||||||
square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS)
|
square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS * scale)
|
||||||
canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size))
|
canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size))
|
||||||
symbol = _stroke_mask(size, size, segments, _STROKE_WIDTH)
|
symbol = _stroke_mask(size, size, scaled, _STROKE_WIDTH * scale)
|
||||||
canvas.paint(symbol, _solid(_WHITE))
|
canvas.paint(symbol, _solid(_WHITE))
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
@@ -351,8 +364,24 @@ def encode_png(width: int, height: int, rgba: bytes) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_ico(png: bytes, size: int) -> bytes:
|
||||||
|
"""Wrap one PNG image in an ICO container.
|
||||||
|
|
||||||
|
@param png The PNG file content.
|
||||||
|
@param size Image side in pixels; 256 is stored as 0.
|
||||||
|
@return The ICO file content.
|
||||||
|
"""
|
||||||
|
stored = 0 if size >= _ICON_SIZE else size
|
||||||
|
offset = _ICON_HEADER_SIZE + _ICON_ENTRY_SIZE
|
||||||
|
header = struct.pack("<HHH", 0, 1, 1)
|
||||||
|
entry = struct.pack(
|
||||||
|
"<BBBBHHII", stored, stored, 0, 0, 1, _ICON_BITS_PER_PIXEL, len(png), offset
|
||||||
|
)
|
||||||
|
return header + entry + png
|
||||||
|
|
||||||
|
|
||||||
def generate_images(output_dir: Path) -> list[Path]:
|
def generate_images(output_dir: Path) -> list[Path]:
|
||||||
"""Write the four PNG assets into a folder.
|
"""Write the four PNG assets and the icon into a folder.
|
||||||
|
|
||||||
@param output_dir Folder to write into; created when missing.
|
@param output_dir Folder to write into; created when missing.
|
||||||
@return The paths of the written files.
|
@return The paths of the written files.
|
||||||
@@ -370,6 +399,11 @@ def generate_images(output_dir: Path) -> list[Path]:
|
|||||||
data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes())
|
data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes())
|
||||||
path.write_bytes(data)
|
path.write_bytes(data)
|
||||||
written.append(path)
|
written.append(path)
|
||||||
|
icon = _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS, _ICON_SIZE)
|
||||||
|
png = encode_png(icon.width, icon.height, icon.to_rgba_bytes())
|
||||||
|
icon_path = output_dir / ICON_FILE_NAME
|
||||||
|
icon_path.write_bytes(encode_ico(png, _ICON_SIZE))
|
||||||
|
written.append(icon_path)
|
||||||
return written
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user