Compare commits

...
Author SHA1 Message Date
Tirsvad e0d990a98d Merge pull request 'MIL-012: the tag v0.2.0 is annotated (RC-081)' (#109) from mil-012-tag-annotated into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 11s
2026-10-15 04:19:56 +02:00
Tirsvad 49c316f5dd Resolve pending commit link of RC-081 2026-10-15 10:19:46 +08:00
Tirsvad 8f135b94bf MIL-012: the tag v0.2.0 is annotated
At the Product Owner's request the lightweight tag on the host was replaced
by an annotated tag on the same commit. Review record RC-081 (Go).

Task: MIL-012#6
2026-10-15 10:19:45 +08:00
Tirsvad 6f7fb534d7 Merge pull request 'MIL-013: Windows Sandbox run passed (RC-080)' (#108) from mil-013-sandbox-results into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
2026-10-15 04:18:02 +02:00
Tirsvad da4a16c572 Resolve pending commit link of RC-080 2026-10-15 10:17:53 +08:00
Tirsvad 6e3fcf8aa3 MIL-013: Windows Sandbox run of the executable passed
The Windows executable passed the smoke test in Windows Sandbox: 5 of 5
runs (window, list override, known button, progress file). Review record
RC-080 (Go) closes criterion 7 of MIL-013 and the open condition of RC-072
on the smoke test.

Task: MIL-013#8
2026-10-15 10:17:52 +08:00
Tirsvad a4e55541c1 Merge pull request 'MIL-013: Debian container and Windows Sandbox checks' (#107) from mil-013-container-checks into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-15 04:10:24 +02:00
Tirsvad 1785dc9635 Resolve pending commit links of the MIL-013 results 2026-10-15 10:10:11 +08:00
Tirsvad 2f192b6936 MIL-013: results of the container and sandbox checks
Review record RC-079 (Go-with-conditions): the Debian checks and the Linux
smoke tests pass; the Windows Sandbox run is pending because it opens a
window on the host.

Task: MIL-013#8
2026-10-15 10:10:10 +08:00
Tirsvad f8ccfca02e MIL-013: Debian container and Windows Sandbox checks
A Debian image (python:3.13-slim-bookworm with Xvfb and GNOME's window
manager) runs the linters, the whole test suite, the screenshots and the
smoke test of a PyInstaller Linux binary and of the source program, so no
window opens on the development machine. The screenshot tool works on
Linux, a smoke driver presses the known button with the virtual mouse, and
Windows Sandbox scripts are written (not run, they open a window on the
host). README and the wiki Install pages describe it. Review records
RC-077 (Python) and RC-078 (shell).

Task: MIL-013#2
Task: MIL-013#3
Task: MIL-013#4
Task: MIL-013#5
Task: MIL-013#6
Task: MIL-013#7
Task: MIL-013#9
Task: MIL-013#10
2026-10-15 10:09:48 +08:00
Tirsvad a72ddb9175 Merge pull request 'MIL-013 plan and documents: container and sandbox checks' (#96) from plan-mil-013-container into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-15 03:55:36 +02:00
27 changed files with 1358 additions and 12 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.venv
.env
.env.*
.codex
**/__pycache__
**/*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
.import_linter_cache
build
dist
docs/doxygen
data/words_to_learn*.csv
+4
View File
@@ -3,3 +3,7 @@
*.toml text eol=lf *.toml text eol=lf
*.csv text eol=lf *.csv text eol=lf
*.png binary *.png binary
*.sh text eol=lf
*.Dockerfile text eol=lf
.dockerignore text eol=lf
*.ps1 text eol=crlf
+20
View File
@@ -147,6 +147,26 @@ The window test in `tests/e2e/` is skipped when no display is available.
Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only). Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only).
### Checks without a desktop (Docker)
The tests, the linters, the screenshots and a smoke test of a Linux binary run in a Debian container on a virtual screen, so no window opens on your own desktop.
```bash
docker build -f docker/debian.Dockerfile -t flash-card-debian .
# linters and the whole test suite, including the real window tests
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh
# screenshots into ./build/container-out
docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out
# build the Linux binary and press the known button 5 times (binary and source)
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5
```
On Windows PowerShell write `${PWD}` for `$PWD`. The repository is mounted read-only and copied inside the container.
- The desktop is Xvfb with GNOME's window manager (mutter). GNOME Shell is tried first and does not start in a container (it needs a login manager), so the scripts fall back to mutter and print which one ran.
- The Windows executable is checked in **Windows Sandbox** (Windows 11 Pro, feature enabled; the version 0.2.0 build passed 5 of 5 runs, see RC-080): `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py`. The sandbox shows its own window while it runs; the click is made inside the sandbox.
- Not covered: macOS (it cannot be run in Docker on Windows or Linux and Apple's licence allows it only on Apple hardware), and Windows itself in the Debian container.
## 📄 License ## 📄 License
GNU Affero General Public License v3.0 or later. See [LICENSE][license]. GNU Affero General Public License v3.0 or later. See [LICENSE][license].
+45
View File
@@ -0,0 +1,45 @@
# Debian based image for the flash card checks (ADR-0005).
#
# Build (from the repository root):
# docker build -f docker/debian.Dockerfile -t flash-card-debian .
# Run the linters and the tests on a virtual desktop:
# docker run --rm -v "$PWD:/app" flash-card-debian
#
# The base image is the official Python image (Debian bookworm), because the
# program needs Python 3.13. The desktop is a virtual X screen (Xvfb) with
# GNOME Shell when it can start in a container, else GNOME's window manager.
FROM python:3.13-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DISPLAY=:99
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
xvfb xauth x11-utils x11-xserver-utils xdotool dbus dbus-x11 \
procps ca-certificates \
libgl1-mesa-dri libegl1 libgles2 \
mutter gnome-session gnome-shell gnome-settings-daemon \
adwaita-icon-theme gsettings-desktop-schemas \
tk fonts-liberation fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# PyInstaller on Linux needs objdump (binutils).
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash flash
WORKDIR /app
# Dependencies first, so a change of the sources does not rebuild this layer.
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN python -m pip install --upgrade pip \
&& python -m pip install -e ".[dev,speech,tools,build]" \
&& chown -R flash:flash /app
USER flash
ENV PATH="/app/tools/docker:${PATH}"
CMD ["bash", "tools/docker/run-checks.sh"]
+1 -1
View File
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| DCD | Design Class Diagram | docs/dcd.md | 002 | | DCD | Design Class Diagram | docs/dcd.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0006 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0006 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 077 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 082 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
+64
View File
@@ -0,0 +1,64 @@
# RC-077: Review of PY-MIL-013
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-077 |
| CrossReference | [PY-MIL-013], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [f8ccfca] |
---
## Artifact Under Review
- Instance reviewed: [PY-MIL-013]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds in `smoke_driver.py`, `capture_screenshots.py` and the new tests; 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 (`known_button_point`, `parse_geometry`, `platform_kind`, `window_box`, `progress_ok`). |
| 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 83 files; the one `type: ignore` for `os.killpg` names the errors it silences. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 49 source files and on the four tool scripts. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Process start and stop failures are turned into a failed run with its reason, not swallowed; 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 | Temporary folders are context-managed or removed in `finally`; processes are stopped as a group. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, functions and constants have Doxygen docstrings; `doxygen Doxyfile` covers `src` and ends with 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` in `src`; the tools write results with `sys.stdout.write` as JSON lines. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No program class changed; the tools are packaging and check support outside the design classes (ADR-0005). |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 272 tests; the pure parts of the smoke driver and the capture tool are unit tested with fakes (no xdotool, no display); the whole suite passed twice in the Debian container including the real-window e2e tests. |
| 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 container image installs the extras `dev`, `speech`, `tools` and `build`. |
| 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 click point, the geometry parsing and the success rule are one function each in `smoke_driver.py`. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the tools import only 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 | Pure functions with injected `run`, `start` and `sleep`; no function mixes the platform choice with drawing. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `tools/docker/` holds the container scripts and the driver; the layers and the test folders are unchanged. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
## Overall Verdict
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
Reviewed in the Debian container (python:3.13-slim-bookworm, Python 3.13.16).
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| none | - | - |
---
[PY-MIL-013]: ../../../tools
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[f8ccfca]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f8ccfca02ef3a518eae472e3acdcd2aef03b5950
+61
View File
@@ -0,0 +1,61 @@
# RC-078: Review of SH-MIL-013
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-078 |
| CrossReference | [SH-MIL-013], [QC-SH-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [f8ccfca] |
---
## Artifact Under Review
- Instance reviewed: [SH-MIL-013]
- Checklist used: [QC-SH-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 | Starts with `#!/usr/bin/env bash` and `set -euo pipefail` (or a comment explains the exception) | Pass | Every executable script starts with `#!/usr/bin/env bash` and `set -euo pipefail`; `start-desktop.sh` is sourced, says so in its header and sets no shell options in the caller. |
| 2 | Every expansion is quoted; lists are arrays; tests use `[[ ]]` and `$(...)` | Pass | Expansions are quoted, tests use `[[ ]]`, command substitutions use `$(...)`; shellcheck reports nothing. |
| 3 | Names follow the conventions: `kebab-case.sh` files, `snake_case` functions and variables, `UPPER_SNAKE` constants and environment variables | Pass | File names are kebab-case; variables are snake_case, environment variables UPPER_SNAKE. |
| 4 | Passes `shellcheck` and `bash -n` with no unexplained `disable` comments | Pass | `shellcheck -x` and `bash -n` pass on all six scripts with no `disable` comment. |
| 5 | Errors go to standard error with an `error:` message and a non-zero exit code; bad or missing arguments print a usage line | Pass | Errors go to standard error with `error:`, a usage line is printed, and bad arguments exit with 2 (`selftest.sh` checks it). |
| 6 | Temporary files use `mktemp` with a `trap ... EXIT` cleanup; no fixed `/tmp` names | Pass | `start-desktop.sh` uses `mktemp -d` with a `trap ... EXIT`; no fixed `/tmp` names remain. |
| 7 | No secret is written in the script, echoed, or put on a command line; secrets come from the environment or a gitignored file | Pass | No secret is used or written. |
| 8 | A script that changes state outside its own directory defaults to a dry run or needs an explicit flag, and says so in its header | Pass | `in-container.sh` is the only script that deletes a folder; its header says so and it refuses the repository and `/` as work folder. |
| 9 | A header comment states purpose, usage, options, environment variables and exit codes | Pass | Each header gives purpose, usage, environment, tools and exit codes. |
| 10 | The script implements a task or design it cites; deviations are recorded | Pass | The scripts implement tasks 3, 4 and 6 of MIL-013 and ADR-0005; the fallback from GNOME Shell to mutter is recorded in the output. |
| 11 | Behaviour is tested for success, failure and any disabled or bypass path | Pass | `selftest.sh` tests the refusals and `bash -n`; the success paths are the container runs of `run-checks.sh`, `screenshots.sh` and `smoke-binary.sh` (recorded in RC-079). |
| 12 | Formatted with `shfmt` (or the project's formatter) | Fail | `shfmt` was not run; Optional, deferred. |
| 13 | Safe to re-run: a second run does not duplicate or corrupt what the first did | Pass | The second container run gave the same result as the first (272 passed, 5 of 5 smoke runs). |
| 14 | Bash version and external tools it needs are stated; GNU-only options are named | Pass | Bash and the external tools are listed under `Needs`; GNU `tar --exclude` is named by the tool list. |
| 15 | A command sequence, constant, path or message used more than once is defined once (a function, a variable or a sourced helper); a helper is shared between scripts only where it expresses the same knowledge | Pass | The desktop start is defined once in `start-desktop.sh` and sourced by the three scripts. |
## Overall Verdict
Go — every Mandatory criterion passes; the failed Optional criteria are deferred with an action item. Reviewed by AI as stand-in for S01; not independent of the author.
Container run of the self test: 11 cases ok.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Criterion 12 (Optional) failed: Formatted with `shfmt` (or the project's formatter); fix or accept the deferral | S01 | 2026-10-21 |
---
[SH-MIL-013]: ../../../tools/docker
[QC-SH-001]: ../../../framework/qc/code/qc-programming-shell.md
[f8ccfca]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f8ccfca02ef3a518eae472e3acdcd2aef03b5950
@@ -0,0 +1,54 @@
# RC-079: Review of the container checks
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-079 |
| CrossReference | [MIL-013] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [2f192b6] |
---
## Artifact Under Review
- Instance reviewed: [MIL-013] (the milestone's Go / No-Go criteria, checked against the container and sandbox checks)
- Checklist used: none exists for this result; the ten criteria of MIL-013 are the checklist
- Scope: full review
- Language and domain: n/a (record of results)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | `docker build` of the Debian image succeeds | Pass | Image `flash-card-debian` built from `docker/debian.Dockerfile` (base `python:3.13-slim-bookworm`, Python 3.13.16); size 1697 MB; `binutils` was missing at first and was added after the first smoke build failed |
| 2 | Linters and the whole test suite run on the virtual desktop and end with exit code 0, e2e tests not skipped | Pass | `run-checks.sh` twice: ruff, ruff format, mypy (49 files), lint-imports (1 kept) and 272 tests passed in 3.5 s; 33 s per run |
| 3 | The full GNOME session is tried and the result recorded; fallback to mutter stated | Pass | `gnome-shell --x11` starts and then fails with `Gio.IOErrorEnum: Could not connect` in `LoginManagerSystemd` (no login manager in a container); the checks ran on GNOME's window manager mutter 43.8, which the script prints (`desktop: mutter on :99`) |
| 4 | Nothing opens or moves on the host desktop during the runs | Pass | All runs of this record were `docker run` commands with no display; note: earlier unit runs of the test suite on the host by a helper agent opened Tk windows briefly before the Product Owner's instruction; from then on the suite ran only in the container |
| 5 | The screenshot script writes the four PNG files from the Debian image | Pass | `screenshot-level.png`, `-front.png`, `-back.png`, `-menu.png` (20 to 23 KB each) written to the mounted folder and read by eye: the level screen, the card with the menu bar (File, About) are right |
| 6 | The Linux binary is built and passes the smoke test 5 consecutive runs, and the source program too | Pass | `smoke-binary.sh 5` twice: the PyInstaller binary 5 of 5 (0.64 s per run) and `python -m flash_card` 5 of 5 (0.38 s); each run: window found, click at the known button (650, 659), progress file has header plus 2 cards |
| 7 | The Windows executable passes the smoke test inside Windows Sandbox, 5 consecutive runs | Fail | Not run. `tools/sandbox/` (`flash-card.wsb`, `smoke.ps1`, `run.ps1`) is written, the PowerShell parses without errors and the configuration is well-formed XML, but starting Windows Sandbox opens a window on the host, and the Product Owner asked that no app runs on the development machine while working. The run waits for the Product Owner's go |
| 8 | The lost-click symptom of RC-072 is investigated and the finding recorded | Pass | 10 of 10 presses of the known button registered in the container (binary and source). The symptom was seen only with the simulated Windows mouse (about 3 of 8 reruns) and is most likely a timing problem of that click automation (cursor move, focus, button down and up) and not a program defect; this is a finding for Linux only and it is not proven for Windows until criterion 7 is run |
| 9 | README and the wiki Install page in three languages describe the commands and the limits; `check-readme.sh` exit 0 | Pass | Sections added; `check-readme.sh` exit 0; macOS stated as not covered, the Debian check stated as not a Windows check, the sandbox requirements stated |
| 10 | BC-001, PP-001 and ADR-0005 are reviewed Go; code review has no failed Mandatory criterion | Pass | RC-073 to RC-076; RC-077 (Python) and RC-078 (shell) are Go |
## Overall Verdict
Go-with-conditions — everything that can run without the host desktop runs and passes: the Debian checks (272 tests, linters), the screenshots, and the smoke test of the Linux binary and the source program. Criterion 7 (the Windows executable in Windows Sandbox) is not run and keeps the issue "Run and record the checks" open. Reviewed by AI as stand-in for S01; not independent of the author.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Run `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py` at a time that suits the Product Owner (Windows Sandbox shows its own window), and record the result in a new review record | S01 | 2026-10-21 |
| Decide whether the GNOME Shell start in the container is worth a login-manager stub (it is not needed for the checks) | S01 | 2026-10-21 |
---
[MIL-013]: ../milestones/mil-013-container-checks.md
[2f192b6]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/2f192b6936a92f85c6c200de3cac3d6cf3572903
+47
View File
@@ -0,0 +1,47 @@
# RC-080: Windows Sandbox run of the executable
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-080 |
| CrossReference | [MIL-013], [RC-079] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [6e3fcf8] |
---
## Artifact Under Review
- Instance reviewed: [MIL-013], criterion 7 (the Windows executable in Windows Sandbox), which [RC-079] left open
- Checklist used: none exists; criterion 7 of MIL-013 and the open condition of RC-072 (smoke test of the executable)
- Scope: delta re-review of criterion 7 of MIL-013 after the Product Owner started the run
- Language and domain: n/a (record of results)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 7 | The Windows executable passes the smoke test inside Windows Sandbox (window titled Flashy, embedded list, list override, known button writes the progress file), 5 consecutive sandbox runs, with the results read from the mapped output folder | Pass | `tools/sandbox/run.ps1 -Runs 5` on Windows 11 Pro; the sandbox ran `Microsoft Windows 11 Enterprise`; `dist/flash-card.exe` (25.8 MB, built from the head of `main` before the container changes) mapped read-only; **5 of 5 runs passed**: window found, click made by the sandbox's own mouse at the known button, progress file with a header and 2 cards (3 rows), 4.5 to 5.2 s per run including the copy and the start; a screenshot of the sandbox desktop shows the card with the menu bar and both button images; the sandbox shut itself down and no sandbox process was left |
| RC-072 #4 | The executable passes the smoke test of MIL-009 | Pass | The list-override case (`ms-da.csv` next to the program, 3 words, one known word, 2 left) passed in all 5 runs; with the earlier 3 of 8 lost clicks on the host, the finding is that they came from the host click automation, not from the program |
## Overall Verdict
Go — criterion 7 of MIL-013 and the open condition of RC-072 on the smoke test of the executable are met. The issue "Run and record the checks" can be closed. The tag of v0.2.0 is still lightweight (RC-072 criterion 5) and the executable is still not attached to a release (upload limit of the host). Reviewed by AI as stand-in for S01; not independent of the author.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Decide whether the lightweight tag `v0.2.0` is replaced by an annotated tag (force update of the tag on the host) | S01 | 2026-10-21 |
| Raise the upload limit of the git host so that `flash-card.exe` and its checksum can be attached to a release | S01 | 2026-10-21 |
---
[MIL-013]: ../milestones/mil-013-container-checks.md
[RC-079]: ./rc-079-container-checks.md
[6e3fcf8]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/6e3fcf8aa3d66b9fa84339e1dce90d5f53cbed67
+45
View File
@@ -0,0 +1,45 @@
# RC-081: The tag v0.2.0 is annotated
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-081 |
| CrossReference | [MIL-012], [RC-072] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [8f135b9] |
---
## Artifact Under Review
- Instance reviewed: [MIL-012], criterion 5 (the tag `v0.2.0` is an annotated tag on the commit that is built; `v0.1.0` is not moved), which [RC-072] marked Fail
- Checklist used: none exists; criterion 5 of MIL-012
- Scope: delta re-review of criterion 5 after the Product Owner asked for the tag to be annotated
- Language and domain: n/a (record of results)
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 5 | The tag `v0.2.0` is an annotated tag on that commit; `v0.1.0` is not moved | Pass | At the Product Owner's request the lightweight tag on the host was replaced by the annotated tag object `a92bdd9` ("Flash Card 0.2.0: menu bar and progress per word list") with a forced update of that one tag; it dereferences to the same commit `d7ca24d`; `v0.1.0` is unchanged (`2c580cc`); the release `v0.2.0` still exists, is not a draft and points to the tag |
## Overall Verdict
Go — criterion 5 of MIL-012 is met. The remaining condition of RC-072 is the attachment of the executable to the release (upload limit of the git host). Reviewed by AI as stand-in for S01; not independent of the author.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Raise the upload limit of the git host, then attach `flash-card.exe` and its checksum to `v0.2.0` | S01 | 2026-10-21 |
---
[MIL-012]: ../milestones/mil-012-release-0-2-0.md
[RC-072]: ./rc-072-release-v0-2-0.md
[8f135b9]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/8f135b94bf88578c01800eef9ca0fc2d29dff485
+11 -2
View File
@@ -57,8 +57,8 @@ updated whenever an artifact instance is created or reviewed.
| ADR-0004 | Architecture Decision Record | - | - | BC-001 | - | [RC-046] | | ADR-0004 | Architecture Decision Record | - | - | BC-001 | - | [RC-046] |
| MIL-010 | Milestone / Gateway | en | it | BC-001 | - | [RC-054], [RC-055] | | MIL-010 | Milestone / Gateway | en | it | BC-001 | - | [RC-054], [RC-055] |
| MIL-011 | Milestone / Gateway | en | it | BC-001 | - | [RC-065] | | MIL-011 | Milestone / Gateway | en | it | BC-001 | - | [RC-065] |
| MIL-012 | Milestone / Gateway | en | it | BC-001 | - | [RC-070], [RC-072] | | MIL-012 | Milestone / Gateway | en | it | BC-001 | - | [RC-070], [RC-072], [RC-081] |
| MIL-013 | Milestone / Gateway | en | it | BC-001 | - | [RC-076] | | MIL-013 | Milestone / Gateway | en | it | BC-001 | - | [RC-076], [RC-079], [RC-080] |
| ADR-0005 | Architecture Decision Record | - | - | BC-001 | - | [RC-075] | | ADR-0005 | Architecture Decision Record | - | - | BC-001 | - | [RC-075] |
| PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] | | PY-MIL-001 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-023] |
| PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] | | PY-MIL-002 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-024] |
@@ -72,6 +72,8 @@ updated whenever an artifact instance is created or reviewed.
| PY-MIL-011 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-066] | | PY-MIL-011 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-066] |
| PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] | | PY-FIX-001 | Python Source Code | - | - | PY-MIL-008 | - | [RC-049] |
| PY-MIL-012 | Python Source Code | - | - | MIL-012 | - | [RC-071] | | PY-MIL-012 | Python Source Code | - | - | MIL-012 | - | [RC-071] |
| PY-MIL-013 | Python Source Code | - | - | MIL-013 | - | [RC-077] |
| SH-MIL-013 | Shell Script | - | - | MIL-013 | - | [RC-078] |
## Coverage Notes ## Coverage Notes
@@ -207,3 +209,10 @@ updated whenever an artifact instance is created or reviewed.
[MIL-013]: ../milestones/mil-013-container-checks.md [MIL-013]: ../milestones/mil-013-container-checks.md
[ADR-0005]: ../adr/adr-0005-container-and-sandbox-checks.md [ADR-0005]: ../adr/adr-0005-container-and-sandbox-checks.md
[f86bc91]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f86bc9136e7719fbcd049a4f803959019b7d2df3 [f86bc91]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/f86bc9136e7719fbcd049a4f803959019b7d2df3
[RC-077]: ./reviews/rc-077-py-mil-013.md
[RC-078]: ./reviews/rc-078-sh-mil-013.md
[PY-MIL-013]: ../../tools
[SH-MIL-013]: ../../tools/docker
[RC-079]: ./reviews/rc-079-container-checks.md
[RC-080]: ./reviews/rc-080-sandbox-run.md
[RC-081]: ./reviews/rc-081-tag-annotated.md
+20
View File
@@ -74,6 +74,26 @@ Scriptet bruger PyInstaller og skriver `dist/flash-card.exe`: én fil uden konso
- `python tools/build_executable.py --no-speech` udelader talepakkerne; `--clean` bygger forfra. - `python tools/build_executable.py --no-speech` udelader talepakkerne; `--clean` bygger forfra.
- Programfilen er ikke i repositoryet. Byg den på det system, hvor den skal køre; Windows kan vise en SmartScreen-advarsel, fordi filen ikke er signeret. - Programfilen er ikke i repositoryet. Byg den på det system, hvor den skal køre; Windows kan vise en SmartScreen-advarsel, fordi filen ikke er signeret.
## Kontroller uden skrivebord (Docker)
Testene, linterne, skærmbillederne og en røgtest af en Linux-binær kører i en Debian-container på en virtuel skærm, så der ikke åbnes noget vindue på dit eget skrivebord.
```bash
docker build -f docker/debian.Dockerfile -t flash-card-debian .
# linters og hele testsuiten, også testene af det rigtige vindue
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh
# skærmbilleder til ./build/container-out
docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out
# byg Linux-binæren og tryk på "kender" 5 gange (binær og kildekode)
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5
```
I Windows PowerShell skriver du `${PWD}` i stedet for `$PWD`. Repositoryet monteres skrivebeskyttet og kopieres inde i containeren.
- Skrivebordet er Xvfb med GNOMEs vindueshåndtering (mutter). GNOME Shell prøves først og starter ikke i en container (den kræver en login-manager), så scripts falder tilbage til mutter og skriver hvilken der kørte.
- Windows-programfilen kontrolleres i **Windows Sandbox** (Windows 11 Pro, funktionen aktiveret): `powershell -File tools\sandbox\run.ps1 -Runs 5` efter `python tools/build_executable.py`. Sandboxen viser sit eget vindue, mens den kører; klikket sker inde i sandboxen.
- Ikke dækket: macOS (kan ikke køres i Docker på Windows eller Linux, og Apples licens tillader det kun på Apple-hardware), og selve Windows i Debian-containeren.
## Dokumentation af kildekoden ## Dokumentation af kildekoden
```bash ```bash
+20
View File
@@ -74,6 +74,26 @@ Skrip menggunakan PyInstaller dan menulis `dist/flash-card.exe`: satu fail tanpa
- `python tools/build_executable.py --no-speech` meninggalkan pakej suara; `--clean` membina semula dari awal. - `python tools/build_executable.py --no-speech` meninggalkan pakej suara; `--clean` membina semula dari awal.
- Fail boleh laku tidak disimpan dalam repositori. Bina di sistem tempat ia akan dijalankan; Windows mungkin menunjukkan amaran SmartScreen kerana fail tidak ditandatangani. - Fail boleh laku tidak disimpan dalam repositori. Bina di sistem tempat ia akan dijalankan; Windows mungkin menunjukkan amaran SmartScreen kerana fail tidak ditandatangani.
## Semakan tanpa desktop (Docker)
Ujian, linter, tangkapan skrin dan ujian asap bagi binari Linux dijalankan dalam kontena Debian pada skrin maya, jadi tiada tetingkap dibuka pada desktop anda sendiri.
```bash
docker build -f docker/debian.Dockerfile -t flash-card-debian .
# linter dan seluruh suite ujian, termasuk ujian tetingkap sebenar
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh
# tangkapan skrin ke ./build/container-out
docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out
# bina binari Linux dan tekan butang "tahu" 5 kali (binari dan kod sumber)
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5
```
Dalam Windows PowerShell tulis `${PWD}` untuk `$PWD`. Repositori dipasang baca sahaja dan disalin di dalam kontena.
- Desktop ialah Xvfb dengan pengurus tetingkap GNOME (mutter). GNOME Shell dicuba dahulu dan tidak bermula dalam kontena (ia memerlukan pengurus log masuk), jadi skrip beralih kepada mutter dan mencetak yang mana dijalankan.
- Fail boleh laku Windows disemak dalam **Windows Sandbox** (Windows 11 Pro, ciri diaktifkan): `powershell -File tools\sandbox\run.ps1 -Runs 5` selepas `python tools/build_executable.py`. Sandbox menunjukkan tetingkapnya sendiri semasa berjalan; klik dibuat di dalam sandbox.
- Tidak dilindungi: macOS (tidak boleh dijalankan dalam Docker pada Windows atau Linux dan lesen Apple hanya membenarkannya pada perkakasan Apple), dan Windows sebenar dalam kontena Debian.
## Dokumentasi kod sumber ## Dokumentasi kod sumber
```bash ```bash
+20
View File
@@ -74,6 +74,26 @@ The script uses PyInstaller and writes `dist/flash-card.exe`: one file, no conso
- `python tools/build_executable.py --no-speech` leaves the speech packages out; `--clean` rebuilds from scratch. - `python tools/build_executable.py --no-speech` leaves the speech packages out; `--clean` rebuilds from scratch.
- The executable is not committed to the repository. Build it on the system you want to run it on; Windows may show a SmartScreen warning because the file is not signed. - The executable is not committed to the repository. Build it on the system you want to run it on; Windows may show a SmartScreen warning because the file is not signed.
## Checks without a desktop (Docker)
The tests, the linters, the screenshots and a smoke test of a Linux binary run in a Debian container on a virtual screen, so no window opens on your own desktop.
```bash
docker build -f docker/debian.Dockerfile -t flash-card-debian .
# linters and the whole test suite, including the real window tests
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh run-checks.sh
# screenshots into ./build/container-out
docker run --rm -v "$PWD:/repo:ro" -v "$PWD/build/container-out:/out" flash-card-debian bash /repo/tools/docker/in-container.sh screenshots.sh /out
# build the Linux binary and press the known button 5 times (binary and source)
docker run --rm -v "$PWD:/repo:ro" flash-card-debian bash /repo/tools/docker/in-container.sh smoke-binary.sh 5
```
On Windows PowerShell write `${PWD}` for `$PWD`. The repository is mounted read-only and copied inside the container.
- The desktop is Xvfb with GNOME's window manager (mutter). GNOME Shell is tried first and does not start in a container (it needs a login manager), so the scripts fall back to mutter and print which one ran.
- The Windows executable is checked in **Windows Sandbox** (Windows 11 Pro, feature enabled): `powershell -File tools\sandbox\run.ps1 -Runs 5` after `python tools/build_executable.py`. The sandbox shows its own window while it runs; the click is made inside the sandbox.
- Not covered: macOS (it cannot be run in Docker on Windows or Linux and Apple's licence allows it only on Apple hardware), and Windows itself in the Debian container.
## Documentation of the source code ## Documentation of the source code
```bash ```bash
+41
View File
@@ -0,0 +1,41 @@
"""Tests of the pure parts of the screenshot tool.
@file test_capture_screenshots.py
@brief Checks platform_kind and window_box of tools/capture_screenshots.py.
"""
import importlib.util
from pathlib import Path
from types import ModuleType
import pytest
pytest.importorskip("PIL")
_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "capture_screenshots.py"
def _load_tool() -> ModuleType:
spec = importlib.util.spec_from_file_location("capture_screenshots", _TOOL_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_platform_kind_is_windows_for_win32() -> None:
assert _load_tool().platform_kind("win32") == "windows"
def test_platform_kind_is_x11_for_linux() -> None:
assert _load_tool().platform_kind("linux") == "x11"
def test_window_box_adds_menu_height_on_x11() -> None:
assert _load_tool().window_box("x11", 10, 100, 900, 726, 28) == (10, 72, 910, 826)
def test_window_box_ignores_menu_height_on_windows() -> None:
box = _load_tool().window_box("windows", 10, 100, 900, 726, 28)
assert box == (10, 100, 910, 826)
+122
View File
@@ -0,0 +1,122 @@
"""Tests of the smoke driver tool.
@file test_smoke_driver.py
@brief Checks the pure helpers and the run loop of tools/docker/smoke_driver.py
with a fake xdotool and a fake program.
"""
import importlib.util
import json
import sys
from collections.abc import Sequence
from pathlib import Path
from types import ModuleType
import pytest
_TOOL_PATH = (
Path(__file__).resolve().parents[2] / "tools" / "docker" / "smoke_driver.py"
)
_GEOMETRY = "WINDOW=7\nX=0\nY=0\nWIDTH=900\nHEIGHT=800\nSCREEN=0\n"
def _load_tool() -> ModuleType:
spec = importlib.util.spec_from_file_location("smoke_driver", _TOOL_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
# dataclasses looks the module up by name while the class is built
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class _FakeProgram:
def __init__(self) -> None:
self.stopped = False
def stop(self) -> None:
self.stopped = True
class _FakeSystem:
"""Fake xdotool and program; a click writes the progress file."""
def __init__(self, has_window: bool, writes_progress: bool) -> None:
self.has_window = has_window
self.writes_progress = writes_progress
self.program = _FakeProgram()
self.data_dir: Path | None = None
def start(self, command: Sequence[str]) -> _FakeProgram:
self.data_dir = Path(command[command.index("--data-dir") + 1])
return self.program
def run(self, args: Sequence[str]) -> str:
if args[1] == "search":
return "7\n" if self.has_window else ""
if args[1] == "getwindowgeometry":
return _GEOMETRY
if args[1] == "mousemove" and self.writes_progress:
assert self.data_dir is not None
progress = self.data_dir / "words_to_learn.csv"
progress.write_text("ms;da\nw1;t1\nw2;t2\n", encoding="utf-8")
return ""
def test_known_button_point_is_101_above_bottom() -> None:
assert _load_tool().known_button_point(900, 800) == (650, 699)
def test_words_file_text_has_header_and_cards() -> None:
assert _load_tool().words_file_text(2) == "ms;da\nw1;t1\nw2;t2\n"
def test_parse_geometry_reads_width_and_height() -> None:
assert _load_tool().parse_geometry(_GEOMETRY) == (900, 800)
def test_parse_geometry_fails_without_size() -> None:
with pytest.raises(ValueError):
_load_tool().parse_geometry("X=0\n")
def test_progress_ok_needs_exactly_one_card_less() -> None:
tool = _load_tool()
assert tool.progress_ok(3, 3)
assert not tool.progress_ok(4, 3)
assert not tool.progress_ok(0, 3)
def test_main_returns_zero_when_click_saves_progress(
capsys: pytest.CaptureFixture[str],
) -> None:
system = _FakeSystem(has_window=True, writes_progress=True)
code = _load_tool().main(
["--repeat", "2", "--words", "3", "--command", "prog", "-m", "x"],
run=system.run,
start=system.start,
sleep=lambda _: None,
)
lines = [json.loads(x) for x in capsys.readouterr().out.splitlines()]
assert code == 0
assert lines[0]["ok"] is True
assert lines[0]["clicked"] == [650, 699]
assert lines[-1] == {"runs": 2, "passed": 2}
assert system.program.stopped
def test_main_fails_when_no_window_appears(
capsys: pytest.CaptureFixture[str],
) -> None:
system = _FakeSystem(has_window=False, writes_progress=False)
code = _load_tool().main(
["--repeat", "1", "--timeout", "1", "--command", "prog"],
run=system.run,
start=system.start,
sleep=lambda _: None,
)
first = json.loads(capsys.readouterr().out.splitlines()[0])
assert code == 1
assert first["window"] is False
assert system.program.stopped
+56 -9
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import argparse import argparse
import ctypes import ctypes
import ctypes.wintypes import os
import random import random
import shutil import shutil
import sys import sys
@@ -23,7 +23,7 @@ import tkinter as tk
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from PIL import ImageGrab from PIL import Image, ImageGrab
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -45,6 +45,46 @@ DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
SCREENSHOT_SEED = 7 SCREENSHOT_SEED = 7
## @brief Extra time in seconds to let the window settle before a grab. ## @brief Extra time in seconds to let the window settle before a grab.
SETTLE_SECONDS = 0.5 SETTLE_SECONDS = 0.5
## @brief Height in pixels of the menu bar above the client area on X11.
MENU_BAR_HEIGHT_X11 = 28
## @brief Platform kind of Windows.
KIND_WINDOWS = "windows"
## @brief Platform kind of every other platform (an X11 display).
KIND_X11 = "x11"
def platform_kind(platform: str) -> str:
"""Choose how to grab the screen.
@param platform A `sys.platform` value.
@return "windows" for win32, otherwise "x11".
"""
return KIND_WINDOWS if platform == "win32" else KIND_X11
def window_box(
kind: str, rootx: int, rooty: int, width: int, height: int, menu_height: int
) -> tuple[int, int, int, int]:
"""Return the screen box to grab for a window.
@param kind The platform kind from `platform_kind`.
@param rootx Left edge of the client area.
@param rooty Top edge of the client area.
@param width Width of the client area.
@param height Height of the client area.
@param menu_height Height of the menu bar above the client area; it is
added on X11 only, as on Windows the frame box is read from the system.
@return Left, top, right and bottom in screen pixels.
"""
top = rooty - menu_height if kind == KIND_X11 else rooty
return rootx, top, rootx + width, rooty + height
def _grab(box: tuple[int, int, int, int]) -> Image.Image:
"""Grab a screen box on the current platform."""
if platform_kind(sys.platform) == KIND_X11:
return ImageGrab.grab(bbox=box, xdisplay=os.environ.get("DISPLAY"))
return ImageGrab.grab(bbox=box)
def _make_dpi_aware() -> None: def _make_dpi_aware() -> None:
@@ -59,6 +99,8 @@ def _frame_box(root: tk.Tk) -> tuple[int, int, int, int]:
@param root The Tk root window. @param root The Tk root window.
@return Left, top, right and bottom in screen pixels. @return Left, top, right and bottom in screen pixels.
""" """
import ctypes.wintypes # Windows only, so it is imported here
handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore] handle = ctypes.windll.user32.GetParent(root.winfo_id()) # type: ignore[attr-defined,unused-ignore]
rect = ctypes.wintypes.RECT() rect = ctypes.wintypes.RECT()
ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore] ctypes.windll.user32.GetWindowRect(handle, ctypes.byref(rect)) # type: ignore[attr-defined,unused-ignore]
@@ -102,13 +144,14 @@ def capture(output_dir: Path) -> list[Path]:
root.lift() root.lift()
root.attributes("-topmost", True) root.attributes("-topmost", True)
_pump(root, SETTLE_SECONDS) _pump(root, SETTLE_SECONDS)
window_box = ( kind = platform_kind(sys.platform)
client = (
root.winfo_rootx(), root.winfo_rootx(),
root.winfo_rooty(), root.winfo_rooty(),
root.winfo_rootx() + root.winfo_width(), root.winfo_width(),
root.winfo_rooty() + root.winfo_height(), root.winfo_height(),
) )
ImageGrab.grab(bbox=window_box).save(level) _grab(window_box(kind, *client, 0)).save(level)
application.start(Level(None)) application.start(Level(None))
view = application.view view = application.view
if not isinstance(view, FlashCardView): if not isinstance(view, FlashCardView):
@@ -123,10 +166,14 @@ def capture(output_dir: Path) -> list[Path]:
+ view.right_button.winfo_height() + view.right_button.winfo_height()
+ 20, + 20,
) )
ImageGrab.grab(bbox=box).save(front) _grab(box).save(front)
ImageGrab.grab(bbox=_frame_box(root)).save(menu) if kind == KIND_WINDOWS:
menu_box = _frame_box(root)
else:
menu_box = window_box(kind, *client, MENU_BAR_HEIGHT_X11)
_grab(menu_box).save(menu)
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS) _pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
ImageGrab.grab(bbox=box).save(back) _grab(box).save(back)
root.destroy() root.destroy()
return [level, front, back, menu] return [level, front, back, menu]
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Run one of the check scripts on a COPY of the repository, so that the
# mounted repository stays untouched (no build/, dist/ or caches in it).
#
# Purpose: isolate the checks from the host (ADR-0005).
# Usage: in-container.sh <script> [args...]
# in-container.sh run-checks.sh
# in-container.sh screenshots.sh /out
# in-container.sh smoke-binary.sh 5
# Env: REPO_DIR (default /repo, mounted read-only), WORK_DIR (default
# $HOME/work; it is deleted and recreated, so it must be a folder of
# the container, never the mounted repository)
# Needs: bash 5, tar
# Exit: 2 on bad arguments or a missing script; else the exit code of the
# script that was run
set -euo pipefail
repo="${REPO_DIR:-/repo}"
work="${WORK_DIR:-${HOME}/work}"
usage() {
echo "usage: in-container.sh <script> [args...]" >&2
echo "scripts: run-checks.sh screenshots.sh smoke-binary.sh" >&2
}
if [[ $# -lt 1 ]]; then
usage
exit 2
fi
script="$1"
shift
if [[ ! -f "${repo}/tools/docker/${script}" ]]; then
echo "error: no such script: tools/docker/${script}" >&2
usage
exit 2
fi
if [[ "${work}" == "${repo}" || "${work}" == "/" ]]; then
echo "error: WORK_DIR must not be the repository or /" >&2
exit 2
fi
rm -rf "${work}"
mkdir -p "${work}"
tar -C "${repo}" \
--exclude=.git --exclude=.venv --exclude=build --exclude=dist \
--exclude=docs/doxygen --exclude=.codex --exclude='__pycache__' \
-cf - . | tar -C "${work}" -xf -
cd "${work}"
export PYTHONPATH="${work}/src"
exec bash "tools/docker/${script}" "$@"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Run the linters and the whole test suite (with the e2e tests of the real Tk
# window) on the virtual desktop.
#
# Purpose: the quality gate without a desktop of the host (ADR-0005).
# Usage: run-checks.sh (from the repository root, via in-container.sh)
# Env: see start-desktop.sh
# Needs: ruff, mypy, import-linter, pytest (the dev extra), the desktop
# Exit: 0 only when every check passes
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
python -m ruff check src tests tools
python -m ruff format --check src tests tools
python -m mypy
lint-imports
python -m pytest -ra -q
echo "checks passed on desktop: ${DESKTOP_KIND}"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Take the screenshots (level screen, card front, card back, window with the
# menu bar) on the virtual desktop.
#
# Purpose: screenshots without a desktop of the host (ADR-0005).
# Usage: screenshots.sh [output-folder] (default /out)
# Env: see start-desktop.sh
# Needs: Pillow (the tools extra), the desktop
# Exit: 0 when the four files were written; else the tool's exit code
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
out="${1:-/out}"
mkdir -p "${out}"
python tools/capture_screenshots.py --output-dir "${out}"
ls -l "${out}"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Tests of the check scripts themselves: argument errors and refusals. The
# success paths are the runs of run-checks.sh, screenshots.sh and
# smoke-binary.sh.
#
# Purpose: QC-SH-001 criterion 11 (success, failure and bypass paths).
# Usage: selftest.sh (from the repository root)
# Needs: bash 5
# Exit: 0 when every case behaves as stated; 1 when one differs
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
fail=0
expect_exit() {
local expected="$1" label="$2"
shift 2
local code=0
"$@" >/dev/null 2>&1 || code=$?
if [[ "${code}" -ne "${expected}" ]]; then
echo "FAIL: ${label}: exit ${code}, expected ${expected}" >&2
fail=1
else
echo "ok: ${label}"
fi
}
expect_exit 2 "in-container without arguments" bash tools/docker/in-container.sh
expect_exit 2 "in-container with an unknown script" bash tools/docker/in-container.sh nope.sh
expect_exit 2 "in-container refuses the repository as work folder" \
env REPO_DIR="$(pwd)" WORK_DIR="$(pwd)" bash tools/docker/in-container.sh run-checks.sh
expect_exit 2 "smoke-binary with a text count" bash tools/docker/smoke-binary.sh many
expect_exit 2 "smoke-binary with zero" bash tools/docker/smoke-binary.sh 0
for script in start-desktop run-checks screenshots smoke-binary in-container selftest; do
expect_exit 0 "bash -n ${script}.sh" bash -n "tools/docker/${script}.sh"
done
exit "${fail}"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the Linux binary with PyInstaller and smoke-test it, and the source
# program, on the virtual desktop. The smoke test starts the program, finds the
# window, presses the known button with the virtual mouse of the desktop and
# checks the progress file.
#
# Purpose: the "does the packaged program work" check without a desktop of the
# host (ADR-0005, RC-072).
# Usage: smoke-binary.sh [repeat-count] (default 5)
# Env: see start-desktop.sh
# Needs: pyinstaller (the build extra), binutils, xdotool, the desktop
# Exit: 0 only when every run of both programs passes; 2 on a bad count
set -euo pipefail
repeat="${1:-5}"
if ! [[ "${repeat}" =~ ^[1-9][0-9]*$ ]]; then
echo "error: repeat-count must be a positive integer, got '${repeat}'" >&2
echo "usage: smoke-binary.sh [repeat-count]" >&2
exit 2
fi
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
# shellcheck source=tools/docker/start-desktop.sh
. tools/docker/start-desktop.sh
python tools/build_executable.py --clean
test -x dist/flash-card
echo "== binary"
python tools/docker/smoke_driver.py --repeat "${repeat}" --command dist/flash-card
echo "== source"
python tools/docker/smoke_driver.py --repeat "${repeat}" --command python -m flash_card
+252
View File
@@ -0,0 +1,252 @@
"""Smoke driver that clicks the known button of the running program.
@file smoke_driver.py
@brief Starts the program on the current X display, finds its window with
xdotool, clicks the tick button and checks that one card was saved as known.
Uses the standard library only, so it runs in a bare container. It needs
`xdotool` and a DISPLAY. Exit code 0 only when every run is ok.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
## @brief Title of the program window.
WINDOW_NAME = "Flashy"
## @brief X of the tick button, in window pixels.
KNOWN_BUTTON_X = 650
## @brief Distance of the tick button centre above the bottom window edge.
KNOWN_BUTTON_BOTTOM_OFFSET = 101
## @brief Seconds between two polls.
POLL_SECONDS = 0.25
## @brief Seconds to wait for the progress file after the click.
PROGRESS_TIMEOUT_SECONDS = 5.0
## @brief Name of the vocabulary file written for a run.
VOCABULARY_NAME = "ms-da.csv"
## @brief Name of the progress file the program writes.
PROGRESS_NAME = "words_to_learn.csv"
class Running(Protocol):
"""A started program that can be stopped."""
def stop(self) -> None:
"""Stop the program and its children."""
## @brief Runs a command and returns its stdout ("" when it fails).
RunCommand = Callable[[Sequence[str]], str]
## @brief Starts the program and returns a handle to stop it.
StartProgram = Callable[[Sequence[str]], Running]
## @brief Waits for a number of seconds.
Sleep = Callable[[float], None]
def known_button_point(width: int, height: int) -> tuple[int, int]:
"""Return the click point of the known button inside the window.
@param width Window width in pixels (the button x is fixed).
@param height Window height in pixels.
@return X and Y relative to the window.
"""
return KNOWN_BUTTON_X, height - KNOWN_BUTTON_BOTTOM_OFFSET
def words_file_text(count: int) -> str:
"""Return the text of a vocabulary file with count cards.
@param count Number of cards.
@return A `ms;da` header and the lines `w1;t1`, `w2;t2`, ...
"""
lines = ["ms;da", *(f"w{i};t{i}" for i in range(1, count + 1))]
return "\n".join(lines) + "\n"
def parse_geometry(shell_output: str) -> tuple[int, int]:
"""Read the size from `xdotool getwindowgeometry --shell`.
@param shell_output The KEY=VALUE lines.
@return Width and height.
@throws ValueError When WIDTH or HEIGHT is missing.
"""
values: dict[str, int] = {}
for line in shell_output.splitlines():
key, _, value = line.partition("=")
if key in ("WIDTH", "HEIGHT") and value.strip().isdigit():
values[key] = int(value)
if "WIDTH" not in values or "HEIGHT" not in values:
raise ValueError("no WIDTH and HEIGHT in the geometry output")
return values["WIDTH"], values["HEIGHT"]
def progress_ok(rows: int, words: int) -> bool:
"""Tell whether the progress file shows exactly one card known.
@param rows Lines in the progress file, the header included.
@param words Cards at the start.
@return True when the file has the header and words - 1 cards.
"""
return rows == words
class _Process:
"""A real subprocess, stopped as a whole process group."""
def __init__(self, process: subprocess.Popen[bytes]) -> None:
self._process = process
def stop(self) -> None:
"""Terminate the process group, then kill it if it stays."""
try:
os.killpg(self._process.pid, signal.SIGTERM) # type: ignore[attr-defined,unused-ignore]
self._process.wait(timeout=3)
except (ProcessLookupError, subprocess.TimeoutExpired):
self._process.kill()
def real_run(args: Sequence[str]) -> str:
"""Run a command and return stdout, or "" when it fails."""
try:
done = subprocess.run(args, capture_output=True, text=True, check=False)
except OSError:
return ""
return done.stdout if done.returncode == 0 else ""
def real_start(command: Sequence[str]) -> Running:
"""Start the program in its own process group."""
return _Process(subprocess.Popen(command, start_new_session=True))
@dataclass(frozen=True)
class RunResult:
"""The outcome of one run."""
window: bool
clicked: tuple[int, int] | None
progress_rows: int
ok: bool
def _count_rows(path: Path) -> int:
if not path.exists():
return 0
return len(path.read_text(encoding="utf-8").splitlines())
def _wait_for_window(run: RunCommand, sleep: Sleep, timeout: float) -> str | None:
waited = 0.0
while True:
found = run(["xdotool", "search", "--name", WINDOW_NAME]).split()
if found:
return found[0]
if waited >= timeout:
return None
sleep(POLL_SECONDS)
waited += POLL_SECONDS
def _wait_for_progress(path: Path, sleep: Sleep) -> int:
waited = 0.0
rows = _count_rows(path)
while rows == 0 and waited < PROGRESS_TIMEOUT_SECONDS:
sleep(POLL_SECONDS)
waited += POLL_SECONDS
rows = _count_rows(path)
return rows
def run_once(
command: Sequence[str],
words: int,
timeout: float,
run: RunCommand,
start: StartProgram,
sleep: Sleep = time.sleep,
) -> RunResult:
"""Start the program, click the known button and check the progress file.
@param command The program command.
@param words Number of cards in the vocabulary.
@param timeout Seconds to wait for the window.
@param run Runs xdotool.
@param start Starts the program.
@param sleep Waits between polls.
@return The result of the run.
"""
folder = Path(tempfile.mkdtemp(prefix="flash-card-smoke-"))
data = folder / "data"
data.mkdir()
(data / VOCABULARY_NAME).write_text(words_file_text(words), encoding="utf-8")
argv = [*command, "--data-dir", str(data), "--level", "all", "--no-speech"]
program = start(argv)
try:
window = _wait_for_window(run, sleep, timeout)
if window is None:
return RunResult(False, None, 0, False)
width, height = parse_geometry(
run(["xdotool", "getwindowgeometry", "--shell", window])
)
x, y = known_button_point(width, height)
run(["xdotool", "mousemove", "--window", window, str(x), str(y), "click", "1"])
rows = _wait_for_progress(data / PROGRESS_NAME, sleep)
return RunResult(True, (x, y), rows, progress_ok(rows, words))
finally:
program.stop()
shutil.rmtree(folder, ignore_errors=True)
def main(
argv: Sequence[str] | None = None,
run: RunCommand = real_run,
start: StartProgram = real_start,
sleep: Sleep = time.sleep,
) -> int:
"""Run the driver.
@param argv Command line arguments, or None for `sys.argv`.
@param run Runs xdotool.
@param start Starts the program.
@param sleep Waits between polls.
@return 0 when every run is ok, otherwise 1.
"""
parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0])
parser.add_argument("--repeat", type=int, default=5)
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--words", type=int, default=3)
parser.add_argument("--command", nargs=argparse.REMAINDER, required=True)
args = parser.parse_args(argv)
passed = 0
for index in range(1, args.repeat + 1):
began = time.monotonic()
result = run_once(args.command, args.words, args.timeout, run, start, sleep)
passed += result.ok
line = {
"run": index,
"window": result.window,
"clicked": list(result.clicked) if result.clicked else None,
"progress_rows": result.progress_rows,
"ok": result.ok,
"seconds": round(time.monotonic() - began, 2),
}
sys.stdout.write(json.dumps(line) + "\n")
summary = {"runs": args.repeat, "passed": passed}
sys.stdout.write(json.dumps(summary) + "\n")
return 0 if passed == args.repeat else 1
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Start a virtual desktop for the checks. Source this file; it must not be run.
#
# Purpose: give the checks a desktop of their own (a virtual X screen) so that
# no window opens on the host (ADR-0005).
# Usage: . tools/docker/start-desktop.sh
# Options: none
# Env in: DISPLAY (default :99), SCREEN_SIZE (default 1280x1024x24),
# SHELL_WAIT_SECONDS (seconds to wait for GNOME Shell, default 25)
# Env out: DISPLAY, DBUS_SESSION_BUS_ADDRESS, DESKTOP_KIND (gnome-shell or
# mutter), DESKTOP_LOG_DIR (logs and the file desktop-kind)
# Needs: bash 5, Xvfb, xdpyinfo, xprop, dbus-launch, gnome-shell, mutter
# Exit: returns 0 when a desktop runs; 1 when no window manager came up
#
# Steps: Xvfb; a private D-Bus session; GNOME Shell first, and GNOME's window
# manager (mutter) when the shell does not come up in a container (it needs a
# login manager that a container does not have). The kind that runs is printed.
export DISPLAY="${DISPLAY:-:99}"
screen_size="${SCREEN_SIZE:-1280x1024x24}"
shell_wait_seconds="${SHELL_WAIT_SECONDS:-25}"
DESKTOP_LOG_DIR="$(mktemp -d)"
export DESKTOP_LOG_DIR
trap 'rm -rf "${DESKTOP_LOG_DIR}"' EXIT
if ! xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1; then
Xvfb "${DISPLAY}" -screen 0 "${screen_size}" -nolisten tcp >"${DESKTOP_LOG_DIR}/xvfb.log" 2>&1 &
for _ in $(seq 1 40); do
xdpyinfo -display "${DISPLAY}" >/dev/null 2>&1 && break
sleep 0.25
done
fi
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
eval "$(dbus-launch --sh-syntax)"
export DBUS_SESSION_BUS_ADDRESS
fi
# Name of the window manager that owns the screen, or nothing.
wm_name() {
local check
check="$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | awk '{print $NF}')"
[[ -n "${check}" ]] || return 0
xprop -id "${check}" _NET_WM_NAME 2>/dev/null | sed -n 's/.*= "\(.*\)"/\1/p'
}
DESKTOP_KIND=""
if command -v gnome-shell >/dev/null 2>&1; then
gnome-shell --x11 --sm-disable >"${DESKTOP_LOG_DIR}/gnome-shell.log" 2>&1 &
gnome_pid=$!
for _ in $(seq 1 $((shell_wait_seconds * 2))); do
if kill -0 "${gnome_pid}" 2>/dev/null && wm_name | grep -qi "gnome"; then
DESKTOP_KIND="gnome-shell"
break
fi
sleep 0.5
done
if [[ -z "${DESKTOP_KIND}" ]]; then
kill "${gnome_pid}" 2>/dev/null || true
fi
fi
if [[ -z "${DESKTOP_KIND}" ]]; then
mutter --x11 --sm-disable >"${DESKTOP_LOG_DIR}/mutter.log" 2>&1 &
for _ in $(seq 1 40); do
if wm_name | grep -qi "mutter"; then
DESKTOP_KIND="mutter"
break
fi
sleep 0.25
done
fi
if [[ -z "${DESKTOP_KIND}" ]]; then
echo "error: no window manager came up on ${DISPLAY}" >&2
return 1
fi
export DESKTOP_KIND
echo "${DESKTOP_KIND}" >"${DESKTOP_LOG_DIR}/desktop-kind"
echo "desktop: ${DESKTOP_KIND} on ${DISPLAY}"
+36
View File
@@ -0,0 +1,36 @@
<!--
Windows Sandbox configuration of the flash card smoke test (ADR-0005).
Do not open this file directly: tools/sandbox/run.ps1 replaces the three
@...@ placeholders with absolute host paths and starts the sandbox.
dist read-only the built flash-card.exe
scripts read-only smoke.ps1, which runs inside the sandbox
out read-write result.json and screenshots written by smoke.ps1
-->
<Configuration>
<VGpu>Disable</VGpu>
<Networking>Disable</Networking>
<AudioInput>Disable</AudioInput>
<VideoInput>Disable</VideoInput>
<ClipboardRedirection>Disable</ClipboardRedirection>
<MappedFolders>
<MappedFolder>
<HostFolder>@DIST@</HostFolder>
<SandboxFolder>C:\fc\dist</SandboxFolder>
<ReadOnly>true</ReadOnly>
</MappedFolder>
<MappedFolder>
<HostFolder>@SCRIPTS@</HostFolder>
<SandboxFolder>C:\fc\scripts</SandboxFolder>
<ReadOnly>true</ReadOnly>
</MappedFolder>
<MappedFolder>
<HostFolder>@OUT@</HostFolder>
<SandboxFolder>C:\fc\out</SandboxFolder>
<ReadOnly>false</ReadOnly>
</MappedFolder>
</MappedFolders>
<LogonCommand>
<Command>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\fc\scripts\smoke.ps1 -Runs @RUNS@</Command>
</LogonCommand>
</Configuration>
+56
View File
@@ -0,0 +1,56 @@
<#
.SYNOPSIS
Start the Windows Sandbox smoke test of dist\flash-card.exe from the host.
.DESCRIPTION
Writes a configuration from flash-card.wsb with absolute paths, starts
Windows Sandbox, waits for out\result.json and prints the summary.
The tests themselves (window, click, progress file) run inside the sandbox
with the sandbox's own mouse; the host's mouse and focus are not used.
Windows Sandbox shows its own window while it runs. Needs Windows 11 Pro
with the feature "Windows Sandbox" enabled.
.EXAMPLE
powershell -File tools\sandbox\run.ps1 -Runs 5
#>
param(
[int]$Runs = 5,
[int]$TimeoutMinutes = 15
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$dist = Join-Path $root 'dist'
$scripts = $PSScriptRoot
$out = Join-Path $root 'build\sandbox-out'
if (-not (Test-Path (Join-Path $dist 'flash-card.exe'))) {
throw "dist\flash-card.exe not found: build it first with python tools\build_executable.py"
}
if (-not (Get-Command WindowsSandbox.exe -ErrorAction SilentlyContinue)) {
throw "WindowsSandbox.exe not found: enable the Windows feature 'Windows Sandbox' (Windows 11 Pro)"
}
if (Get-Process -Name 'WindowsSandbox*' -ErrorAction SilentlyContinue) {
throw "A Windows Sandbox is already running: close it first (only one can run at a time)"
}
Remove-Item $out -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $out -Force | Out-Null
$config = (Get-Content (Join-Path $scripts 'flash-card.wsb') -Raw) `
-replace '@DIST@', $dist -replace '@SCRIPTS@', $scripts -replace '@OUT@', $out -replace '@RUNS@', $Runs
$wsb = Join-Path $out 'run.wsb'
Set-Content -Path $wsb -Value $config -Encoding UTF8
Write-Host "Starting Windows Sandbox ($Runs runs); results in $out"
Start-Process -FilePath WindowsSandbox.exe -ArgumentList "`"$wsb`""
$result = Join-Path $out 'result.json'
$end = (Get-Date).AddMinutes($TimeoutMinutes)
while ((Get-Date) -lt $end -and -not (Test-Path $result)) { Start-Sleep -Seconds 5 }
if (-not (Test-Path $result)) { throw "No result.json after $TimeoutMinutes minutes" }
Start-Sleep -Seconds 3
$summary = Get-Content $result -Raw | ConvertFrom-Json
Write-Host ("passed {0} of {1} runs in {2}" -f $summary.passed, $summary.runs, $summary.os)
$summary.results | Format-Table run, window, clicked, progress_rows, ok, seconds
if ($summary.passed -ne $summary.runs) { exit 1 }
+132
View File
@@ -0,0 +1,132 @@
<#
.SYNOPSIS
Smoke test of flash-card.exe, run INSIDE Windows Sandbox (ADR-0005).
.DESCRIPTION
Starts the executable on the sandbox's own desktop, presses the known button
with the sandbox's own mouse, reads the progress file, takes screenshots and
writes result.json to C:\fc\out. Then it shuts the sandbox down.
It never touches the host: the host sees only the mapped folders.
#>
param(
[int]$Runs = 5,
[string]$Exe = 'C:\fc\dist\flash-card.exe',
[string]$Out = 'C:\fc\out'
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class Win {
public delegate bool EnumProc(IntPtr h, IntPtr l);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
[DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
[DllImport("user32.dll")] public static extern bool ClientToScreen(IntPtr h, ref POINT p);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int v);
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
public static IntPtr Find(string title) {
IntPtr found = IntPtr.Zero;
EnumWindows(delegate (IntPtr h, IntPtr l) {
if (IsWindowVisible(h)) {
var sb = new StringBuilder(256);
GetWindowText(h, sb, 256);
if (sb.ToString() == title) { found = h; return false; }
}
return true;
}, IntPtr.Zero);
return found;
}
}
'@
[void][Win]::SetProcessDpiAwareness(2)
function Wait-Window([int]$Seconds) {
$end = (Get-Date).AddSeconds($Seconds)
while ((Get-Date) -lt $end) {
$h = [Win]::Find('Flashy')
if ($h -ne [IntPtr]::Zero) { return $h }
Start-Sleep -Milliseconds 250
}
return [IntPtr]::Zero
}
function Save-Screen([string]$Path) {
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
}
Add-Type -AssemblyName System.Windows.Forms
$work = Join-Path $env:TEMP 'fc-smoke'
$results = @()
for ($n = 1; $n -le $Runs; $n++) {
$run = [ordered]@{ run = $n; window = $false; clicked = $false; progress_rows = $null; ok = $false; seconds = 0 }
$t0 = Get-Date
$dir = Join-Path $work "run$n"
Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path (Join-Path $dir 'data') -Force | Out-Null
Copy-Item $Exe (Join-Path $dir 'flash-card.exe')
# three words: after one known word the progress file has a header and two cards
[IO.File]::WriteAllText((Join-Path $dir 'data\ms-da.csv'), "ms;da`naaa;bbb`nccc;ddd`neee;fff`n", (New-Object Text.UTF8Encoding $false))
$p = Start-Process -FilePath (Join-Path $dir 'flash-card.exe') -ArgumentList '--level', 'all', '--no-speech' -WorkingDirectory $dir -PassThru
try {
$h = Wait-Window 60
if ($h -ne [IntPtr]::Zero) {
$run.window = $true
Start-Sleep -Milliseconds 1500
if ($n -eq 1) { Save-Screen (Join-Path $Out 'sandbox-card.png') }
$r = New-Object Win+RECT
[void][Win]::GetClientRect($h, [ref]$r)
$pt = New-Object Win+POINT
[void][Win]::ClientToScreen($h, [ref]$pt)
$x = $pt.X + 650
$y = $pt.Y + $r.Bottom - 101
[void][Win]::SetForegroundWindow($h)
Start-Sleep -Milliseconds 400
[void][Win]::SetCursorPos($x - 12, $y - 12)
Start-Sleep -Milliseconds 300
[void][Win]::SetCursorPos($x, $y)
Start-Sleep -Milliseconds 400
[Win]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 120
[Win]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
$run.clicked = $true
$prog = Join-Path $dir 'data\words_to_learn.csv'
$end = (Get-Date).AddSeconds(6)
while ((Get-Date) -lt $end -and -not (Test-Path $prog)) { Start-Sleep -Milliseconds 250 }
if (Test-Path $prog) {
$rows = ([IO.File]::ReadAllLines($prog) | Where-Object { $_.Trim() -ne '' }).Count
$run.progress_rows = $rows
$run.ok = ($rows -eq 3)
}
}
}
finally {
if (-not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue }
Get-Process -Name 'flash-card' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
$run.seconds = [math]::Round(((Get-Date) - $t0).TotalSeconds, 1)
$results += [pscustomobject]$run
Start-Sleep -Seconds 1
}
$summary = [ordered]@{
runs = $Runs
passed = ($results | Where-Object { $_.ok }).Count
os = (Get-CimInstance Win32_OperatingSystem).Caption
results = $results
}
$summary | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $Out 'result.json') -Encoding UTF8
shutdown.exe /s /t 5