Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,132 @@
|
|||||||
# 031-flash-card
|
# 🚀 Flash Card
|
||||||
|
|
||||||
|
A flash card program for learning the most frequent Malay words with their Danish translations. It is the solution of day 31 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse the repository for ideas.
|
||||||
|
|
||||||
|
## 📚 Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#-overview)
|
||||||
|
- [Requirements](#-requirements)
|
||||||
|
- [Setup](#-setup)
|
||||||
|
- [Run](#-run)
|
||||||
|
- [Tests](#-tests)
|
||||||
|
- [License](#-license)
|
||||||
|
- [Links](#-links)
|
||||||
|
- [Appendix: Project documents](#-appendix-project-documents)
|
||||||
|
|
||||||
|
## 🧭 Overview
|
||||||
|
|
||||||
|
The program shows a Malay word on a card. After three seconds the card turns over and shows the Danish translation. You press the green tick when you know the word and the red cross when you do not. A known word is removed from the words to learn, and the words still to learn are saved, so the next start continues where you stopped.
|
||||||
|
|
||||||
|
| Part | What it is for |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` |
|
||||||
|
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
|
||||||
|
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
|
||||||
|
| `docs/` | Project documents (business case, plan, design, review records) |
|
||||||
|
| `Doxyfile` | Doxygen configuration for the source code documentation |
|
||||||
|
|
||||||
|
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
| Need | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) |
|
||||||
|
| `git` | To clone the repository |
|
||||||
|
| Doxygen (optional) | To build the source code documentation |
|
||||||
|
| `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection |
|
||||||
|
|
||||||
|
## 🛠️ Setup
|
||||||
|
|
||||||
|
Create a virtual environment in `.venv`, activate it, upgrade pip and install the project. Run the commands in the repository folder.
|
||||||
|
|
||||||
|
Windows (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux and macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `python -m pip install -e .` if you only want to run the program; the `dev` extra adds `pytest`, `ruff`, `mypy` and `import-linter`.
|
||||||
|
- Add the spoken word with `python -m pip install -e ".[speech]"`.
|
||||||
|
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once, or use `.\.venv\Scripts\activate.bat` in `cmd`.
|
||||||
|
- Leave the environment with `deactivate`. Delete the `.venv` folder to start over.
|
||||||
|
|
||||||
|
## ▶️ Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m flash_card
|
||||||
|
```
|
||||||
|
|
||||||
|
The program is run from the activated `.venv`. The window opens with the first card; the program is built step by step and the pull requests and milestones show each step.
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality checks used by the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m ruff check src tests
|
||||||
|
python -m mypy
|
||||||
|
lint-imports
|
||||||
|
```
|
||||||
|
|
||||||
|
The window test in `tests/e2e/` is skipped when no display is available.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
GNU Affero General Public License v3.0 or later. See [LICENSE][license].
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- [Repository][repository]
|
||||||
|
- [Documentation][documentation]
|
||||||
|
- [Issue tracker][issues]
|
||||||
|
|
||||||
|
## 📎 Appendix: Project documents
|
||||||
|
|
||||||
|
The documents a newcomer needs, in reading order.
|
||||||
|
|
||||||
|
| Document | What it tells you |
|
||||||
|
| --- | --- |
|
||||||
|
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
|
||||||
|
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
|
||||||
|
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
|
||||||
|
| [Project Plan][PP-001] | The gateways and their schedule |
|
||||||
|
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
|
||||||
|
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
|
||||||
|
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
|
||||||
|
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
|
||||||
|
| [Framework README][framework-readme] | The framework this project uses |
|
||||||
|
| [Framework changelog][framework-changelog] | What changed in each framework release |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[license]: ./LICENSE
|
||||||
|
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card
|
||||||
|
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki
|
||||||
|
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
|
||||||
|
[agents]: ./AGENTS.md
|
||||||
|
[BC-001]: ./docs/business-case.md
|
||||||
|
[SA-001]: ./docs/stakeholder-analysis.md
|
||||||
|
[PP-001]: ./docs/project-plan.md
|
||||||
|
[milestones]: ./docs/milestones/
|
||||||
|
[registry]: ./docs/artifact-registry.md
|
||||||
|
[DICT-001]: ./docs/dictionary.md
|
||||||
|
[TM-001]: ./docs/sqa/traceability-matrix.md
|
||||||
|
[framework-readme]: ./framework/README.md
|
||||||
|
[framework-changelog]: ./framework/CHANGELOG.md
|
||||||
|
|||||||
@@ -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
+70
-70
@@ -1,70 +1,70 @@
|
|||||||
# Artifact Registry
|
# Artifact Registry
|
||||||
|
|
||||||
This project's artifact state. Types, short names and `CrossReference
|
This project's artifact state. Types, short names and `CrossReference
|
||||||
Candidates` come from the framework catalog
|
Candidates` come from the framework catalog
|
||||||
(`framework/registry/artifact-catalog.md`); this file only records where
|
(`framework/registry/artifact-catalog.md`); this file only records where
|
||||||
each document lives in *this* project and the next version to use.
|
each document lives in *this* project and the next version to use.
|
||||||
|
|
||||||
Delete rows for types you don't use. Add a row the first time you create a
|
Delete rows for types you don't use. Add a row the first time you create a
|
||||||
document of a type. `Primary File` may contain a glob (e.g.
|
document of a type. `Primary File` may contain a glob (e.g.
|
||||||
`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
|
`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
|
||||||
|
|
||||||
| Short Name | Artifact Type | Primary File | Next Available Version |
|
| Short Name | Artifact Type | Primary File | Next Available Version |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| BC | Business Case | docs/business-case.md | 002 |
|
| BC | Business Case | docs/business-case.md | 002 |
|
||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
|
| MIL | Milestone / Gateway | docs/milestones/*.md | 008 |
|
||||||
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
||||||
| US | User Story | docs/user-stories.md | 002 |
|
| US | User Story | docs/user-stories.md | 002 |
|
||||||
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
| UC | Use Case | docs/uc-*/uc.md | 002 |
|
||||||
| OC | Operation Contract | docs/uc-*/oc.md | 002 |
|
| OC | Operation Contract | docs/uc-*/oc.md | 002 |
|
||||||
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
|
| SD | Sequence Diagram | docs/uc-*/sd.md | 002 |
|
||||||
| DM | Domain Model | docs/domain-model.md | 002 |
|
| DM | Domain Model | docs/domain-model.md | 002 |
|
||||||
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
|
| SSD | System Sequence Diagram | docs/uc-*/ssd.md | 002 |
|
||||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 023 |
|
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 029 |
|
||||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
Set the PO language when the project starts; `project-planning` asks for it
|
Set the PO language when the project starts; `project-planning` asks for it
|
||||||
if it is missing. An artifact of a type marked "Written in the PO language"
|
if it is missing. An artifact of a type marked "Written in the PO language"
|
||||||
exists once, in that language, under its normal name (`business-case.md`); the
|
exists once, in that language, under its normal name (`business-case.md`); the
|
||||||
`artifact` skill ("One file per artifact") has the rule.
|
`artifact` skill ("One file per artifact") has the rule.
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| PO language | en |
|
| PO language | en |
|
||||||
| PO domain | it |
|
| PO domain | it |
|
||||||
| High-level register | IT Executive English |
|
| High-level register | IT Executive English |
|
||||||
| Technical register | IT Professional English |
|
| Technical register | IT Professional English |
|
||||||
| Wiki languages | en, da and ms (`framework/process/wiki.md`: en and the PO language at minimum; da and ms added by the Product Owner) |
|
| Wiki languages | en, da and ms (`framework/process/wiki.md`: en and the PO language at minimum; da and ms added by the Product Owner) |
|
||||||
|
|
||||||
Every artifact of a type marked "Yes" below states its language and domain in
|
Every artifact of a type marked "Yes" below states its language and domain in
|
||||||
its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from
|
its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from
|
||||||
`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`).
|
`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`).
|
||||||
`Domain` is a value from this list; add a row to introduce a domain, so a
|
`Domain` is a value from this list; add a row to introduce a domain, so a
|
||||||
reviewer can see at once which professional vocabulary a document uses.
|
reviewer can see at once which professional vocabulary a document uses.
|
||||||
|
|
||||||
| Domain | Meaning |
|
| Domain | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| it | Software and IT |
|
| it | Software and IT |
|
||||||
| medical | Healthcare and medical devices |
|
| medical | Healthcare and medical devices |
|
||||||
| construction | Construction and civil engineering |
|
| construction | Construction and civil engineering |
|
||||||
|
|
||||||
| Artifact types | Register | Written in the PO language |
|
| Artifact types | Register | Written in the PO language |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| BC, KPI, PP, MIL | IT Executive English | Yes |
|
| BC, KPI, PP, MIL | IT Executive English | Yes |
|
||||||
| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes |
|
| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes |
|
||||||
| OC, SD, DCD, ERD, ADR, TM, RC, QC, source code | IT Professional English | No |
|
| OC, SD, DCD, ERD, ADR, TM, RC, QC, source code | IT Professional English | No |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- "Next Available Version" is the zero-padded (3-digit) version to use the
|
- "Next Available Version" is the zero-padded (3-digit) version to use the
|
||||||
*next* time a new document of that type is created. Increment it only when
|
*next* time a new document of that type is created. Increment it only when
|
||||||
a brand-new document is created, not when an existing document's
|
a brand-new document is created, not when an existing document's
|
||||||
`## Version History` gets a row.
|
`## Version History` gets a row.
|
||||||
- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types.
|
- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types.
|
||||||
|
|||||||
@@ -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
|
||||||
+119
-101
@@ -1,101 +1,119 @@
|
|||||||
# Traceability Matrix: Flash Card
|
# Traceability Matrix: Flash Card
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
| Key | Value |
|
| Key | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| ID | TM-001 |
|
| ID | TM-001 |
|
||||||
| CrossReference | [BC-001] |
|
| CrossReference | [BC-001] |
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
| Date | Status | Author | Reviewer | Change | Commit |
|
| Date | Status | Author | Reviewer | Change | Commit |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
|
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c3fbcbc] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Tracks backward/forward links between artifact instances so that the Business Case's
|
Tracks backward/forward links between artifact instances so that the Business Case's
|
||||||
cross-artifact traceability success criterion is measurable. A row is added or
|
cross-artifact traceability success criterion is measurable. A row is added or
|
||||||
updated whenever an artifact instance is created or reviewed.
|
updated whenever an artifact instance is created or reviewed.
|
||||||
|
|
||||||
## Traceability Table
|
## Traceability Table
|
||||||
|
|
||||||
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
|
| Artifact Instance | Type | Language | Domain | Upstream (Backward Link) | Downstream (Forward Link) | Last Reviewed (RC-ID) |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] |
|
| BC-001 | Business Case | en | it | - | SA-001, PP-001 | [RC-001] |
|
||||||
| SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] |
|
| SA-001 | Stakeholder Analysis | en | it | BC-001 | PP-001, DICT-001 | [RC-002] |
|
||||||
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] |
|
| PP-001 | Project Plan | en | it | BC-001, SA-001 | MIL-001 to MIL-007 | [RC-003] |
|
||||||
| UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] |
|
| UCD-001 | Use Case Diagram | en | it | SA-001, BC-001 | US-001, UC-001 | [RC-004] |
|
||||||
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] |
|
| US-001 | User Story | en | it | UCD-001, BC-001 | MIL-002 to MIL-006 | [RC-005] |
|
||||||
| UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] |
|
| UC-001 | Use Case | en | it | UCD-001, US-001 | DM-001, SSD-001 | [RC-006] |
|
||||||
| DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] |
|
| DM-001 | Domain Model | en | it | UC-001 | SSD-001, DICT-001, DCD-001 | [RC-007] |
|
||||||
| SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] |
|
| SSD-001 | System Sequence Diagram | en | it | UC-001, DM-001 | DCD-001 | [RC-008] |
|
||||||
| OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] |
|
| OC-001 | Operation Contract | - | - | SSD-001, DM-001 | SD-001 | [RC-009] |
|
||||||
| SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] |
|
| SD-001 | Sequence Diagram | - | - | OC-001 | DCD-001 | [RC-010] |
|
||||||
| DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] |
|
| DCD-001 | Design Class Diagram | - | - | DM-001, SD-001, DICT-001 | src/flash_card | [RC-011] |
|
||||||
| DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] |
|
| DICT-001 | Domain Dictionary | en | it | BC-001, SA-001, DM-001 | DCD-001 | [RC-012] |
|
||||||
| ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] |
|
| ADR-0001 | Architecture Decision Record | - | - | BC-001 | MIL-001, MIL-003, MIL-006 | [RC-013] |
|
||||||
| ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] |
|
| ADR-0002 | Architecture Decision Record | - | - | BC-001 | DCD-001 | [RC-014] |
|
||||||
| ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] |
|
| ADR-0003 | Architecture Decision Record | - | - | BC-001 | - | [RC-015] |
|
||||||
| MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] |
|
| MIL-001 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-001 | [RC-016] |
|
||||||
| MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] |
|
| MIL-002 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-002 | [RC-017] |
|
||||||
| MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] |
|
| MIL-003 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-003 | [RC-018] |
|
||||||
| MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] |
|
| MIL-004 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-004 | [RC-019] |
|
||||||
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
|
| MIL-005 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-005 | [RC-020] |
|
||||||
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
|
| MIL-006 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-006 | [RC-021] |
|
||||||
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
|
| MIL-007 | Milestone / Gateway | en | it | BC-001, PP-001 | tasks of MIL-007 | [RC-022] |
|
||||||
|
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
|
||||||
## Coverage Notes
|
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
|
||||||
|
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||||
- `-` in Upstream means foundational; in Downstream it means nothing is built on it yet; in Last Reviewed it means no `RC-*` exists yet. Every row has a review record from the baseline review (autonomous mode, AI as stand-in for S01).
|
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
||||||
- Types not created for this project, and why: ADR-0003.
|
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||||
- Source code (`PY`) is added as `src/` is reviewed, one row per milestone.
|
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||||
|
|
||||||
---
|
## Coverage Notes
|
||||||
|
|
||||||
[BC-001]: ../business-case.md
|
- `-` in Upstream means foundational; in Downstream it means nothing is built on it yet; in Last Reviewed it means no `RC-*` exists yet. Every row has a review record from the baseline review (autonomous mode, AI as stand-in for S01).
|
||||||
[SA-001]: ../stakeholder-analysis.md
|
- Types not created for this project, and why: ADR-0003.
|
||||||
[PP-001]: ../project-plan.md
|
- Source code (`PY`) is added as `src/` is reviewed, one row per milestone.
|
||||||
[UCD-001]: ../use-case-diagram.md
|
|
||||||
[US-001]: ../user-stories.md
|
---
|
||||||
[UC-001]: ../uc-001/uc.md
|
|
||||||
[DM-001]: ../domain-model.md
|
[BC-001]: ../business-case.md
|
||||||
[SSD-001]: ../uc-001/ssd.md
|
[SA-001]: ../stakeholder-analysis.md
|
||||||
[OC-001]: ../uc-001/oc.md
|
[PP-001]: ../project-plan.md
|
||||||
[SD-001]: ../uc-001/sd.md
|
[UCD-001]: ../use-case-diagram.md
|
||||||
[DCD-001]: ../dcd.md
|
[US-001]: ../user-stories.md
|
||||||
[DICT-001]: ../dictionary.md
|
[UC-001]: ../uc-001/uc.md
|
||||||
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
|
[DM-001]: ../domain-model.md
|
||||||
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
|
[SSD-001]: ../uc-001/ssd.md
|
||||||
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
|
[OC-001]: ../uc-001/oc.md
|
||||||
[MIL-001]: ../milestones/mil-001-foundation.md
|
[SD-001]: ../uc-001/sd.md
|
||||||
[MIL-002]: ../milestones/mil-002-step1-user-interface.md
|
[DCD-001]: ../dcd.md
|
||||||
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
|
[DICT-001]: ../dictionary.md
|
||||||
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md
|
[ADR-0001]: ../adr/adr-0001-standard-library-and-optional-speech.md
|
||||||
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
|
[ADR-0002]: ../adr/adr-0002-clean-architecture-and-mvvm.md
|
||||||
[MIL-006]: ../milestones/mil-006-malay-speech.md
|
[ADR-0003]: ../adr/adr-0003-skipped-artifact-types.md
|
||||||
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md
|
[MIL-001]: ../milestones/mil-001-foundation.md
|
||||||
[RC-001]: ./reviews/rc-001-business-case.md
|
[MIL-002]: ../milestones/mil-002-step1-user-interface.md
|
||||||
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
[MIL-003]: ../milestones/mil-003-step2-new-flash-cards.md
|
||||||
[RC-003]: ./reviews/rc-003-project-plan.md
|
[MIL-004]: ../milestones/mil-004-step3-flip-cards.md
|
||||||
[RC-004]: ./reviews/rc-004-use-case-diagram.md
|
[MIL-005]: ../milestones/mil-005-step4-save-progress.md
|
||||||
[RC-005]: ./reviews/rc-005-user-stories.md
|
[MIL-006]: ../milestones/mil-006-malay-speech.md
|
||||||
[RC-006]: ./reviews/rc-006-use-case.md
|
[MIL-007]: ../milestones/mil-007-documentation-and-publication.md
|
||||||
[RC-007]: ./reviews/rc-007-domain-model.md
|
[PY-MIL-001]: ../../src/flash_card
|
||||||
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md
|
[RC-001]: ./reviews/rc-001-business-case.md
|
||||||
[RC-009]: ./reviews/rc-009-operation-contracts.md
|
[RC-002]: ./reviews/rc-002-stakeholder-analysis.md
|
||||||
[RC-010]: ./reviews/rc-010-sequence-diagrams.md
|
[RC-003]: ./reviews/rc-003-project-plan.md
|
||||||
[RC-011]: ./reviews/rc-011-design-class-diagram.md
|
[RC-004]: ./reviews/rc-004-use-case-diagram.md
|
||||||
[RC-012]: ./reviews/rc-012-domain-dictionary.md
|
[RC-005]: ./reviews/rc-005-user-stories.md
|
||||||
[RC-013]: ./reviews/rc-013-adr-0001.md
|
[RC-006]: ./reviews/rc-006-use-case.md
|
||||||
[RC-014]: ./reviews/rc-014-adr-0002.md
|
[RC-007]: ./reviews/rc-007-domain-model.md
|
||||||
[RC-015]: ./reviews/rc-015-adr-0003.md
|
[RC-008]: ./reviews/rc-008-system-sequence-diagram.md
|
||||||
[RC-016]: ./reviews/rc-016-mil-001.md
|
[RC-009]: ./reviews/rc-009-operation-contracts.md
|
||||||
[RC-017]: ./reviews/rc-017-mil-002.md
|
[RC-010]: ./reviews/rc-010-sequence-diagrams.md
|
||||||
[RC-018]: ./reviews/rc-018-mil-003.md
|
[RC-011]: ./reviews/rc-011-design-class-diagram.md
|
||||||
[RC-019]: ./reviews/rc-019-mil-004.md
|
[RC-012]: ./reviews/rc-012-domain-dictionary.md
|
||||||
[RC-020]: ./reviews/rc-020-mil-005.md
|
[RC-013]: ./reviews/rc-013-adr-0001.md
|
||||||
[RC-021]: ./reviews/rc-021-mil-006.md
|
[RC-014]: ./reviews/rc-014-adr-0002.md
|
||||||
[RC-022]: ./reviews/rc-022-mil-007.md
|
[RC-015]: ./reviews/rc-015-adr-0003.md
|
||||||
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
|
[RC-016]: ./reviews/rc-016-mil-001.md
|
||||||
|
[RC-017]: ./reviews/rc-017-mil-002.md
|
||||||
|
[RC-018]: ./reviews/rc-018-mil-003.md
|
||||||
|
[RC-019]: ./reviews/rc-019-mil-004.md
|
||||||
|
[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
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "flash-card"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Flash card program for learning Malay words with Danish translations (Udemy 100 Days of Code, day 31)"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
|
authors = [{ name = "Jens Tirsvad Nielsen" }]
|
||||||
|
keywords = ["flashcards", "tkinter", "language-learning", "malay", "danish", "udemy"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Environment :: X11 Applications",
|
||||||
|
"Topic :: Education",
|
||||||
|
]
|
||||||
|
# The core program uses the Python standard library only (ADR-0001).
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Spoken Malay word (text to speech). Optional; the program works without it.
|
||||||
|
speech = ["gTTS>=2.5", "playsound==1.2.2"]
|
||||||
|
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
flash-card = "flash_card.presentation.app:run"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
flash_card = ["presentation/assets/*.png"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
addopts = "-ra"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py313"
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.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,206 @@
|
|||||||
|
"""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,
|
||||||
|
highlightthickness=0,
|
||||||
|
bd=0,
|
||||||
|
command=self.next_card,
|
||||||
|
)
|
||||||
|
self.wrong_button.grid(row=1, column=0)
|
||||||
|
## @brief Button for a word the user knows.
|
||||||
|
self.right_button = tk.Button(
|
||||||
|
root,
|
||||||
|
image=self._right_image,
|
||||||
|
highlightthickness=0,
|
||||||
|
bd=0,
|
||||||
|
command=self.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,356 @@
|
|||||||
|
"""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_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,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