Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7ca24dc69 | ||
|
|
2d94678899 | ||
|
|
7c1f1b6423 | ||
|
|
f9ca635a15 | ||
|
|
08c11b2108 | ||
|
|
0264b6084f | ||
|
|
f13f32052b | ||
|
|
6c6fd723bf | ||
|
|
ce4aacb4bc | ||
|
|
5d626dc890 |
@@ -4,6 +4,35 @@ All notable changes of the flash card program. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the version follows
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.2.0] - 2026-10-15
|
||||
|
||||
Adds the classic menu bar and keeps the progress of each word list apart.
|
||||
|
||||
### Added
|
||||
|
||||
- A menu bar. File: Open... (another word list from a CSV file with a header
|
||||
of two language codes), Change level... (the level choice without a restart),
|
||||
Reset word list... (after a confirmation the progress of the open list is
|
||||
removed and all its words are to learn again), Exit. About: Contact (the
|
||||
author's name and title, and a link to the author's LinkedIn page).
|
||||
- The progress of each word list is a file of its own: `words_to_learn.csv` for
|
||||
`ms-da.csv` (unchanged) and `words_to_learn_<name>.csv` for another list.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A word list that is not UTF-8 text gives a message that names the file,
|
||||
instead of an error.
|
||||
- The level screen has the same window title, padding and background as the
|
||||
card screen.
|
||||
|
||||
### Known limits
|
||||
|
||||
- The executable is not signed; Windows SmartScreen may warn about it.
|
||||
- The executable is not attached to the release: the upload limit of the git
|
||||
host rejects a file of its size (HTTP 413, already at 1 MB). Build it with
|
||||
`python tools/build_executable.py`.
|
||||
- An opened word list is not remembered after a restart.
|
||||
|
||||
## [0.1.0] - 2026-10-15
|
||||
|
||||
First release. The solution of day 31 of Udemy's *100 Days of Code: The Complete
|
||||
@@ -32,3 +61,4 @@ Python Pro Bootcamp*, for Malay words with Danish translations.
|
||||
- The Danish column of the word list is machine translated.
|
||||
|
||||
[0.1.0]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0
|
||||
[0.2.0]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.2.0
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
PROJECT_NAME = "Flash Card"
|
||||
PROJECT_BRIEF = "Malay to Danish flash cards (Udemy 100 Days of Code, day 31)"
|
||||
PROJECT_NUMBER = 0.1.0
|
||||
PROJECT_NUMBER = 0.2.0
|
||||
OUTPUT_DIRECTORY = docs/doxygen
|
||||
CREATE_SUBDIRS = NO
|
||||
|
||||
|
||||
@@ -102,11 +102,25 @@ 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_<name>.csv`. The opened list is not remembered after a restart.
|
||||
|
||||
### Executable (Windows, no Python needed to run it)
|
||||
|
||||
The [release page][release-0-1-0] of v0.1.0 has the release notes. The executable is not attached there yet, because the server's upload limit rejects a file of this size (HTTP 413). Build it yourself:
|
||||
The release pages of [v0.2.0][release-0-2-0] and [v0.1.0][release-0-1-0] have the release notes. The executable is not attached there yet, because the server's upload limit rejects a file of this size (HTTP 413). Build it yourself:
|
||||
|
||||
```powershell
|
||||
python -m pip install -e ".[build,speech]"
|
||||
@@ -142,7 +156,7 @@ GNU Affero General Public License v3.0 or later. See [LICENSE][license].
|
||||
- [Repository][repository]
|
||||
- [Documentation][documentation]
|
||||
- [Issue tracker][issues]
|
||||
- [Releases][releases] (v0.1.0: release notes; the executable is built from source, see Run)
|
||||
- [Releases][releases] (v0.2.0 and v0.1.0: release notes; the executable is built from source, see Run)
|
||||
|
||||
## 📎 Appendix: Project documents
|
||||
|
||||
@@ -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
|
||||
@@ -176,6 +191,7 @@ The documents a newcomer needs, in reading order.
|
||||
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
|
||||
[releases]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases
|
||||
[release-0-1-0]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0
|
||||
[release-0-2-0]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.2.0
|
||||
[agents]: ./AGENTS.md
|
||||
[BC-001]: ./docs/business-case.md
|
||||
[SA-001]: ./docs/stakeholder-analysis.md
|
||||
|
||||
@@ -14,7 +14,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| BC | Business Case | docs/business-case.md | 002 |
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 012 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 013 |
|
||||
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
||||
| US | User Story | docs/user-stories.md | 002 |
|
||||
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
||||
@@ -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 | 072 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Scope: release of the executable (new request of S01) | [9c8e294] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | In Scope: menu bar (new request of S01) | [2211d0f] |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | In Scope: menu bar (new request of S01) | [2211d0f] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Scope: tagged releases v0.1.0 and v0.2.0 (new request of S01) | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
@@ -56,7 +56,7 @@ A small, well-structured reference solution gives the Product Owner a language l
|
||||
- A choice of level (the first 100 words, the first 1000 words or all words of the frequency ordered word list) before learning starts.
|
||||
- A menu bar with the commands to open another word list (CSV), change the level, reset a word list, exit, and the author's contact.
|
||||
- A single Windows executable file, built with PyInstaller by a script of this repository, that runs without an installed Python.
|
||||
- A tagged release `v0.1.0` of the repository with the executable, its SHA-256 checksum and release notes attached.
|
||||
- Tagged releases of the repository (`v0.1.0`, `v0.2.0`), each with release notes and, when the git host accepts the upload, the executable and its SHA-256 checksum attached.
|
||||
- `pyproject.toml`, `.gitignore`, `Doxyfile`, virtual environment instructions, `pytest` tests and `constants.py`.
|
||||
- README from the framework template, screenshots, and a wiki in English, Danish and Bahasa Malaysia.
|
||||
- Repository description and topics on the git host.
|
||||
@@ -151,5 +151,5 @@ Proceed — the scope is small, the risks are mitigated, and the result serves t
|
||||
---
|
||||
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[9c8e294]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/9c8e2940217e615f82eda2d48aca9b2fd58f3687
|
||||
[2211d0f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2211d0f4cbd8182314d18cbdefd939ae50c39e6d
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,84 @@
|
||||
# MIL-012: Release v0.2.0
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-012 |
|
||||
| CrossReference | [BC-001], [US-001], [UC-001], [ADR-0004] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the project can be handed out as version 0.2.0: the version is raised, the tag `v0.2.0` is made on the commit that is built, and the release page has notes and, when the git host accepts the upload, the executable with its checksum. Version 0.2.0 is the minor release that adds the menu bar and the progress per word list to 0.1.0.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`pyproject.toml`, `flash_card.__version__` and the `Doxyfile` say `0.2.0`; `CHANGELOG.md` has the entry for 0.2.0; the annotated tag `v0.2.0` is on the head of `main`; the release `v0.2.0` exists with notes; `flash-card.exe` and `flash-card.exe.sha256` are attached if the upload limit of the git host allows it, otherwise the notes say how to build the executable and the open issue stays open.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The three version places say `0.2.0` and a test fails when `flash_card.__version__` and `pyproject.toml` differ | Test passes; grep shows the same version | A different version |
|
||||
| 2 | `CHANGELOG.md` has a `[0.2.0]` entry listing the menu bar, the progress per word list and the message for a file that is not UTF-8, and `[0.1.0]` is unchanged | File read in the review record | A missing or changed entry |
|
||||
| 3 | The upload limit of the git host is probed before the build with a draft release that is deleted afterwards, and the result is recorded | Result in the review record | No probe |
|
||||
| 4 | The executable is built with `--clean` from the commit to be tagged and passes the smoke test of MIL-009 (window, embedded list, progress beside it, list override) | Smoke test output in the review record | A failing case |
|
||||
| 5 | The tag `v0.2.0` is an annotated tag on that commit; `v0.1.0` is not moved | `git show v0.2.0` and `git rev-parse v0.1.0` | A moved or lightweight tag |
|
||||
| 6 | The release `v0.2.0` is published with notes that state what is new, that the file is unsigned, the `data` folder rule and how to build | Notes read back from the API | A statement is missing |
|
||||
| 7 | When the probe allowed it: the release has `flash-card.exe` and `flash-card.exe.sha256`, and the SHA-256 of the downloaded file equals the checksum; when the probe did not allow it: the notes say so and the issue "attach the files" stays open | Hash compared, or the statement is present | A wrong or unverifiable hash |
|
||||
| 8 | README, CHANGELOG and the Install page of the wiki in three languages link `v0.2.0` and say whether the executable is attached; `check-readme.sh` exit 0 | Exit code 0 | A link is missing |
|
||||
| 9 | `pytest`, `ruff`, `mypy --strict`, `lint-imports` and `doxygen` are clean after the version change; BC-001, US-001 and PP-001 are reviewed `Go` | `RC-*` verdict Go and exit codes 0 | Otherwise |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-011 | The menu bar that makes this release |
|
||||
| MIL-010 | The release procedure and its open issue about the upload limit |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objective 4 (a repository others can use) | [BC-001] |
|
||||
| US-001.08 Download the executable | [US-001] |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 (AI as stand-in in autonomous mode) |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-18 — within the delivery window that ends 2026-10-21 ([BC-001] constraint).
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Update the documents for the release | BC-001 (releases in plural), US-001.08 (the latest release instead of v0.1.0) and PP-001; each changed document gets a Version History row and a review. | No | BC-001, US-001 |
|
||||
| 2 | Raise the version to 0.2.0 | `pyproject.toml`, `flash_card.__version__` in `src/flash_card/__init__.py` and `PROJECT_NUMBER` of the `Doxyfile`; one unit test compares the version of the package with `pyproject.toml`, so the places cannot drift. | No | |
|
||||
| 3 | Write the CHANGELOG entry and the release notes | `[0.2.0]` with Added (menu bar: open another word list, change level, reset word list, exit, contact; progress kept apart for each word list), Fixed (a file that is not UTF-8 gives a message) and the known limits; the same text is the release body. | No | |
|
||||
| 4 | Probe the upload limit of the git host | Create a draft release, try uploads of 1, 5, 10 and 30 MB random files one after another and stop at the first refusal, delete the draft and its tag; record the largest accepted size. The token is read inside the script. | No | |
|
||||
| 5 | Build and smoke-test the executable from the tagged commit | `python tools/build_executable.py --clean` from a clean tree of the head of `main` after the version change, the smoke test, and the SHA-256. | Yes | US-001.08 |
|
||||
| 6 | Tag v0.2.0 | Annotated tag `v0.2.0` on the head of `main` and push it; `v0.1.0` stays where it is. | No | |
|
||||
| 7 | Create the release and attach the files | Create the release `v0.2.0` with the notes through the API; attach `flash-card.exe` and `flash-card.exe.sha256` when the probe allows it and compare the hash after a download; otherwise say so in the notes and leave the issue open. | Yes | US-001.08 |
|
||||
| 8 | Link the release | README, CHANGELOG and the Install page of the wiki in English, Danish and Bahasa Malaysia link `v0.2.0`; the wiki is republished. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[US-001]: ../user-stories.md
|
||||
[UC-001]: ../uc-001/uc.md
|
||||
[ADR-0004]: ../adr/adr-0004-pyinstaller-executable.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
+10
-5
@@ -4,15 +4,15 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | PP-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [US-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010], [MIL-011] |
|
||||
| CrossReference | [BC-001], [SA-001], [US-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010], [MIL-011], [MIL-012] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added gateway MIL-010 Release v0.1.0 (new request of S01) | [9c8e294] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added gateway MIL-011 Menu bar (new request of S01) | [2211d0f] |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added gateway MIL-011 Menu bar (new request of S01) | [2211d0f] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added gateway MIL-012 Release v0.2.0 (new request of S01) | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,6 +43,7 @@ This plan schedules the seven phases (gateways) that deliver the flash card prog
|
||||
| Executable with PyInstaller | [MIL-009] | 2026-10-16 | 2026-10-16 | S01 | US-001.07 | One Windows executable that runs without Python, built by a script; documents, wiki and README updated | |
|
||||
| Release v0.1.0 | [MIL-010] | 2026-10-16 | 2026-10-16 | S01 | US-001.08 | Tag v0.1.0 and a release with `flash-card.exe`, its checksum and release notes | |
|
||||
| Menu bar | [MIL-011] | 2026-10-17 | 2026-10-17 | S01 | US-001.09 to US-001.12 | File menu (open word list, change level, reset word list, exit) and About menu (contact); documents, wiki and screenshots updated | |
|
||||
| Release v0.2.0 | [MIL-012] | 2026-10-18 | 2026-10-18 | S01 | US-001.08 | Version 0.2.0, tag `v0.2.0` and a release with the menu bar, notes and, if the host accepts it, the executable | |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -69,6 +70,8 @@ Project starts 2026-10-14
|
||||
[Release Go/No-Go] happens 2026-10-16
|
||||
[Menu bar] starts 2026-10-17 and ends 2026-10-17
|
||||
[Menu bar Go/No-Go] happens 2026-10-17
|
||||
[Release 0.2.0] starts 2026-10-18 and ends 2026-10-18
|
||||
[Release 0.2.0 Go/No-Go] happens 2026-10-18
|
||||
@endgantt
|
||||
```
|
||||
|
||||
@@ -87,12 +90,13 @@ Project starts 2026-10-14
|
||||
| Executable built with PyInstaller (new request after MIL-008) | MIL-009 |
|
||||
| Release with the executable attached (new request after MIL-009) | MIL-010 |
|
||||
| Menu bar with File and About menus (new request after MIL-009) | MIL-011 |
|
||||
| Release v0.2.0 with the menu bar (new request after MIL-011) | MIL-012 |
|
||||
| Framework documents of the baseline | Baseline pull request before MIL-001 |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
Baseline → MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010 → MIL-011
|
||||
Baseline → MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010 → MIL-011 → MIL-012
|
||||
```
|
||||
|
||||
A No-Go on a phase moves every later date by the days the rework takes; MIL-008 was added after MIL-007 and takes the unplanned day 2026-10-20, so no buffer is left inside the window; a No-Go moves the end date past 2026-10-21 and S01 decides whether to accept that.
|
||||
@@ -126,6 +130,7 @@ A No-Go on a phase moves every later date by the days the rework takes; MIL-008
|
||||
[MIL-009]: ./milestones/mil-009-executable.md
|
||||
[MIL-010]: ./milestones/mil-010-release.md
|
||||
[MIL-011]: ./milestones/mil-011-menu-bar.md
|
||||
[MIL-012]: ./milestones/mil-012-release-0-2-0.md
|
||||
[MS-001]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/165
|
||||
[MS-002]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/166
|
||||
[MS-003]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/167
|
||||
@@ -133,5 +138,5 @@ A No-Go on a phase moves every later date by the days the rework takes; MIL-008
|
||||
[MS-005]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/169
|
||||
[MS-006]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/170
|
||||
[MS-007]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/171
|
||||
[9c8e294]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/9c8e2940217e615f82eda2d48aca9b2fd58f3687
|
||||
[2211d0f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2211d0f4cbd8182314d18cbdefd939ae50c39e6d
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 | [ce4aacb] |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[ce4aacb]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/ce4aacb4bc7ce742e3b2397e05a43572497402ef
|
||||
@@ -0,0 +1,70 @@
|
||||
# RC-067: Review of BC-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-067 |
|
||||
| CrossReference | [BC-001], [QC-BC-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 | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [BC-001]
|
||||
- Checklist used: [QC-BC-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 | ROI/Cost-Benefit analysis is quantitative, or where qualitative, is explicitly justified | Pass | The Cost-Benefit table is qualitative and says why: no revenue is expected, so a quantitative return does not apply. |
|
||||
| 2 | Risks are identified with documented impact and mitigation | Pass | The Risks table has 5 risks, each with an impact and a mitigation. |
|
||||
| 3 | Success criteria are measurable, stating explicit targets rather than vague aspirations | Pass | The Success Criteria table has 8 rows with numeric targets and a measure each (for example 0 third-party packages; 5 commands or fewer). |
|
||||
| 4 | Scope explicitly separates In Scope vs Out of Scope | Pass | Scope has `### In Scope` and `### Out of Scope`; the releases are In Scope, an installer and signing stay Out of Scope. |
|
||||
| 5 | Stakeholders are cross-referenced to Stakeholder Analysis IDs rather than re-described inline | Pass | The Stakeholders table cites S01, S02 and S03 and does not re-describe their roles. |
|
||||
| 6 | Methodology and quality-standard foundation are stated explicitly (e.g. ISO/IEC 25010, Larman) | Pass | Methodological and Standards Foundation names the framework, Larman, ISO/IEC 25010:2023 and PEP 8. |
|
||||
| 7 | Assumptions and constraints are explicit and clearly distinguished from one another | Pass | Assumptions and Constraints are separate sections. |
|
||||
| 8 | Document supports executive decision-making with a clear, unambiguous recommendation | Pass | Recommendation reads `Proceed` with a one-sentence rationale. |
|
||||
|
||||
## 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 BC-001 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.
|
||||
|
||||
Full review (not a delta) after the change for MIL-012 (release v0.2.0).
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../../../docs/business-case.md
|
||||
[QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.md
|
||||
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
@@ -0,0 +1,70 @@
|
||||
# RC-068: Review of US-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-068 |
|
||||
| CrossReference | [US-001], [QC-US-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 | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [US-001]
|
||||
- Checklist used: [QC-US-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 | Follows INVEST criteria (Independent, Negotiable, Valuable, Estimable, Small, Testable) | Pass | INVEST Check section confirms the six properties for all twelve stories and states one dependency exception. |
|
||||
| 2 | Written in "As a / I want / So that" form | Pass | All twelve stories use `As a / I want / so that`. |
|
||||
| 3 | Clear, testable acceptance criteria are included | Pass | Every story, including US-001.08 in its new wording, has Given/When/Then acceptance criteria. |
|
||||
| 4 | Traceable to a use case or epic | Pass | Every story traces to a milestone and to UC-001. |
|
||||
| 5 | Story is sized to fit within a single iteration | Pass | Each story is one milestone, which fits one iteration. |
|
||||
| 6 | Story statement avoids technical implementation detail | Fail | Acceptance criteria name `words_to_learn.csv`, `ms-da.csv` and 3000 ms, which are assignment-mandated names and values rather than pure behaviour. |
|
||||
| 7 | Role named in the story matches an actor defined in the Use Case Diagram | Pass | The role `Learner` is the actor of UCD-001. |
|
||||
|
||||
## 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 US-001 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 Professional 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.
|
||||
|
||||
Full review (not a delta) after the change for MIL-012 (release v0.2.0).
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Criterion 6 (Optional) failed: Story statement avoids technical implementation detail; fix or accept the deferral | S01 | 2026-10-21 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
[US-001]: ../../../docs/user-stories.md
|
||||
[QC-US-001]: ../../../framework/qc/artifacts/qc-user-story.md
|
||||
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
@@ -0,0 +1,54 @@
|
||||
# RC-069: Review of PP-001
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-069 |
|
||||
| CrossReference | [PP-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 | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PP-001]
|
||||
- Checklist used: none exists for this type, 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`)
|
||||
|
||||
## 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 PP-001 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 — no checklist exists for the Project Plan type; the plan now schedules MIL-012 on 2026-10-18 inside the Business Case constraint (delivery by 2026-10-21) and the language checklist passes. Accepted as the Product Owner's stand-in, after the Product Owner accepted the MIL-012 plan in chat. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
[PP-001]: ../../../docs/project-plan.md
|
||||
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
@@ -0,0 +1,66 @@
|
||||
# RC-070: Review of MIL-012
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-070 |
|
||||
| CrossReference | [MIL-012], [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 | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-012]
|
||||
- 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-012 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.
|
||||
|
||||
## 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-012]: ../../../docs/milestones/mil-012-release-0-2-0.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-071: Review of PY-MIL-012
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-071 |
|
||||
| CrossReference | [PY-MIL-012], [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 | [7c1f1b6] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-012]
|
||||
- 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 change is one assignment and a test module; names follow PEP 8. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | `test_version_of_the_package_equals_pyproject_version` and similar names state the behaviour. |
|
||||
| 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 | Test functions are annotated `-> None`; `mypy --strict` passes on 49 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 | Pass | `pyproject.toml` is read with `tomllib` through a `with open` context. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | The module docstring keeps `@file` and `@brief`; `doxygen Doxyfile` ends with 0 warnings. |
|
||||
| 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 version string is not part of the design. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 3 tests compare `__version__`, `pyproject.toml` and the `Doxyfile` `PROJECT_NUMBER` and the `MAJOR.MINOR.PATCH` form; 3 consecutive full runs (261 tests) green. |
|
||||
| 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 | `dependencies` stays empty; `tomllib` is 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 version is stated once per file type; the test makes the three places agree. |
|
||||
| 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. |
|
||||
| 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 test module with one reason to change. |
|
||||
| 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 | The test sits 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 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-012 tasks 2, 3, 4 and 8 (this change set): 1 the three version places say 0.2.0 and a test fails if they differ; 2 CHANGELOG has `[0.2.0]` and `[0.1.0]` is unchanged; 3 the upload limit probe ran before the build: a draft release with a 1 MB random file was refused with `413 Request Entity Too Large`, so the largest accepted size is below 1 MB; the draft release and its tag were deleted afterwards (the tag list and the release list were checked); 8 README, CHANGELOG and the Install page of the wiki in three languages link v0.2.0 and say that the executable is not attached; `check-readme.sh` exit 0. The tag, the build and the release are criteria 4 to 7 and are recorded in a separate review record. Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-012]: ../../../src/flash_card/__init__.py
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[7c1f1b6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/7c1f1b6423a876d25afafd24f7f206d16ac6d669
|
||||
@@ -14,7 +14,8 @@
|
||||
| 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 | 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) | [061b4f4] |
|
||||
| 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) | [9c8e294] |
|
||||
| 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) | [2211d0f] |
|
||||
| 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) | [2211d0f] |
|
||||
| 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) | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,11 +29,11 @@ updated whenever an artifact instance is created or reviewed.
|
||||
|
||||
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-056] |
|
||||
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-067] |
|
||||
| SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] |
|
||||
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-008 | [RC-064] |
|
||||
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-008 | [RC-069] |
|
||||
| UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] |
|
||||
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006, MIL-008 | [RC-057] |
|
||||
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006, MIL-008 | [RC-068] |
|
||||
| UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-058] |
|
||||
| DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-034] |
|
||||
| SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-059] |
|
||||
@@ -55,6 +56,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| ADR-0004 | Architecture Decision Record | - | - | BC-001 | - | [RC-046] |
|
||||
| MIL-010 | Milestone / Gateway | en | it | BC-001 | - | [RC-054], [RC-055] |
|
||||
| MIL-011 | Milestone / Gateway | en | it | BC-001 | - | [RC-065] |
|
||||
| MIL-012 | Milestone / Gateway | en | it | BC-001 | - | [RC-070] |
|
||||
| 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-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||
@@ -64,7 +66,9 @@ 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] |
|
||||
| PY-MIL-012 | Python Source Code | - | - | MIL-012 | - | [RC-071] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -182,3 +186,13 @@ 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
|
||||
[RC-067]: ./reviews/rc-067-business-case-r020.md
|
||||
[RC-068]: ./reviews/rc-068-user-stories-r020.md
|
||||
[RC-069]: ./reviews/rc-069-project-plan-r020.md
|
||||
[RC-070]: ./reviews/rc-070-mil-012.md
|
||||
[MIL-012]: ../milestones/mil-012-release-0-2-0.md
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
[RC-071]: ./reviews/rc-071-py-mil-012.md
|
||||
[PY-MIL-012]: ../../src/flash_card/__init__.py
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added US-001.08 Download the executable | [9c8e294] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added US-001.09 to US-001.12 (menu bar) | [2211d0f] |
|
||||
| 2026-10-15 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Added US-001.09 to US-001.12 (menu bar) | [2211d0f] |
|
||||
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | US-001.08 names the latest release instead of v0.1.0 | [0264b60] |
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +126,7 @@ These stories cover the epic "Study Flash Cards": the four steps of the course a
|
||||
|
||||
**Acceptance Criteria**
|
||||
|
||||
- Given the release `v0.1.0` exists, when I open the releases page of the repository, then I find `flash-card.exe` and a checksum file attached to it.
|
||||
- Given a release exists (for example `v0.2.0`), when I open the releases page of the repository, then I find release notes and, when the git host accepts the upload, `flash-card.exe` and a checksum file attached to it.
|
||||
- Given I downloaded `flash-card.exe`, when I compute its SHA-256 hash, then it equals the value in the checksum file and in the release notes.
|
||||
- Given I read the release notes, when I look for what the file is, then they say that it is an unsigned Windows build, what the `data` folder next to it holds, and how to build it myself.
|
||||
|
||||
@@ -207,5 +207,5 @@ All twelve stories are Independent in delivery order (each builds on the one bef
|
||||
[MIL-009]: ./milestones/mil-009-executable.md
|
||||
[MIL-010]: ./milestones/mil-010-release.md
|
||||
[MIL-011]: ./milestones/mil-011-menu-bar.md
|
||||
[9c8e294]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/9c8e2940217e615f82eda2d48aca9b2fd58f3687
|
||||
[2211d0f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2211d0f4cbd8182314d18cbdefd939ae50c39e6d
|
||||
[0264b60]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/0264b6084f26bac5af716ea71596ce3be961fec8
|
||||
|
||||
@@ -58,7 +58,7 @@ Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
|
||||
|
||||
## Udgivelse og programfil (Windows)
|
||||
|
||||
[Udgivelsessiden](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) for v0.1.0 har udgivelsesnoterne. Programfilen er endnu ikke vedhæftet, fordi serverens grænse for upload afviser en fil af denne størrelse (HTTP 413); byg den som beskrevet nedenfor. Filen er ikke signeret, så Windows kan vise en SmartScreen-advarsel.
|
||||
Udgivelsessiderne for [v0.2.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.2.0) og [v0.1.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) har udgivelsesnoterne. Programfilen er endnu ikke vedhæftet, fordi serverens grænse for upload afviser en fil af denne størrelse (HTTP 413); byg den som beskrevet nedenfor. Filen er ikke signeret, så Windows kan vise en SmartScreen-advarsel.
|
||||
|
||||
## Byg en programfil (Windows)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
|
||||
|
||||
## Keluaran dan fail boleh laku (Windows)
|
||||
|
||||
[Halaman keluaran](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) v0.1.0 mempunyai nota keluaran. Fail boleh laku belum dilampirkan di sana kerana had muat naik pelayan menolak fail sebesar ini (HTTP 413); bina seperti yang diterangkan di bawah. Fail tidak ditandatangani, jadi Windows mungkin menunjukkan amaran SmartScreen.
|
||||
Halaman keluaran [v0.2.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.2.0) dan [v0.1.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) mempunyai nota keluaran. Fail boleh laku belum dilampirkan di sana kerana had muat naik pelayan menolak fail sebesar ini (HTTP 413); bina seperti yang diterangkan di bawah. Fail tidak ditandatangani, jadi Windows mungkin menunjukkan amaran SmartScreen.
|
||||
|
||||
## Bina fail boleh laku (Windows)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ The window test in `tests/e2e/` is skipped when no display is available.
|
||||
|
||||
## Release and executable (Windows)
|
||||
|
||||
The [release page](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) of v0.1.0 has the release notes. The executable is not attached there yet, because the server's upload limit rejects a file of this size (HTTP 413); build it as described below. The file is not signed, so Windows may show a SmartScreen warning.
|
||||
The release pages of [v0.2.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.2.0) and [v0.1.0](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/releases/tag/v0.1.0) have the release notes. The executable is not attached there yet, because the server's upload limit rejects a file of this size (HTTP 413); build it as described below. The file is not signed, so Windows may show a SmartScreen warning.
|
||||
|
||||
## Build an executable (Windows)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
| Menu | Kommando | Hvad den gør |
|
||||
| --- | --- | --- |
|
||||
| File | Open... | Åbner en anden ordliste fra en CSV-fil (overskrift med to sprogkoder, fx `en;da`). Fremgang gemmes adskilt for hver liste |
|
||||
| File | Change level... | Viser niveauvalget igen uden genstart; kendte ord forbliver kendte |
|
||||
| File | Reset word list... | Fjerner efter en bekræftelse fremgangen for den åbne liste, så alle dens ord skal læres igen; listefilen ændres ikke |
|
||||
| File | Exit | Lukker programmet |
|
||||
| About | Contact | Forfatteren, Jens Tirsvad Nielsen, softwareudvikler; åbner LinkedIn-siden i din browser |
|
||||
|
||||
Fremgangen for den medfølgende `ms-da.csv` er `data/words_to_learn.csv`; en anden liste får `data/words_to_learn_<navn>.csv`. Den åbnede liste huskes ikke efter en genstart.
|
||||
|
||||
## Din fremgang
|
||||
|
||||
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
|
||||
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi pada tahap itu, kad menunjukkan "Nothing left to learn"; mulakan program semula dan pilih tahap yang lebih tinggi.
|
||||
|
||||
## Bar menu
|
||||
|
||||

|
||||
|
||||
| Menu | Perintah | Apa yang dilakukan |
|
||||
| --- | --- | --- |
|
||||
| File | Open... | Membuka senarai perkataan lain daripada fail CSV (baris pertama dengan dua kod bahasa, contohnya `en;da`). Kemajuan disimpan berasingan bagi setiap senarai |
|
||||
| File | Change level... | Menunjukkan pilihan tahap semula tanpa memulakan semula; perkataan yang diketahui kekal diketahui |
|
||||
| File | Reset word list... | Selepas pengesahan membuang kemajuan senarai yang dibuka, supaya semua perkataannya perlu dipelajari semula; fail senarai tidak diubah |
|
||||
| File | Exit | Menutup program |
|
||||
| About | Contact | Pengarang, Jens Tirsvad Nielsen, jurutera perisian; membuka halaman LinkedIn dalam pelayar anda |
|
||||
|
||||
Kemajuan bagi `ms-da.csv` yang disertakan ialah `data/words_to_learn.csv`; senarai lain mendapat `data/words_to_learn_<nama>.csv`. Senarai yang dibuka tidak diingati selepas dimulakan semula.
|
||||
|
||||
## Kemajuan anda
|
||||
|
||||
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
|
||||
A word you marked as known is removed and does not come up again. When no word of the level is left, the card says "Nothing left to learn"; restart the program and choose a higher level.
|
||||
|
||||
## The menu bar
|
||||
|
||||

|
||||
|
||||
| Menu | Command | What it does |
|
||||
| --- | --- | --- |
|
||||
| File | Open... | Opens another word list from a CSV file (header with two language codes, for example `en;da`). Progress is kept apart for each list |
|
||||
| File | Change level... | Shows the level choice again without a restart; known words stay known |
|
||||
| File | Reset word list... | After a confirmation removes the progress of the open list, so all its words are to learn again; the list file is not changed |
|
||||
| File | Exit | Closes the program |
|
||||
| About | Contact | The author, Jens Tirsvad Nielsen, Software engineer; opens the LinkedIn page in your browser |
|
||||
|
||||
The progress of the delivered `ms-da.csv` is `data/words_to_learn.csv`; another list gets `data/words_to_learn_<name>.csv`. The opened list is not remembered after a restart.
|
||||
|
||||
## Your progress
|
||||
|
||||
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "flash-card"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Flash card program for learning Malay words with Danish translations (Udemy 100 Days of Code, day 31)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
@brief Package root of the flash card program.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Name of the words to learn file of a word list.
|
||||
|
||||
@file progress_file_name.py
|
||||
@brief Keeps the progress of every word list in a file of its own.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.constants import (
|
||||
PROGRESS_FILE_NAME,
|
||||
PROGRESS_FILE_NAME_TEMPLATE,
|
||||
PROGRESS_STEM_FORBIDDEN,
|
||||
PROGRESS_STEM_REPLACEMENT,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
|
||||
|
||||
def progress_file_name(list_path: Path) -> str:
|
||||
"""Name the words to learn file of a word list.
|
||||
|
||||
@param list_path Path of the word list file.
|
||||
@return The default file name for the delivered list, else a name made of
|
||||
the list's file name stem with other characters than letters, digits,
|
||||
hyphen and underscore replaced by an underscore.
|
||||
"""
|
||||
if list_path.name == VOCABULARY_FILE_NAME:
|
||||
return PROGRESS_FILE_NAME
|
||||
stem = re.sub(PROGRESS_STEM_FORBIDDEN, PROGRESS_STEM_REPLACEMENT, list_path.stem)
|
||||
return PROGRESS_FILE_NAME_TEMPLATE.format(stem=stem)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Entry point and composition root of the flash card program.
|
||||
|
||||
@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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Menu bar feature."""
|
||||
@@ -0,0 +1,241 @@
|
||||
"""View of the menu bar.
|
||||
|
||||
@file menu_view.py
|
||||
@brief Builds the menu bar of the wireframe and asks the dialogs of its commands.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox
|
||||
from typing import Protocol
|
||||
|
||||
from flash_card.constants import (
|
||||
MENU_ABOUT_UNDERLINE,
|
||||
MENU_COMMAND_CHANGE_LEVEL,
|
||||
MENU_COMMAND_EXIT,
|
||||
MENU_COMMAND_OPEN,
|
||||
MENU_COMMAND_RESET,
|
||||
MENU_CONTACT_UNDERLINE,
|
||||
MENU_FILE_UNDERLINE,
|
||||
OPEN_DIALOG_TITLE,
|
||||
OPEN_ERROR_TEMPLATE,
|
||||
OPEN_ERROR_TITLE,
|
||||
OPEN_FILE_TYPES,
|
||||
RESET_CONFIRM_TEMPLATE,
|
||||
RESET_CONFIRM_TITLE,
|
||||
RESET_ERROR_TEMPLATE,
|
||||
RESET_ERROR_TITLE,
|
||||
)
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||
|
||||
## @brief Logger of the menu view.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MenuApplication(Protocol):
|
||||
"""What the menu view needs from the application."""
|
||||
|
||||
@property
|
||||
def word_list_name(self) -> str:
|
||||
"""Return the file name of the word list in use.
|
||||
|
||||
@return The file name.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def word_list_folder(self) -> Path:
|
||||
"""Return the folder of the word list in use.
|
||||
|
||||
@return The folder.
|
||||
"""
|
||||
...
|
||||
|
||||
def open_word_list(self, path: Path) -> None:
|
||||
"""Switch to the word list in a file.
|
||||
|
||||
@param path The CSV file.
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
|
||||
def change_level(self) -> None:
|
||||
"""Show the level screen.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
|
||||
def reset_word_list(self) -> None:
|
||||
"""Forget the progress of the word list in use.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
|
||||
def exit_application(self) -> None:
|
||||
"""Close the window.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MenuView:
|
||||
"""The menu bar of the window: File and About."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
view_model: MenuViewModel,
|
||||
application: MenuApplication,
|
||||
*,
|
||||
ask_open_file: Callable[..., str] = filedialog.askopenfilename,
|
||||
confirm: Callable[..., bool] = messagebox.askyesno,
|
||||
show_error: Callable[..., str] = messagebox.showerror,
|
||||
open_url: Callable[[str], bool] = webbrowser.open,
|
||||
) -> None:
|
||||
"""Build the menu bar on a root.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param view_model The labels and the contact data.
|
||||
@param application The commands the entries run.
|
||||
@param ask_open_file Shows the file dialog and returns the chosen path,
|
||||
or an empty value when cancelled.
|
||||
@param confirm Asks a yes or no question.
|
||||
@param show_error Shows an error message.
|
||||
@param open_url Opens an address in the web browser.
|
||||
"""
|
||||
## @brief The Tk root window.
|
||||
self._root = root
|
||||
## @brief The labels and the contact data.
|
||||
self._view_model = view_model
|
||||
## @brief The commands the entries run.
|
||||
self._application = application
|
||||
## @brief Shows the file dialog.
|
||||
self._ask_open_file = ask_open_file
|
||||
## @brief Asks a yes or no question.
|
||||
self._confirm = confirm
|
||||
## @brief Shows an error message.
|
||||
self._show_error = show_error
|
||||
## @brief Opens an address in the web browser.
|
||||
self._open_url = open_url
|
||||
## @brief The menu bar of the window.
|
||||
self.menubar = tk.Menu(root, tearoff=False)
|
||||
## @brief The File menu.
|
||||
self.file_menu = tk.Menu(self.menubar, tearoff=False)
|
||||
## @brief The About menu.
|
||||
self.about_menu = tk.Menu(self.menubar, tearoff=False)
|
||||
## @brief The Contact submenu of the About menu.
|
||||
self.contact_menu = tk.Menu(self.about_menu, tearoff=False)
|
||||
self._build()
|
||||
root.config(menu=self.menubar)
|
||||
|
||||
def open_word_list(self) -> None:
|
||||
"""Ask for a CSV file and switch to it; a refused file shows a message.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
chosen = self._ask_open_file(
|
||||
parent=self._root,
|
||||
title=OPEN_DIALOG_TITLE,
|
||||
initialdir=str(self._application.word_list_folder),
|
||||
filetypes=OPEN_FILE_TYPES,
|
||||
)
|
||||
if not chosen:
|
||||
return
|
||||
path = Path(chosen)
|
||||
try:
|
||||
self._application.open_word_list(path)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
self._show_error(
|
||||
OPEN_ERROR_TITLE,
|
||||
OPEN_ERROR_TEMPLATE.format(name=path.name, reason=error),
|
||||
parent=self._root,
|
||||
)
|
||||
|
||||
def change_level(self) -> None:
|
||||
"""Show the level screen again.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._application.change_level()
|
||||
|
||||
def reset_word_list(self) -> None:
|
||||
"""Ask to confirm, then start the word list in use from the beginning.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
name = self._application.word_list_name
|
||||
question = RESET_CONFIRM_TEMPLATE.format(name=name)
|
||||
if not self._confirm(RESET_CONFIRM_TITLE, question, parent=self._root):
|
||||
return
|
||||
try:
|
||||
self._application.reset_word_list()
|
||||
except OSError as error:
|
||||
_logger.error("%s", error)
|
||||
self._show_error(
|
||||
RESET_ERROR_TITLE,
|
||||
RESET_ERROR_TEMPLATE.format(name=name, reason=error),
|
||||
parent=self._root,
|
||||
)
|
||||
|
||||
def show_contact(self) -> None:
|
||||
"""Open the LinkedIn page of the author in the web browser.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
try:
|
||||
self._open_url(self._view_model.contact_url)
|
||||
except (webbrowser.Error, OSError) as error:
|
||||
_logger.error("%s", error)
|
||||
|
||||
def exit_program(self) -> None:
|
||||
"""Close the window.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._application.exit_application()
|
||||
|
||||
def _build(self) -> None:
|
||||
"""Fill the menus and add them to the menu bar.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
commands = {
|
||||
MENU_COMMAND_OPEN: self.open_word_list,
|
||||
MENU_COMMAND_CHANGE_LEVEL: self.change_level,
|
||||
MENU_COMMAND_RESET: self.reset_word_list,
|
||||
MENU_COMMAND_EXIT: self.exit_program,
|
||||
}
|
||||
for entry in self._view_model.file_entries:
|
||||
if entry.command == MENU_COMMAND_EXIT:
|
||||
self.file_menu.add_separator()
|
||||
self.file_menu.add_command(
|
||||
label=entry.label,
|
||||
underline=entry.underline,
|
||||
command=commands[entry.command],
|
||||
)
|
||||
self.contact_menu.add_command(
|
||||
label=self._view_model.contact_label, command=self.show_contact
|
||||
)
|
||||
self.about_menu.add_cascade(
|
||||
label=self._view_model.contact_menu_label,
|
||||
underline=MENU_CONTACT_UNDERLINE,
|
||||
menu=self.contact_menu,
|
||||
)
|
||||
self.menubar.add_cascade(
|
||||
label=self._view_model.file_label,
|
||||
underline=MENU_FILE_UNDERLINE,
|
||||
menu=self.file_menu,
|
||||
)
|
||||
self.menubar.add_cascade(
|
||||
label=self._view_model.about_label,
|
||||
underline=MENU_ABOUT_UNDERLINE,
|
||||
menu=self.about_menu,
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""View model of the menu bar.
|
||||
|
||||
@file menu_view_model.py
|
||||
@brief Exposes the labels, the order and the contact data; free of Tkinter.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from flash_card.constants import (
|
||||
CONTACT_LABEL_TEMPLATE,
|
||||
CONTACT_NAME,
|
||||
CONTACT_TITLE,
|
||||
CONTACT_URL,
|
||||
MENU_ABOUT_LABEL,
|
||||
MENU_CHANGE_LEVEL_LABEL,
|
||||
MENU_CHANGE_LEVEL_UNDERLINE,
|
||||
MENU_COMMAND_CHANGE_LEVEL,
|
||||
MENU_COMMAND_EXIT,
|
||||
MENU_COMMAND_OPEN,
|
||||
MENU_COMMAND_RESET,
|
||||
MENU_CONTACT_LABEL,
|
||||
MENU_EXIT_LABEL,
|
||||
MENU_EXIT_UNDERLINE,
|
||||
MENU_FILE_LABEL,
|
||||
MENU_OPEN_LABEL,
|
||||
MENU_OPEN_UNDERLINE,
|
||||
MENU_RESET_LABEL,
|
||||
MENU_RESET_UNDERLINE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MenuEntry:
|
||||
"""One entry of the File menu."""
|
||||
|
||||
## @brief Text of the entry.
|
||||
label: str
|
||||
## @brief Name of the command of the menu view the entry runs.
|
||||
command: str
|
||||
## @brief Index of the mnemonic letter in the label.
|
||||
underline: int
|
||||
|
||||
|
||||
class MenuViewModel:
|
||||
"""Data the menu view shows."""
|
||||
|
||||
@property
|
||||
def file_label(self) -> str:
|
||||
"""Return the label of the File menu.
|
||||
|
||||
@return The label.
|
||||
"""
|
||||
return MENU_FILE_LABEL
|
||||
|
||||
@property
|
||||
def file_entries(self) -> tuple[MenuEntry, ...]:
|
||||
"""Return the entries of the File menu.
|
||||
|
||||
@return Open, Change level, Reset word list and Exit, in this order.
|
||||
"""
|
||||
return (
|
||||
MenuEntry(MENU_OPEN_LABEL, MENU_COMMAND_OPEN, MENU_OPEN_UNDERLINE),
|
||||
MenuEntry(
|
||||
MENU_CHANGE_LEVEL_LABEL,
|
||||
MENU_COMMAND_CHANGE_LEVEL,
|
||||
MENU_CHANGE_LEVEL_UNDERLINE,
|
||||
),
|
||||
MenuEntry(MENU_RESET_LABEL, MENU_COMMAND_RESET, MENU_RESET_UNDERLINE),
|
||||
MenuEntry(MENU_EXIT_LABEL, MENU_COMMAND_EXIT, MENU_EXIT_UNDERLINE),
|
||||
)
|
||||
|
||||
@property
|
||||
def about_label(self) -> str:
|
||||
"""Return the label of the About menu.
|
||||
|
||||
@return The label.
|
||||
"""
|
||||
return MENU_ABOUT_LABEL
|
||||
|
||||
@property
|
||||
def contact_menu_label(self) -> str:
|
||||
"""Return the label of the Contact submenu.
|
||||
|
||||
@return The label.
|
||||
"""
|
||||
return MENU_CONTACT_LABEL
|
||||
|
||||
@property
|
||||
def contact_label(self) -> str:
|
||||
"""Return the text of the contact entry.
|
||||
|
||||
@return The name and the title of the author.
|
||||
"""
|
||||
return CONTACT_LABEL_TEMPLATE.format(name=CONTACT_NAME, title=CONTACT_TITLE)
|
||||
|
||||
@property
|
||||
def contact_url(self) -> str:
|
||||
"""Return the address the contact entry opens.
|
||||
|
||||
@return The LinkedIn page of the author.
|
||||
"""
|
||||
return CONTACT_URL
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import shutil
|
||||
import 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="")
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""End-to-end tests of the menu bar and its commands on a real Tk window.
|
||||
|
||||
@file test_menu_bar.py
|
||||
@brief Builds the real menu bar with injected dialogs and checks the commands.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.constants import CONTACT_URL, WINDOW_TITLE
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.level.level_view import LevelView
|
||||
from flash_card.presentation.features.menu.menu_view import MenuView
|
||||
from flash_card.presentation.features.menu.menu_view_model import MenuViewModel
|
||||
|
||||
DEFAULT_LIST = "ms;da\naku;mig\nkau;du\n"
|
||||
OTHER_LIST = "ms;da\nkucing;kat\n"
|
||||
CONTACT_LABEL = "Jens Tirsvad Nielsen, Software engineer"
|
||||
|
||||
FILE_INDEX = 0
|
||||
ABOUT_INDEX = 1
|
||||
OPEN, CHANGE_LEVEL, RESET, SEPARATOR, EXIT = range(5)
|
||||
|
||||
|
||||
def _last(cards: Sequence[Card]) -> Card:
|
||||
return cards[-1]
|
||||
|
||||
|
||||
class _Dialogs:
|
||||
"""Records and answers the dialogs the menu view asks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.file = ""
|
||||
self.answer = True
|
||||
self.asked_files: list[dict[str, object]] = []
|
||||
self.questions: list[str] = []
|
||||
self.errors: list[tuple[str, str]] = []
|
||||
self.urls: list[str] = []
|
||||
|
||||
def ask_open_file(self, **options: object) -> str:
|
||||
self.asked_files.append(options)
|
||||
return self.file
|
||||
|
||||
def confirm(self, title: str, message: str, **options: object) -> bool:
|
||||
self.questions.append(message)
|
||||
return self.answer
|
||||
|
||||
def show_error(self, title: str, message: str, **options: object) -> str:
|
||||
self.errors.append((title, message))
|
||||
return "ok"
|
||||
|
||||
def open_url(self, url: str) -> bool:
|
||||
self.urls.append(url)
|
||||
return True
|
||||
|
||||
|
||||
class _Window:
|
||||
def __init__(
|
||||
self,
|
||||
root: tk.Tk,
|
||||
application: app.Application,
|
||||
menu: MenuView,
|
||||
dialogs: _Dialogs,
|
||||
folder: Path,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.application = application
|
||||
self.menu = menu
|
||||
self.dialogs = dialogs
|
||||
self.folder = folder
|
||||
|
||||
|
||||
def _repositories_for(
|
||||
folder: Path,
|
||||
) -> Callable[[Path], tuple[VocabularyRepository, ProgressRepository]]:
|
||||
def build(list_path: Path) -> tuple[VocabularyRepository, ProgressRepository]:
|
||||
return (
|
||||
CsvVocabularyRepository(list_path),
|
||||
CsvProgressRepository(folder / progress_file_name(list_path)),
|
||||
)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def _close(window: _Window) -> None:
|
||||
view = window.application.view
|
||||
if isinstance(view, FlashCardView):
|
||||
view.close()
|
||||
elif isinstance(view, LevelView):
|
||||
view.destroy()
|
||||
window.root.config(menu="")
|
||||
window.menu.menubar.destroy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(tk_root: tk.Tk, tmp_path: Path) -> Iterator[_Window]:
|
||||
default = tmp_path / "ms-da.csv"
|
||||
default.write_bytes(DEFAULT_LIST.encode("utf-8"))
|
||||
application = app.Application(
|
||||
tk_root, NullSpeech(), _last, _repositories_for(tmp_path), default
|
||||
)
|
||||
dialogs = _Dialogs()
|
||||
menu = MenuView(
|
||||
tk_root,
|
||||
MenuViewModel(),
|
||||
application,
|
||||
ask_open_file=dialogs.ask_open_file,
|
||||
confirm=dialogs.confirm,
|
||||
show_error=dialogs.show_error,
|
||||
open_url=dialogs.open_url,
|
||||
)
|
||||
application.show_level_screen()
|
||||
instance = _Window(tk_root, application, menu, dialogs, tmp_path)
|
||||
yield instance
|
||||
_close(instance)
|
||||
|
||||
|
||||
def _labels(menu: tk.Menu, last: int) -> list[str]:
|
||||
return [
|
||||
"-" if menu.type(i) == "separator" else str(menu.entrycget(i, "label"))
|
||||
for i in range(last + 1)
|
||||
]
|
||||
|
||||
|
||||
def _start_first_level(window: _Window) -> FlashCardView:
|
||||
level_view = window.application.view
|
||||
assert isinstance(level_view, LevelView)
|
||||
level_view.buttons[0].invoke()
|
||||
card_view = window.application.view
|
||||
assert isinstance(card_view, FlashCardView)
|
||||
return card_view
|
||||
|
||||
|
||||
def _word(view: FlashCardView) -> str:
|
||||
return str(view.canvas.itemcget(view.word_text_id, "text"))
|
||||
|
||||
|
||||
def test_menu_bar_has_file_and_about_in_order(window: _Window) -> None:
|
||||
menubar = window.menu.menubar
|
||||
assert str(window.root.cget("menu")) == str(menubar)
|
||||
assert menubar.index("end") == ABOUT_INDEX
|
||||
assert _labels(menubar, ABOUT_INDEX) == ["File", "About"]
|
||||
|
||||
|
||||
def test_file_menu_has_four_entries_and_a_separator_before_exit(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
file_menu = window.menu.file_menu
|
||||
assert file_menu.index("end") == EXIT
|
||||
assert _labels(file_menu, EXIT) == [
|
||||
"Open...",
|
||||
"Change level...",
|
||||
"Reset word list...",
|
||||
"-",
|
||||
"Exit",
|
||||
]
|
||||
assert file_menu.type(SEPARATOR) == "separator"
|
||||
|
||||
|
||||
def test_file_entries_have_mnemonics(window: _Window) -> None:
|
||||
file_menu = window.menu.file_menu
|
||||
assert int(str(file_menu.entrycget(OPEN, "underline"))) == 0
|
||||
assert int(str(file_menu.entrycget(EXIT, "underline"))) == 1
|
||||
assert int(str(window.menu.menubar.entrycget(FILE_INDEX, "underline"))) == 0
|
||||
|
||||
|
||||
def test_about_menu_leads_to_the_contact_entry(window: _Window) -> None:
|
||||
about = window.menu.about_menu
|
||||
assert _labels(about, 0) == ["Contact"]
|
||||
assert about.type(0) == "cascade"
|
||||
contact = window.menu.contact_menu
|
||||
assert contact.index("end") == 0
|
||||
assert _labels(contact, 0) == [CONTACT_LABEL]
|
||||
|
||||
|
||||
def test_open_starts_the_chosen_list_after_a_level_choice(window: _Window) -> None:
|
||||
other = window.folder / "animals.csv"
|
||||
other.write_bytes(OTHER_LIST.encode("utf-8"))
|
||||
window.dialogs.file = str(other)
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert window.application.word_list_name == "animals.csv"
|
||||
assert _word(_start_first_level(window)) == "kucing"
|
||||
asked = window.dialogs.asked_files[0]
|
||||
assert asked["initialdir"] == str(window.folder)
|
||||
assert ("CSV files", "*.csv") in asked["filetypes"] # type: ignore[operator]
|
||||
assert window.root.title() == WINDOW_TITLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[None, b"ms;da\n\xff\xfe;x\n", "ms;da\nkøkken;x\n".encode("latin-1")],
|
||||
ids=["missing", "invalid-utf8", "latin-1"],
|
||||
)
|
||||
def test_open_of_an_invalid_file_shows_a_message_and_keeps_the_list(
|
||||
window: _Window, content: bytes | None
|
||||
) -> None:
|
||||
bad = window.folder / "bad.csv"
|
||||
if content is not None:
|
||||
bad.write_bytes(content)
|
||||
window.dialogs.file = str(bad)
|
||||
level_view = window.application.view
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert len(window.dialogs.errors) == 1
|
||||
assert "bad.csv" in window.dialogs.errors[0][1]
|
||||
assert window.application.view is level_view
|
||||
assert window.application.word_list_name == "ms-da.csv"
|
||||
assert _word(_start_first_level(window)) == "kau"
|
||||
|
||||
|
||||
def test_open_cancelled_changes_nothing(window: _Window) -> None:
|
||||
window.dialogs.file = ""
|
||||
level_view = window.application.view
|
||||
|
||||
window.menu.file_menu.invoke(OPEN)
|
||||
|
||||
assert window.application.view is level_view
|
||||
assert window.application.word_list_name == "ms-da.csv"
|
||||
assert not window.dialogs.errors
|
||||
|
||||
|
||||
def test_change_level_shows_the_level_screen_and_keeps_known_words(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
saved = progress.read_bytes()
|
||||
|
||||
window.menu.file_menu.invoke(CHANGE_LEVEL)
|
||||
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert card_view.flip_timer is None
|
||||
assert progress.read_bytes() == saved
|
||||
|
||||
|
||||
def test_reset_asks_and_on_yes_removes_progress_and_shows_level_screen(
|
||||
window: _Window,
|
||||
) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
assert progress.is_file()
|
||||
|
||||
window.menu.file_menu.invoke(RESET)
|
||||
|
||||
assert window.dialogs.questions == [
|
||||
"Start ms-da.csv from the beginning? All words will be to learn again."
|
||||
]
|
||||
assert not progress.exists()
|
||||
assert isinstance(window.application.view, LevelView)
|
||||
assert (window.folder / "ms-da.csv").read_bytes() == DEFAULT_LIST.encode("utf-8")
|
||||
|
||||
|
||||
def test_reset_answered_no_changes_nothing(window: _Window) -> None:
|
||||
card_view = _start_first_level(window)
|
||||
card_view.right_button.invoke()
|
||||
progress = window.folder / "words_to_learn.csv"
|
||||
window.dialogs.answer = False
|
||||
|
||||
window.menu.file_menu.invoke(RESET)
|
||||
|
||||
assert progress.is_file()
|
||||
assert window.application.view is card_view
|
||||
|
||||
|
||||
def test_contact_opens_the_linkedin_page(window: _Window) -> None:
|
||||
window.menu.contact_menu.invoke(0)
|
||||
assert window.dialogs.urls == [CONTACT_URL]
|
||||
assert CONTACT_URL == "https://www.linkedin.com/in/tirsvad74"
|
||||
|
||||
|
||||
def test_exit_cancels_the_timer_and_destroys_the_window(
|
||||
window: _Window, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
destroyed: list[bool] = []
|
||||
monkeypatch.setattr(window.root, "destroy", lambda: destroyed.append(True))
|
||||
card_view = _start_first_level(window)
|
||||
assert card_view.flip_timer is not None
|
||||
|
||||
window.menu.file_menu.invoke(EXIT)
|
||||
|
||||
assert destroyed == [True]
|
||||
assert card_view.flip_timer is None
|
||||
assert window.application.exit_code == 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Integration tests: every word list keeps its progress in a file of its own."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.value_objects.level import Level
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
MS_DA = "ms;da\naku;mig\nkau;du\nini;denne\n"
|
||||
ANIMALS = "ms;da\nkucing;kat\nanjing;hund\n"
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _lists(folder: Path) -> tuple[Path, Path]:
|
||||
default = folder / "ms-da.csv"
|
||||
other = folder / "animals.csv"
|
||||
default.write_bytes(MS_DA.encode("utf-8"))
|
||||
other.write_bytes(ANIMALS.encode("utf-8"))
|
||||
return default, other
|
||||
|
||||
|
||||
def _progress(folder: Path, list_path: Path) -> CsvProgressRepository:
|
||||
return CsvProgressRepository(folder / progress_file_name(list_path))
|
||||
|
||||
|
||||
def _know_first_word(folder: Path, list_path: Path) -> str:
|
||||
session = open_study_session(
|
||||
CsvVocabularyRepository(list_path),
|
||||
_progress(folder, list_path),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
Level(None),
|
||||
)
|
||||
card = session.next_card()
|
||||
assert card is not None
|
||||
session.mark_known()
|
||||
return card.front_text
|
||||
|
||||
|
||||
def _remaining(folder: Path, list_path: Path) -> list[str]:
|
||||
deck = _progress(folder, list_path).load()
|
||||
assert deck is not None
|
||||
return [card.front for card in deck.cards]
|
||||
|
||||
|
||||
def test_known_words_of_one_list_never_appear_in_the_other(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
|
||||
assert _know_first_word(tmp_path, default) == "aku"
|
||||
assert _know_first_word(tmp_path, other) == "kucing"
|
||||
|
||||
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||
assert _remaining(tmp_path, other) == ["anjing"]
|
||||
assert (tmp_path / "words_to_learn.csv").is_file()
|
||||
assert (tmp_path / "words_to_learn_animals.csv").is_file()
|
||||
|
||||
|
||||
def test_reset_of_one_list_deletes_only_its_progress_file(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
_know_first_word(tmp_path, default)
|
||||
_know_first_word(tmp_path, other)
|
||||
|
||||
_progress(tmp_path, other).reset()
|
||||
|
||||
assert not (tmp_path / "words_to_learn_animals.csv").exists()
|
||||
assert _remaining(tmp_path, default) == ["kau", "ini"]
|
||||
|
||||
|
||||
def test_reset_leaves_the_word_list_file_byte_identical(tmp_path: Path) -> None:
|
||||
default, other = _lists(tmp_path)
|
||||
_know_first_word(tmp_path, other)
|
||||
|
||||
_progress(tmp_path, other).reset()
|
||||
|
||||
assert other.read_bytes() == ANIMALS.encode("utf-8")
|
||||
assert default.read_bytes() == MS_DA.encode("utf-8")
|
||||
|
||||
|
||||
def test_delivered_list_keeps_words_to_learn_file_name(tmp_path: Path) -> None:
|
||||
default, _other = _lists(tmp_path)
|
||||
assert progress_file_name(default) == "words_to_learn.csv"
|
||||
@@ -110,3 +110,18 @@ def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
|
||||
assert deck is not None
|
||||
assert deck.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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests of the name of the words to learn file of a word list."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.persistence.progress_file_name import (
|
||||
progress_file_name,
|
||||
)
|
||||
|
||||
|
||||
def test_progress_file_name_is_default_for_the_delivered_list() -> None:
|
||||
assert progress_file_name(Path("data") / "ms-da.csv") == "words_to_learn.csv"
|
||||
|
||||
|
||||
def test_progress_file_name_uses_the_stem_for_another_list() -> None:
|
||||
assert progress_file_name(Path("x") / "animals.csv") == "words_to_learn_animals.csv"
|
||||
|
||||
|
||||
def test_progress_file_name_keeps_hyphen_and_underscore() -> None:
|
||||
name = progress_file_name(Path("my_list-2.csv"))
|
||||
assert name == "words_to_learn_my_list-2.csv"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "expected"),
|
||||
[
|
||||
("my list.csv", "words_to_learn_my_list.csv"),
|
||||
("a.b;c.csv", "words_to_learn_a_b_c.csv"),
|
||||
("ord (1).csv", "words_to_learn_ord__1_.csv"),
|
||||
],
|
||||
)
|
||||
def test_progress_file_name_replaces_other_characters(
|
||||
file_name: str, expected: str
|
||||
) -> None:
|
||||
assert progress_file_name(Path(file_name)) == expected
|
||||
+143
-2
@@ -63,6 +63,15 @@ class _FakeLevelView:
|
||||
class _FakeFlashCardView:
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Keep the version declared in code, pyproject.toml and the Doxyfile in step.
|
||||
|
||||
@file test_version.py
|
||||
@brief Unit tests that the three version declarations cannot drift.
|
||||
"""
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
import flash_card
|
||||
from flash_card.constants import PROJECT_ROOT
|
||||
|
||||
_SEMVER_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
_PROJECT_NUMBER_PATTERN = re.compile(r"^PROJECT_NUMBER\s*=\s*(\S+)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
with (PROJECT_ROOT / "pyproject.toml").open("rb") as file:
|
||||
version: str = tomllib.load(file)["project"]["version"]
|
||||
return version
|
||||
|
||||
|
||||
def test_version_matches_package_version_when_read_from_pyproject() -> None:
|
||||
assert _pyproject_version() == flash_card.__version__
|
||||
|
||||
|
||||
def test_version_has_major_minor_patch_form() -> None:
|
||||
assert _SEMVER_PATTERN.match(flash_card.__version__) is not None
|
||||
|
||||
|
||||
def test_version_matches_doxyfile_project_number() -> None:
|
||||
text = (PROJECT_ROOT / "Doxyfile").read_text(encoding="utf-8")
|
||||
match = _PROJECT_NUMBER_PATTERN.search(text)
|
||||
assert match is not None
|
||||
assert match.group(1) == flash_card.__version__
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user