Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4f2655c4 | ||
|
|
92894268c5 | ||
|
|
b2cc709031 | ||
|
|
bc055e3c05 | ||
|
|
b3c5116110 | ||
|
|
5900fda90a | ||
|
|
9e56a6b787 | ||
|
|
8dbaaa3059 | ||
|
|
da4e9382cf | ||
|
|
83fb0b1ae8 | ||
|
|
02e0c2c55e | ||
|
|
9ecae3e6b2 | ||
|
|
3acb3ba03d | ||
|
|
8d66c43eea | ||
|
|
c6c737ce40 | ||
|
|
736729c99f | ||
|
|
019565635d | ||
|
|
aecf7ad43c | ||
|
|
31ae445f5c | ||
|
|
cc24b7d890 | ||
|
|
934d7e4902 |
@@ -0,0 +1,5 @@
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.csv text eol=lf
|
||||
*.png binary
|
||||
+77
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +1,170 @@
|
||||
# 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), `docs/wiki/` and `docs/images/` |
|
||||
| `tools/` | Helper scripts: `generate_images.py` and `capture_screenshots.py` |
|
||||
| `Doxyfile` | Doxygen configuration for the source code documentation |
|
||||
|
||||
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
|
||||
|
||||
### Screenshots
|
||||
|
||||
| Front: the Malay word | Back: the Danish translation after 3 seconds |
|
||||
| --- | --- |
|
||||
| ![The front of a card][screenshot-front] | ![The back of a card][screenshot-back] |
|
||||
|
||||
### Where is each assignment step?
|
||||
|
||||
| Assignment step | Code |
|
||||
| --- | --- |
|
||||
| 1. User interface | `FlashCardView.__init__` in `src/flash_card/presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
|
||||
| 2. New flash cards | `CsvVocabularyRepository` (the standard library `csv` instead of `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| 3. Flip the cards | `FlashCardView.flip_card` and `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| 4. Save your progress | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository` |
|
||||
| Extra: spoken word | `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
|
||||
|
||||
The wiki ([English][wiki-en], [Dansk][wiki-da], [Bahasa Malaysia][wiki-ms]) explains the same in three languages.
|
||||
|
||||
### Data
|
||||
|
||||
`data/ms-da.csv` holds 1000 frequent Malay words with a Danish machine translation; see [data/README.md][data-readme] for the format and origin.
|
||||
|
||||
## 📋 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 a card. Press the green tick for a word you know and the red cross for a word you do not. Options:
|
||||
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder with `ms-da.csv` and `words_to_learn.csv` |
|
||||
| `--no-speech` | Do not speak the Malay word |
|
||||
|
||||
To start again, delete `data/words_to_learn.csv`.
|
||||
|
||||
## 🧪 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.
|
||||
|
||||
Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only).
|
||||
|
||||
## 📄 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
|
||||
[screenshot-front]: ./docs/images/screenshot-front.png
|
||||
[screenshot-back]: ./docs/images/screenshot-back.png
|
||||
[wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home
|
||||
[wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da
|
||||
[wiki-ms]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-ms
|
||||
[data-readme]: ./data/README.md
|
||||
[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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 023 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 030 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+14
-7
@@ -31,13 +31,13 @@ This plan schedules the seven phases (gateways) that deliver the flash card prog
|
||||
|
||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | |
|
||||
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | |
|
||||
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | |
|
||||
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | |
|
||||
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | |
|
||||
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | |
|
||||
| Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | |
|
||||
| Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | [Milestone MIL-001][MS-001] |
|
||||
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | [Milestone MIL-002][MS-002] |
|
||||
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | [Milestone MIL-003][MS-003] |
|
||||
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | [Milestone MIL-004][MS-004] |
|
||||
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | [Milestone MIL-005][MS-005] |
|
||||
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | [Milestone MIL-006][MS-006] |
|
||||
| Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | [Milestone MIL-007][MS-007] |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -106,3 +106,10 @@ A No-Go on a phase moves every later date by the days the rework takes; the buff
|
||||
[MIL-006]: ./milestones/mil-006-malay-speech.md
|
||||
[MIL-007]: ./milestones/mil-007-documentation-and-publication.md
|
||||
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
|
||||
[MS-001]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/165
|
||||
[MS-002]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/166
|
||||
[MS-003]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/167
|
||||
[MS-004]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/168
|
||||
[MS-005]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/169
|
||||
[MS-006]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/170
|
||||
[MS-007]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/171
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-027: Review of PY-MIL-005
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-027 |
|
||||
| CrossReference | [PY-MIL-005], [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 | [8dbaaa3] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-005]
|
||||
- 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 dictionary's IT terms and the assignment's (`is_known`, `mark_known`, `ProgressRepository`). |
|
||||
| 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 47 files with no suppressions; `[tool.ruff.format] line-ending = "lf"` and `.gitattributes` fix the line endings. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 33 source files. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | A malformed progress file raises `VocabularyFormatError` and is never overwritten; the temporary file is removed when a write fails (re-raise, no swallow); 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 | Pass | Reading uses `with`; the write goes through a temporary file that is replaced atomically. |
|
||||
| 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 secret or personal data is logged. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `ProgressRepository`, `CsvProgressRepository`, `StudySession.mark_known` and `is_known` are in DCD-001 and OC-001 (`markKnown`); `open_study_session` takes no speech port until MIL-006, as DCD-001 shows for the final design. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 107 tests (6 consecutive full runs all green) include the assignment's 3 record example, restart, round trip, finished state and e2e on the real window over `tmp_path`; 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. |
|
||||
| 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 | Reader and writer share `CSV_DELIMITER`, `CSV_ENCODING` and the row width in `csv_word_list.py`; the removal rule is only in `Deck.remove`. |
|
||||
| 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 `ProgressRepository` Protocol is in `domain`, its adapter in `infrastructure`. |
|
||||
| 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 use case depends on the Protocol and receives the adapter; the view model sequences two session calls only. |
|
||||
| 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 | New files are in the layer 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 follow the rule. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-005 Go criteria: 1 the 3 record example leaves the other two cards in order with the header (integration test); 2 a start uses the progress file when it exists and the word list otherwise (integration test); 3 the wrong button never writes (unit and e2e); 4 the last card known gives a finished state (unit); 5 a malformed progress file raises `VocabularyFormatError` and stays untouched (unit); 6 round trip readable as a word list (integration); 7 this record, `pytest` 107 passed. A manual run on a copy of `ms-da.csv` removed the known word and wrote 999 cards. Defect found and fixed in this milestone: a flaky `TclError` caused by pytest's descriptor capture and Tcl file handles; the view now loads image bytes with `Path.read_bytes()` and `PhotoImage(data=...)`. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-005]: ../../../src/flash_card
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[8dbaaa3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8dbaaa30595a3650bc85006a2188f81ab903125e
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-028: Review of PY-MIL-006
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-028 |
|
||||
| CrossReference | [PY-MIL-006], [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 | [b3c5116] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-006]
|
||||
- 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 DCD-001 IT terms (`SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech`). |
|
||||
| 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 55 files with no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 38 source files; third-party modules are imported with `importlib.import_module`, so no `type: ignore` is needed. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The one broad `except Exception` in `GttsSpeech` is at the thread boundary, is commented, and logs with `exc_info`; it is not silent. 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 | Pass | Audio is written to a `tempfile.TemporaryDirectory` used as a context manager, so the file is always removed. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New 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 | Failures are logged with `logging`, not printed; only the spoken text (a word) is in the log context, no personal data. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `open_study_session(vocabulary_repository, progress_repository, speech, choose)` and `build_speech` match DCD-001 and SD-001 (the `speak` message of `startStudySession` and `markUnknown`). |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 124 tests (3 consecutive runs green) with fakes for the synthesiser and player; no test uses the network or plays a sound. |
|
||||
| 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` is still empty; the extra `speech` pins `playsound==1.2.2` and `gTTS>=2.5`, and no module imports them outside `GttsSpeech`. |
|
||||
| 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 speak rule is only in `StudySession.next_card`; the unused `SPEECH_LANGUAGE_CODE` constant was removed. |
|
||||
| 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 port is in `application`, the adapters in `infrastructure`, the choice in the composition root. |
|
||||
| 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 | `StudySession` depends on the `SpeechPort` Protocol and receives the adapter; `NullSpeech` honours the same contract (Null Object), so no `if speech` test exists. |
|
||||
| 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 | `application/ports/` and `infrastructure/speech/` follow the folder structure of the clean-architecture skill. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-006 Go criteria: 1 `speak(text, front_code)` once per drawn card and never when empty (unit tests); 2 `gtts` and `playsound` imported only when speaking, temporary file removed (unit tests with fakes); 3 service or player failure is logged and not raised (unit tests with caplog); 4 `build_speech` returns `NullSpeech` when the extra is missing or `--no-speech` is given (unit tests); 5 `dependencies` empty, extra pins `playsound==1.2.2` (pyproject.toml); 6 `speak` returns before a blocked player is released (unit test with an Event); 7 this record, `pytest` 124 passed. A manual check with the extra installed synthesised an mp3 of `kata` in Malay (7872 bytes) and `build_speech(True)` returned `GttsSpeech`; the audio was not played on this machine. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-006]: ../../../src/flash_card
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[b3c5116]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/b3c51161107b4cdcbc4c5334e28608423a299638
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-029: Review of PY-MIL-007
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-029 |
|
||||
| CrossReference | [PY-MIL-007], [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 | [9289426] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-007]
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds in `tools/capture_screenshots.py` and the changed view; 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 (`capture`, `_make_dpi_aware`, `SCREENSHOT_SEED`). |
|
||||
| 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 56 files; the one `type: ignore` is for a Windows-only attribute and says which error it silences. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on `src` (38 files) and on both tool scripts. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added. |
|
||||
| 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 | The data copy uses `tempfile.TemporaryDirectory` as a context manager; the Tk root is destroyed. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Module, functions and constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | The tool writes the two output paths with `sys.stdout.write`; no `print`, no secret. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The only source change is the button background in `FlashCardView`, which follows `wireframe.md`; the tool is outside the design classes and is not part of the program. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 125 tests, 2 consecutive full runs green; one e2e assertion was added for the button background; the tool is checked by running it and reading both images. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue on the touched files; no `Any`. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; Pillow is the optional extra `tools` and is imported only by the tool. |
|
||||
| 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 seed, output folder and delay come from one definition each; the delay is `FLIP_DELAY_MS`. |
|
||||
| 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 tool imports the composition root only. |
|
||||
| 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 tool has one purpose (capture); the view change adds no rule. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `tools/` holds helper scripts outside `src/`; the layers are unchanged. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule (`docs/images`, `docs/wiki` pages are exceptions for wiki names). |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-007 Go criteria: 1 `check-readme.sh` exit 0 and both screenshots linked from the README; 2 `docs/wiki/` holds Home, Install, Usage and Architecture as `<Page>.md`, `<Page>-da.md`, `<Page>-ms.md` (12 pages) and `_Sidebar.md` listing the three languages; 3 the screenshots are captured from the running program by `tools/capture_screenshots.py` and were inspected; 4 the README Setup and Run need 5 commands (create `.venv`, activate, upgrade pip, install, run); 5 the repository has a description and 10 topics (read back from the API); 6 `doxygen Doxyfile` 0 warnings; 7 `docs/sqa/traceability-matrix.md` has a row for every created artifact with its latest `RC-*`, and the plan links each milestone; 8 `check-languages.sh` exit 0 (`check-plan.sh` is a commit-message hook and was not run). The wiki is pushed to the host's wiki after this merge. The Danish and Malay wiki pages were written by the AI and have not been read by a native speaker; this is stated in the end-of-run summary. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-007]: ../../../tools
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[9289426]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/92894268c51ec77d56cff48d968566e6265795a2
|
||||
@@ -45,6 +45,13 @@ 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-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] |
|
||||
| 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] |
|
||||
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -76,6 +83,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
|
||||
[MIL-006]: ../milestones/mil-006-malay-speech.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-002]: ./reviews/rc-002-stakeholder-analysis.md
|
||||
[RC-003]: ./reviews/rc-003-project-plan.md
|
||||
@@ -98,4 +106,17 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[RC-020]: ./reviews/rc-020-mil-005.md
|
||||
[RC-021]: ./reviews/rc-021-mil-006.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
|
||||
[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
|
||||
[RC-027]: ./reviews/rc-027-py-mil-005.md
|
||||
[PY-MIL-005]: ../../src/flash_card
|
||||
[RC-028]: ./reviews/rc-028-py-mil-006.md
|
||||
[PY-MIL-006]: ../../src/flash_card
|
||||
[RC-029]: ./reviews/rc-029-py-mil-007.md
|
||||
[PY-MIL-007]: ../../src/flash_card
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Arkitektur
|
||||
|
||||
🌐 [English](Architecture) · **Dansk** · [Bahasa Malaysia](Architecture-ms)
|
||||
|
||||
Programmet er bygget i fire lag under `src/flash_card/`. Importer peger indad; `lint-imports` kontrollerer det.
|
||||
|
||||
| Lag | Indeholder | Må importere |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, fejlene og repository-grænsefladerne | ingenting |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` og `SpeechPort` | `domain` |
|
||||
| `infrastructure` | CSV-repositories og tale-adaptere | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) og kompositionsroden `app.py` | alle lag |
|
||||
|
||||
Alle konstanter står i `src/flash_card/constants.py`.
|
||||
|
||||
## Hvor er hvert opgavetrin?
|
||||
|
||||
| Opgave | Kode |
|
||||
| --- | --- |
|
||||
| Trin 1: vindue, lærred, billede, tekster, knapper | `FlashCardView.__init__` i `presentation/features/flash_card/flash_card_view.py`; billederne laves af `tools/generate_images.py` |
|
||||
| Trin 2: læs CSV, tilfældigt ord ved hvert tryk | `CsvVocabularyRepository` og `read_word_list` (erstatter `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Trin 3: vend efter 3 sekunder | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Trin 4: kendte ord, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Ekstra: tale | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` i `presentation/app.py` |
|
||||
|
||||
## Hvorfor en lagdelt løsning til en lille opgave?
|
||||
|
||||
Reglerne (hvilket kort der kommer næste gang, hvad et kendt ord gør) testes uden vindue og uden filsystem. Vinduet, CSV-filerne og taletjenesten kan udskiftes uden at røre reglerne. Se ADR-0001 og ADR-0002 i `docs/adr/`.
|
||||
|
||||
## Test
|
||||
|
||||
| Mappe | Hvad den tester |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Hvert lag for sig, med falske objekter til portene |
|
||||
| `tests/integration/` | Den rigtige ordliste og fremgangsfilen i en midlertidig mappe |
|
||||
| `tests/e2e/` | Det rigtige Tkinter-vindue (springes over uden skærm) |
|
||||
|
||||
## Projektdokumenter
|
||||
|
||||
Business case, plan, design og gennemgangsrapporter ligger i `docs/`. Arbejdet blev udført i syv milepæle, én pull request hver: se `docs/milestones/`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Seni bina
|
||||
|
||||
🌐 [English](Architecture) · [Dansk](Architecture-da) · **Bahasa Malaysia**
|
||||
|
||||
Program dibina dalam empat lapisan di bawah `src/flash_card/`. Import menuju ke dalam; `lint-imports` memeriksanya.
|
||||
|
||||
| Lapisan | Mengandungi | Boleh mengimport |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, ralat dan antara muka repositori | tiada |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` dan `SpeechPort` | `domain` |
|
||||
| `infrastructure` | Repositori CSV dan penyesuai suara | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) dan akar komposisi `app.py` | semua lapisan |
|
||||
|
||||
Semua pemalar terdapat dalam `src/flash_card/constants.py`.
|
||||
|
||||
## Di manakah setiap langkah tugasan?
|
||||
|
||||
| Tugasan | Kod |
|
||||
| --- | --- |
|
||||
| Langkah 1: tetingkap, kanvas, imej, teks, butang | `FlashCardView.__init__` dalam `presentation/features/flash_card/flash_card_view.py`; imej dibuat oleh `tools/generate_images.py` |
|
||||
| Langkah 2: baca CSV, perkataan rawak setiap tekanan | `CsvVocabularyRepository` dan `read_word_list` (menggantikan `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Langkah 3: terbalik selepas 3 saat | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Langkah 4: perkataan diketahui, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Tambahan: suara | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` dalam `presentation/app.py` |
|
||||
|
||||
## Mengapa program berlapis untuk tugasan kecil?
|
||||
|
||||
Peraturan (kad mana yang seterusnya, apa yang berlaku apabila perkataan diketahui) diuji tanpa tetingkap atau sistem fail. Tetingkap, fail CSV dan perkhidmatan suara boleh diganti tanpa menyentuh peraturan. Lihat ADR-0001 dan ADR-0002 dalam `docs/adr/`.
|
||||
|
||||
## Ujian
|
||||
|
||||
| Folder | Apa yang diuji |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Setiap lapisan sendiri, dengan objek palsu untuk port |
|
||||
| `tests/integration/` | Senarai perkataan sebenar dan fail kemajuan dalam folder sementara |
|
||||
| `tests/e2e/` | Tetingkap Tkinter sebenar (dilangkau tanpa paparan) |
|
||||
|
||||
## Dokumen projek
|
||||
|
||||
Kes perniagaan, pelan, reka bentuk dan rekod semakan terdapat dalam `docs/`. Kerja dilakukan dalam tujuh pencapaian, satu pull request setiap satu: lihat `docs/milestones/`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Architecture
|
||||
|
||||
🌐 **English** · [Dansk](Architecture-da) · [Bahasa Malaysia](Architecture-ms)
|
||||
|
||||
The program is built in four layers under `src/flash_card/`. Imports point inward; `lint-imports` checks this.
|
||||
|
||||
| Layer | Holds | May import |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, the errors and the repository interfaces | nothing |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` and `SpeechPort` | `domain` |
|
||||
| `infrastructure` | The CSV repositories and the speech adapters | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) and the composition root `app.py` | all layers |
|
||||
|
||||
All constants are in `src/flash_card/constants.py`.
|
||||
|
||||
## Where is each assignment step?
|
||||
|
||||
| Assignment | Code |
|
||||
| --- | --- |
|
||||
| Step 1: window, canvas, image, texts, buttons | `FlashCardView.__init__` in `presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
|
||||
| Step 2: read the CSV, random word on each press | `CsvVocabularyRepository` and `read_word_list` (replace `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Step 3: flip after 3 seconds | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Step 4: known words, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Extra: speech | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
|
||||
|
||||
## Why a layered program for a small assignment?
|
||||
|
||||
The rules (which card comes next, what a known word does) are tested without a window or a file system. The window, the CSV files and the speech service can be replaced without touching the rules. See ADR-0001 and ADR-0002 in `docs/adr/`.
|
||||
|
||||
## Tests
|
||||
|
||||
| Folder | What it tests |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Each layer alone, with fakes for the ports |
|
||||
| `tests/integration/` | The real word list and the progress file in a temporary folder |
|
||||
| `tests/e2e/` | The real Tkinter window (skipped when no display exists) |
|
||||
|
||||
## Project documents
|
||||
|
||||
The business case, plan, design and review records are in `docs/`. The work was done in seven milestones, one pull request each: see `docs/milestones/`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 [English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
|
||||
|
||||
Et flashkortprogram til at lære de mest almindelige malajiske ord med danske oversættelser. Det er løsningen på dag 31 i Udemys kursus *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Side | Læs den, når du vil |
|
||||
| --- | --- |
|
||||
| [Installation](Install-da) | Oprette `.venv`, installere programmet og køre testene |
|
||||
| [Brug](Usage-da) | Øve kortene, forstå fremgangsfilen og bruge indstillingerne |
|
||||
| [Arkitektur](Architecture-da) | Se lagene og finde koden til hvert opgavetrin |
|
||||
|
||||
## De fire opgavetrin
|
||||
|
||||
| Trin | Hvad programmet gør | Milepæl |
|
||||
| --- | --- | --- |
|
||||
| 1. Brugerflade | Et vindue med et kort og to knapper | MIL-002 |
|
||||
| 2. Nye flashkort | Et tilfældigt ord fra ordlisten ved hvert tryk på en knap | MIL-003 |
|
||||
| 3. Vend kortene | Efter 3 sekunder viser kortet den danske oversættelse | MIL-004 |
|
||||
| 4. Gem din fremgang | Kendte ord fjernes, og resten gemmes i `words_to_learn.csv` | MIL-005 |
|
||||
| Ekstra | Det malajiske ord bliver udtalt (valgfrit) | MIL-006 |
|
||||
|
||||
## Til kursister
|
||||
|
||||
Opgavens funktionsnavne er bevaret: `next_card`, `flip_card`, `is_known` og attributterne `flip_timer` og `current_card`. Data læses med standardbibliotekets `csv`-modul i stedet for `pandas`, så kerneprogrammet ikke har nogen afhængigheder ved kørsel.
|
||||
|
||||
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 [English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
|
||||
|
||||
Program kad imbas untuk mempelajari perkataan Melayu yang paling kerap digunakan beserta terjemahan Denmark. Ia ialah penyelesaian hari ke-31 kursus Udemy *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Halaman | Baca apabila anda mahu |
|
||||
| --- | --- |
|
||||
| [Pemasangan](Install-ms) | Mencipta `.venv`, memasang program dan menjalankan ujian |
|
||||
| [Penggunaan](Usage-ms) | Berlatih dengan kad, memahami fail kemajuan dan menggunakan pilihan |
|
||||
| [Seni bina](Architecture-ms) | Melihat lapisan dan mencari kod bagi setiap langkah tugasan |
|
||||
|
||||
## Empat langkah tugasan
|
||||
|
||||
| Langkah | Apa yang program lakukan | Pencapaian |
|
||||
| --- | --- | --- |
|
||||
| 1. Antara muka pengguna | Tetingkap dengan satu kad dan dua butang | MIL-002 |
|
||||
| 2. Kad imbas baharu | Satu perkataan rawak daripada senarai perkataan setiap kali butang ditekan | MIL-003 |
|
||||
| 3. Terbalikkan kad | Selepas 3 saat kad menunjukkan terjemahan Denmark | MIL-004 |
|
||||
| 4. Simpan kemajuan anda | Perkataan yang diketahui dibuang dan selebihnya disimpan dalam `words_to_learn.csv` | MIL-005 |
|
||||
| Tambahan | Perkataan Melayu disebut (pilihan) | MIL-006 |
|
||||
|
||||
## Untuk peserta kursus
|
||||
|
||||
Nama fungsi tugasan dikekalkan: `next_card`, `flip_card`, `is_known`, serta atribut `flip_timer` dan `current_card`. Data dibaca dengan modul `csv` pustaka standard dan bukan `pandas`, jadi program teras tidak mempunyai kebergantungan semasa larian.
|
||||
|
||||
Repositori: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 **English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
|
||||
|
||||
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*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Page | Read it when you want to |
|
||||
| --- | --- |
|
||||
| [Install](Install) | Create the `.venv`, install the program and run the tests |
|
||||
| [Usage](Usage) | Study the cards, understand the progress file, use the options |
|
||||
| [Architecture](Architecture) | See the layers and find the code of each assignment step |
|
||||
|
||||
## The four assignment steps
|
||||
|
||||
| Step | What the program does | Milestone |
|
||||
| --- | --- | --- |
|
||||
| 1. User interface | A window with a card and two buttons | MIL-002 |
|
||||
| 2. New flash cards | A random word from the word list on every button press | MIL-003 |
|
||||
| 3. Flip the cards | After 3 seconds the card shows the Danish translation | MIL-004 |
|
||||
| 4. Save your progress | Known words are removed and the rest is saved in `words_to_learn.csv` | MIL-005 |
|
||||
| Extra | The Malay word is spoken (optional) | MIL-006 |
|
||||
|
||||
## For course participants
|
||||
|
||||
The function names of the assignment are kept: `next_card`, `flip_card`, `is_known`, and the attributes `flip_timer` and `current_card`. The data is read with the standard library `csv` module instead of `pandas`, so the core program has no runtime dependency.
|
||||
|
||||
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,65 @@
|
||||
# Installation
|
||||
|
||||
🌐 [English](Install) · **Dansk** · [Bahasa Malaysia](Install-ms)
|
||||
|
||||
## Krav
|
||||
|
||||
- Python 3.13 eller nyere med Tkinter (på Debian eller Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Valgfrit: en internetforbindelse og tilvalget `speech`, hvis du vil høre det malajiske ord.
|
||||
|
||||
## Hent koden
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Opret og brug et lokalt virtuelt miljø
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux og macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
- `.[dev]` tilføjer `pytest`, `ruff`, `mypy` og `import-linter`. Brug `.`, hvis du kun vil køre programmet.
|
||||
- Hvis PowerShell nægter at køre `Activate.ps1`, så kør `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` én gang.
|
||||
- Forlad miljøet med `deactivate`; slet mappen `.venv` for at starte forfra.
|
||||
|
||||
## Udtalt malajisk ord (valgfrit)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
Det installerer `gTTS` og `playsound==1.2.2`. Uden tilvalget virker programmet uden lyd.
|
||||
|
||||
## Kør og test
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
|
||||
|
||||
## Dokumentation af kildekoden
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
HTML-filerne skrives til `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Pemasangan
|
||||
|
||||
🌐 [English](Install) · [Dansk](Install-da) · **Bahasa Malaysia**
|
||||
|
||||
## Keperluan
|
||||
|
||||
- Python 3.13 atau lebih baharu dengan Tkinter (pada Debian atau Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Pilihan: sambungan internet dan tambahan `speech` jika anda mahu mendengar perkataan Melayu.
|
||||
|
||||
## Dapatkan kod
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Cipta dan gunakan persekitaran maya setempat
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux dan macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
- `.[dev]` menambah `pytest`, `ruff`, `mypy` dan `import-linter`. Gunakan `.` jika anda hanya mahu menjalankan program.
|
||||
- Jika PowerShell enggan menjalankan `Activate.ps1`, jalankan `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` sekali.
|
||||
- Keluar daripada persekitaran dengan `deactivate`; padam folder `.venv` untuk bermula semula.
|
||||
|
||||
## Perkataan Melayu bersuara (pilihan)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
Ini memasang `gTTS` dan `playsound==1.2.2`. Tanpa tambahan ini program berfungsi tanpa bunyi.
|
||||
|
||||
## Jalankan dan uji
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
|
||||
|
||||
## Dokumentasi kod sumber
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
HTML ditulis ke `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Install
|
||||
|
||||
🌐 **English** · [Dansk](Install-da) · [Bahasa Malaysia](Install-ms)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.13 or later with Tkinter (on Debian or Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Optional: an internet connection and the `speech` extra to hear the Malay word.
|
||||
|
||||
## Get the code
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Create and use a local virtual environment
|
||||
|
||||
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]"
|
||||
```
|
||||
|
||||
- `.[dev]` adds `pytest`, `ruff`, `mypy` and `import-linter`. Use `.` if you only want to run the program.
|
||||
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once.
|
||||
- Leave the environment with `deactivate`; delete the `.venv` folder to start over.
|
||||
|
||||
## Spoken Malay word (optional)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
This installs `gTTS` and `playsound==1.2.2`. Without it the program works silently.
|
||||
|
||||
## Run and test
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
The window test in `tests/e2e/` is skipped when no display is available.
|
||||
|
||||
## Documentation of the source code
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
The HTML is written to `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Brug
|
||||
|
||||
🌐 [English](Usage) · **Dansk** · [Bahasa Malaysia](Usage-ms)
|
||||
|
||||
## Øv kortene
|
||||
|
||||
1. Start programmet med `python -m flash_card`.
|
||||
2. Et kort viser et malajisk ord. Prøv at huske den danske oversættelse.
|
||||
3. Efter tre sekunder vender kortet og viser oversættelsen.
|
||||
4. Tryk på det grønne flueben, hvis du kendte ordet, eller på det røde kryds, hvis du ikke gjorde.
|
||||
5. Et nyt tilfældigt ord vises. Gentag.
|
||||
|
||||
Et ord, du har markeret som kendt, fjernes og kommer ikke igen. Når der ikke er flere ord, står der "Nothing left to learn" på kortet.
|
||||
|
||||
## Din fremgang
|
||||
|
||||
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
|
||||
- Næste start bruger den fil. Findes den ikke, bruger programmet hele ordlisten `data/ms-da.csv`.
|
||||
- For at starte forfra skal du slette `data/words_to_learn.csv`.
|
||||
- Det røde kryds ændrer aldrig filen.
|
||||
|
||||
## Filformat
|
||||
|
||||
Begge filer er UTF-8-tekst med ét kort pr. linje og cellerne adskilt af semikolon. Første linje har de to sprogkoder.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Indstillinger
|
||||
|
||||
| Indstilling | Betydning |
|
||||
| --- | --- |
|
||||
| `--data-dir <mappe>` | Mappen med `ms-da.csv` og `words_to_learn.csv` (standard: `data/` i repositoryet) |
|
||||
| `--no-speech` | Udtal ikke det malajiske ord, selv om tilvalget til tale er installeret |
|
||||
|
||||
## Brug et andet sprogpar
|
||||
|
||||
Læg en fil med samme format i datamappen og kald den `ms-da.csv`, eller ret `VOCABULARY_FILE_NAME` og `LANGUAGE_NAMES` i `src/flash_card/constants.py`. Kodene i overskriften vises som sprognavne fra `LANGUAGE_NAMES`; ukendte koder vises med store bogstaver.
|
||||
|
||||
## Fejlfinding
|
||||
|
||||
| Problem | Hvad du skal gøre |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Installer Tkinter (`python3-tk` på Debian og Ubuntu) |
|
||||
| Ingen lyd | Installer tilvalget `speech` og tjek netværket; programmet logger en advarsel, når udtalen mislykkes |
|
||||
| Programmet stopper med en meddelelse om filen | CSV-filen er forkert opbygget; meddelelsen nævner filen og linjen. Filen bliver ikke overskrevet |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Penggunaan
|
||||
|
||||
🌐 [English](Usage) · [Dansk](Usage-da) · **Bahasa Malaysia**
|
||||
|
||||
## Berlatih dengan kad
|
||||
|
||||
1. Mulakan program dengan `python -m flash_card`.
|
||||
2. Kad menunjukkan satu perkataan Melayu. Cuba ingat terjemahan Denmarknya.
|
||||
3. Selepas tiga saat kad terbalik dan menunjukkan terjemahan.
|
||||
4. Tekan tanda semak hijau jika anda tahu perkataan itu, atau pangkah merah jika tidak.
|
||||
5. Satu perkataan rawak baharu muncul. Ulang.
|
||||
|
||||
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi, kad menunjukkan "Nothing left to learn".
|
||||
|
||||
## Kemajuan anda
|
||||
|
||||
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
|
||||
- Permulaan seterusnya menggunakan fail itu. Jika ia tiada, program menggunakan senarai penuh `data/ms-da.csv`.
|
||||
- Untuk bermula semula, padam `data/words_to_learn.csv`.
|
||||
- Pangkah merah tidak pernah mengubah fail.
|
||||
|
||||
## Format fail
|
||||
|
||||
Kedua-dua fail ialah teks UTF-8 dengan satu kad setiap baris dan sel dipisahkan oleh koma bertitik. Baris pertama mengandungi dua kod bahasa.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Pilihan
|
||||
|
||||
| Pilihan | Maksud |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder yang mengandungi `ms-da.csv` dan `words_to_learn.csv` (lalai: `data/` dalam repositori) |
|
||||
| `--no-speech` | Jangan sebut perkataan Melayu, walaupun tambahan suara dipasang |
|
||||
|
||||
## Gunakan pasangan bahasa lain
|
||||
|
||||
Letakkan fail dengan format yang sama dalam folder data dan namakannya `ms-da.csv`, atau ubah `VOCABULARY_FILE_NAME` dan `LANGUAGE_NAMES` dalam `src/flash_card/constants.py`. Kod pada baris pertama dipaparkan sebagai nama bahasa daripada `LANGUAGE_NAMES`; kod yang tidak dikenali dipaparkan dengan huruf besar.
|
||||
|
||||
## Penyelesaian masalah
|
||||
|
||||
| Masalah | Apa yang perlu dilakukan |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Pasang Tkinter (`python3-tk` pada Debian dan Ubuntu) |
|
||||
| Tiada bunyi | Pasang tambahan `speech` dan periksa rangkaian; program mencatat amaran apabila penyebutan gagal |
|
||||
| Program berhenti dengan mesej tentang fail | Fail CSV tidak betul; mesej menyebut fail dan barisnya. Fail tidak ditulis ganti |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Usage
|
||||
|
||||
🌐 **English** · [Dansk](Usage-da) · [Bahasa Malaysia](Usage-ms)
|
||||
|
||||
## Study the cards
|
||||
|
||||
1. Start the program with `python -m flash_card`.
|
||||
2. A card shows a Malay word. Try to remember its Danish translation.
|
||||
3. After three seconds the card turns over and shows the translation.
|
||||
4. Press the green tick when you knew the word, or the red cross when you did not.
|
||||
5. A new random word appears. Repeat.
|
||||
|
||||
A word you marked as known is removed and does not come up again. When no word is left, the card says "Nothing left to learn".
|
||||
|
||||
## Your progress
|
||||
|
||||
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
|
||||
- The next start uses that file. If it does not exist, the program uses the full word list `data/ms-da.csv`.
|
||||
- To start again, delete `data/words_to_learn.csv`.
|
||||
- The red cross never changes the file.
|
||||
|
||||
## File format
|
||||
|
||||
Both files are UTF-8 text with one card per line and the cells separated by a semicolon. The first line holds the two language codes.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder that holds `ms-da.csv` and `words_to_learn.csv` (default: `data/` of the repository) |
|
||||
| `--no-speech` | Do not speak the Malay word, even when the speech extra is installed |
|
||||
|
||||
## Use another language pair
|
||||
|
||||
Put a file with the same format in the data folder and name it `ms-da.csv`, or change `VOCABULARY_FILE_NAME` and `LANGUAGE_NAMES` in `src/flash_card/constants.py`. The header codes are shown as language names from `LANGUAGE_NAMES`; unknown codes are shown in upper case.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | What to do |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Install Tkinter (`python3-tk` on Debian and Ubuntu) |
|
||||
| No sound | Install the `speech` extra and check the network; the program logs a warning when speaking fails |
|
||||
| The program stops with a message about the file | The CSV file is malformed; the message names the file and the line. The file is not overwritten |
|
||||
@@ -0,0 +1,12 @@
|
||||
**Flash Card**
|
||||
|
||||
- [Home](Home)
|
||||
- [Install](Install)
|
||||
- [Usage](Usage)
|
||||
- [Architecture](Architecture)
|
||||
|
||||
**Languages**
|
||||
|
||||
- English: [Home](Home) · [Install](Install) · [Usage](Usage) · [Architecture](Architecture)
|
||||
- Dansk: [Hjem](Home-da) · [Installation](Install-da) · [Brug](Usage-da) · [Arkitektur](Architecture-da)
|
||||
- Bahasa Malaysia: [Utama](Home-ms) · [Pemasangan](Install-ms) · [Penggunaan](Usage-ms) · [Seni bina](Architecture-ms)
|
||||
@@ -0,0 +1,80 @@
|
||||
[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"]
|
||||
# Only for tools/capture_screenshots.py; not needed to run the program.
|
||||
tools = ["pillow>=10"]
|
||||
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.format]
|
||||
line-ending = "lf"
|
||||
|
||||
[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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 @@
|
||||
"""Ports of the application layer.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the interfaces the use cases need from outside.
|
||||
"""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Port for spoken words.
|
||||
|
||||
@file speech_port.py
|
||||
@brief The SpeechPort interface the study session uses to say a word aloud.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SpeechPort(Protocol):
|
||||
"""Speaks a text aloud in a language."""
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Speak a text without blocking the caller for long.
|
||||
|
||||
An implementation does not raise: a failure is logged and ignored.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text, for example "ms".
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application use cases.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the use cases of the flash card program.
|
||||
"""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""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.ports.speech_port import SpeechPort
|
||||
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.progress_repository import ProgressRepository
|
||||
from flash_card.domain.repositories.vocabulary_repository import VocabularyRepository
|
||||
|
||||
|
||||
def open_study_session(
|
||||
vocabulary_repository: VocabularyRepository,
|
||||
progress_repository: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
) -> StudySession:
|
||||
"""Build a session over the saved words to learn, else the whole word list.
|
||||
|
||||
@param vocabulary_repository Source of the delivered word list.
|
||||
@param progress_repository Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@return The new session.
|
||||
@throws VocabularyError When the saved progress, or the word list when no
|
||||
progress exists, cannot be loaded.
|
||||
"""
|
||||
deck = progress_repository.load()
|
||||
if deck is None:
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose, progress_repository, speech)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""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.application.ports.speech_port import SpeechPort
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
|
||||
|
||||
class StudySession:
|
||||
"""Draws cards from a deck, one after another."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Deck,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
progress: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param progress Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
"""
|
||||
## @brief Speaks the front text of each drawn card.
|
||||
self._speech = speech
|
||||
## @brief Store of the words still to learn.
|
||||
self._progress = progress
|
||||
## @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
|
||||
card = self._deck.draw(self._choose, exclude=self._current)
|
||||
self._current = card
|
||||
self._speech.speak(card.front, self._deck.language_pair.front_code)
|
||||
return self._to_dto(card)
|
||||
|
||||
def mark_known(self) -> None:
|
||||
"""Remove the current card from the deck and save the remaining cards.
|
||||
|
||||
Does nothing and saves nothing when no card is shown.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._current is None:
|
||||
return
|
||||
self._deck.remove(self._current)
|
||||
self._progress.save(self._deck)
|
||||
self._current = None
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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 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"
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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, ...]
|
||||
@@ -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,29 @@
|
||||
"""Port to the words still to learn.
|
||||
|
||||
@file progress_repository.py
|
||||
@brief The ProgressRepository interface.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
|
||||
|
||||
class ProgressRepository(Protocol):
|
||||
"""Store of the words still to learn."""
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, or None when no progress was saved.
|
||||
@throws VocabularyError When the saved progress is unusable.
|
||||
"""
|
||||
...
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
@@ -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,44 @@
|
||||
"""CSV implementation of the progress port.
|
||||
|
||||
@file csv_progress_repository.py
|
||||
@brief The CsvProgressRepository adapter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.infrastructure.persistence.csv_word_list import (
|
||||
read_word_list,
|
||||
write_word_list,
|
||||
)
|
||||
|
||||
|
||||
class CsvProgressRepository:
|
||||
"""Keeps the words still to learn in a CSV file."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Create the repository.
|
||||
|
||||
@param path The words to learn file.
|
||||
"""
|
||||
## @brief The words to learn file.
|
||||
self._path = path
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
"""Load the saved words to learn.
|
||||
|
||||
@return The deck, possibly empty, or None when the file does not exist.
|
||||
@throws VocabularyFormatError When the file is malformed; it is left as is.
|
||||
"""
|
||||
if not self._path.exists():
|
||||
return None
|
||||
pair, cards = read_word_list(self._path)
|
||||
return Deck(pair, cards)
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
"""Save the words still to learn.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@return Nothing.
|
||||
"""
|
||||
write_word_list(self._path, deck.language_pair, deck.cards)
|
||||
@@ -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,116 @@
|
||||
"""Reading and writing of the word list CSV file.
|
||||
|
||||
@file csv_word_list.py
|
||||
@brief Reads and writes a semicolon separated word list with a language code
|
||||
header; the format is defined once here.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from collections.abc import Iterable
|
||||
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)
|
||||
|
||||
|
||||
def write_word_list(
|
||||
path: Path, language_pair: LanguagePair, cards: Iterable[Card]
|
||||
) -> None:
|
||||
"""Write a word list file atomically.
|
||||
|
||||
The rows go to a temporary file in the same folder, which then replaces the
|
||||
target, so a crash never leaves a half written file.
|
||||
|
||||
@param path The CSV file to create or replace.
|
||||
@param language_pair The languages, written as the header row.
|
||||
@param cards The cards, written one row each in the given order.
|
||||
@return Nothing.
|
||||
@throws OSError When the file cannot be written.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
rows = [(language_pair.front_code, language_pair.back_code)]
|
||||
rows.extend((card.front, card.back) for card in cards)
|
||||
try:
|
||||
with temporary.open("w", encoding=CSV_ENCODING, newline="") as file:
|
||||
writer = csv.writer(file, delimiter=CSV_DELIMITER, lineterminator="\n")
|
||||
writer.writerows(rows)
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Speech adapters.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the implementations of the speech port.
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Speech adapter over the gTTS service.
|
||||
|
||||
@file gtts_speech.py
|
||||
@brief GttsSpeech, the SpeechPort that synthesises with gTTS and plays the file.
|
||||
The packages gtts and playsound are optional and imported only when used.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
## @brief Logger of the speech adapter.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
## @brief File name of the synthesised speech inside the temporary folder.
|
||||
_SPEECH_FILE_NAME = "speech.mp3"
|
||||
|
||||
## @brief Writes an mp3 of a text and language to a path.
|
||||
Synthesize = Callable[[str, str, Path], None]
|
||||
## @brief Plays an audio file.
|
||||
Play = Callable[[Path], None]
|
||||
|
||||
|
||||
def _default_synthesize(text: str, language_code: str, path: Path) -> None:
|
||||
"""Write an mp3 of the text with gTTS.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@param path File to write.
|
||||
@return Nothing.
|
||||
@throws ImportError When the gtts package is not installed.
|
||||
"""
|
||||
# Imported by name so the program starts without the optional extra.
|
||||
module = importlib.import_module("gtts")
|
||||
module.gTTS(text=text, lang=language_code).save(str(path))
|
||||
|
||||
|
||||
def _default_play(path: Path) -> None:
|
||||
"""Play an audio file and wait until it has been played.
|
||||
|
||||
@param path File to play.
|
||||
@return Nothing.
|
||||
@throws ImportError When the playsound package is not installed.
|
||||
"""
|
||||
# Imported by name so the program starts without the optional extra.
|
||||
module = importlib.import_module("playsound")
|
||||
module.playsound(str(path))
|
||||
|
||||
|
||||
class GttsSpeech:
|
||||
"""Speaks a text in a background thread; never raises."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
synthesize: Synthesize | None = None,
|
||||
play: Play | None = None,
|
||||
) -> None:
|
||||
"""Create the adapter.
|
||||
|
||||
@param synthesize Writes an mp3 of a text and language to a path, or
|
||||
None for gTTS.
|
||||
@param play Plays an audio file, or None for playsound.
|
||||
"""
|
||||
## @brief Writes the audio file.
|
||||
self._synthesize = _default_synthesize if synthesize is None else synthesize
|
||||
## @brief Plays the audio file.
|
||||
self._play = _default_play if play is None else play
|
||||
## @brief The thread of the latest speak call, or None.
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Start speaking and return at once.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@return Nothing.
|
||||
"""
|
||||
thread = threading.Thread(
|
||||
target=self._run, args=(text, language_code), daemon=True
|
||||
)
|
||||
self._thread = thread
|
||||
thread.start()
|
||||
|
||||
def wait(self) -> None:
|
||||
"""Block until the latest speech has ended.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._thread is not None:
|
||||
self._thread.join()
|
||||
|
||||
def _run(self, text: str, language_code: str) -> None:
|
||||
"""Synthesise and play in a temporary folder, then remove the file.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@return Nothing.
|
||||
"""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / _SPEECH_FILE_NAME
|
||||
self._synthesize(text, language_code, path)
|
||||
self._play(path)
|
||||
except Exception: # thread boundary: speech must never crash the program
|
||||
_logger.warning("Speech failed for %r", text, exc_info=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Silent speech adapter.
|
||||
|
||||
@file null_speech.py
|
||||
@brief NullSpeech, the SpeechPort used when speech is off or unavailable.
|
||||
"""
|
||||
|
||||
|
||||
class NullSpeech:
|
||||
"""Speaks nothing."""
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Do nothing.
|
||||
|
||||
@param text The text, ignored.
|
||||
@param language_code The language, ignored.
|
||||
@return Nothing.
|
||||
"""
|
||||
return None
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""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 importlib.util
|
||||
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.ports.speech_port import SpeechPort
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyError
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
)
|
||||
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
## @brief Optional packages the spoken words need.
|
||||
_SPEECH_PACKAGES = ("gtts", "playsound")
|
||||
|
||||
|
||||
def build_speech(enabled: bool) -> SpeechPort:
|
||||
"""Choose the speech adapter.
|
||||
|
||||
@param enabled False when the user switched speech off.
|
||||
@return GttsSpeech when enabled and gtts and playsound are installed,
|
||||
else NullSpeech.
|
||||
"""
|
||||
if enabled and all(
|
||||
importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
|
||||
):
|
||||
return GttsSpeech()
|
||||
return NullSpeech()
|
||||
|
||||
|
||||
def create_application(
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
) -> 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.
|
||||
@param speech_enabled False to run without spoken words.
|
||||
@return The root window and the view built on it.
|
||||
@throws VocabularyError When the word list or saved progress is unusable.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
|
||||
speech = build_speech(speech_enabled)
|
||||
session = open_study_session(repository, progress, speech, 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",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-speech",
|
||||
action="store_true",
|
||||
help="do not speak the words",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root, _view = create_application(args.data_dir, not args.no_speech)
|
||||
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,212 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
|
||||
"""Load an asset image.
|
||||
|
||||
The bytes are read here and handed to Tk as data, so the image does not
|
||||
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
|
||||
start, and a process that swaps them (pytest fd capture) makes the first
|
||||
Tcl file open fail with an empty error.
|
||||
|
||||
@param root The Tk root window that owns the image.
|
||||
@param name The file name in the assets folder.
|
||||
@return The image; the caller must keep a reference.
|
||||
"""
|
||||
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
|
||||
|
||||
|
||||
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 = _load_image(root, CARD_FRONT_IMAGE_NAME)
|
||||
## @brief Card back image, kept so Tk does not drop it.
|
||||
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
|
||||
## @brief Wrong button image, kept so Tk does not drop it.
|
||||
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
|
||||
## @brief Right button image, kept so Tk does not drop it.
|
||||
self._right_image = _load_image(root, 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,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
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,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.is_known,
|
||||
)
|
||||
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 is_known(self) -> None:
|
||||
"""Mark the card as known, show the next card front, restart the timer.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
self._cancel_flip_timer()
|
||||
self._view_model.is_known()
|
||||
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,119 @@
|
||||
"""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; does nothing when finished.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is None:
|
||||
return
|
||||
self._card = self._session.next_card()
|
||||
self._side = Side.FRONT
|
||||
|
||||
def is_known(self) -> None:
|
||||
"""Mark the card as known, save the progress and show the next card.
|
||||
|
||||
Does nothing when finished.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._card is None:
|
||||
return
|
||||
self._session.mark_known()
|
||||
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
|
||||
@@ -0,0 +1,364 @@
|
||||
"""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
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.constants import (
|
||||
BACK_TEXT_COLOR,
|
||||
BACKGROUND_COLOR,
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
FINISHED_MESSAGE,
|
||||
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.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
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]
|
||||
|
||||
|
||||
class _MemoryProgress:
|
||||
"""Progress store that keeps nothing: the shared window never deletes cards."""
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _RecordingSpeech:
|
||||
"""Speech port that records what would have been spoken."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.spoken: list[tuple[str, str]] = []
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
self.spoken.append((text, language_code))
|
||||
|
||||
|
||||
SPEECH = _RecordingSpeech()
|
||||
|
||||
|
||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
|
||||
return FlashCardView(root, FlashCardViewModel(session))
|
||||
|
||||
|
||||
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_buttons_sit_on_the_window_background(
|
||||
window: tuple[tk.Tk, FlashCardView],
|
||||
) -> None:
|
||||
_root, view = window
|
||||
assert view.wrong_button.cget("bg") == BACKGROUND_COLOR
|
||||
assert view.right_button.cget("bg") == BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_wrong_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.wrong_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.wrong_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)
|
||||
|
||||
|
||||
HEADER_ONLY = "ms;da\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files_view(
|
||||
window: tuple[tk.Tk, FlashCardView], tmp_path: Path
|
||||
) -> Iterator[tuple[FlashCardView, Path]]:
|
||||
"""A second view on the shared root, over real repositories in tmp_path."""
|
||||
root, _shared = window
|
||||
words = tmp_path / "words.csv"
|
||||
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
|
||||
progress = tmp_path / "words_to_learn.csv"
|
||||
session = open_study_session(
|
||||
CsvVocabularyRepository(words),
|
||||
CsvProgressRepository(progress),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
)
|
||||
view = FlashCardView(root, FlashCardViewModel(session))
|
||||
yield view, progress
|
||||
if view.flip_timer is not None:
|
||||
root.after_cancel(view.flip_timer)
|
||||
for widget in (view.canvas, view.wrong_button, view.right_button):
|
||||
widget.destroy()
|
||||
|
||||
|
||||
def _saved(progress: Path) -> str:
|
||||
return progress.read_bytes().decode("utf-8-sig")
|
||||
|
||||
|
||||
def test_right_button_removes_card_and_writes_progress_file(
|
||||
files_view: tuple[FlashCardView, Path],
|
||||
) -> None:
|
||||
view, progress = files_view
|
||||
assert _shown(view) == ("Malay", "aku")
|
||||
view.right_button.invoke()
|
||||
assert _shown(view) == ("Malay", "kau")
|
||||
assert _saved(progress) == "ms;da\nkau;du\nini;denne\n"
|
||||
|
||||
|
||||
def test_wrong_button_shows_next_card_and_leaves_progress_file_absent(
|
||||
files_view: tuple[FlashCardView, Path],
|
||||
) -> None:
|
||||
view, progress = files_view
|
||||
view.wrong_button.invoke()
|
||||
assert _shown(view) == ("Malay", "kau")
|
||||
assert not progress.exists()
|
||||
|
||||
|
||||
def test_buttons_do_nothing_visible_when_finished(
|
||||
files_view: tuple[FlashCardView, Path],
|
||||
) -> None:
|
||||
view, progress = files_view
|
||||
for _ in range(3):
|
||||
view.right_button.invoke()
|
||||
assert _shown(view) == ("", FINISHED_MESSAGE)
|
||||
assert view.flip_timer is None
|
||||
assert _saved(progress) == HEADER_ONLY
|
||||
view.right_button.invoke()
|
||||
view.wrong_button.invoke()
|
||||
assert _shown(view) == ("", FINISHED_MESSAGE)
|
||||
assert view.flip_timer is None
|
||||
assert _saved(progress) == HEADER_ONLY
|
||||
|
||||
|
||||
def test_button_press_speaks_the_malay_word_of_the_shown_card(
|
||||
window: tuple[tk.Tk, FlashCardView],
|
||||
) -> None:
|
||||
root, view = window
|
||||
SPEECH.spoken.clear()
|
||||
view.wrong_button.invoke()
|
||||
assert SPEECH.spoken == [(_word(view), "ms")]
|
||||
_reset(root, view)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Integration tests of saving and resuming progress over real CSV files."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
WORDS = "fr;en\nchaque;each\nparlé;speak\narrivé;come\n"
|
||||
|
||||
Choose = Callable[[Sequence[Card]], Card]
|
||||
|
||||
|
||||
def _first(cards: Sequence[Card]) -> Card:
|
||||
return cards[0]
|
||||
|
||||
|
||||
def _choose_front(front: str) -> Choose:
|
||||
def choose(cards: Sequence[Card]) -> Card:
|
||||
return next(card for card in cards if card.front == front)
|
||||
|
||||
return choose
|
||||
|
||||
|
||||
def _open(folder: Path, choose: Choose = _first) -> StudySession:
|
||||
return open_study_session(
|
||||
CsvVocabularyRepository(folder / "words.csv"),
|
||||
CsvProgressRepository(folder / "words_to_learn.csv"),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
)
|
||||
|
||||
|
||||
def _setup(folder: Path) -> Path:
|
||||
(folder / "words.csv").write_text(WORDS, encoding="utf-8")
|
||||
return folder / "words_to_learn.csv"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_bytes().decode("utf-8-sig")
|
||||
|
||||
|
||||
def test_mark_known_writes_remaining_cards_in_order(tmp_path: Path) -> None:
|
||||
progress = _setup(tmp_path)
|
||||
session = _open(tmp_path, _choose_front("parlé"))
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert _text(progress) == "fr;en\nchaque;each\narrivé;come\n"
|
||||
|
||||
|
||||
def test_restart_draws_only_from_progress_file(tmp_path: Path) -> None:
|
||||
_setup(tmp_path)
|
||||
first = _open(tmp_path, _choose_front("parlé"))
|
||||
first.next_card()
|
||||
first.mark_known()
|
||||
|
||||
second = _open(tmp_path)
|
||||
fronts: set[str] = set()
|
||||
for _ in range(6):
|
||||
card = second.next_card()
|
||||
assert card is not None
|
||||
fronts.add(card.front_text)
|
||||
|
||||
assert second.remaining == 2
|
||||
assert fronts == {"chaque", "arrivé"}
|
||||
|
||||
|
||||
def test_progress_file_is_readable_as_vocabulary(tmp_path: Path) -> None:
|
||||
progress = _setup(tmp_path)
|
||||
session = _open(tmp_path, _choose_front("chaque"))
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
vocabulary = CsvVocabularyRepository(progress).load()
|
||||
assert vocabulary.cards == (Card("parlé", "speak"), Card("arrivé", "come"))
|
||||
|
||||
|
||||
def test_header_only_progress_file_gives_finished_session(tmp_path: Path) -> None:
|
||||
progress = _setup(tmp_path)
|
||||
progress.write_text("fr;en\n", encoding="utf-8")
|
||||
session = _open(tmp_path)
|
||||
assert session.remaining == 0
|
||||
assert session.next_card() is None
|
||||
|
||||
|
||||
def test_unknown_card_never_creates_progress_file(tmp_path: Path) -> None:
|
||||
progress = _setup(tmp_path)
|
||||
session = _open(tmp_path)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert not progress.exists()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Integration tests of the word list reader on the delivered file."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
PROGRESS_FILE_NAME,
|
||||
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_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
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(tmp_path: Path) -> None:
|
||||
repository = CsvVocabularyRepository(PATH)
|
||||
cards = repository.load().cards
|
||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(
|
||||
repository, progress, NullSpeech(), 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,101 @@
|
||||
"""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.deck import Deck
|
||||
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 _RecordingProgress:
|
||||
def __init__(self, stored: Deck | None = None) -> None:
|
||||
self.stored = stored
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return self.stored
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _SilentSpeech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
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, _RecordingProgress(), _SilentSpeech(), _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(), _RecordingProgress(), _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
|
||||
def test_open_study_session_prefers_saved_progress() -> None:
|
||||
pair = LanguagePair("ms", "da")
|
||||
vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du")))
|
||||
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
assert session.remaining == 1
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
|
||||
|
||||
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
|
||||
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
|
||||
|
||||
session = open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
assert session.remaining == 0
|
||||
|
||||
|
||||
def test_open_study_session_never_saves_on_start() -> None:
|
||||
vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),))
|
||||
progress = _RecordingProgress()
|
||||
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
)
|
||||
session.next_card()
|
||||
|
||||
assert progress.saved == []
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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]
|
||||
|
||||
|
||||
class _RecordingProgress:
|
||||
def __init__(self) -> None:
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _RecordingSpeech:
|
||||
def __init__(self) -> None:
|
||||
self.spoken: list[tuple[str, str]] = []
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
self.spoken.append((text, language_code))
|
||||
|
||||
|
||||
def _session(
|
||||
*cards: Card,
|
||||
progress: _RecordingProgress | None = None,
|
||||
speech: _RecordingSpeech | None = None,
|
||||
) -> StudySession:
|
||||
store = _RecordingProgress() if progress is None else progress
|
||||
voice = _RecordingSpeech() if speech is None else speech
|
||||
return StudySession(Deck(PAIR, cards), _first, store, voice)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_mark_known_removes_current_card_and_saves_remaining_once() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, B, progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert session.remaining == 1
|
||||
assert session.current_card is None
|
||||
assert progress.saved == [(B,)]
|
||||
|
||||
|
||||
def test_mark_known_saves_nothing_when_no_current_card() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, progress=progress)
|
||||
session.mark_known()
|
||||
assert session.remaining == 1
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_mark_known_on_last_card_leaves_session_finished() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, progress=progress)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert progress.saved == [()]
|
||||
assert session.next_card() is None
|
||||
|
||||
|
||||
def test_next_card_never_saves() -> None:
|
||||
progress = _RecordingProgress()
|
||||
session = _session(A, B, progress=progress)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_next_card_speaks_front_text_once_in_front_language() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
|
||||
|
||||
def test_next_card_does_not_speak_when_deck_is_empty() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == []
|
||||
|
||||
|
||||
def test_next_card_speaks_again_on_every_draw() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
def test_mark_known_does_not_speak_but_following_draw_does() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
@@ -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
|
||||
@@ -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,112 @@
|
||||
"""Tests of write_word_list and CsvProgressRepository."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.errors.vocabulary_errors import VocabularyFormatError
|
||||
from flash_card.domain.value_objects.language_pair import LanguagePair
|
||||
from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
CsvProgressRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_word_list import (
|
||||
read_word_list,
|
||||
write_word_list,
|
||||
)
|
||||
|
||||
PAIR = LanguagePair("ms", "da")
|
||||
CARDS = (Card("aku", "mig"), Card("kau", "du"))
|
||||
|
||||
|
||||
def test_write_word_list_round_trips_through_read_word_list(tmp_path: Path) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
write_word_list(path, PAIR, CARDS)
|
||||
assert read_word_list(path) == (PAIR, CARDS)
|
||||
|
||||
|
||||
def test_write_word_list_writes_header_and_rows_with_semicolon(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
write_word_list(path, PAIR, CARDS)
|
||||
raw = path.read_bytes()
|
||||
assert raw.decode("utf-8-sig") == "ms;da\naku;mig\nkau;du\n"
|
||||
assert b"\r" not in raw
|
||||
|
||||
|
||||
def test_write_word_list_keeps_non_ascii_text(tmp_path: Path) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
cards = (Card("chaque", "each"), Card("parlé", "speak"))
|
||||
write_word_list(path, LanguagePair("fr", "en"), cards)
|
||||
assert read_word_list(path)[1] == cards
|
||||
|
||||
|
||||
def test_write_word_list_writes_header_only_for_no_cards(tmp_path: Path) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
write_word_list(path, PAIR, ())
|
||||
assert read_word_list(path) == (PAIR, ())
|
||||
|
||||
|
||||
def test_write_word_list_creates_parent_folder(tmp_path: Path) -> None:
|
||||
path = tmp_path / "data" / "deep" / "w.csv"
|
||||
write_word_list(path, PAIR, CARDS)
|
||||
assert path.is_file()
|
||||
|
||||
|
||||
def test_write_word_list_replaces_file_and_leaves_no_temporary_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
write_word_list(path, PAIR, CARDS)
|
||||
write_word_list(path, PAIR, CARDS[:1])
|
||||
assert read_word_list(path)[1] == CARDS[:1]
|
||||
assert [item.name for item in tmp_path.iterdir()] == ["w.csv"]
|
||||
|
||||
|
||||
def test_write_word_list_keeps_old_file_and_no_temporary_when_write_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "w.csv"
|
||||
write_word_list(path, PAIR, CARDS)
|
||||
unencodable = (Card("aku", "\ud800"),)
|
||||
with pytest.raises(UnicodeEncodeError):
|
||||
write_word_list(path, PAIR, unencodable)
|
||||
assert read_word_list(path)[1] == CARDS
|
||||
assert [item.name for item in tmp_path.iterdir()] == ["w.csv"]
|
||||
|
||||
|
||||
def test_progress_load_returns_none_when_file_is_missing(tmp_path: Path) -> None:
|
||||
assert CsvProgressRepository(tmp_path / "p.csv").load() is None
|
||||
|
||||
|
||||
def test_progress_load_returns_empty_deck_for_header_only_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "p.csv"
|
||||
path.write_text("ms;da\n", encoding="utf-8")
|
||||
deck = CsvProgressRepository(path).load()
|
||||
assert deck is not None
|
||||
assert deck.is_empty
|
||||
assert deck.language_pair == PAIR
|
||||
|
||||
|
||||
def test_progress_load_raises_format_error_and_keeps_malformed_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "p.csv"
|
||||
content = b"ms;da\naku;mig;extra\n"
|
||||
path.write_bytes(content)
|
||||
with pytest.raises(VocabularyFormatError):
|
||||
CsvProgressRepository(path).load()
|
||||
assert path.read_bytes() == content
|
||||
|
||||
|
||||
def test_progress_save_then_load_returns_same_cards(tmp_path: Path) -> None:
|
||||
repository = CsvProgressRepository(tmp_path / "p.csv")
|
||||
repository.save(Deck(PAIR, CARDS))
|
||||
deck = repository.load()
|
||||
assert deck is not None
|
||||
assert deck.cards == CARDS
|
||||
assert deck.language_pair == PAIR
|
||||
@@ -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()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests of the GttsSpeech adapter with fake synthesizer and player."""
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.speech import gtts_speech
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.synthesized: list[tuple[str, str]] = []
|
||||
self.played: list[Path] = []
|
||||
self.existed_when_played = False
|
||||
|
||||
def synthesize(self, text: str, language_code: str, path: Path) -> None:
|
||||
self.synthesized.append((text, language_code))
|
||||
path.write_bytes(b"mp3")
|
||||
|
||||
def play(self, path: Path) -> None:
|
||||
self.played.append(path)
|
||||
self.existed_when_played = path.exists()
|
||||
|
||||
|
||||
def test_speak_synthesizes_and_plays_then_removes_file() -> None:
|
||||
recorder = _Recorder()
|
||||
speech = GttsSpeech(recorder.synthesize, recorder.play)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert recorder.synthesized == [("aku", "ms")]
|
||||
assert len(recorder.played) == 1
|
||||
assert recorder.existed_when_played
|
||||
assert not recorder.played[0].exists()
|
||||
|
||||
|
||||
def test_speak_returns_before_player_has_finished() -> None:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def blocked_play(path: Path) -> None:
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
|
||||
speech = GttsSpeech(lambda text, code, path: None, blocked_play)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
|
||||
assert started.wait(timeout=5)
|
||||
assert speech._thread is not None
|
||||
assert speech._thread.is_alive()
|
||||
release.set()
|
||||
speech.wait()
|
||||
|
||||
|
||||
def test_speak_logs_and_does_not_raise_when_synthesize_fails(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
played: list[Path] = []
|
||||
|
||||
def failing(text: str, language_code: str, path: Path) -> None:
|
||||
raise OSError("no network")
|
||||
|
||||
speech = GttsSpeech(failing, played.append)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert played == []
|
||||
assert "Speech failed" in caplog.text
|
||||
assert "no network" in caplog.text
|
||||
|
||||
|
||||
def test_speak_logs_and_does_not_raise_when_play_fails(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
def failing(path: Path) -> None:
|
||||
raise RuntimeError("no audio device")
|
||||
|
||||
speech = GttsSpeech(lambda text, code, path: None, failing)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert "no audio device" in caplog.text
|
||||
|
||||
|
||||
def test_speak_logs_missing_package_when_default_synthesizer_raises_import_error(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def missing(name: str) -> object:
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
|
||||
monkeypatch.setattr(gtts_speech.importlib, "import_module", missing)
|
||||
speech = GttsSpeech()
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert "No module named 'gtts'" in caplog.text
|
||||
|
||||
|
||||
def test_wait_returns_when_nothing_was_spoken() -> None:
|
||||
GttsSpeech().wait()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Tests of the NullSpeech adapter."""
|
||||
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
|
||||
def test_speak_does_nothing_and_returns_none() -> None:
|
||||
assert NullSpeech().speak("aku", "ms") is None
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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, speech_enabled: bool = True
|
||||
) -> 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
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests of the speech wiring in the entry point."""
|
||||
|
||||
import importlib.util
|
||||
from importlib.machinery import ModuleSpec
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
|
||||
|
||||
class _FakeRoot:
|
||||
def mainloop(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _found(name: str) -> ModuleSpec:
|
||||
return ModuleSpec(name, None)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", _found)
|
||||
assert isinstance(app.build_speech(False), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_packages_are_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
|
||||
assert isinstance(app.build_speech(True), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_one_package_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
importlib.util,
|
||||
"find_spec",
|
||||
lambda name: None if name == "playsound" else _found(name),
|
||||
)
|
||||
assert isinstance(app.build_speech(True), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_gtts_speech_when_both_packages_are_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", _found)
|
||||
assert isinstance(app.build_speech(True), GttsSpeech)
|
||||
|
||||
|
||||
def test_main_passes_speech_disabled_with_no_speech_option(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
seen: list[bool] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
seen.append(speech_enabled)
|
||||
return _FakeRoot(), object()
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path), "--no-speech"]) == 0
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 0
|
||||
assert seen == [False, True]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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]
|
||||
|
||||
|
||||
class _RecordingProgress:
|
||||
def __init__(self) -> None:
|
||||
self.saved: list[tuple[Card, ...]] = []
|
||||
|
||||
def load(self) -> Deck | None:
|
||||
return None
|
||||
|
||||
def save(self, deck: Deck) -> None:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _SilentSpeech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _session(
|
||||
code: str, *cards: Card, progress: _RecordingProgress | None = None
|
||||
) -> StudySession:
|
||||
store = _RecordingProgress() if progress is None else progress
|
||||
return StudySession(
|
||||
Deck(LanguagePair(code, "da"), cards), _first, store, _SilentSpeech()
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_is_known_saves_remaining_and_shows_next_front() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
|
||||
view_model.flip_card()
|
||||
view_model.is_known()
|
||||
assert progress.saved == [(B,)]
|
||||
assert view_model.side is Side.FRONT
|
||||
assert view_model.text == "kau"
|
||||
assert not view_model.is_finished
|
||||
|
||||
|
||||
def test_is_known_on_last_card_finishes() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, progress=progress))
|
||||
view_model.is_known()
|
||||
assert view_model.is_finished
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
assert progress.saved == [()]
|
||||
|
||||
|
||||
def test_is_known_does_nothing_when_finished() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", progress=progress))
|
||||
view_model.is_known()
|
||||
assert view_model.is_finished
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_next_card_does_nothing_when_finished() -> None:
|
||||
view_model = FlashCardViewModel(_session("ms"))
|
||||
view_model.next_card()
|
||||
assert view_model.is_finished
|
||||
assert view_model.text == FINISHED_MESSAGE
|
||||
|
||||
|
||||
def test_next_card_never_saves() -> None:
|
||||
progress = _RecordingProgress()
|
||||
view_model = FlashCardViewModel(_session("ms", A, B, progress=progress))
|
||||
view_model.next_card()
|
||||
assert progress.saved == []
|
||||
@@ -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)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Capture screenshots of the running flash card window.
|
||||
|
||||
@file capture_screenshots.py
|
||||
@brief Starts the program, grabs the front of a card and, three seconds later,
|
||||
the back, and saves both as PNG files.
|
||||
|
||||
The tool needs Pillow (`python -m pip install -e ".[tools]"`), which is a tool
|
||||
dependency only and not a dependency of the program. It grabs the screen area
|
||||
of the window, so it needs a visible desktop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageGrab
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from flash_card.constants import ( # noqa: E402
|
||||
DEFAULT_DATA_DIR,
|
||||
FLIP_DELAY_MS,
|
||||
PROJECT_ROOT,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.presentation.app import create_application # noqa: E402
|
||||
|
||||
## @brief Default folder of the screenshots.
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
|
||||
## @brief Seed that makes the first card the same on every run.
|
||||
SCREENSHOT_SEED = 7
|
||||
## @brief Extra time in seconds to let the window settle before a grab.
|
||||
SETTLE_SECONDS = 0.5
|
||||
|
||||
|
||||
def _make_dpi_aware() -> None:
|
||||
"""Make Windows report real pixel coordinates (no effect elsewhere)."""
|
||||
if sys.platform == "win32":
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
|
||||
def _pump(root: object, seconds: float) -> None:
|
||||
"""Run the Tk event loop for a while.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param seconds How long to run.
|
||||
"""
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
root.update() # type: ignore[attr-defined]
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def capture(output_dir: Path) -> list[Path]:
|
||||
"""Capture the front and the back of one card.
|
||||
|
||||
@param output_dir Folder to write `screenshot-front.png` and
|
||||
`screenshot-back.png` into.
|
||||
@return The two files written, front first.
|
||||
"""
|
||||
_make_dpi_aware()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
random.seed(SCREENSHOT_SEED)
|
||||
with tempfile.TemporaryDirectory() as data_dir:
|
||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, data_dir)
|
||||
root, view = create_application(Path(data_dir), speech_enabled=False)
|
||||
root.lift()
|
||||
root.attributes("-topmost", True)
|
||||
_pump(root, SETTLE_SECONDS)
|
||||
box = (
|
||||
view.canvas.winfo_rootx() - 20,
|
||||
view.canvas.winfo_rooty() - 20,
|
||||
view.canvas.winfo_rootx() + view.canvas.winfo_width() + 20,
|
||||
view.canvas.winfo_rooty()
|
||||
+ view.canvas.winfo_height()
|
||||
+ view.right_button.winfo_height()
|
||||
+ 20,
|
||||
)
|
||||
front = output_dir / "screenshot-front.png"
|
||||
back = output_dir / "screenshot-back.png"
|
||||
ImageGrab.grab(bbox=box).save(front)
|
||||
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
|
||||
ImageGrab.grab(bbox=box).save(back)
|
||||
root.destroy()
|
||||
return [front, back]
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the tool.
|
||||
|
||||
@param argv Command line arguments, or None for `sys.argv`.
|
||||
@return The exit code, 0 on success.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||
args = parser.parse_args(argv)
|
||||
for path in capture(args.output_dir):
|
||||
sys.stdout.write(f"{path}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user