Compare commits

..
Author SHA1 Message Date
Tirsvad 83fb0b1ae8 Resolve pending commit links of MIL-004 2026-10-14 22:05:52 +08:00
Tirsvad 02e0c2c55e MIL-004: Step 3 - Flip the cards
Review record RC-026 (Go, autonomous mode, not independent).

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
2026-10-14 22:05:48 +08:00
Tirsvad 9ecae3e6b2 Merge pull request 'MIL-003: Step 2 - New flash cards' (#40) from mil-003-step2-new-flash-cards into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:59:43 +02:00
Tirsvad 3acb3ba03d Resolve pending commit links of MIL-003 2026-10-14 21:59:36 +08:00
Tirsvad 8d66c43eea MIL-003: Step 2 - New flash cards
Review record RC-025 (Go, autonomous mode, not independent).

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
2026-10-14 21:59:29 +08:00
Tirsvad c6c737ce40 Merge pull request 'MIL-002: Step 1 - User interface' (#39) from mil-002-step1-user-interface into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-14 15:53:19 +02:00
Tirsvad 736729c99f Resolve pending commit links of MIL-002 2026-10-14 21:53:12 +08:00
Tirsvad 019565635d MIL-002: Step 1 - User interface
Review record RC-024 (Go, autonomous mode, not independent).

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
2026-10-14 21:53:07 +08:00
Tirsvad aecf7ad43c Merge pull request 'MIL-001: Project foundation' (#38) from mil-001-foundation into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-14 15:39:32 +02:00
Tirsvad 31ae445f5c Resolve pending commit link of RC-023 2026-10-14 21:39:22 +08:00
Tirsvad cc24b7d890 MIL-001: project foundation
pyproject.toml (Python >=3.13, no runtime dependencies, speech and dev extras),
Python .gitignore, layer skeleton with constants.py, Doxyfile, README with the
.venv instructions, the Malay to Danish word list and review record RC-023.

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Task: MIL-001#5
Task: MIL-001#6
2026-10-14 21:39:14 +08:00
Tirsvad 934d7e4902 Merge pull request 'Plan baseline: business case, stakeholders, plan, design, milestones' (#1) from baseline-plan into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:34:07 +02:00
61 changed files with 3775 additions and 2 deletions
+77
View File
@@ -0,0 +1,77 @@
# Local secrets: tokens are for personal use and are never part of the project
.env
.env.*
# Learner's own progress (created by the program on the first "known" answer)
data/words_to_learn.csv
# Virtual environments
.venv/
venv/
env/
# Generated documentation (doxygen Doxyfile)
docs/doxygen/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
# Type checkers and linters
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
.import_linter_cache/
# Jupyter, IDEs and operating systems
.ipynb_checkpoints
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
+36
View File
@@ -0,0 +1,36 @@
# Doxygen configuration for the flash card program.
# Build: doxygen Doxyfile (output: docs/doxygen/html/index.html)
# Only the settings that differ from the Doxygen defaults are listed.
PROJECT_NAME = "Flash Card"
PROJECT_BRIEF = "Malay to Danish flash cards (Udemy 100 Days of Code, day 31)"
PROJECT_NUMBER = 0.1.0
OUTPUT_DIRECTORY = docs/doxygen
CREATE_SUBDIRS = NO
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
EXCLUDE_PATTERNS = */__pycache__/* */.venv/*
# Python
OPTIMIZE_OUTPUT_JAVA = YES
PYTHON_DOCSTRING = NO
EXTRACT_ALL = NO
EXTRACT_PRIVATE = NO
EXTRACT_STATIC = YES
JAVADOC_AUTOBRIEF = YES
AUTOLINK_SUPPORT = YES
MARKDOWN_SUPPORT = YES
# Diagrams need Graphviz; leave them off so the build works everywhere.
HAVE_DOT = NO
# Output
GENERATE_HTML = YES
GENERATE_LATEX = NO
SOURCE_BROWSER = YES
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES
WARN_NO_PARAMDOC = YES
+131 -1
View File
@@ -1,2 +1,132 @@
# 031-flash-card # 🚀 Flash Card
A flash card program for learning the most frequent Malay words with their Danish translations. It is the solution of day 31 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse the repository for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The program shows a Malay word on a card. After three seconds the card turns over and shows the Danish translation. You press the green tick when you know the word and the red cross when you do not. A known word is removed from the words to learn, and the words still to learn are saved, so the next start continues where you stopped.
| Part | What it is for |
| --- | --- |
| `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` |
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
| `docs/` | Project documents (business case, plan, design, review records) |
| `Doxyfile` | Doxygen configuration for the source code documentation |
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
## 📋 Requirements
| Need | Why |
| --- | --- |
| Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) |
| `git` | To clone the repository |
| Doxygen (optional) | To build the source code documentation |
| `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection |
## 🛠️ Setup
Create a virtual environment in `.venv`, activate it, upgrade pip and install the project. Run the commands in the repository folder.
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
- Use `python -m pip install -e .` if you only want to run the program; the `dev` extra adds `pytest`, `ruff`, `mypy` and `import-linter`.
- Add the spoken word with `python -m pip install -e ".[speech]"`.
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once, or use `.\.venv\Scripts\activate.bat` in `cmd`.
- Leave the environment with `deactivate`. Delete the `.venv` folder to start over.
## ▶️ Run
```bash
python -m flash_card
```
The program is run from the activated `.venv`. The window opens with the first card; the program is built step by step and the pull requests and milestones show each step.
## 🧪 Tests
```bash
python -m pytest
```
Quality checks used by the project:
```bash
python -m ruff check src tests
python -m mypy
lint-imports
```
The window test in `tests/e2e/` is skipped when no display is available.
## 📄 License
GNU Affero General Public License v3.0 or later. See [LICENSE][license].
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[registry]: ./docs/artifact-registry.md
[DICT-001]: ./docs/dictionary.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+21
View File
@@ -0,0 +1,21 @@
# Data
| File | Content | In git |
| --- | --- | --- |
| `ms-da.csv` | The word list: 1000 of the most frequent Malay words with a Danish translation | yes |
| `words_to_learn.csv` | The words you have not marked as known; created by the program | no (`.gitignore`) |
## Format
- UTF-8 text, one card per line, cells separated by a semicolon (`;`).
- The first line is a header with the two language codes: `ms;da` (Malay, Danish).
- Blank lines are ignored.
- `words_to_learn.csv` has the same format, so it can be used as a word list.
## Origin
The Malay words are a frequency list built from film and series subtitles (the *FrequencyWords* lists by the GitHub user `hermitdave`). The Danish column was filled with machine translation in a spreadsheet, as the course suggests. A word can therefore have a translation that does not fit its use; edit the file if you find one.
## Start again
Delete `words_to_learn.csv`. The next start uses `ms-da.csv`.
+1001
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| DCD | Design Class Diagram | docs/dcd.md | 002 | | DCD | Design Class Diagram | docs/dcd.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 023 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 027 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
+64
View File
@@ -0,0 +1,64 @@
# RC-023: Review of PY-MIL-001
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-023 |
| CrossReference | [PY-MIL-001], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [cc24b7d] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-001]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and constants use snake_case and UPPER_SNAKE; 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 (`FLIP_DELAY_MS`, `DEFAULT_DATA_DIR`); no abbreviations. |
| 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 | Every function has annotated parameters and return type; `mypy --strict` passes on 9 files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling exists yet; no bare except. |
| 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 | N-A | No file or connection is opened yet. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every module and function has a Doxygen docstring with `@file`/`@brief`/`@param`/`@return`; `doxygen Doxyfile` ends with no warning. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no logging needed yet. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `app.main` and `app.run` are the `app.main` of DCD-001; constants have no design class. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | `tests/unit/test_constants.py` and `test_app.py` (7 tests) name the behaviour and use no network. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any` is used. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is empty; extras are declared in `pyproject.toml` with version ranges, and `playsound` is pinned. |
| 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 | Every constant is defined once in `constants.py`. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports the contract `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 | Modules are small and single-purpose; nothing to extend yet. |
| 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 | `domain`, `application`, `infrastructure`, `presentation` packages exist under `src/flash_card/`, and `tests/unit`, `integration`, `e2e` exist. |
| 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 are English lower snake_case with no space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-001 Go criteria: 1 `pip install -e ".[dev]"` succeeded in a fresh `.venv`; 2 `git check-ignore` reports `.venv/`, `.env`, `data/words_to_learn.csv`, `docs/doxygen/`; 3 packages and test folders exist; 4 `doxygen Doxyfile` exit 0 with no warning; 5 `check-readme.sh` exit 0; 6 `pytest` 7 passed. All six Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-001]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[cc24b7d]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/cc24b7d890dbef41c303ad14aea4119cd505c51d
+64
View File
@@ -0,0 +1,64 @@
# RC-024: Review of PY-MIL-002
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-024 |
| CrossReference | [PY-MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [0195656] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-002]
- 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 | Modules, classes and functions use snake_case and PascalCase; ruff rule set N passes on `src`, `tests` and `tools`. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`FlashCardView`, `language_text_id`, `generate_images`). |
| 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 | Every signature is annotated; `mypy --strict` passes on 13 source files and on `tools/generate_images.py`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The only `except` is `tk.TclError` in the e2e fixture, used to skip when no display exists; none is bare or 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 | Files are written with `Path.write_bytes`; the Tk root is destroyed in the fixture teardown. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions 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`; no logging is needed in a view. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `FlashCardView` and `FlashCardViewModel` are classes of DCD-001 with the names and responsibilities given there; `create_application` is the composition root `app.main` of DCD-001. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 18 tests: view model, image generator, application entry, and 5 e2e tests of the real window (not skipped on this machine); none uses the network. |
| 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; the tool uses the standard library only. |
| 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 | Colours, sizes, fonts and file names come from `constants.py` once; the image generator reads sizes from it. |
| 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; the 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 only builds widgets; the texts come from the view model (single responsibility); nothing is switched on type. |
| 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/flash_card/` holds view and view model as the clean-architecture skill's default layout; tests are in `unit` and `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 are English snake_case; no space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-002 Go criteria: 1 title, colour and padding asserted by the e2e test; 2 canvas 800 x 526 with one image and two texts (Arial 40 italic, Arial 60 bold) asserted; 3 buttons use `wrong.png`/`right.png`, bd 0, grid row 1 columns 0 and 1 asserted; 4 the view model supplies the texts and its tests import no Tkinter; 5 this record; 6 `pytest` 18 passed. All Go. The generated card images were inspected by eye.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-002]: ../../../src/flash_card/presentation
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[0195656]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/019565635df17077c937b9e54685cd3d7bdb1e3e
+64
View File
@@ -0,0 +1,64 @@
# RC-025: Review of PY-MIL-003
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-025 |
| CrossReference | [PY-MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8d66c43] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-003]
- 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 | Packages, modules, classes and functions follow PEP 8 casing; 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 in the dictionary's IT terms (`Deck`, `Vocabulary`, `StudySession`, `CardDto`). |
| 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 43 files with no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 31 source files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | `read_word_list` raises `VocabularyNotFoundError` with `from` to keep the cause; no bare or swallowed exception; `Deck.remove` raises `ValueError` (not in OC-001, a deviation recorded here). |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin shadowed (`Card`, `Deck` do not shadow). |
| 7 | Files, locks and connections are managed with context managers | Pass | The CSV file is opened with `with` in `read_word_list`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions 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`; `app.main` logs errors with `logging`; no personal data logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | Classes and operations are those of DCD-001 (`Card`, `LanguagePair`, `Vocabulary`, `Deck`, `StudySession`, `CardDto`, `CsvVocabularyRepository`, view and view model). Deviations: `Deck.remove` raises `ValueError` when the card is absent; `read_word_list` is a shared function behind both CSV repositories, as MIL-005 task 1 requires. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 66 tests (unit by layer, integration on the real `data/ms-da.csv`, e2e on the real window) named for behaviour; none uses the network or depends on order. |
| 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; `csv`, `random` and `pathlib` are standard library (ADR-0001). |
| 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 CSV format, encoding and delimiter are defined once in `constants.py` and `read_word_list`; the draw rule is only in `Deck.draw`. |
| 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` or `random` import in `domain` or `application`. |
| 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 reason to change per class; the choice function is injected into `StudySession` and `Deck.draw`, and the repository is a Protocol that the use case receives. |
| 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 | Source is in `domain`, `application`, `infrastructure`, `presentation` with the sub-folders of the clean-architecture skill; tests 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 are English snake_case, no space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-003 Go criteria: 1 integration test reads 1000 cards, pair ms/da; 2 missing header, bad row and missing file raise `VocabularyFormatError`/`VocabularyNotFoundError` (unit tests); 3 a press shows a different card when two or more exist and the same when one (unit tests); 4 blank lines are ignored (unit test); 5 no `tkinter`, `csv` or `random` import in domain/application (grep, empty); 6 this record and `pytest` 66 passed. A manual smoke run on the real file showed a new word after a press. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-003]: ../../../src/flash_card
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[8d66c43]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8d66c43eea9673728e4f7497d7673498306e8b13
+64
View File
@@ -0,0 +1,64 @@
# RC-026: Review of PY-MIL-004
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-026 |
| CrossReference | [PY-MIL-004], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [02e0c2c] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-004]
- 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; 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 are the assignment's (`flip_card`, `flip_timer`, `next_card`) and the IT terms of the dictionary; `Side` states its purpose. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass with no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 31 source files. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added; none is bare. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers | Pass | No file or connection is opened in this change. |
| 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`; no logging needed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `Side`, `flip_card`, `flip_timer` and the view's timer follow DCD-001 and SD-001 (`flip_card` row of Method Traceability, timer step of SSD-001). |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 76 tests: view model sides, finished state, flip rules; e2e front and back state, recording scheduler, real 50 ms timer; no network, no order dependency (state reset in each test). |
| 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. |
| 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 side rule exists once in the view model; image and colour names come from `constants.py`; `_show_card` is the only place that draws a card. |
| 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; the 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 holds the side rule and the view only draws; a new side would extend `Side` and `_show_card`. |
| 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 | Files stay in `presentation/features/flash_card/`; tests in `unit` and `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 | Names follow the rule; no new file contains a space. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Milestone MIL-004 Go criteria: 1 `flip_card` changes side, language name and text (unit test); 2 `next_card` returns to the front (unit test); 3 `after(3000, flip_card)` is scheduled and the earlier id is cancelled first (recording scheduler e2e test); 4 back image and white text, front image and black text (e2e); 5 `PhotoImage` objects are attributes created in `__init__`, none in a callback (grep); 6 this record, `pytest` 76 passed. A manual run with a 100 ms delay showed `Danish` and white text. All Go.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-004]: ../../../src/flash_card/presentation
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[02e0c2c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/02e0c2c55e2a62ca13e2dec9dbf3aa823cf16242
+12
View File
@@ -45,6 +45,10 @@ updated whenever an artifact instance is created or reviewed.
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] | | MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] | | MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] | | MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
| 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] |
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
## Coverage Notes ## Coverage Notes
@@ -76,6 +80,7 @@ updated whenever an artifact instance is created or reviewed.
[MIL-005]: ../milestones/mil-005-step4-save-progress.md [MIL-005]: ../milestones/mil-005-step4-save-progress.md
[MIL-006]: ../milestones/mil-006-malay-speech.md [MIL-006]: ../milestones/mil-006-malay-speech.md
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md [MIL-007]: ../milestones/mil-007-documentation-and-publication.md
[PY-MIL-001]: ../../src/flash_card
[RC-001]: ./reviews/rc-001-business-case.md [RC-001]: ./reviews/rc-001-business-case.md
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md [RC-002]: ./reviews/rc-002-stakeholder-analysis.md
[RC-003]: ./reviews/rc-003-project-plan.md [RC-003]: ./reviews/rc-003-project-plan.md
@@ -98,4 +103,11 @@ updated whenever an artifact instance is created or reviewed.
[RC-020]: ./reviews/rc-020-mil-005.md [RC-020]: ./reviews/rc-020-mil-005.md
[RC-021]: ./reviews/rc-021-mil-006.md [RC-021]: ./reviews/rc-021-mil-006.md
[RC-022]: ./reviews/rc-022-mil-007.md [RC-022]: ./reviews/rc-022-mil-007.md
[RC-023]: ./reviews/rc-023-py-mil-001.md
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3 [c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
[RC-024]: ./reviews/rc-024-py-mil-002.md
[PY-MIL-002]: ../../src/flash_card
[RC-025]: ./reviews/rc-025-py-mil-003.md
[PY-MIL-003]: ../../src/flash_card
[RC-026]: ./reviews/rc-026-py-mil-004.md
[PY-MIL-004]: ../../src/flash_card
+75
View File
@@ -0,0 +1,75 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "flash-card"
version = "0.1.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"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
authors = [{ name = "Jens Tirsvad Nielsen" }]
keywords = ["flashcards", "tkinter", "language-learning", "malay", "danish", "udemy"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.13",
"Environment :: X11 Applications",
"Topic :: Education",
]
# The core program uses the Python standard library only (ADR-0001).
dependencies = []
[project.optional-dependencies]
# Spoken Malay word (text to speech). Optional; the program works without it.
speech = ["gTTS>=2.5", "playsound==1.2.2"]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
[project.scripts]
flash-card = "flash_card.presentation.app:run"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
flash_card = ["presentation/assets/*.png"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra"
[tool.ruff]
line-length = 88
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "N", "D"]
ignore = ["D203", "D213"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D"]
[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.mypy]
python_version = "3.13"
strict = true
mypy_path = ["src"]
packages = ["flash_card"]
[tool.importlinter]
root_package = "flash_card"
[[tool.importlinter.contracts]]
name = "Layers point inward"
type = "layers"
layers = [
"flash_card.presentation",
"flash_card.infrastructure",
"flash_card.application",
"flash_card.domain",
]
+7
View File
@@ -0,0 +1,7 @@
"""Flash card program that teaches Malay words with Danish translations.
@file __init__.py
@brief Package root of the flash card program.
"""
__version__ = "0.1.0"
+10
View File
@@ -0,0 +1,10 @@
"""Run the flash card program with ``python -m flash_card``.
@file __main__.py
@brief Module entry point that calls the presentation layer.
"""
from flash_card.presentation.app import run
if __name__ == "__main__":
run()
+5
View File
@@ -0,0 +1,5 @@
"""Application layer: use cases and ports.
@file __init__.py
@brief Holds use cases and the ports they need; may import only the domain layer.
"""
@@ -0,0 +1,5 @@
"""Application data transfer objects.
@file __init__.py
@brief Holds the plain data that crosses to the presentation layer.
"""
@@ -0,0 +1,21 @@
"""Card data for the presentation layer.
@file card_dto.py
@brief The CardDto: a card as plain text.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class CardDto:
"""A card as plain data."""
## @brief Word on the card front.
front_text: str
## @brief Translation on the card back.
back_text: str
## @brief Language code of the front.
front_language: str
## @brief Language code of the back.
back_language: str
@@ -0,0 +1,5 @@
"""Application use cases.
@file __init__.py
@brief Holds the use cases of the flash card program.
"""
@@ -0,0 +1,27 @@
"""Start a study session.
@file open_study_session.py
@brief The open_study_session use case.
"""
from collections.abc import Callable, Sequence
from flash_card.application.use_cases.study_session import StudySession
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
def open_study_session(
vocabulary_repository: VocabularyRepository,
choose: Callable[[Sequence[Card]], Card],
) -> StudySession:
"""Load the word list and build a session over all its cards.
@param vocabulary_repository Source of the word list.
@param choose Picks one card from a non-empty sequence.
@return The new session.
@throws VocabularyError When the word list cannot be loaded.
"""
deck = Deck.from_vocabulary(vocabulary_repository.load())
return StudySession(deck, choose)
@@ -0,0 +1,66 @@
"""One study session.
@file study_session.py
@brief The StudySession use case: draws the cards the user studies.
"""
from collections.abc import Callable, Sequence
from flash_card.application.dto.card_dto import CardDto
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
class StudySession:
"""Draws cards from a deck, one after another."""
def __init__(self, deck: Deck, choose: Callable[[Sequence[Card]], Card]) -> None:
"""Create a session.
@param deck The cards still to learn.
@param choose Picks one card from a non-empty sequence.
"""
## @brief The cards still to learn.
self._deck = deck
## @brief Picks one card from a non-empty sequence.
self._choose = choose
## @brief The card shown now, or None.
self._current: Card | None = None
@property
def current_card(self) -> CardDto | None:
"""Return the card shown now.
@return The card, or None before the first draw or when none is left.
"""
return self._to_dto(self._current)
@property
def remaining(self) -> int:
"""Return the number of cards still to learn.
@return The card count.
"""
return len(self._deck)
def next_card(self) -> CardDto | None:
"""Draw the next card, different from the current one when possible.
@return The new card, or None when the deck is empty.
"""
if self._deck.is_empty:
self._current = None
return None
self._current = self._deck.draw(self._choose, exclude=self._current)
return self._to_dto(self._current)
def _to_dto(self, card: Card | None) -> CardDto | None:
"""Map a card to its DTO.
@param card The card, or None.
@return The DTO, or None when there is no card.
"""
if card is None:
return None
pair = self._deck.language_pair
return CardDto(card.front, card.back, pair.front_code, pair.back_code)
+70
View File
@@ -0,0 +1,70 @@
"""All constants of the flash card program, defined once.
@file constants.py
@brief Window, card, font, language, file and asset constants.
Imports only the standard library.
"""
from collections.abc import Mapping
from pathlib import Path
from typing import Final
## @brief Window background colour.
BACKGROUND_COLOR: Final[str] = "#B1DDC6"
## @brief Window title.
WINDOW_TITLE: Final[str] = "Flashy"
## @brief Padding around the card in pixels.
WINDOW_PADDING: Final[int] = 50
## @brief Card width in pixels.
CARD_WIDTH: Final[int] = 800
## @brief Card height in pixels.
CARD_HEIGHT: Final[int] = 526
## @brief Horizontal centre of the card in pixels.
CARD_CENTER_X: Final[int] = 400
## @brief Vertical position of the language text in pixels.
LANGUAGE_TEXT_Y: Final[int] = 150
## @brief Vertical position of the word text in pixels.
WORD_TEXT_Y: Final[int] = 263
## @brief Delay before the card flips, in milliseconds.
FLIP_DELAY_MS: Final[int] = 3000
## @brief Font of the language text: family, size, style.
LANGUAGE_FONT: Final[tuple[str, int, str]] = ("Arial", 40, "italic")
## @brief Font of the word text: family, size, style.
WORD_FONT: Final[tuple[str, int, str]] = ("Arial", 60, "bold")
## @brief Text colour on the card front.
FRONT_TEXT_COLOR: Final[str] = "black"
## @brief Text colour on the card back.
BACK_TEXT_COLOR: Final[str] = "white"
## @brief Display names by language code; the caller shows unknown codes in upper case.
LANGUAGE_NAMES: Final[Mapping[str, str]] = {"ms": "Malay", "da": "Danish"}
## @brief Message shown when no word is left to learn.
FINISHED_MESSAGE: Final[str] = "Nothing left to learn"
## @brief Delimiter of the CSV files.
CSV_DELIMITER: Final[str] = ";"
## @brief Encoding of the CSV files; drops a leading byte order mark.
CSV_ENCODING: Final[str] = "utf-8-sig"
## @brief File name of the vocabulary.
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 Language code used for speech.
SPEECH_LANGUAGE_CODE: Final[str] = "ms"
## @brief Root folder of the project.
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
## @brief Default folder of the data files.
DEFAULT_DATA_DIR: Final[Path] = PROJECT_ROOT / "data"
## @brief Folder of the image assets.
ASSETS_DIR: Final[Path] = Path(__file__).resolve().parent / "presentation" / "assets"
## @brief File name of the card front image.
CARD_FRONT_IMAGE_NAME: Final[str] = "card_front.png"
## @brief File name of the card back image.
CARD_BACK_IMAGE_NAME: Final[str] = "card_back.png"
## @brief File name of the right button image.
RIGHT_IMAGE_NAME: Final[str] = "right.png"
## @brief File name of the wrong button image.
WRONG_IMAGE_NAME: Final[str] = "wrong.png"
+6
View File
@@ -0,0 +1,6 @@
"""Domain layer: entities and rules of the flash card program.
@file __init__.py
@brief Holds entities, value objects and rules.
May import nothing outside the standard library.
"""
@@ -0,0 +1,5 @@
"""Domain entities.
@file __init__.py
@brief Holds the entities of the flash card program.
"""
+17
View File
@@ -0,0 +1,17 @@
"""A flash card.
@file card.py
@brief The Card entity: a word and its translation.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Card:
"""A word and its translation."""
## @brief The word in the front language.
front: str
## @brief The translation in the back language.
back: str
+95
View File
@@ -0,0 +1,95 @@
"""The cards still to learn.
@file deck.py
@brief The Deck entity: holds cards, draws one and removes one.
"""
from collections.abc import Callable, Iterable, Sequence
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair
class Deck:
"""The cards still to learn, of one language pair."""
def __init__(self, language_pair: LanguagePair, cards: Iterable[Card]) -> None:
"""Create a deck.
@param language_pair The languages of the cards.
@param cards The cards of the deck.
"""
## @brief The languages of the cards.
self._language_pair = language_pair
## @brief The cards still in the deck.
self._cards = list(cards)
@classmethod
def from_vocabulary(cls, vocabulary: Vocabulary) -> "Deck":
"""Create a deck holding every card of a vocabulary.
@param vocabulary The delivered word list.
@return A new deck.
"""
return cls(vocabulary.language_pair, vocabulary.cards)
@property
def language_pair(self) -> LanguagePair:
"""Return the languages of the cards.
@return The language pair.
"""
return self._language_pair
@property
def cards(self) -> tuple[Card, ...]:
"""Return a copy of the cards.
@return The cards in deck order.
"""
return tuple(self._cards)
@property
def is_empty(self) -> bool:
"""Tell whether the deck has no card.
@return True when no card is left.
"""
return not self._cards
def __len__(self) -> int:
"""Return the number of cards.
@return The card count.
"""
return len(self._cards)
def draw(
self,
choose: Callable[[Sequence[Card]], Card],
exclude: Card | None = None,
) -> Card:
"""Pick a card without removing it.
The pick avoids the excluded card when another card exists.
@param choose Picks one card from a non-empty sequence.
@param exclude A card to avoid, usually the one shown now.
@return The picked card.
@throws EmptyDeckError When the deck has no card.
"""
if not self._cards:
raise EmptyDeckError("cannot draw from an empty deck")
others = [card for card in self._cards if card != exclude]
return choose(others or self._cards)
def remove(self, card: Card) -> None:
"""Remove the first card equal to the given one.
@param card The card to remove.
@return Nothing.
@throws ValueError When the deck holds no such card.
"""
self._cards.remove(card)
@@ -0,0 +1,20 @@
"""The delivered word list.
@file vocabulary.py
@brief The Vocabulary entity: all cards of a language pair.
"""
from dataclasses import dataclass
from flash_card.domain.entities.card import Card
from flash_card.domain.value_objects.language_pair import LanguagePair
@dataclass(frozen=True, slots=True)
class Vocabulary:
"""All cards of a language pair as delivered."""
## @brief The languages of the cards.
language_pair: LanguagePair
## @brief The cards in file order.
cards: tuple[Card, ...]
+5
View File
@@ -0,0 +1,5 @@
"""Domain errors.
@file __init__.py
@brief Holds the errors the domain rules raise.
"""
@@ -0,0 +1,21 @@
"""Errors of the vocabulary and the deck.
@file vocabulary_errors.py
@brief The VocabularyError hierarchy.
"""
class VocabularyError(Exception):
"""Base of every error about the word list or the deck."""
class VocabularyFormatError(VocabularyError):
"""The word list is not in the expected format."""
class VocabularyNotFoundError(VocabularyError):
"""The word list does not exist."""
class EmptyDeckError(VocabularyError):
"""A card was drawn from a deck with no card."""
@@ -0,0 +1,5 @@
"""Domain repository ports.
@file __init__.py
@brief Holds the repository interfaces of the domain.
"""
@@ -0,0 +1,21 @@
"""Port to the delivered word list.
@file vocabulary_repository.py
@brief The VocabularyRepository interface.
"""
from typing import Protocol
from flash_card.domain.entities.vocabulary import Vocabulary
class VocabularyRepository(Protocol):
"""Source of the delivered word list."""
def load(self) -> Vocabulary:
"""Load the delivered word list.
@return The vocabulary.
@throws VocabularyError When the word list cannot be loaded.
"""
...
@@ -0,0 +1,5 @@
"""Domain value objects.
@file __init__.py
@brief Holds the immutable value objects of the domain.
"""
@@ -0,0 +1,17 @@
"""The two languages of a word list.
@file language_pair.py
@brief The LanguagePair value object.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class LanguagePair:
"""The language codes of the front and the back of the cards."""
## @brief Language code of the card front.
front_code: str
## @brief Language code of the card back.
back_code: str
@@ -0,0 +1,5 @@
"""Infrastructure layer: adapters for files, speech and other outside systems.
@file __init__.py
@brief Holds implementations of ports; may import the application and domain layers.
"""
@@ -0,0 +1,5 @@
"""Persistence adapters.
@file __init__.py
@brief Holds the adapters that read and write files.
"""
@@ -0,0 +1,35 @@
"""CSV implementation of the vocabulary port.
@file csv_vocabulary_repository.py
@brief The CsvVocabularyRepository adapter.
"""
from pathlib import Path
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
class CsvVocabularyRepository:
"""Loads the delivered word list from a CSV file."""
def __init__(self, path: Path) -> None:
"""Create the repository.
@param path The word list file.
"""
## @brief The word list file.
self._path = path
def load(self) -> Vocabulary:
"""Load the word list.
@return The vocabulary.
@throws VocabularyNotFoundError When the file does not exist.
@throws VocabularyFormatError When the file is malformed or has no card.
"""
pair, cards = read_word_list(self._path)
if not cards:
raise VocabularyFormatError(f"{self._path}: word list has no card")
return Vocabulary(pair, cards)
@@ -0,0 +1,86 @@
"""Reading of the word list CSV file.
@file csv_word_list.py
@brief Reads a semicolon separated word list with a language code header.
"""
import csv
from pathlib import Path
from flash_card.constants import CSV_DELIMITER, CSV_ENCODING
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import (
VocabularyFormatError,
VocabularyNotFoundError,
)
from flash_card.domain.value_objects.language_pair import LanguagePair
## @brief Number of cells in the header and in every card row.
_CELLS_PER_ROW = 2
def _format_error(path: Path, line: int, reason: str) -> VocabularyFormatError:
"""Build a format error naming the file and line.
@param path The file read.
@param line The line number of the fault.
@param reason What is wrong.
@return The error to raise.
"""
return VocabularyFormatError(f"{path}, line {line}: {reason}")
def _parse_header(path: Path, line: int, row: list[str]) -> LanguagePair:
"""Parse the header row.
@param path The file read.
@param line The line number of the row.
@param row The cells of the row.
@return The language pair.
@throws VocabularyFormatError When the header is not two codes.
"""
cells = [cell.strip() for cell in row]
if len(cells) != _CELLS_PER_ROW or not all(cells):
raise _format_error(path, line, "header must hold two language codes")
return LanguagePair(cells[0], cells[1])
def _parse_card(path: Path, line: int, row: list[str]) -> Card:
"""Parse a card row.
@param path The file read.
@param line The line number of the row.
@param row The cells of the row.
@return The card.
@throws VocabularyFormatError When the row is not two cells.
"""
if len(row) != _CELLS_PER_ROW:
raise _format_error(path, line, "row must hold exactly two cells")
return Card(row[0].strip(), row[1].strip())
def read_word_list(path: Path) -> tuple[LanguagePair, tuple[Card, ...]]:
"""Read a word list file.
@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.
"""
pair: LanguagePair | None = None
cards: list[Card] = []
try:
with path.open(encoding=CSV_ENCODING, newline="") as file:
reader = csv.reader(file, delimiter=CSV_DELIMITER)
for row in reader:
if not any(cell.strip() for cell in row):
continue
if pair is None:
pair = _parse_header(path, reader.line_num, row)
else:
cards.append(_parse_card(path, reader.line_num, row))
except FileNotFoundError as error:
raise VocabularyNotFoundError(f"word list not found: {path}") from error
if pair is None:
raise _format_error(path, 1, "file is empty")
return pair, tuple(cards)
+5
View File
@@ -0,0 +1,5 @@
"""Presentation layer: views, view models and the entry point.
@file __init__.py
@brief Holds the user interface and the composition root; may import every inner layer.
"""
+77
View File
@@ -0,0 +1,77 @@
"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, its view model and view, and runs the main loop.
"""
import argparse
import logging
import random
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from typing import NoReturn
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
def create_application(
data_dir: Path | None = None,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it.
@throws VocabularyError When the word list cannot be loaded.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
session = open_study_session(repository, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
return root, view
def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for those of the process.
@return The exit code, 0 on success and 1 when the word list is unusable.
"""
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir)
except VocabularyError as error:
_logger.error("%s", error)
return 1
root.mainloop()
return 0
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
"""Features of the presentation layer, one folder each."""
@@ -0,0 +1 @@
"""The flash card feature: its view and view model."""
@@ -0,0 +1,181 @@
"""View of the flash card screen.
@file flash_card_view.py
@brief Builds the window of the wireframe: card canvas and two buttons.
"""
import tkinter as tk
from flash_card.constants import (
ASSETS_DIR,
BACK_TEXT_COLOR,
BACKGROUND_COLOR,
CARD_BACK_IMAGE_NAME,
CARD_CENTER_X,
CARD_FRONT_IMAGE_NAME,
CARD_HEIGHT,
CARD_WIDTH,
FLIP_DELAY_MS,
FRONT_TEXT_COLOR,
LANGUAGE_FONT,
LANGUAGE_TEXT_Y,
RIGHT_IMAGE_NAME,
WINDOW_PADDING,
WINDOW_TITLE,
WORD_FONT,
WORD_TEXT_Y,
WRONG_IMAGE_NAME,
)
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
Side,
)
class FlashCardView:
"""The flash card window: a card and the wrong and right buttons."""
def __init__(self, root: tk.Tk, view_model: FlashCardViewModel) -> None:
"""Build the window on a root.
@param root The Tk root window.
@param view_model The data to show.
"""
## @brief The Tk root window.
self._root = root
## @brief The data source of the texts.
self._view_model = view_model
root.title(WINDOW_TITLE)
root.config(padx=WINDOW_PADDING, pady=WINDOW_PADDING, bg=BACKGROUND_COLOR)
# Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Card front image, kept so Tk does not drop it.
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
## @brief Card back image, kept so Tk does not drop it.
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it.
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
## @brief Right button image, kept so Tk does not drop it.
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
## @brief Canvas that draws the card.
self.canvas = tk.Canvas(
root,
width=CARD_WIDTH,
height=CARD_HEIGHT,
bg=BACKGROUND_COLOR,
highlightthickness=0,
)
## @brief Canvas item id of the card image.
self.image_id = self.canvas.create_image(
CARD_CENTER_X, CARD_HEIGHT / 2, image=self._card_front_image
)
## @brief Canvas item id of the language name text.
self.language_text_id = self.canvas.create_text(
CARD_CENTER_X,
LANGUAGE_TEXT_Y,
text=view_model.language_name,
font=LANGUAGE_FONT,
fill=FRONT_TEXT_COLOR,
)
## @brief Canvas item id of the word text.
self.word_text_id = self.canvas.create_text(
CARD_CENTER_X,
WORD_TEXT_Y,
text=view_model.text,
font=WORD_FONT,
fill=FRONT_TEXT_COLOR,
)
self.canvas.grid(row=0, column=0, columnspan=2)
## @brief Button for a word the user does not know.
self.wrong_button = tk.Button(
root,
image=self._wrong_image,
highlightthickness=0,
bd=0,
command=self.next_card,
)
self.wrong_button.grid(row=1, column=0)
## @brief Button for a word the user knows.
self.right_button = tk.Button(
root,
image=self._right_image,
highlightthickness=0,
bd=0,
command=self.next_card,
)
self.right_button.grid(row=1, column=1)
## @brief Id of the pending flip timer, or None when none is pending.
self.flip_timer: str | None = None
self._start_flip_timer()
@property
def card_front_image(self) -> tk.PhotoImage:
"""Return the card front image.
@return The image shown while the front side is up.
"""
return self._card_front_image
@property
def card_back_image(self) -> tk.PhotoImage:
"""Return the card back image.
@return The image shown while the back side is up.
"""
return self._card_back_image
def next_card(self) -> None:
"""Show the next card front and restart the flip timer.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.next_card()
self._show_card()
self._start_flip_timer()
def flip_card(self) -> None:
"""Show the back of the current card.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.flip_card()
self._show_card()
def _cancel_flip_timer(self) -> None:
"""Cancel the pending flip timer, if any.
@return Nothing.
"""
if self.flip_timer is not None:
self._root.after_cancel(self.flip_timer)
self.flip_timer = None
def _start_flip_timer(self) -> None:
"""Schedule the flip unless no card is left.
@return Nothing.
"""
if not self._view_model.is_finished:
self.flip_timer = self._root.after(FLIP_DELAY_MS, self.flip_card)
def _show_card(self) -> None:
"""Draw image and texts of the side the view model shows.
@return Nothing.
"""
if self._view_model.side is Side.BACK:
image, color = self._card_back_image, BACK_TEXT_COLOR
else:
image, color = self._card_front_image, FRONT_TEXT_COLOR
self.canvas.itemconfig(self.image_id, image=image)
self.canvas.itemconfig(
self.language_text_id, text=self._view_model.language_name, fill=color
)
self.canvas.itemconfig(
self.word_text_id, text=self._view_model.text, fill=color
)
@@ -0,0 +1,104 @@
"""View model of the flash card screen.
@file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import.
"""
import enum
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class Side(enum.Enum):
"""The side of a card that is shown."""
## @brief The front: the word to learn.
FRONT = enum.auto()
## @brief The back: the translation.
BACK = enum.auto()
class FlashCardViewModel:
"""Data the flash card view shows."""
def __init__(
self,
session: StudySession,
language_names: Mapping[str, str] = LANGUAGE_NAMES,
) -> None:
"""Create the view model and draw the first card.
@param session The study session that supplies the cards.
@param language_names Display names by language code.
"""
## @brief The study session that supplies the cards.
self._session = session
## @brief Display names by language code.
self._language_names = language_names
## @brief The card shown now, or None when none is left.
self._card: CardDto | None = session.next_card()
## @brief The side of the card shown.
self._side = Side.FRONT
@property
def is_finished(self) -> bool:
"""Tell whether no card is left to show.
@return True when there is no card.
"""
return self._card is None
@property
def side(self) -> Side:
"""Return the side of the card shown.
@return FRONT or BACK; FRONT when no card is left.
"""
return self._side
@property
def language_name(self) -> str:
"""Return the language name shown on the card.
@return The display name of the language of the side shown, upper case
code when unknown, or an empty string when no card is left.
"""
if self._card is None:
return ""
if self._side is Side.BACK:
code = self._card.back_language
else:
code = self._card.front_language
return self._language_names.get(code, code.upper())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The text of the side shown, or the finished message when no
card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card and show its front.
@return Nothing.
"""
self._card = self._session.next_card()
self._side = Side.FRONT
def flip_card(self) -> None:
"""Show the back of the card; does nothing when finished or flipped.
@return Nothing.
"""
if self._card is not None:
self._side = Side.BACK
View File
+247
View File
@@ -0,0 +1,247 @@
"""End-to-end tests of the flash card window.
@file test_flash_card_window.py
@brief Builds the real window with Tk and checks it against the wireframe.
"""
import time
import tkinter as tk
from collections.abc import Callable, Iterator, Sequence
import pytest
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import (
BACK_TEXT_COLOR,
BACKGROUND_COLOR,
CARD_HEIGHT,
CARD_WIDTH,
FLIP_DELAY_MS,
FRONT_TEXT_COLOR,
LANGUAGE_FONT,
WINDOW_PADDING,
WINDOW_TITLE,
WORD_FONT,
)
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.presentation.features.flash_card import flash_card_view
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
CARDS = (Card("aku", "mig"), Card("kau", "du"), Card("ini", "denne"))
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _build_view(root: tk.Tk) -> FlashCardView:
deck = Deck(LanguagePair("ms", "da"), CARDS)
return FlashCardView(root, FlashCardViewModel(StudySession(deck, _first)))
def _shown(view: FlashCardView) -> tuple[str, str]:
return (
str(view.canvas.itemcget(view.language_text_id, "text")),
str(view.canvas.itemcget(view.word_text_id, "text")),
)
@pytest.fixture(scope="module")
def window() -> Iterator[tuple[tk.Tk, FlashCardView]]:
# One Tk per module: a second interpreter right after destroying the
# first fails to find init.tcl on some Windows Python installs.
try:
root = tk.Tk()
except tk.TclError:
pytest.skip("no display available for Tk")
view = _build_view(root)
root.update_idletasks()
yield root, view
root.destroy()
def test_window_has_title_background_and_padding(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, _view = window
assert root.title() == WINDOW_TITLE
assert root.cget("bg") == BACKGROUND_COLOR
assert int(str(root.cget("padx"))) == WINDOW_PADDING
assert int(str(root.cget("pady"))) == WINDOW_PADDING
def test_canvas_has_card_size_and_spans_two_columns(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
assert int(str(view.canvas.cget("width"))) == CARD_WIDTH
assert int(str(view.canvas.cget("height"))) == CARD_HEIGHT
info = view.canvas.grid_info()
assert (int(info["row"]), int(info["column"])) == (0, 0)
assert int(info["columnspan"]) == 2
def test_canvas_holds_one_image_and_two_texts(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
types = [view.canvas.type(item) for item in view.canvas.find_all()]
assert types.count("image") == 1
assert types.count("text") == 2
assert len(types) == 3
def test_texts_show_first_card_with_font_and_colour(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
canvas = view.canvas
expected = [
(view.language_text_id, "Malay", LANGUAGE_FONT),
(view.word_text_id, "aku", WORD_FONT),
]
for item, text, font in expected:
assert canvas.itemcget(item, "text") == text
assert canvas.itemcget(item, "fill") == FRONT_TEXT_COLOR
family, size, style = font
assert canvas.itemcget(item, "font").split() == [family, str(size), style]
def test_buttons_sit_in_row_one_with_their_images(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
wrong = view.wrong_button.grid_info()
right = view.right_button.grid_info()
assert (int(wrong["row"]), int(wrong["column"])) == (1, 0)
assert (int(right["row"]), int(right["column"])) == (1, 1)
wrong_image = str(view.wrong_button.cget("image"))
right_image = str(view.right_button.cget("image"))
assert wrong_image
assert right_image
assert wrong_image != right_image
assert str(view.wrong_button.cget("command"))
assert str(view.right_button.cget("command"))
def test_each_button_press_shows_next_card(
window: tuple[tk.Tk, FlashCardView],
) -> None:
_root, view = window
assert _shown(view) == ("Malay", "aku")
view.wrong_button.invoke()
assert _shown(view) == ("Malay", "kau")
view.right_button.invoke()
assert _shown(view) == ("Malay", "aku")
def _word(view: FlashCardView) -> str:
return _shown(view)[1]
def _image(view: FlashCardView) -> str:
return str(view.canvas.itemcget(view.image_id, "image"))
def _fill(view: FlashCardView) -> tuple[str, str]:
return (
str(view.canvas.itemcget(view.language_text_id, "fill")),
str(view.canvas.itemcget(view.word_text_id, "fill")),
)
def _reset(root: tk.Tk, view: FlashCardView) -> None:
"""Show a fresh front with no pending timer."""
view.next_card()
if view.flip_timer is not None:
root.after_cancel(view.flip_timer)
view.flip_timer = None
def test_front_state_uses_front_image_black_text_and_front_language(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
assert _word(view) in {card.front for card in CARDS}
def test_flip_card_shows_back_image_white_text_and_translation(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
front = _word(view)
view.flip_card()
translations = {card.front: card.back for card in CARDS}
assert _image(view) == str(view.card_back_image)
assert _image(view) != str(view.card_front_image)
assert _fill(view) == (BACK_TEXT_COLOR, BACK_TEXT_COLOR)
assert _shown(view) == ("Danish", translations[front])
assert view.flip_timer is None
def test_next_card_after_flip_shows_front_again(
window: tuple[tk.Tk, FlashCardView],
) -> None:
root, view = window
_reset(root, view)
view.flip_card()
view.right_button.invoke()
assert _image(view) == str(view.card_front_image)
assert _fill(view) == (FRONT_TEXT_COLOR, FRONT_TEXT_COLOR)
assert _shown(view)[0] == "Malay"
_reset(root, view)
def test_next_card_cancels_pending_timer_and_schedules_flip(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
_reset(root, view)
calls: list[tuple[str, object]] = []
def fake_after(delay: int, callback: Callable[[], None]) -> str:
calls.append(("after", (delay, callback)))
return "timer-id"
def fake_cancel(timer_id: str) -> None:
calls.append(("cancel", timer_id))
monkeypatch.setattr(root, "after", fake_after, raising=False)
monkeypatch.setattr(root, "after_cancel", fake_cancel, raising=False)
view.flip_timer = "old-id"
view.next_card()
assert calls == [
("cancel", "old-id"),
("after", (FLIP_DELAY_MS, view.flip_card)),
]
assert view.flip_timer == "timer-id"
view.flip_timer = None
def test_real_timer_flips_card_after_delay(
window: tuple[tk.Tk, FlashCardView],
monkeypatch: pytest.MonkeyPatch,
) -> None:
root, view = window
monkeypatch.setattr(flash_card_view, "FLIP_DELAY_MS", 50)
view.next_card()
assert _image(view) == str(view.card_front_image)
deadline = time.monotonic() + 2
while view.flip_timer is not None and time.monotonic() < deadline:
root.update()
time.sleep(0.005)
assert view.flip_timer is None
assert _image(view) == str(view.card_back_image)
assert _shown(view)[0] == "Danish"
_reset(root, view)
View File
+31
View File
@@ -0,0 +1,31 @@
"""Integration tests of the word list reader on the delivered file."""
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
from flash_card.domain.entities.card import Card
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME
def test_read_word_list_reads_real_file() -> None:
pair, cards = read_word_list(PATH)
assert pair == LanguagePair("ms", "da")
assert len(cards) == 1000
assert cards[0] == Card("aku", "mig")
def test_open_study_session_yields_card_from_real_file() -> None:
repository = CsvVocabularyRepository(PATH)
cards = repository.load().cards
session = open_study_session(repository, lambda candidates: candidates[0])
card = session.next_card()
assert card is not None
assert Card(card.front_text, card.back_text) in cards
assert (card.front_language, card.back_language) == ("ms", "da")
@@ -0,0 +1,44 @@
"""Tests of the open_study_session use case."""
from collections.abc import Sequence
import pytest
from flash_card.application.dto.card_dto import CardDto
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.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import VocabularyNotFoundError
from flash_card.domain.value_objects.language_pair import LanguagePair
class _FakeRepository:
def __init__(self, vocabulary: Vocabulary) -> None:
self._vocabulary = vocabulary
def load(self) -> Vocabulary:
return self._vocabulary
class _MissingRepository:
def load(self) -> Vocabulary:
raise VocabularyNotFoundError("missing")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def test_open_study_session_builds_session_over_all_cards() -> None:
cards = (Card("aku", "mig"), Card("kau", "du"))
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
session = open_study_session(repository, _first)
assert session.remaining == 2
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
def test_open_study_session_propagates_repository_error() -> None:
with pytest.raises(VocabularyNotFoundError):
open_study_session(_MissingRepository(), _first)
@@ -0,0 +1,60 @@
"""Tests of the StudySession use case."""
from collections.abc import Sequence
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair
PAIR = LanguagePair("ms", "da")
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(*cards: Card) -> StudySession:
return StudySession(Deck(PAIR, cards), _first)
def test_current_card_is_none_before_first_draw() -> None:
assert _session(A).current_card is None
def test_next_card_returns_chosen_card_as_dto() -> None:
assert _session(A, B).next_card() == CardDto("aku", "mig", "ms", "da")
def test_next_card_returns_different_card_when_two_or_more() -> None:
session = _session(A, B)
first = session.next_card()
second = session.next_card()
assert first != second
assert second == CardDto("kau", "du", "ms", "da")
def test_next_card_returns_same_card_when_one() -> None:
session = _session(A)
assert session.next_card() == session.next_card()
def test_next_card_returns_none_when_deck_is_empty() -> None:
session = _session()
assert session.next_card() is None
assert session.current_card is None
def test_current_card_follows_last_draw() -> None:
session = _session(A, B)
shown = session.next_card()
assert session.current_card == shown
def test_remaining_counts_cards_in_deck() -> None:
session = _session(A, B)
session.next_card()
assert session.remaining == 2
+38
View File
@@ -0,0 +1,38 @@
"""Tests of the Card, LanguagePair and Vocabulary value types."""
import dataclasses
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.value_objects.language_pair import LanguagePair
def test_card_equals_card_with_same_texts() -> None:
assert Card("aku", "mig") == Card("aku", "mig")
def test_card_is_frozen() -> None:
card = Card("aku", "mig")
with pytest.raises(dataclasses.FrozenInstanceError):
card.front = "kau" # type: ignore[misc]
def test_language_pair_holds_codes() -> None:
pair = LanguagePair("ms", "da")
assert (pair.front_code, pair.back_code) == ("ms", "da")
def test_language_pair_is_frozen() -> None:
pair = LanguagePair("ms", "da")
with pytest.raises(dataclasses.FrozenInstanceError):
pair.front_code = "en" # type: ignore[misc]
def test_vocabulary_holds_pair_and_cards() -> None:
pair = LanguagePair("ms", "da")
cards = (Card("aku", "mig"),)
vocabulary = Vocabulary(pair, cards)
assert vocabulary.language_pair == pair
assert vocabulary.cards == cards
+73
View File
@@ -0,0 +1,73 @@
"""Tests of the Deck entity."""
from collections.abc import Sequence
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.entities.vocabulary import Vocabulary
from flash_card.domain.errors.vocabulary_errors import EmptyDeckError
from flash_card.domain.value_objects.language_pair import LanguagePair
PAIR = LanguagePair("ms", "da")
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def test_from_vocabulary_holds_all_cards() -> None:
deck = Deck.from_vocabulary(Vocabulary(PAIR, (A, B)))
assert deck.cards == (A, B)
assert deck.language_pair == PAIR
assert len(deck) == 2
def test_cards_returns_copy_when_deck_changes_later() -> None:
deck = Deck(PAIR, [A])
snapshot = deck.cards
deck.remove(A)
assert snapshot == (A,)
assert deck.is_empty
def test_draw_returns_chosen_card() -> None:
assert Deck(PAIR, [A, B]).draw(_first) == A
def test_draw_avoids_excluded_card_when_another_exists() -> None:
assert Deck(PAIR, [A, B]).draw(_first, exclude=A) == B
def test_draw_returns_excluded_card_when_it_is_the_only_one() -> None:
assert Deck(PAIR, [A]).draw(_first, exclude=A) == A
def test_draw_keeps_card_in_deck() -> None:
deck = Deck(PAIR, [A, B])
deck.draw(_first)
assert len(deck) == 2
def test_draw_raises_when_deck_is_empty() -> None:
with pytest.raises(EmptyDeckError):
Deck(PAIR, []).draw(_first)
def test_remove_deletes_first_equal_card() -> None:
deck = Deck(PAIR, [A, B, A])
deck.remove(A)
assert deck.cards == (B, A)
def test_remove_raises_when_card_is_absent() -> None:
with pytest.raises(ValueError):
Deck(PAIR, [A]).remove(B)
def test_is_empty_reflects_cards() -> None:
assert Deck(PAIR, []).is_empty
assert not Deck(PAIR, [A]).is_empty
@@ -0,0 +1,21 @@
"""Tests of the vocabulary error hierarchy."""
import pytest
from flash_card.domain.errors.vocabulary_errors import (
EmptyDeckError,
VocabularyError,
VocabularyFormatError,
VocabularyNotFoundError,
)
@pytest.mark.parametrize(
"error", [VocabularyFormatError, VocabularyNotFoundError, EmptyDeckError]
)
def test_error_is_a_vocabulary_error(error: type[VocabularyError]) -> None:
assert issubclass(error, VocabularyError)
def test_vocabulary_error_is_an_exception() -> None:
assert issubclass(VocabularyError, Exception)
@@ -0,0 +1,95 @@
"""Tests of read_word_list and CsvVocabularyRepository."""
from pathlib import Path
import pytest
from flash_card.domain.entities.card import Card
from flash_card.domain.errors.vocabulary_errors import (
VocabularyFormatError,
VocabularyNotFoundError,
)
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
def _write(path: Path, content: str) -> Path:
path.write_bytes(content.encode("utf-8"))
return path
def test_read_word_list_returns_pair_and_cards(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\nkau;du\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_drops_byte_order_mark(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\r\naku;mig\r\n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_skips_blank_lines(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "\nms;da\n\naku;mig\n \n;\nkau;du\n\n")
_pair, cards = read_word_list(path)
assert cards == (Card("aku", "mig"), Card("kau", "du"))
def test_read_word_list_strips_whitespace(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", " ms ; da \n aku ; mig \n")
pair, cards = read_word_list(path)
assert pair == LanguagePair("ms", "da")
assert cards == (Card("aku", "mig"),)
def test_read_word_list_raises_not_found_when_file_is_missing(
tmp_path: Path,
) -> None:
with pytest.raises(VocabularyNotFoundError) as info:
read_word_list(tmp_path / "missing.csv")
assert isinstance(info.value.__cause__, FileNotFoundError)
def test_read_word_list_raises_format_error_when_file_is_empty(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "\n\n")
with pytest.raises(VocabularyFormatError, match="empty"):
read_word_list(path)
@pytest.mark.parametrize("header", ["ms\n", "ms;da;en\n", "ms;\n"])
def test_read_word_list_raises_format_error_for_bad_header(
tmp_path: Path, header: str
) -> None:
path = _write(tmp_path / "w.csv", header + "aku;mig\n")
with pytest.raises(VocabularyFormatError, match="line 1"):
read_word_list(path)
def test_read_word_list_raises_format_error_with_line_number_for_bad_row(
tmp_path: Path,
) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n\nkau;du;extra\n")
with pytest.raises(VocabularyFormatError, match="line 4") as info:
read_word_list(path)
assert str(path) in str(info.value)
def test_repository_load_returns_vocabulary(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\naku;mig\n")
vocabulary = CsvVocabularyRepository(path).load()
assert vocabulary.language_pair == LanguagePair("ms", "da")
assert vocabulary.cards == (Card("aku", "mig"),)
def test_repository_load_raises_format_error_when_no_card(tmp_path: Path) -> None:
path = _write(tmp_path / "w.csv", "ms;da\n")
with pytest.raises(VocabularyFormatError):
CsvVocabularyRepository(path).load()
+43
View File
@@ -0,0 +1,43 @@
"""Tests of the entry point.
@file test_app.py
@brief Checks main of flash_card.presentation.app without opening a window.
"""
from pathlib import Path
import pytest
from flash_card.presentation import app
class _FakeRoot:
def __init__(self) -> None:
self.mainloop_calls = 0
def mainloop(self) -> None:
self.mainloop_calls += 1
def test_main_runs_main_loop_and_returns_zero(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
root = _FakeRoot()
seen: list[Path | None] = []
def fake_create(data_dir: Path | None = None) -> tuple[_FakeRoot, object]:
seen.append(data_dir)
return root, object()
monkeypatch.setattr(app, "create_application", fake_create)
assert app.main(["--data-dir", str(tmp_path)]) == 0
assert root.mainloop_calls == 1
assert seen == [tmp_path]
def test_main_returns_one_when_word_list_is_missing(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
assert app.main(["--data-dir", str(tmp_path / "nowhere")]) == 1
assert "word list not found" in caplog.text
+37
View File
@@ -0,0 +1,37 @@
"""Tests of the constants module.
@file test_constants.py
@brief Checks the key values of flash_card.constants.
"""
from flash_card import constants
def test_flip_delay_ms_is_three_seconds() -> None:
assert constants.FLIP_DELAY_MS == 3000
def test_background_color_is_green() -> None:
assert constants.BACKGROUND_COLOR == "#B1DDC6"
def test_default_data_dir_ends_with_data() -> None:
assert constants.DEFAULT_DATA_DIR.name == "data"
def test_file_names_are_csv() -> None:
assert constants.VOCABULARY_FILE_NAME.endswith(".csv")
assert constants.PROGRESS_FILE_NAME.endswith(".csv")
def test_language_names_cover_ms_and_da() -> None:
assert "ms" in constants.LANGUAGE_NAMES
assert "da" in constants.LANGUAGE_NAMES
def test_project_root_contains_pyproject() -> None:
assert (constants.PROJECT_ROOT / "pyproject.toml").is_file()
def test_csv_encoding_drops_byte_order_mark() -> None:
assert constants.CSV_ENCODING == "utf-8-sig"
+92
View File
@@ -0,0 +1,92 @@
"""Tests of the flash card view model over a real session and in-memory deck."""
from collections.abc import Sequence
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE
from flash_card.domain.entities.card import Card
from flash_card.domain.entities.deck import Deck
from flash_card.domain.value_objects.language_pair import LanguagePair
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
Side,
)
A = Card("aku", "mig")
B = Card("kau", "du")
def _first(cards: Sequence[Card]) -> Card:
return cards[0]
def _session(code: str, *cards: Card) -> StudySession:
return StudySession(Deck(LanguagePair(code, "da"), cards), _first)
def test_view_model_draws_first_card_on_construction() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
assert view_model.text == "aku"
assert view_model.language_name == "Malay"
assert not view_model.is_finished
def test_next_card_shows_next_card() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
view_model.next_card()
assert view_model.text == "kau"
def test_language_name_shows_unknown_code_in_upper_case() -> None:
view_model = FlashCardViewModel(_session("xx", A))
assert view_model.language_name == "XX"
def test_language_name_uses_given_names() -> None:
view_model = FlashCardViewModel(_session("ms", A), {"ms": "Melayu"})
assert view_model.language_name == "Melayu"
def test_view_model_shows_finished_message_when_no_card_is_left() -> None:
view_model = FlashCardViewModel(_session("ms"))
assert view_model.is_finished
assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE
def test_view_model_starts_on_front_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
assert view_model.side is Side.FRONT
def test_flip_card_shows_back_language_and_translation() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.language_name == "Danish"
assert view_model.text == "mig"
def test_flip_card_twice_keeps_back_side() -> None:
view_model = FlashCardViewModel(_session("ms", A))
view_model.flip_card()
view_model.flip_card()
assert view_model.side is Side.BACK
assert view_model.text == "mig"
def test_flip_card_does_nothing_when_finished() -> None:
view_model = FlashCardViewModel(_session("ms"))
view_model.flip_card()
assert view_model.side is Side.FRONT
assert view_model.language_name == ""
assert view_model.text == FINISHED_MESSAGE
def test_next_card_resets_side_to_front() -> None:
view_model = FlashCardViewModel(_session("ms", A, B))
view_model.flip_card()
view_model.next_card()
assert view_model.side is Side.FRONT
assert view_model.language_name == "Malay"
assert view_model.text == "kau"
+58
View File
@@ -0,0 +1,58 @@
"""Tests of the image generator tool.
@file test_generate_images.py
@brief Checks the PNG files written by tools/generate_images.py.
"""
import importlib.util
import struct
from pathlib import Path
from types import ModuleType
import pytest
from flash_card.constants import (
CARD_BACK_IMAGE_NAME,
CARD_FRONT_IMAGE_NAME,
CARD_HEIGHT,
CARD_WIDTH,
RIGHT_IMAGE_NAME,
WRONG_IMAGE_NAME,
)
_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "generate_images.py"
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def _load_tool() -> ModuleType:
spec = importlib.util.spec_from_file_location("generate_images", _TOOL_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def generated(tmp_path_factory: pytest.TempPathFactory) -> Path:
output_dir = tmp_path_factory.mktemp("assets")
assert _load_tool().main(["--output-dir", str(output_dir)]) == 0
return output_dir
@pytest.mark.parametrize(
("name", "width", "height"),
[
(CARD_FRONT_IMAGE_NAME, CARD_WIDTH, CARD_HEIGHT),
(CARD_BACK_IMAGE_NAME, CARD_WIDTH, CARD_HEIGHT),
(RIGHT_IMAGE_NAME, 100, 100),
(WRONG_IMAGE_NAME, 100, 100),
],
)
def test_generate_images_writes_png_with_expected_size(
generated: Path, name: str, width: int, height: int
) -> None:
data = (generated / name).read_bytes()
assert data.startswith(_SIGNATURE)
assert data[12:16] == b"IHDR"
assert struct.unpack(">II", data[16:24]) == (width, height)
+393
View File
@@ -0,0 +1,393 @@
"""Generate the PNG assets of the flash card program.
@file generate_images.py
@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs.
Uses the standard library only; run it to regenerate the assets.
"""
import argparse
import math
import struct
import sys
import zlib
from collections.abc import Callable, Sequence
from pathlib import Path
_SRC_DIR = Path(__file__).resolve().parents[1] / "src"
if str(_SRC_DIR) not in sys.path:
sys.path.insert(0, str(_SRC_DIR))
from flash_card.constants import ( # noqa: E402
ASSETS_DIR,
CARD_BACK_IMAGE_NAME,
CARD_FRONT_IMAGE_NAME,
CARD_HEIGHT,
CARD_WIDTH,
RIGHT_IMAGE_NAME,
WRONG_IMAGE_NAME,
)
Color = tuple[int, int, int]
Point = tuple[float, float]
Segment = tuple[Point, Point]
Box = tuple[float, float, float, float]
Span = tuple[float, float] | None
_SUPERSAMPLE = 4
_CARD_RADIUS = 60
_CARD_INSET = 2
_SHADOW_OFFSET = 10
_SHADOW_BLUR = 5
_SHADOW_COLOR: Color = (70, 70, 70)
_SHADOW_OPACITY = 0.4
_CARD_FRONT_COLOR: Color = (255, 255, 255)
_CARD_BACK_COLOR: Color = (0x91, 0xC2, 0xAF)
_BUTTON_SIZE = 100
_BUTTON_RADIUS = 28
_STROKE_WIDTH = 11.0
_WHITE: Color = (255, 255, 255)
_RIGHT_GRADIENT: tuple[Color, Color] = ((0xA5, 0xE0, 0x60), (0x2F, 0xBF, 0x71))
_WRONG_GRADIENT: tuple[Color, Color] = ((0xFF, 0x6B, 0x8B), (0xD9, 0x5A, 0x55))
_CHECK_SEGMENTS: tuple[Segment, ...] = (
((27.0, 52.0), (43.0, 68.0)),
((43.0, 68.0), (74.0, 33.0)),
)
_CROSS_SEGMENTS: tuple[Segment, ...] = (
((32.0, 32.0), (68.0, 68.0)),
((68.0, 32.0), (32.0, 68.0)),
)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
class _Canvas:
"""Premultiplied float RGBA canvas that shapes are composited onto."""
def __init__(self, width: int, height: int) -> None:
"""Create a fully transparent canvas.
@param width Width in pixels.
@param height Height in pixels.
"""
self.width = width
self.height = height
size = width * height
self.red = [0.0] * size
self.green = [0.0] * size
self.blue = [0.0] * size
self.alpha = [0.0] * size
def paint(
self,
mask: list[float],
color_at: Callable[[int], Color],
opacity: float = 1.0,
) -> None:
"""Composite a coverage mask over the canvas.
@param mask Coverage per pixel in 0..1, row-major.
@param color_at Colour for a pixel row index.
@param opacity Extra opacity applied to the whole mask.
"""
for y in range(self.height):
r, g, b = color_at(y)
for i in range(y * self.width, (y + 1) * self.width):
a = min(1.0, mask[i]) * opacity
if a <= 0.0:
continue
keep = 1.0 - a
self.red[i] = r * a + self.red[i] * keep
self.green[i] = g * a + self.green[i] * keep
self.blue[i] = b * a + self.blue[i] * keep
self.alpha[i] = a + self.alpha[i] * keep
def to_rgba_bytes(self) -> bytes:
"""Return the pixels as straight-alpha 8-bit RGBA rows.
@return The raw RGBA bytes, row-major.
"""
out = bytearray()
pixels = zip(self.red, self.green, self.blue, self.alpha, strict=True)
for r, g, b, a in pixels:
if a <= 0.0:
out += b"\x00\x00\x00\x00"
continue
out.append(min(255, round(r / a)))
out.append(min(255, round(g / a)))
out.append(min(255, round(b / a)))
out.append(min(255, round(a * 255)))
return bytes(out)
def _rounded_rect_span(box: Box, radius: float, y: float) -> Span:
"""Return the horizontal extent of a rounded rectangle at height y.
@param box Left, top, right, bottom of the rectangle.
@param radius Corner radius.
@param y Vertical sample position.
@return The (start, end) x range, or None when y is outside.
"""
left, top, right, bottom = box
if y < top or y > bottom:
return None
if y < top + radius:
centre_y = top + radius
elif y > bottom - radius:
centre_y = bottom - radius
else:
return (left, right)
inset = radius - math.sqrt(max(0.0, radius * radius - (centre_y - y) ** 2))
return (left + inset, right - inset)
def _rounded_rect_mask(width: int, height: int, box: Box, radius: float) -> list[float]:
"""Rasterise a rounded rectangle with 4x vertical supersampling.
Horizontal coverage of each sub-row is computed exactly.
@param width Mask width.
@param height Mask height.
@param box Left, top, right, bottom of the rectangle.
@param radius Corner radius.
@return Coverage per pixel in 0..1.
"""
mask = [0.0] * (width * height)
weight = 1.0 / _SUPERSAMPLE
for py in range(height):
base = py * width
for sub in range(_SUPERSAMPLE):
span = _rounded_rect_span(box, radius, py + (sub + 0.5) * weight)
if span is None:
continue
start = max(span[0], 0.0)
end = min(span[1], float(width))
for px in range(int(start), min(width, math.ceil(end))):
overlap = min(end, px + 1.0) - max(start, float(px))
mask[base + px] += overlap * weight
return mask
def _distance_to_segment(px: float, py: float, segment: Segment) -> float:
"""Return the distance from a point to a line segment.
@param px Point x.
@param py Point y.
@param segment Segment start and end.
@return The Euclidean distance.
"""
a, b = segment
dx, dy = b[0] - a[0], b[1] - a[1]
length_sq = dx * dx + dy * dy
t = ((px - a[0]) * dx + (py - a[1]) * dy) / length_sq if length_sq else 0.0
t = max(0.0, min(1.0, t))
return math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy))
def _stroke_mask(
width: int, height: int, segments: Sequence[Segment], stroke_width: float
) -> list[float]:
"""Rasterise thick round-capped lines with 4x4 supersampling.
@param width Mask width.
@param height Mask height.
@param segments Line segments.
@param stroke_width Line thickness in pixels.
@return Coverage per pixel in 0..1.
"""
mask = [0.0] * (width * height)
half = stroke_width / 2.0
weight = 1.0 / (_SUPERSAMPLE * _SUPERSAMPLE)
for segment in segments:
a, b = segment
x_min = max(0, int(min(a[0], b[0]) - half) - 1)
x_max = min(width, math.ceil(max(a[0], b[0]) + half) + 1)
y_min = max(0, int(min(a[1], b[1]) - half) - 1)
y_max = min(height, math.ceil(max(a[1], b[1]) + half) + 1)
for py in range(y_min, y_max):
for px in range(x_min, x_max):
hits = 0
for sy in range(_SUPERSAMPLE):
for sx in range(_SUPERSAMPLE):
x = px + (sx + 0.5) / _SUPERSAMPLE
y = py + (sy + 0.5) / _SUPERSAMPLE
if _distance_to_segment(x, y, segment) <= half:
hits += 1
index = py * width + px
mask[index] = max(mask[index], hits * weight)
return mask
def _box_blur(mask: list[float], width: int, height: int, radius: int) -> list[float]:
"""Blur a mask with a separable box filter.
@param mask Coverage per pixel.
@param width Mask width.
@param height Mask height.
@param radius Blur radius in pixels.
@return The blurred mask.
"""
window = 2 * radius + 1
horizontal = [0.0] * len(mask)
for y in range(height):
row = mask[y * width : (y + 1) * width]
total = sum(row[: radius + 1])
for x in range(width):
horizontal[y * width + x] = total / window
if x + radius + 1 < width:
total += row[x + radius + 1]
if x - radius >= 0:
total -= row[x - radius]
result = [0.0] * len(mask)
for x in range(width):
column = horizontal[x::width]
total = sum(column[: radius + 1])
for y in range(height):
result[y * width + x] = total / window
if y + radius + 1 < height:
total += column[y + radius + 1]
if y - radius >= 0:
total -= column[y - radius]
return result
def _solid(color: Color) -> Callable[[int], Color]:
"""Return a colour function that always gives one colour.
@param color The colour.
@return A function from row index to colour.
"""
return lambda _row: color
def _vertical_gradient(
top: Color, bottom: Color, height: int
) -> Callable[[int], Color]:
"""Return a colour function for a top to bottom gradient.
@param top Colour at the first row.
@param bottom Colour at the last row.
@param height Number of rows.
@return A function from row index to colour.
"""
def color_at(row: int) -> Color:
t = (row + 0.5) / height
r, g, b = (
round(c0 + (c1 - c0) * t) for c0, c1 in zip(top, bottom, strict=True)
)
return (r, g, b)
return color_at
def _render_card(fill: Color) -> _Canvas:
"""Draw a card with a soft drop shadow.
@param fill Fill colour of the card.
@return The canvas.
"""
width, height = CARD_WIDTH, CARD_HEIGHT
margin = _SHADOW_OFFSET + _SHADOW_BLUR + _CARD_INSET
box: Box = (
float(_CARD_INSET),
float(_CARD_INSET),
float(width - margin),
float(height - margin),
)
shadow_box: Box = (
box[0] + _SHADOW_OFFSET,
box[1] + _SHADOW_OFFSET,
box[2] + _SHADOW_OFFSET,
box[3] + _SHADOW_OFFSET,
)
shadow = _rounded_rect_mask(width, height, shadow_box, _CARD_RADIUS)
shadow = _box_blur(shadow, width, height, _SHADOW_BLUR)
canvas = _Canvas(width, height)
canvas.paint(shadow, _solid(_SHADOW_COLOR), _SHADOW_OPACITY)
card = _rounded_rect_mask(width, height, box, _CARD_RADIUS)
canvas.paint(card, _solid(fill))
return canvas
def _render_button(
gradient: tuple[Color, Color], segments: Sequence[Segment]
) -> _Canvas:
"""Draw a rounded gradient square with a white symbol.
@param gradient Top and bottom colour.
@param segments Line segments of the symbol.
@return The canvas.
"""
size = _BUTTON_SIZE
canvas = _Canvas(size, size)
box: Box = (0.0, 0.0, float(size), float(size))
square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS)
canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size))
symbol = _stroke_mask(size, size, segments, _STROKE_WIDTH)
canvas.paint(symbol, _solid(_WHITE))
return canvas
def encode_png(width: int, height: int, rgba: bytes) -> bytes:
"""Encode raw RGBA pixels as a PNG file.
@param width Image width.
@param height Image height.
@param rgba Row-major straight-alpha RGBA bytes.
@return The PNG file content.
"""
stride = width * 4
raw = b"".join(b"\x00" + rgba[y * stride : (y + 1) * stride] for y in range(height))
def chunk(kind: bytes, data: bytes) -> bytes:
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return (
_PNG_SIGNATURE
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def generate_images(output_dir: Path) -> list[Path]:
"""Write the four PNG assets into a folder.
@param output_dir Folder to write into; created when missing.
@return The paths of the written files.
"""
output_dir.mkdir(parents=True, exist_ok=True)
images = {
CARD_FRONT_IMAGE_NAME: _render_card(_CARD_FRONT_COLOR),
CARD_BACK_IMAGE_NAME: _render_card(_CARD_BACK_COLOR),
RIGHT_IMAGE_NAME: _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS),
WRONG_IMAGE_NAME: _render_button(_WRONG_GRADIENT, _CROSS_SEGMENTS),
}
written: list[Path] = []
for name, canvas in images.items():
path = output_dir / name
data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes())
path.write_bytes(data)
written.append(path)
return written
def main(argv: Sequence[str] | None = None) -> int:
"""Run the generator from the command line.
@param argv Command line arguments, or None for sys.argv.
@return The exit code, 0 on success.
"""
parser = argparse.ArgumentParser(description="Generate the flash card images.")
parser.add_argument(
"--output-dir", type=Path, default=ASSETS_DIR, help="folder for the PNG files"
)
args = parser.parse_args(argv)
for path in generate_images(args.output_dir):
sys.stdout.write(f"wrote {path}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())