4 Commits
Author SHA1 Message Date
claude@clouddev1 f06cbbc788 Merge branch 'website'
website / deploy (push) Successful in 54s
2026-06-22 13:32:43 +00:00
claude@clouddev1 88204f25c5 docs(tt4,nfr): record Tier-4 + NFR verification (ADR-0008 A1, ADR-0057)
Document the regression-hardening work from this session:

- ADR-0008 Amendment 1 — Tier 4 realized: the portable-pty + vt100 harness,
  expectrl dropped, serial/fail-fast design, the four flows, sidebar-region
  reads, command pacing (issue #39), CI default-target (advances TT5).
- ADR-0057 (new) — NFR verification strategy: contrast + ΔE2000 gates
  (NFR-5/7), startup/RSS measured (NFR-1/3: release ~29 ms / ~10 MB),
  NFR-2 by architecture, NFR-4/6 reviewer note. Records the two shipped
  light-theme contrast defects this work caught and fixed.
- requirements.md: TT4 [~]->[x], TT5 [/] (only a Windows exec runner left),
  NFR-1/2/3/5/7 ->[x], NFR-4/6 ->[/], each with evidence.
- CHANGELOG.md (new): Keep a Changelog + SemVer, [Unreleased] + [0.2.0].
- README ADR index updated; handoff-76; the working plan.

Docs-only (CI gate skips via paths-ignore).
2026-06-22 13:15:45 +00:00
claude@clouddev1 fd63de3441 test(tt4): Tier-4 PTY end-to-end tests for the four critical flows
ci / manifests (push) Successful in 3s
ci / gate (push) Successful in 1m58s
Wire the Tier-4 tier from ADR-0008 — the first tests that drive the actual
built binary in a real pseudo-terminal. tests/e2e_pty.rs spawns the binary
under portable-pty at a fixed 100x30, parses output with vt100, and asserts
on the rendered screen with tight (3s) fail-fast waits. Serial (one PTY at a
time) for predictable timing on the low-parallelism runner; each test gets
its own temp --data-dir so it never touches real projects.

The four flows mirror ADR-0008's Tier-4 scope:
- cold launch -> create table -> graceful quit (Ctrl-C)
- create + add column -> quit -> reopen (--resume) -> schema (incl. column)
  restored
- export -> import into a fresh project -> schema + data rebuilt
- undo after DROP TABLE, through the confirm modal (y)

Table presence is read from the Tables sidebar region (not whole-screen)
since the Output panel echoes commands; commands are paced to completion
(real-user cadence) — a command sent mid-rebuild can be misread against a
stale schema cache (issue #39).

Also two NFR perf measurements via the harness (NFR-1 startup, NFR-3 idle
RSS) against generous debug-binary bounds for gross-regression detection;
the real release figures (median 29 ms / 10 MB) are recorded in the NFR doc.

Deps (dev): portable-pty 0.9, vt100 0.16 (both current; expectrl from
ADR-0008 dropped — it bundles a conflicting PTY layer). Runs by default in
`cargo test`, so the existing Linux CI gate exercises it (advances TT5).
PTY-in-container verified (openpty+spawn works under Docker).

Full suite: 2519 passed / 0 failed / 1 ignored. clippy + fmt clean.
Relates to TT4 / ADR-0008.
2026-06-22 13:06:09 +00:00
claude@clouddev1 65eab71439 fix(theme): meet WCAG-AA contrast in light theme; gate contrast + token distinctness
ci / gate (push) Successful in 2m1s
ci / manifests (push) Successful in 4s
Computing actual WCAG ratios surfaced two real accessibility defects in
the shipped v0.2.0 light theme: tok_string (4.42:1) and tok_flag
(3.15:1) fell below the 4.5:1 the scheme promises for normal text
(NFR-5). Darken both to clear it with headroom (5.18 / 5.98).

Also widen two dark-theme token pairs that were perceptually close
despite distinct hex (hard to separate on good screens): tok_type and
tok_function move from ΔE2000 ~14 to ~18-19 vs their neighbours.

Add four gated tests in theme.rs:
- all_text_colours_meet_wcag_aa_contrast: every text fg >= 4.5:1 on bg,
  both themes (the regression gate that would have caught this).
- advanced_mode_border_meets_ui_contrast: 3:1 for the mode-alert border;
  plain border is decorative chrome, exempt.
- delta_e_2000_matches_reference_vectors: validates the CIEDE2000 metric
  against Sharma et al. reference data.
- syntax_token_colours_are_perceptually_distinct: ΔE2000 >= 15 for every
  token-class pair, both themes.

Persist scripts/palette-preview.py: the true-colour swatch + contrast +
ΔE2000 audit tool used to drive these changes (reads the live palette
from theme.rs), for future palette work.

Tests: 2513 passed / 0 failed / 1 ignored (was 2509; +4). clippy and fmt
clean. Relates to NFR-5/NFR-7 (ADR-0057).
2026-06-22 12:26:34 +00:00
12 changed files with 1569 additions and 37 deletions
+53
View File
@@ -0,0 +1,53 @@
# Changelog
All notable, user-facing changes to RDBMS Playground are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
## [Unreleased]
### Added
- Install via **Scoop**, **Homebrew**, and **winget** in addition to the
existing channels.
- Tier-4 end-to-end test suite that exercises the real application in a
terminal (cold launch, save/reopen, export/import, undo) — guards against
regressions the unit and render tests can't see.
- Automated colour-accessibility checks: every theme now has its
foreground/background contrast and its syntax-colour distinctness verified
on each build.
### Fixed
- **Light theme:** string-literal and flag colours in syntax highlighting
were below the WCAG-AA contrast bar; both are now legible. Two dark-theme
token colours that were hard to tell apart have been separated.
- Windows `install.ps1` now works on the in-box Windows PowerShell 5.1 and
updates `PATH` for the current session.
## [0.2.0] - 2026-06-17
### Added
- **Version surfaces:** a `--version` / `-V` command-line flag and an in-app
`version` command, both reporting the build's exact version.
- **Installation options:** publish to crates.io (`cargo install
rdbms-playground`), `cargo binstall` support, a one-line `curl | sh`
installer, and a Windows `install.ps1` — so you no longer have to hand-pick
a release asset.
- **Documentation & landing site** at <https://relplay.org> — the canonical
user guide plus screencast demos.
- A demonstration-mode key alias (Ctrl-G acting as F1) so screencasts can
show the in-app help/hint key.
### Fixed
- Corrected several errors in the contextual-hint help text.
## [0.1.0] - 2026-06-15
First public release: the cross-platform terminal sandbox for learning
relational-database concepts — tables, keys, relationships, indexes, queries
and query plans — in a guided simple mode or a full advanced (SQL) mode, with
projects, undo, and export/import.
[Unreleased]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.2.0...HEAD
[0.2.0]: https://git.lazyeval.net/oli/rdbms-playground/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.lazyeval.net/oli/rdbms-playground/releases/tag/v0.1.0
Generated
+113 -3
View File
@@ -64,6 +64,12 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]] [[package]]
name = "atomic" name = "atomic"
version = "0.6.1" version = "0.6.1"
@@ -164,6 +170,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.1"
@@ -481,6 +493,12 @@ dependencies = [
"litrs", "litrs",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -976,7 +994,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [ dependencies = [
"nix", "nix 0.29.0",
"winapi", "winapi",
] ]
@@ -1038,6 +1056,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -1046,7 +1076,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases 0.2.1",
"libc", "libc",
"memoffset", "memoffset",
] ]
@@ -1360,6 +1390,27 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-pty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix 0.28.0",
"serial2",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@@ -1548,6 +1599,7 @@ dependencies = [
"futures-util", "futures-util",
"gethostname", "gethostname",
"insta", "insta",
"portable-pty",
"pretty_assertions", "pretty_assertions",
"rand 0.10.1", "rand 0.10.1",
"ratatui", "ratatui",
@@ -1559,6 +1611,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"vt100",
"zip", "zip",
] ]
@@ -1738,6 +1791,17 @@ dependencies = [
"unsafe-libyaml-norway", "unsafe-libyaml-norway",
] ]
[[package]]
name = "serial2"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
dependencies = [
"cfg-if",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1758,6 +1822,22 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1968,7 +2048,7 @@ dependencies = [
"libc", "libc",
"log", "log",
"memmem", "memmem",
"nix", "nix 0.29.0",
"num-derive", "num-derive",
"num-traits", "num-traits",
"ordered-float", "ordered-float",
@@ -2240,6 +2320,27 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
dependencies = [
"itoa",
"unicode-width",
"vte",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"memchr",
]
[[package]] [[package]]
name = "vtparse" name = "vtparse"
version = "0.6.2" version = "0.6.2"
@@ -2639,6 +2740,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
+2
View File
@@ -59,8 +59,10 @@ zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.47.2", features = ["yaml"] } insta = { version = "1.47.2", features = ["yaml"] }
portable-pty = "0.9"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
tempfile = "3.27.0" tempfile = "3.27.0"
vt100 = "0.16"
# Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test` # Dev/test build hygiene (see CLAUDE.md "Build hygiene"). `cargo test`
# links ~25 separate integration-test binaries, each statically # links ~25 separate integration-test binaries, each statically
+53
View File
@@ -147,3 +147,56 @@ and on a nightly schedule for any extended coverage.
only slow tier and is kept narrow. only slow tier and is kept narrow.
- Adding a feature implies adding tests at the appropriate tier - Adding a feature implies adding tests at the appropriate tier
(or tiers); coverage is not retrofitted later. (or tiers); coverage is not retrofitted later.
## Amendment 1 — 2026-06-22: Tier 4 realized (TT4)
Tier 4 was a specification only (no PTY deps, no tests) until this
amendment. It is now implemented in `tests/e2e_pty.rs`, and the realized
shape refines the original decision in three ways.
**Tooling — `expectrl` dropped.** The decision named
`portable-pty` + `expectrl` + `vt100`. We kept **`portable-pty`** (0.9, to
spawn the real binary in a PTY at a fixed size) and **`vt100`** (0.16, to
parse the output stream into an inspectable cell grid), both current and
maintained. We **dropped `expectrl`**: it bundles its own PTY abstraction
(which conflicts with `portable-pty`) and matches line-by-line, a poor fit
for a full-screen TUI. A small hand-rolled `wait_for(predicate, timeout)`
that polls the vt100 screen replaces it — fewer dependencies, and assertions
read the actual rendered grid.
**Harness shape.** Each test spawns the binary
(`env!("CARGO_BIN_EXE_rdbms-playground")`) under a fresh PTY at **100×30**
(the wide three-region layout, ADR-0046), with its **own temp `--data-dir`**
so it never touches real projects or resume state, and `--theme dark` for
deterministic styling. Tests run **serially** (one PTY + child at a time)
via a poison-tolerant global lock, so timing stays predictable on the
low-parallelism self-hosted runner. Waits are **tight and fail-fast** (3 s):
a slow wait is a genuine hang, and a timeout panics with a full screen dump
for debuggability.
Two non-obvious robustness rules the harness encodes:
- **Read table presence from the Tables *sidebar* region, not the whole
screen** — the Output panel echoes every command, so a table name typed
in a command would pollute a whole-screen match.
- **Pace each command to completion before the next** (real-user / cast
cadence). A command submitted while the previous one's worker rebuild is
still in flight can be misread against a stale schema cache (issue #39,
discovered here — interactive use is unaffected).
**The four flows** mirror this ADR's Tier-4 scope: (1) cold launch → first
DDL → graceful quit (Ctrl-C); (2) create → quit → reopen via `--resume`,
asserting the *column* (not just the table name) round-trips; (3) export →
import into a fresh project → schema **and** data rebuilt; (4) `undo` after
`DROP TABLE`, through the Y/N confirm modal.
**CI.** The target runs by default in `cargo test`, so the existing Linux
gate exercises it on every push — this **advances TT5** (Tier 4 now runs in
CI on Linux). PTY-in-container was validated (openpty + child spawn work
under the Docker runtime the Gitea runner uses); the first real CI run is
the final confirmation. **Windows execution remains out of scope** (no
Windows runner), and the original Tier-4 note about a *nightly broader
coverage* schedule is **not** implemented — the focused four flows are the
committed TT4 scope; broader coverage is deferred.
**Piggybacked NFR measurement.** The same harness measures NFR-1 (startup
to first frame) and NFR-3 (idle RSS, Linux) — see ADR-0057.
+122
View File
@@ -0,0 +1,122 @@
# ADR-0057: Non-functional-requirement verification strategy
## Status
Accepted (2026-06-22).
## Context
The non-functional requirements (`requirements.md` NFR-1..7 — startup,
input latency, memory, distinctive design, colour use, cross-platform
parity, light/dark legibility) were quality bars that had **never been
formally verified**, even though v0.2.0 ships public binaries. With users
now pulling updates, we want each NFR either **gated by an automated test**
(so a regression fails CI) or **measured-and-documented** with a recorded
figure and a reviewer judgement — not left as an aspiration.
The decision below was taken with the user: contrast is a hard gate;
startup and memory are measured against generous bounds (not the literal
target — a tight timing/RSS gate would flake on a shared runner); the
qualitative bars are verified by argument and reviewer note.
## Decision
### NFR-5 / NFR-7 — colour contrast & legibility: **gated**
Unit tests in `src/theme.rs` (Tier 1, run by the existing gate):
- **`all_text_colours_meet_wcag_aa_contrast`** — every text-bearing
foreground (`fg`, `muted`, mode labels, `system`/`error`/`warning`,
`plan_efficient`, and all `tok_*`) must clear **WCAG-AA 4.5:1** against
`bg`, in **both** themes.
- **`advanced_mode_border_meets_ui_contrast`** — the advanced-mode border
carries a mode-warning signal, so it meets the **3:1** non-text /
UI-component threshold. The plain `border` is decorative structural
chrome and is **deliberately exempt** (it sits at 2.20:1 dark / 1.82:1
light by design, so panel borders recede behind content — recorded here
rather than silently tolerated).
- **`syntax_token_colours_are_perceptually_distinct`** — contrast against
the background is necessary but not sufficient: two token colours can
each clear 4.5:1 yet be hard to tell *apart* on a good screen. Every pair
of syntax-token classes that can share a line must clear **CIEDE2000
(ΔE2000) ≥ 15** (post-tuning minimum is ~18). The metric is itself
validated against the Sharma et al. reference vectors
(`delta_e_2000_matches_reference_vectors`).
Computing the real ratios while writing these tests **uncovered two
shipped defects** in the v0.2.0 light theme — `tok_string` at 4.42:1 and
`tok_flag` at 3.15:1, both below the 4.5:1 the scheme promises — plus two
dark-theme token pairs that were perceptually close despite distinct hex
(`tok_type`↔`tok_keyword`, `tok_function`↔`tok_identifier`, ΔE2000 ~14).
All four were fixed in the same change (commit `65eab71`); the gates now
hold the palette to both bars permanently. A dev tool,
`scripts/palette-preview.py`, renders the live palette with contrast +
ΔE2000 for future palette work.
### NFR-1 / NFR-3 — startup & memory: **measured, generously bounded**
The Tier-4 PTY harness (ADR-0008 Amendment 1) measures both:
- **`startup_to_first_frame_is_reasonable`** — spawn → first rendered frame.
- **`idle_memory_footprint_is_reasonable`** — child `/proc/<pid>` VmRSS
after the app is idle (Linux only).
These run against the **debug** binary, so they assert *generous* bounds
(startup < 2000 ms, RSS < 150 MB) for **gross-regression detection**, not
the literal NFR targets — a tight 500 ms / 50 MB gate on a debug binary
under a loaded shared runner would flake. The **real release figures**
(measured 2026-06-22, 5 trials each, this dev machine) are the documented
verification:
| NFR | Target | Release median | Margin |
| --- | --- | --- | --- |
| NFR-1 startup → first frame | < 500 ms | **~29 ms** | ~17× under |
| NFR-3 idle RSS | < 50 MB | **~10 MB** | ~5× under |
(Measurement granularity is the harness poll interval, ~20 ms — figures
are "tens of ms", not sub-ms precise.) Per the user decision these are
**not** wired as tight CI gates; the generous debug-bound tests are the
ongoing regression signal.
### NFR-2 — input responsiveness: **verified by architecture**
"Long-running queries execute off the UI thread so the interface stays
responsive." This is structural and already true: database work runs on a
dedicated **worker thread** (ADR-0010), and terminal input is read on a
**separate Tokio task** (`spawn_event_reader` in `src/runtime.rs`), so a
running query cannot block keystroke handling or rendering. There is no
automated keystroke-latency test — a deterministic sub-16 ms measurement
in a PTY harness would be flaky and low-value — so NFR-2 is verified by
this architectural argument rather than a gate.
### NFR-4 / NFR-6 — distinctive design & cross-platform parity: **reviewer note**
Qualitative bars. **NFR-4** (deliberate, identifiable palette/layout using
box-drawing) is satisfied by the bespoke two-theme palette, the
three-region framed layout, and the annotated plan/relationship rendering —
reviewer judgement, not automatable. **NFR-6** is partly evidenced by the
CI matrix (Linux + macOS **execute** the suite; Windows is **build-only**,
no runner) and is otherwise a reviewer judgement; one documented divergence:
on terminals/multiplexers without true-colour passthrough (e.g. a tmux
session missing `Tc`/`RGB`), the 24-bit palette is quantised to the
256-colour cube and near hues can collide — a terminal-capability issue,
not a palette one.
## Consequences
- Contrast and perceptual distinctness are now **permanently gated** — the
palette cannot regress below WCAG-AA or into look-alike token colours
without failing the build.
- Startup and memory have **recorded figures** and a generous
gross-regression test; the literal targets are met with large margins.
- The qualitative and architectural NFRs are **documented with evidence**
rather than asserted.
- A latent behaviour found during this work — fast DDL→insert misparsing
against a stale schema cache — is tracked as **issue #39** (deferred; no
interactive-user impact).
## Requirements bookkeeping
NFR-1, NFR-3, NFR-5, NFR-7 → `[x]` (gated test and/or measured with
evidence). NFR-2 → `[x]` (architectural). NFR-4, NFR-6 → `[/]` (reviewer
note; not fully automatable).
+2 -1
View File
File diff suppressed because one or more lines are too long
+98
View File
@@ -0,0 +1,98 @@
# Session handoff — 2026-06-22 (76)
Continues from handoff-75 (D3 package managers). This session took up the
**regression-hardening** trio identified as "what's next" now that public
binaries ship: **TT4** (Tier-4 PTY tests), **NFR verification**, and a
**CHANGELOG** — plus a real palette accessibility fix the work uncovered.
## §1. State
**Branch `main`.** Three commits this session:
- `65eab71` `fix(theme)` — WCAG-AA palette fix + contrast/ΔE2000 gates +
`scripts/palette-preview.py`.
- `fd63de3` `test(tt4)` — Tier-4 PTY end-to-end suite (`tests/e2e_pty.rs`).
- **(pending)** a docs commit — ADR-0008 Amendment 1, ADR-0057, README index,
`requirements.md`, `CHANGELOG.md`, this handoff, and the plan doc
`docs/plans/20260622-tt4-nfr-changelog.md`. *(Propose + confirm before
committing; not yet committed at time of writing.)*
**Test baseline: 2519 passed / 0 failed / 1 ignored** (was 2509; +4 theme
gates, +6 e2e_pty). `clippy --all-targets -D warnings` + `fmt --check` clean.
Full suite verified green 3× under parallel load.
## §2. What shipped
**Palette accessibility (ADR-0057, commit `65eab71`).** Computing real WCAG
ratios while writing the gate **found two shipped defects** in v0.2.0's light
theme — `tok_string` (4.42:1) and `tok_flag` (3.15:1), below the 4.5:1 the
scheme promises — and two dark token pairs that were perceptually close
despite distinct hex. All four fixed (only `theme.rs` colours changed, 4
values). New gated tests in `src/theme.rs`:
- `all_text_colours_meet_wcag_aa_contrast` (≥4.5:1, both themes),
- `advanced_mode_border_meets_ui_contrast` (≥3:1; plain border exempt),
- `delta_e_2000_matches_reference_vectors` (CIEDE2000 validated vs Sharma data),
- `syntax_token_colours_are_perceptually_distinct` (ΔE2000 ≥15).
Dev tool **`scripts/palette-preview.py`** renders the live palette (truecolor
swatch + contrast + ΔE2000), supports `theme:key=HEX` overrides for previewing
changes.
**TT4 — Tier-4 PTY tests (ADR-0008 Amendment 1, commit `fd63de3`).**
`tests/e2e_pty.rs` drives the **real built binary** in a pseudo-terminal:
`portable-pty` 0.9 + `vt100` 0.16 (**`expectrl` dropped** — conflicting PTY
layer; replaced by a hand-rolled `wait_for` on the vt100 grid). 100×30, temp
`--data-dir`, serial, tight 3 s fail-fast waits. The four ADR-0008 flows all
green. Runs by default in `cargo test` → the Linux gate now exercises Tier 4
(**advances TT5**). PTY-in-container validated (openpty+spawn under Docker).
- **Gotchas baked into the harness:** read table presence from the Tables
**sidebar** region (the Output panel echoes commands, polluting whole-screen
matches); **pace commands to completion** (a command sent mid-rebuild
misparses — issue #39); simple-mode insert needs the `values` keyword and a
single-value insert needs `add column` (a 2nd col in `with pk …` makes a
**compound PK**).
**NFR verification (ADR-0057).** All seven NFRs formally verified:
- NFR-1/3 **measured** via the PTY harness — release figures **~29 ms** startup
/ **~10 MB** idle RSS (well under 500 ms / 50 MB). Harness tests gate generous
*debug*-binary bounds for gross-regression detection (tight gates declined as
flaky — user decision).
- NFR-5/7 **gated** (the contrast/ΔE2000 tests above).
- NFR-2 by architecture (worker thread ADR-0010 + separate input task).
- NFR-4/6 reviewer note (`[/]`).
**CHANGELOG.md** — Keep a Changelog + SemVer; `[Unreleased]` + `[0.2.0]`
(0.1.0 = pre-history). v0.2.0 tag = `bd5be5e`; the palette fix + package
managers + TT4 are post-tag → Unreleased.
## §3. Decisions taken with the user
- Palette: fix the two light contrast bugs **and** the two dark near-duplicates
(4 hex changes, approved after a rendered preview).
- Borders <3:1 → **accept + document** (decorative chrome).
- Work directly on `main`; ADR-0057 numbered now (not draft).
- NFR perf: measured + generously bounded, **not** tightly CI-gated.
- CHANGELOG depth: 0.2.0 + Unreleased only.
- Schema-cache finding → **file a Gitea issue, defer the fix** (see §4).
## §4. Open / follow-ups
- **Issue #39** (filed this session): simple-mode **Form-B insert**
(`insert into T values (…)`) submitted faster than the post-DDL **schema-cache
refresh** validates against stale schema and is misparsed as SQL. No
interactive-user impact (cast driver + tests pace); deferred fix. Repro +
diagnosis in the issue.
- **TT5 remaining:** only a **Windows execution runner** now (macOS + Tier-4
gaps closed this session). First real CI run is the final confirmation that
the PTY tests pass in the Gitea container (validated locally; very low risk).
- Untouched larger items from the "what's next" survey: hint/help issues
**#36/#37/#38**; the big features (V4 session journal, TU1 tutorial). The
CHANGELOG can later feed `--release-notes-url` into the CI winget job
(handoff-75 §6).
## §5. Process pins
- Commits user-confirmed, no AI attribution, append-only, **push is the user's
step**.
- `/runda` + DA pass was run on both the plan and the implementation (it caught
the two light-theme contrast bugs and the #39 schema-cache behaviour — both
before finalizing).
- Consider a `cargo sweep` at this milestone (release build added ~ a GB).
+189
View File
@@ -0,0 +1,189 @@
# Plan — TT4 (Tier-4 PTY tests) + NFR verification + CHANGELOG
Status: **approved 2026-06-22**, implementation pending. Supersedes the
transient harness plan-mode copy. This is the persistent, version-controlled
plan per project convention (`docs/plans/`).
## Context
RDBMS Playground now ships public binaries (v0.2.0 on crates.io, plus
Scoop/Homebrew/winget). With real users pulling updates, the priority is
**regression protection before the next release**. Three tracked gaps are taken
up together:
- **TT4** (`requirements.md`, ADR-0008 Tier 4): the four critical end-to-end
flows are specified but **no PTY harness exists** — no deps, no tests. Tiers
1–3 never exercise the *real built binary* through a terminal.
- **NFR-1..7**: quality bars (startup, latency, memory, contrast, distinctive
design, parity) that were **never formally verified** despite shipping.
- **CHANGELOG**: none exists; useful now that releases are public (and lets the
CI `winget` job pass `--release-notes-url` later).
Baseline (Phase 1): `cargo test` = **2509 passed / 0 failed / 1 ignored**;
clippy + fmt clean. This is the number Phase 5 verifies against (new tests add
to it; nothing may regress).
User decisions (Phase 3):
- **TT4 in CI:** own `e2e_pty` test target, runs by default in `cargo test`
(so the Linux gate exercises it every push). **Tight, fail-fast timeouts** —
the runner is self-hosted, low-parallelism, low expected flakiness (so a
failure surfaces fast rather than hanging on a generous timeout).
- **NFR:** WCAG contrast = hard gated test; startup + idle-memory = measured via
the PTY harness against *generous* bounds + numbers recorded; NFR-2/4/6 =
verify-by-argument + reviewer note. Each requirement → `[x]`/`[/]` with
evidence.
- **CHANGELOG:** Keep a Changelog + SemVer; `[Unreleased]` + a single
consolidated `[0.2.0]` only (0.1.0 treated as pre-history).
---
## Workstream 1 — TT4: Tier-4 PTY harness + 4 flows
### Tooling (refines ADR-0008's stated `portable-pty`+`expectrl`+`vt100`)
- **`portable-pty`** (wezterm, actively maintained) — spawn the real binary in a
PTY with a fixed window size.
- **`vt100`** — parse the PTY output stream into an inspectable cell grid.
- **Drop `expectrl`** — it bundles its *own* PTY abstraction (conflicts with
portable-pty) and is line-oriented, a poor fit for a full-screen TUI. Replace
it with a small hand-rolled `wait_for(predicate, timeout)` polling the vt100
screen. This deviation is recorded in the ADR-0008 amendment.
- **Checkpoint at implementation:** verify `vt100` is still maintained/current
(global currency rule). If stale, evaluate `avt` as the screen parser before
committing the dep. `cargo add` picks latest; pin sensibly.
### Harness (`tests/e2e_pty.rs` — new `[[test]]` target)
A `support` module providing:
- `struct PtyApp` — opens a PTY (e.g. **100×30**, the wide three-region
layout), spawns `env!("CARGO_BIN_EXE_rdbms-playground")` with a **per-test
temp `--data-dir`** (`tempfile::TempDir`), `--theme dark`, `TERM=xterm-256color`;
a reader thread feeds bytes into a `vt100::Parser`.
- `send(bytes)` (raw; `\r` = Enter, `\x03` = Ctrl-C), `send_line(s)` =
`s` + `\r`.
- `wait_for(&str, Duration)` — poll the vt100 screen text until the substring
appears or the (tight, ~**3 s**) timeout fires; on timeout **panic with a full
screen dump** (fail-fast + debuggable). A `screen_text()` helper for assertions.
- `quit()` — `send("\x03")`, wait for child exit, assert success.
- Run **serially** (`serial_test` dep, or a shared `Mutex`/single-threaded
target) — PTYs + temp dirs shouldn't race; also keeps timing stable.
- **Instrument** with `eprintln!`/screen dumps on every wait so a CI failure is
diagnosable from logs (per the project's logging discipline).
### The four flows (one `#[test]` each, mirroring ADR-0008 §Tier-4)
1. **Cold launch → DDL → quit.** Launch fresh; `wait_for("SIMPLE")` +
`wait_for("(none yet)")`; `send_line("create table Customers with pk
id(serial)")`; `wait_for("Customers")` in the Tables panel; `quit()`.
2. **Save → restart → reopen.** Launch project path P under the temp data-dir;
create a table; `quit()` (autosave already persisted per-command); relaunch
**same P**; `wait_for("Customers")` — schema restored from text/rebuild.
3. **Export → import → rebuild.** Project A: create table + insert a row;
`send_line("export A.zip")` (explicit path under the temp dir);
`wait_for` export-success note; `quit()`. Fresh project B:
`send_line("import <path>/A.zip")`; `wait_for` import note (rebuild auto-runs
on missing `.db`); assert the table + row are present (`show data` /
Tables panel).
4. **Undo after DROP.** Create table; `send_line("drop table Customers")`;
confirm gone; `send_line("undo")`; **`wait_for("Restore that earlier
state?")`** (the real modal); `send("y")`; `wait_for("Customers")` restored.
### CI
No workflow edit needed for execution: `e2e_pty` is a default target, so the
existing `gate` job's `cargo test --no-fail-fast` runs it on the Linux
container. **Validate locally first** that a PTY opens inside the
`rdbms-playground-ci` image (container `/dev/ptmx`); if not, add `--init`/pts
mount notes to the ADR. Advances **TT5** (Tier-4-in-CI on Linux); Windows
execution runner stays out of scope (no runner).
---
## Workstream 2 — NFR verification
### Gated: WCAG contrast (NFR-5, NFR-7) — `src/theme.rs` `#[cfg(test)]`
- Add a `relative_luminance(Color) -> f64` + `contrast_ratio(a,b) -> f64`
helper (WCAG 2.x sRGB formula) in the test module; a `match Color::Rgb` channel
extractor (panics on non-RGB — also a guard against a future named-colour
regression).
- For **both** `Theme::light()` and `Theme::dark()`, assert **≥ 4.5:1** for
normal-text foregrounds on `bg`: `fg`, `error`, `warning`, `system`,
`plan_efficient`, `mode_simple`, `mode_advanced`, and the info-carrying syntax
tokens (`tok_keyword/identifier/type/number/string/flag/function/error`).
- **`muted`/`tok_punct`** (deliberately dim secondary text) and **borders**
(`border`/`border_advanced`, non-text UI): assert the WCAG **non-text 3:1**
threshold, with a comment citing the WCAG large-text/non-text allowance. If
any *fails even 3:1*, that's a real finding → surface to the user, don't
silently relax. (Supersedes the existing inequality-only theme tests.)
- This runs in the existing gate — permanent regression protection on the palette.
### Measured + documented: startup (NFR-1) + idle memory (NFR-3)
Two measurement tests in `e2e_pty.rs`:
- **Startup:** time from spawn to `wait_for("SIMPLE")` (first rendered frame);
assert a **generous** bound (e.g. < 1500 ms locally) and `eprintln!` the actual.
- **Idle memory:** after the app is idle, read the child's `/proc/<pid>/status`
`VmRSS` (Linux); assert a generous bound and print actual. (Linux-gated via
`#[cfg(target_os = "linux")]`.)
The point is gross-regression detection, not a tight SLA gate (user decision).
### Verify-by-argument + reviewer note (NFR-2, NFR-4, NFR-6)
- **NFR-2** (input off-thread): already architecturally true — DB runs on the
worker thread (ADR-0010) and input on a separate Tokio task (confirmed in
`runtime.rs`). Record the evidence; optionally a test that a query doesn't
block a subsequent keystroke render.
- **NFR-4** (distinctive design) / **NFR-6** (cross-platform parity): qualitative
/ partly-CI. Written reviewer verification (DA-hat) in the NFR verification doc;
parity partly evidenced by the CI matrix (Linux+macOS execute, Windows builds).
### Requirements bookkeeping
Move NFR-5/7 → `[x]` (gated test), NFR-1/3 → `[x]` (measured + bounded test +
recorded numbers), NFR-2 → `[x]` (architectural evidence), NFR-4/6 → `[/]` with
the reviewer note (honest: not fully automatable). Each with a commit/test ref.
---
## Workstream 3 — CHANGELOG
- New **`CHANGELOG.md`** at repo root, **Keep a Changelog** + SemVer.
- `[Unreleased]` (empty/seeded) + one consolidated **`[0.2.0] - <release date>`**
with Added/Changed/Fixed of the notable user-facing changes (install methods,
packaging, `--version`/`version`, seed improvements, hint feature, readline
keys, cross-mode history) mined from ADRs 0053–0056 + handoffs 73–75. 0.1.0 =
pre-history (a one-line note).
- Optional follow-up (flag, don't auto-do): wire `--release-notes-url` into the
CI `winget`/release path — out of scope unless you want it now.
---
## Docs / ADR updates (project discipline)
- **Amend ADR-0008** (Tier-4 section): record the realized tooling
(`portable-pty` + `vt100` + hand-rolled `wait_for`, `expectrl` dropped),
serial execution, tight-timeout/fail-fast stance, the default-target CI wiring,
and the four implemented flows. Update `docs/adr/README.md` in the same edit.
- **New ADR-0057 — NFR verification strategy** (next free number; assigned at
merge per ADR-0000): the contrast-gate + thresholds (incl. the 3:1
muted/border allowance), the measured-not-tightly-gated stance for
startup/memory, and the verify-by-argument treatment of NFR-2/4/6. Add a
short **NFR verification record** (the measured numbers + reviewer notes) —
either in the ADR or a `docs/` companion. Update the README index.
- Update `docs/requirements.md`: TT4 `[~]`→`[x]`, TT5 `[/]` note (Tier-4 now in
Linux CI; Windows-exec still pending), NFR rows as above.
- A handoff note (next sequence number) per project convention.
---
## Verification (Phase 5)
1. `cargo test` — full suite green; new e2e_pty + contrast tests pass; **2509 +
new, 0 failed, ≤1 ignored**; no regressions vs baseline.
2. `cargo clippy --all-targets -- -D warnings` + `cargo fmt --check` clean.
3. Run `e2e_pty` repeatedly (e.g. 5–10×) locally to confirm non-flaky under the
tight timeouts before relying on the gate.
4. Confirm a PTY opens inside the CI image (or document the fix).
5. DA-hat pass over each workstream + the NFR reviewer judgements, written down.
6. `cargo sweep` at the milestone (CLAUDE.md build hygiene).
## Risks / checkpoints
- **vt100 currency** — verify before adding; fall back to `avt` if stale.
- **PTY-in-container** — validate early; the whole TT4 CI value depends on it.
- **muted/border contrast** — may fail even 3:1; if so it's a finding to escalate,
not silently relax (could mean a small palette tweak — touches a decided area,
so escalate).
- **Commits** — confirm each message; no AI attribution; append-only; never push.
+72 -27
View File
@@ -918,28 +918,34 @@ since ADR-0027.)
for representative views. for representative views.
- [x] **TT3** Tier 3: synthetic event-loop integration tests - [x] **TT3** Tier 3: synthetic event-loop integration tests
covering the user-facing flows in this checklist. covering the user-facing flows in this checklist.
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical - [x] **TT4** Tier 4: PTY-based end-to-end for the four critical
flows named in ADR-0008 (cold launch → DDL → quit; save → flows named in ADR-0008 (cold launch → DDL → quit; save →
reopen; export → import → rebuild; undo after DROP). reopen; export → import → rebuild; undo after DROP).
*(Verified 2026-06-07: **nothing is wired** — no *(Implemented 2026-06-22 — ADR-0008 **Amendment 1**;
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test `tests/e2e_pty.rs` (commit `fd63de3`). Drives the actual built
files; ADR-0008 §Tier-4 is a specification only. The Tier-3 binary in a real pseudo-terminal: `portable-pty` + `vt100`
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not (**`expectrl` dropped** — conflicting PTY layer, replaced by a
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is hand-rolled `wait_for` on the vt100 grid). Fixed 100×30, per-test
wired only for the listed critical flows" — it was not wired at temp `--data-dir`, serial, tight fail-fast 3 s waits; table
all. Genuinely deferred.)* presence read from the Tables sidebar region (Output echoes
commands); commands paced to completion (stale-schema-cache
misparse on faster-than-human input → issue #39). All four flows
green (flow 2 asserts a **column** round-trips, not just the
table name). Was previously a spec-only deferral; the earlier
2026-06-07 "nothing is wired" note is now resolved.)*
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on - [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
stable Rust. stable Rust.
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea *(Partial, updated 2026-06-22. **CI is live** on the self-hosted
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` + Gitea Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings`
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch + `cargo test` (now Tiers **1–4** — the `e2e_pty` target runs by
push / PR, and `release-macos` runs the suite natively on the default) on the **Linux** runner for every branch push / PR, and
**macOS** runner. **Windows is build-only** — cross-compiled, not `release-macos` runs the suite natively on the **macOS** runner.
executed (no Windows runner). **Tier 4** (PTY, TT4) is still **Tier 4 in CI on Linux is now met** (TT4); PTY-in-container was
unwired, so "all tiers" is not yet fully met. "Stable Rust" is validated (openpty + spawn under Docker), first real CI run is the
satisfied by the flake's pinned `1.95.0` (a stable release, not final confirmation. **Windows is build-only** — cross-compiled,
nightly). Remaining for full TT5: a Windows execution runner and not executed (no Windows runner). "Stable Rust" is satisfied by
Tier-4 PTY in CI.)* the flake's pinned `1.95.0`. **Remaining for full TT5: a Windows
execution runner** (the macOS + Tier-4 gaps are now closed).)*
## Cross-cutting ## Cross-cutting
@@ -1015,41 +1021,80 @@ target is measurable, it is stated numerically; where it is
necessarily qualitative, the criterion is named and the bar is necessarily qualitative, the criterion is named and the bar is
"reviewer judgement against the criterion." "reviewer judgement against the criterion."
- [ ] **NFR-1 Performance — startup.** Cold launch to first All seven were formally verified 2026-06-22 (ADR-0057). Approach:
contrast/distinctness **gated** by tests, startup/memory **measured**
against the targets, the rest **verified by argument / reviewer note**.
- [x] **NFR-1 Performance — startup.** Cold launch to first
rendered frame under 500ms on commodity hardware (developer rendered frame under 500ms on commodity hardware (developer
laptop, mid-range desktop). Measured in CI on the Linux runner laptop, mid-range desktop).
as a regression gate. *(Verified 2026-06-22, ADR-0057 — measured via the Tier-4 PTY
- [ ] **NFR-2 Performance — input latency.** Keystroke-to-render harness; release-binary median **~29 ms** to first frame (5
trials, this dev machine), ~17× under target. The harness test
`startup_to_first_frame_is_reasonable` gates a generous
debug-binary bound for gross-regression detection; a tight 500 ms
CI gate was declined as flaky on a shared runner — user decision.)*
- [x] **NFR-2 Performance — input latency.** Keystroke-to-render
latency under 16ms during normal editing; long-running queries latency under 16ms during normal editing; long-running queries
must execute off the UI thread so the interface remains must execute off the UI thread so the interface remains
responsive (typing, scrolling, mode switching) while a query is responsive (typing, scrolling, mode switching) while a query is
running. running.
- [ ] **NFR-3 Performance — resource footprint.** Idle memory *(Verified 2026-06-22, ADR-0057 — by architecture: database work
runs on the dedicated worker thread (ADR-0010) and terminal input
on a separate Tokio task (`spawn_event_reader`, `runtime.rs`), so
a running query cannot block input/render. No automated sub-16 ms
latency test — it would be flaky and low-value.)*
- [x] **NFR-3 Performance — resource footprint.** Idle memory
under 50MB on the smallest target platform; no busy-loops; CPU under 50MB on the smallest target platform; no busy-loops; CPU
near zero when waiting for input. near zero when waiting for input.
- [ ] **NFR-4 Visual quality — distinctive design.** Colour *(Verified 2026-06-22, ADR-0057 — release-binary idle RSS
**~10 MB** (5 trials, Linux), ~5× under target. The event loop
blocks on `recv` (no busy-loop). The harness test
`idle_memory_footprint_is_reasonable` gates a generous
debug-binary RSS bound on Linux.)*
- [/] **NFR-4 Visual quality — distinctive design.** Colour
palette and typography are deliberate and consistent across palette and typography are deliberate and consistent across
views; layout uses Unicode box-drawing and symbols where they views; layout uses Unicode box-drawing and symbols where they
add clarity; rendering avoids the generic flat-default look add clarity; rendering avoids the generic flat-default look
that ships with most TUI frameworks. Criterion: a reviewer can that ships with most TUI frameworks. Criterion: a reviewer can
identify the app from a screenshot of any view. identify the app from a screenshot of any view.
- [ ] **NFR-5 Visual quality — colour use.** Colour conveys *(Reviewer note 2026-06-22, ADR-0057 — satisfied by the bespoke
two-theme palette, the three-region framed layout, and the
annotated plan/relationship rendering; qualitative, not
automatable.)*
- [x] **NFR-5 Visual quality — colour use.** Colour conveys
information rather than decoration: mode indication, query information rather than decoration: mode indication, query
result types (numeric vs text vs null), error severity, result types (numeric vs text vs null), error severity,
syntax highlighting categories. Foreground/background syntax highlighting categories. Foreground/background
combinations meet WCAG-AA contrast (4.5:1 for normal text) combinations meet WCAG-AA contrast (4.5:1 for normal text)
even though we have not committed to broader accessibility. even though we have not committed to broader accessibility.
- [ ] **NFR-6 Cross-platform parity.** Behaviour and visual *(Verified 2026-06-22, ADR-0057 — **gated** in `src/theme.rs`:
every text foreground clears 4.5:1 on both themes; the
advanced-mode border clears 3:1 (plain border decorative-exempt);
token pairs clear ΔE2000 ≥ 15. Writing the gate caught + fixed two
shipped light-theme defects (`tok_string` 4.42:1, `tok_flag`
3.15:1) and two near-duplicate dark pairs, `65eab71`.)*
- [/] **NFR-6 Cross-platform parity.** Behaviour and visual
quality are equivalent across Linux, macOS, and Windows on quality are equivalent across Linux, macOS, and Windows on
crossterm-supported terminals. Platform-specific divergence crossterm-supported terminals. Platform-specific divergence
(e.g. font fallbacks) is documented, not silently tolerated. (e.g. font fallbacks) is documented, not silently tolerated.
- [ ] **NFR-7 Light and dark background support.** The colour *(Reviewer note 2026-06-22, ADR-0057 — partly evidenced by the CI
matrix (Linux + macOS execute the suite incl. Tier 4; Windows is
build-only). Documented divergence: on terminals/multiplexers
without true-colour passthrough the 24-bit palette is quantised to
256 colours and near hues can collide (a terminal-capability
issue). Full parity awaits a Windows execution runner — see TT5.)*
- [x] **NFR-7 Light and dark background support.** The colour
scheme remains legible and visually coherent on both light and scheme remains legible and visually coherent on both light and
dark terminal backgrounds. The mechanism (auto-detect via dark terminal backgrounds. The mechanism (auto-detect via
terminal query, explicit user setting, or both) is an terminal query, explicit user setting, or both) is an
implementation choice, but the outcome is non-negotiable: no implementation choice, but the outcome is non-negotiable: no
dark-on-dark or light-on-light readability failures on either dark-on-dark or light-on-light readability failures on either
background. background.
*(Verified 2026-06-22, ADR-0057 — the WCAG-AA contrast gate
(NFR-5) runs over **both** themes, so the legibility outcome is
enforced on light and dark. Selection mechanism is `--theme` +
`COLORFGBG` auto-detect (OSC-11 querying deferred).)*
--- ---
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Palette preview + WCAG contrast + CIEDE2000 perceptual audit.
A dev tool for working on the colour palette (`src/theme.rs`). It reads
the live `dark()` / `light()` constructors so it never drifts from the
code, renders a true-colour swatch + sample for every palette entry on
the theme background, prints the WCAG-AA contrast ratio, and reports the
pairwise CIEDE2000 (ΔE2000) distance between the syntax-token colours so
near-duplicates (distinct hex, indistinguishable on screen) are obvious.
Usage:
scripts/palette-preview.py
scripts/palette-preview.py dark:tok_type=F58AAE light:tok_flag=7A5C00
Each `theme:key=HEX` argument previews a change without editing the
source — handy for trying candidate colours. The gates mirrored here are
enforced for real by the tests in `src/theme.rs` (NFR-5/NFR-7, ADR-0057):
text foregrounds must clear 4.5:1; token pairs must clear ΔE2000 15.
"""
import math
import os
import re
import sys
THEME_RS = os.path.join(os.path.dirname(__file__), "..", "src", "theme.rs")
# --- WCAG contrast -----------------------------------------------------
def _lin(c):
c = c / 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def _luminance(rgb):
r, g, b = rgb
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
def contrast(fg, bg):
a, b = _luminance(fg), _luminance(bg)
hi, lo = max(a, b), min(a, b)
return (hi + 0.05) / (lo + 0.05)
# --- sRGB -> CIELAB (D65) + CIEDE2000 ----------------------------------
def _f(t):
return t ** (1 / 3) if t > 0.008856 else 7.787 * t + 16 / 116
def rgb_to_lab(rgb):
r, g, b = (_lin(c) for c in rgb)
x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047
y = r * 0.2126 + g * 0.7152 + b * 0.0722
z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883
fx, fy, fz = _f(x), _f(y), _f(z)
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
def de2000(lab1, lab2):
L1, a1, b1 = lab1
L2, a2, b2 = lab2
avg_Lp = (L1 + L2) / 2
C1, C2 = math.hypot(a1, b1), math.hypot(a2, b2)
avg_C = (C1 + C2) / 2
G = 0.5 * (1 - math.sqrt(avg_C ** 7 / (avg_C ** 7 + 25 ** 7))) if avg_C > 0 else 0
a1p, a2p = (1 + G) * a1, (1 + G) * a2
C1p, C2p = math.hypot(a1p, b1), math.hypot(a2p, b2)
avg_Cp = (C1p + C2p) / 2
def hp(ap, b):
if ap == 0 and b == 0:
return 0
ang = math.degrees(math.atan2(b, ap))
return ang + 360 if ang < 0 else ang
h1p, h2p = hp(a1p, b1), hp(a2p, b2)
dLp, dCp = L2 - L1, C2p - C1p
if C1p * C2p == 0:
dhp = 0
elif abs(h2p - h1p) <= 180:
dhp = h2p - h1p
elif h2p - h1p > 180:
dhp = h2p - h1p - 360
else:
dhp = h2p - h1p + 360
dHp = 2 * math.sqrt(C1p * C2p) * math.sin(math.radians(dhp) / 2)
if C1p * C2p == 0:
avg_hp = h1p + h2p
elif abs(h1p - h2p) <= 180:
avg_hp = (h1p + h2p) / 2
elif h1p + h2p < 360:
avg_hp = (h1p + h2p + 360) / 2
else:
avg_hp = (h1p + h2p - 360) / 2
T = (1 - 0.17 * math.cos(math.radians(avg_hp - 30))
+ 0.24 * math.cos(math.radians(2 * avg_hp))
+ 0.32 * math.cos(math.radians(3 * avg_hp + 6))
- 0.20 * math.cos(math.radians(4 * avg_hp - 63)))
d_ro = 30 * math.exp(-((avg_hp - 275) / 25) ** 2)
Rc = 2 * math.sqrt(avg_Cp ** 7 / (avg_Cp ** 7 + 25 ** 7)) if avg_Cp > 0 else 0
Sl = 1 + (0.015 * (avg_Lp - 50) ** 2) / math.sqrt(20 + (avg_Lp - 50) ** 2)
Sc, Sh = 1 + 0.045 * avg_Cp, 1 + 0.015 * avg_Cp * T
Rt = -math.sin(math.radians(2 * d_ro)) * Rc
return math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2
+ Rt * (dCp / Sc) * (dHp / Sh))
# --- parse the live palette from src/theme.rs --------------------------
def parse_palette(path):
text = open(path, encoding="utf-8").read()
field = re.compile(
r"(\w+):\s*Color::Rgb\(0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2}),\s*0x([0-9A-Fa-f]{2})\)"
)
def block(start, end):
s = text.index(start)
e = text.index(end, s)
return {m.group(1): (m.group(2) + m.group(3) + m.group(4)).upper()
for m in field.finditer(text[s:e])}
return {"dark": block("fn dark()", "fn light()"),
"light": block("fn light()", "fn highlight_class_color")}
def h(s):
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
def fg(rgb):
r, g, b = rgb
return f"\x1b[38;2;{r};{g};{b}m"
def bg(rgb):
r, g, b = rgb
return f"\x1b[48;2;{r};{g};{b}m"
R = "\x1b[0m"
TOKENS = ["tok_keyword", "tok_identifier", "tok_type", "tok_number",
"tok_string", "tok_flag", "tok_function"]
NONTEXT = {"border", "border_advanced"}
SAMPLE = {
"tok_keyword": "create table", "tok_identifier": "Customers",
"tok_type": "serial", "tok_number": "42", "tok_string": "'hello'",
"tok_flag": "--all-rows", "tok_function": "count(", "tok_punct": ", ;",
"tok_error": "bad", "fg": "body text", "muted": "(none yet)",
"system": "done.", "error": "error: nope", "warning": "[WRN] slow",
"plan_efficient": "SEARCH idx", "mode_simple": "SIMPLE",
"mode_advanced": "ADVANCED", "border": "──────", "border_advanced": "──────",
}
def main():
themes = parse_palette(THEME_RS)
for arg in sys.argv[1:]:
th, rest = arg.split(":")
k, v = rest.split("=")
themes[th][k] = v.upper()
for tn, t in themes.items():
B = h(t["bg"])
print(f"\n{'=' * 64}\n {tn.upper()} THEME (bg #{t['bg']})\n{'=' * 64}")
for k, hexv in t.items():
if k == "bg":
continue
c = h(hexv)
cr = contrast(c, B)
swatch = f"{bg(c)} {R}"
text = f"{bg(B)}{fg(c)} {SAMPLE.get(k, k):14}{R}"
floor = 3.0 if k in NONTEXT else 4.5
warn = "" if cr >= floor else f" !! < {floor}"
print(f" {swatch} {text} #{hexv} {cr:5.2f}:1{warn} {k}")
print("\n -- token ΔE2000 (<12 hard to distinguish; gate is >=15) --")
labs = {k: rgb_to_lab(h(t[k])) for k in TOKENS if k in t}
pairs = sorted(
(de2000(labs[a], labs[b]), a, b)
for i, a in enumerate(labs) for b in list(labs)[i + 1:]
)
for d, a, b in pairs[:6]:
flag = " !! TOO CLOSE" if d < 15 else (" ~ close" if d < 18 else "")
print(f" {d:5.1f} {a:14} vs {b:14}{flag}")
if __name__ == "__main__":
main()
+275 -4
View File
@@ -106,13 +106,13 @@ impl Theme {
// distinct from the mode-banner blue. // distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier tok_type: Color::Rgb(0xF5, 0x8A, 0xAE), // rose-pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier (ADR-0057: widened ΔE from keyword 14.5→18.4)
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type tok_function: Color::Rgb(0x6C, 0xB2, 0xFF), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + rose type (ADR-0057: widened ΔE from identifier 14.3→19.5)
} }
} }
@@ -139,9 +139,9 @@ impl Theme {
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green tok_string: Color::Rgb(0x1B, 0x7A, 0x33), // forest green (ADR-0057: darkened for WCAG-AA, 4.42→5.18:1)
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard tok_flag: Color::Rgb(0x7A, 0x5C, 0x00), // dark mustard/olive (ADR-0057: darkened for WCAG-AA, 3.15→5.98:1)
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
} }
@@ -264,4 +264,275 @@ mod tests {
assert_ne!(t.tok_function, t.tok_type); assert_ne!(t.tok_function, t.tok_type);
} }
} }
// ---- NFR-5 / NFR-7 (ADR-0057): WCAG-AA contrast gate ----
//
// Every text-bearing foreground must clear WCAG-AA 4.5:1 against
// the panel background, in BOTH themes, so the colour scheme stays
// legible on light and dark terminals (no dark-on-dark / light-on-
// light failures). Borders are non-text structural chrome and are
// handled by `advanced_mode_border_meets_ui_contrast` below; the
// plain `border` is intentionally exempt (decorative — see ADR-0057).
//
// These checks rely on every `Theme` colour being `Color::Rgb`
// (true-colour). `rgb_channels` panics on any other variant, which
// also guards against a future regression to a named palette colour
// (whose real contrast can't be known).
fn rgb_channels(c: Color) -> (f64, f64, f64) {
match c {
Color::Rgb(r, g, b) => (f64::from(r), f64::from(g), f64::from(b)),
other => panic!("theme colours must be Color::Rgb for contrast checks; got {other:?}"),
}
}
/// WCAG 2.x relative luminance of an sRGB colour.
// The luminance/Lab/CIEDE2000 maths below are published standards; we
// keep them in canonical `a*x + b*y` form (verified against reference
// vectors) rather than refactoring into `mul_add`, which would obscure
// the formula for a negligible test-only gain.
#[allow(clippy::suboptimal_flops)]
fn relative_luminance(c: Color) -> f64 {
let chan = |v: f64| {
let v = v / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
let (r, g, b) = rgb_channels(c);
0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
}
/// WCAG 2.x contrast ratio between two colours (1.0 ..= 21.0).
fn contrast_ratio(a: Color, b: Color) -> f64 {
let (la, lb) = (relative_luminance(a), relative_luminance(b));
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
(hi + 0.05) / (lo + 0.05)
}
fn theme_label(t: &Theme) -> &'static str {
match t.background {
Background::Dark => "dark",
Background::Light => "light",
}
}
/// Every text foreground (incl. dimmed `muted`/`tok_punct` and the
/// syntax-token classes) on `bg`, both themes.
fn text_foregrounds(t: &Theme) -> [(&'static str, Color); 17] {
[
("fg", t.fg),
("muted", t.muted),
("mode_simple", t.mode_simple),
("mode_advanced", t.mode_advanced),
("system", t.system),
("error", t.error),
("warning", t.warning),
("plan_efficient", t.plan_efficient),
("tok_keyword", t.tok_keyword),
("tok_identifier", t.tok_identifier),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_punct", t.tok_punct),
("tok_flag", t.tok_flag),
("tok_error", t.tok_error),
("tok_function", t.tok_function),
]
}
#[test]
fn all_text_colours_meet_wcag_aa_contrast() {
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
for (name, c) in text_foregrounds(&t) {
let ratio = contrast_ratio(c, t.bg);
assert!(
ratio >= 4.5,
"{label} theme: {name} contrast {ratio:.2}:1 on bg is below WCAG-AA 4.5:1",
);
}
}
}
#[test]
fn advanced_mode_border_meets_ui_contrast() {
// The advanced-mode border carries a mode-warning signal, so it
// meets WCAG's 3:1 non-text / UI-component threshold in both
// themes. The plain `border` is decorative structural chrome and
// is intentionally exempt (recorded in ADR-0057).
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
let ratio = contrast_ratio(t.border_advanced, t.bg);
assert!(
ratio >= 3.0,
"{label} theme: border_advanced contrast {ratio:.2}:1 below the 3:1 UI bar",
);
}
}
// ---- NFR-5 perceptual distinctness (ADR-0057) ----
//
// Contrast-against-background is necessary but not sufficient: two
// token colours can each clear 4.5:1 yet be hard to tell APART from
// each other (a real pain reported on high-quality screens). We lock
// that in with a CIEDE2000 (ΔE2000) floor between every pair of
// syntax-token classes that can share a line. The metric itself is
// validated against the Sharma et al. reference vectors.
/// sRGB `Color` → CIELAB (D65), for perceptual-difference checks.
#[allow(clippy::suboptimal_flops)]
fn rgb_to_lab(c: Color) -> (f64, f64, f64) {
let lin = |v: f64| {
let v = v / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
let (r0, g0, b0) = rgb_channels(c);
let (r, g, b) = (lin(r0), lin(g0), lin(b0));
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.950_47;
let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.088_83;
let f = |t: f64| {
if t > 0.008_856 {
t.cbrt()
} else {
7.787 * t + 16.0 / 116.0
}
};
let (fx, fy, fz) = (f(x), f(y), f(z));
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
}
/// CIEDE2000 colour difference between two CIELAB values.
#[allow(clippy::suboptimal_flops)]
fn delta_e_2000(lab1: (f64, f64, f64), lab2: (f64, f64, f64)) -> f64 {
let (l1, a1, b1) = lab1;
let (l2, a2, b2) = lab2;
let pow7 = |v: f64| v.powi(7);
let k_l = pow7(25.0);
let avg_lp = (l1 + l2) / 2.0;
let c1 = a1.hypot(b1);
let c2 = a2.hypot(b2);
let avg_c = (c1 + c2) / 2.0;
let g = if avg_c > 0.0 {
0.5 * (1.0 - (pow7(avg_c) / (pow7(avg_c) + k_l)).sqrt())
} else {
0.0
};
let a1p = (1.0 + g) * a1;
let a2p = (1.0 + g) * a2;
let c1p = a1p.hypot(b1);
let c2p = a2p.hypot(b2);
let avg_cp = (c1p + c2p) / 2.0;
let hp = |ap: f64, b: f64| -> f64 {
if ap == 0.0 && b == 0.0 {
return 0.0;
}
let ang = b.atan2(ap).to_degrees();
if ang < 0.0 { ang + 360.0 } else { ang }
};
let h1p = hp(a1p, b1);
let h2p = hp(a2p, b2);
let dlp = l2 - l1;
let dcp = c2p - c1p;
let dhp = if c1p * c2p == 0.0 {
0.0
} else if (h2p - h1p).abs() <= 180.0 {
h2p - h1p
} else if h2p - h1p > 180.0 {
h2p - h1p - 360.0
} else {
h2p - h1p + 360.0
};
let d_big_hp = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
let avg_hp = if c1p * c2p == 0.0 {
h1p + h2p
} else if (h1p - h2p).abs() <= 180.0 {
(h1p + h2p) / 2.0
} else if h1p + h2p < 360.0 {
(h1p + h2p + 360.0) / 2.0
} else {
(h1p + h2p - 360.0) / 2.0
};
let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
+ 0.24 * (2.0 * avg_hp).to_radians().cos()
+ 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
- 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
let d_ro = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
let rc = if avg_cp > 0.0 {
2.0 * (pow7(avg_cp) / (pow7(avg_cp) + k_l)).sqrt()
} else {
0.0
};
let sl = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
let sc = 1.0 + 0.045 * avg_cp;
let sh = 1.0 + 0.015 * avg_cp * t;
let rt = -(2.0 * d_ro.to_radians()).sin() * rc;
((dlp / sl).powi(2)
+ (dcp / sc).powi(2)
+ (d_big_hp / sh).powi(2)
+ rt * (dcp / sc) * (d_big_hp / sh))
.sqrt()
}
#[test]
fn delta_e_2000_matches_reference_vectors() {
// Sharma, Wu & Dalal (2005) CIEDE2000 test data — validates the
// metric so the distinctness gate below rests on a correct base.
let cases = [
((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425),
((50.0, 3.1571, -77.2803), (50.0, 0.0, -82.7485), 2.8615),
((50.0, 2.4900, -0.0010), (50.0, -2.4900, 0.0009), 7.1792),
((50.0, -1.0, 2.0), (50.0, 0.0, 0.0), 2.3669),
];
for (l1, l2, exp) in cases {
let got = delta_e_2000(l1, l2);
assert!(
(got - exp).abs() < 0.01,
"ΔE2000 {got:.4} != reference {exp:.4}",
);
}
}
#[test]
fn syntax_token_colours_are_perceptually_distinct() {
// Every pair of syntax-token classes that can appear together on
// one line must be perceptually separable, not merely unequal in
// hex. Floor at ΔE2000 >= 15 (post-ADR-0057 minimum is ~18).
// Excludes tok_punct (== muted) and tok_error (== error), which
// deliberately alias other roles.
const MIN_DE: f64 = 15.0;
for t in [Theme::dark(), Theme::light()] {
let label = theme_label(&t);
let toks = [
("tok_keyword", t.tok_keyword),
("tok_identifier", t.tok_identifier),
("tok_type", t.tok_type),
("tok_number", t.tok_number),
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
("tok_function", t.tok_function),
];
for (i, (name_a, ca)) in toks.iter().enumerate() {
for (name_b, cb) in toks.iter().skip(i + 1) {
let de = delta_e_2000(rgb_to_lab(*ca), rgb_to_lab(*cb));
assert!(
de >= MIN_DE,
"{label} theme: {name_a} vs {name_b} ΔE2000 {de:.1} below {MIN_DE} — too similar",
);
}
}
}
}
} }
+420
View File
@@ -0,0 +1,420 @@
//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
//!
//! These drive the **actual built binary** in a real pseudo-terminal and
//! assert on what a user would see on screen — catching what the lower
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
//! initial Tier-4 scope exactly:
//!
//! 1. cold launch → first DDL command → graceful quit
//! 2. project save → reopen → identical state
//! 3. project export → import into a fresh project → rebuilt state
//! 4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
//!
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
//! PTY at a fixed window size, `vt100` to parse the output stream into an
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
//! `wait_for` polling the vt100 screen replaces it.
//!
//! Determinism: each test gets its own temp `--data-dir` (no contact with
//! the user's real projects / resume state), a fixed 100×30 terminal (the
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
//! **serially** (one PTY + child process at a time) so timing stays
//! predictable on the low-parallelism self-hosted CI runner, with tight,
//! fail-fast timeouts: a slow wait means a real hang, not contention.
// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
// (to run PTY tests one at a time), and the screen-reading helpers hold the
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
// flags both as droppable-earlier, but tightening them is either impossible
// (the guard *is* the serialization) or pointless here.
#![allow(clippy::significant_drop_tightening)]
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tempfile::TempDir;
const COLS: u16 = 100;
const ROWS: u16 = 30;
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
/// so anything slower than this is a genuine hang worth failing on.
const WAIT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);
/// Tier-4 tests share one global lock so only one PTY + child runs at a
/// time (predictable timing; no resource races). Poison-tolerant so a
/// panicking test doesn't cascade-fail the rest.
static SERIAL: Mutex<()> = Mutex::new(());
fn lock_serial() -> MutexGuard<'static, ()> {
SERIAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// A running instance of the app under a pseudo-terminal.
struct PtyApp {
_serial: MutexGuard<'static, ()>,
/// Present when this instance owns its data dir (single-launch
/// flows); `None` when the test owns it (multi-launch flows that
/// reopen the same dir).
_owned_dir: Option<TempDir>,
_master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
parser: Arc<Mutex<vt100::Parser>>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_reader: thread::JoinHandle<()>,
/// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
/// for NFR-1; note this is the *debug* binary, so it's a generous
/// gross-regression signal, not the release startup figure.
startup: Duration,
}
impl PtyApp {
/// Launch against a fresh, owned temp data dir.
fn launch(args: &[&str]) -> Self {
let dir = TempDir::new().expect("create temp data dir");
let mut app = Self::launch_in(dir.path(), args);
app._owned_dir = Some(dir);
app
}
/// Launch against a caller-owned data dir (so a later launch can
/// reopen the same projects — flow 2).
fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
let serial = lock_serial();
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: ROWS,
cols: COLS,
pixel_width: 0,
pixel_height: 0,
})
.expect("open pty");
let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
cmd.arg("--data-dir");
cmd.arg(data_dir);
cmd.arg("--theme");
cmd.arg("dark");
for a in args {
cmd.arg(a);
}
cmd.env("TERM", "xterm-256color");
cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
cmd.cwd(data_dir);
let started = Instant::now();
let child = pair.slave.spawn_command(cmd).expect("spawn binary");
drop(pair.slave); // we never write to the slave directly
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
let writer = pair.master.take_writer().expect("take pty writer");
let parser_for_reader = Arc::clone(&parser);
let reader_handle = thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => parser_for_reader
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.process(&buf[..n]),
}
}
});
let mut app = Self {
_serial: serial,
_owned_dir: None,
_master: pair.master,
writer,
parser,
child,
_reader: reader_handle,
startup: Duration::ZERO,
};
// Every flow needs a booted, idle app; block on the first frame.
app.wait_for("SIMPLE");
app.startup = started.elapsed();
app
}
/// Current visible screen as text (one line per row, trailing blanks
/// trimmed) — what a human would read.
fn screen_text(&self) -> String {
self.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.screen()
.contents()
}
/// Text of just the left **Tables sidebar** region (first
/// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
/// a table name typed in a command pollutes a whole-screen match —
/// table presence/absence must be read from the sidebar, where the
/// items list actually lives.
fn sidebar(&self) -> String {
const SIDEBAR_W: u16 = 28;
let parser = self
.parser
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let screen = parser.screen();
let mut out = String::new();
for row in 0..ROWS {
for col in 0..SIDEBAR_W {
if let Some(cell) = screen.cell(row, col) {
out.push_str(cell.contents());
}
}
out.push('\n');
}
out
}
/// Poll a predicate to the tight timeout, panicking with a screen dump.
fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
let deadline = Instant::now() + WAIT;
loop {
if pred(self) {
return;
}
assert!(
Instant::now() < deadline,
"timed out after {WAIT:?} waiting for {desc}\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
thread::sleep(POLL);
}
}
/// Wait for `needle` anywhere on screen (Output-panel messages, modals).
fn wait_for(&self, needle: &str) {
self.wait_until(&format!("{needle:?} on screen"), |a| {
a.screen_text().contains(needle)
});
}
/// Wait for a table `name` to appear in the Tables sidebar.
fn wait_for_table(&self, name: &str) {
self.wait_until(&format!("table {name:?} in sidebar"), |a| {
a.sidebar().contains(name)
});
}
/// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
fn wait_for_no_tables(&self) {
self.wait_until("empty Tables sidebar", |a| {
a.sidebar().contains("(none yet)")
});
}
fn send(&mut self, bytes: &[u8]) {
self.writer.write_all(bytes).expect("write to pty");
self.writer.flush().expect("flush pty");
}
/// Type a command and submit it (Enter == carriage return in raw mode).
fn submit(&mut self, line: &str) {
self.send(line.as_bytes());
self.send(b"\r");
}
fn pid(&self) -> Option<u32> {
self.child.process_id()
}
/// Resident set size of the child in KiB (Linux only).
#[cfg(target_os = "linux")]
fn rss_kib(&self) -> Option<u64> {
let pid = self.pid()?;
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
status.lines().find_map(|l| {
let rest = l.strip_prefix("VmRSS:")?;
rest.split_whitespace().next()?.parse::<u64>().ok()
})
}
/// Send Ctrl-C and assert the process exits cleanly within the wait.
fn quit(mut self) {
self.send(b"\x03");
let deadline = Instant::now() + WAIT;
loop {
match self.child.try_wait() {
Ok(Some(status)) => {
assert!(status.success(), "app exited unsuccessfully: {status:?}");
return;
}
Ok(None) => {}
Err(e) => panic!("waiting on child failed: {e}"),
}
if Instant::now() >= deadline {
let _ = self.child.kill();
panic!(
"app did not exit within {WAIT:?} of Ctrl-C\n\
----- screen -----\n{}\n------------------",
self.screen_text(),
);
}
thread::sleep(POLL);
}
}
}
impl Drop for PtyApp {
fn drop(&mut self) {
// Never leak a child process, even if a test panicked mid-flow.
let _ = self.child.kill();
let _ = self.child.wait();
}
}
// ============================ the four flows ===========================
/// Flow 1 — cold launch → first DDL command → graceful quit.
#[test]
fn cold_launch_ddl_and_quit() {
let mut app = PtyApp::launch(&[]);
app.wait_for_no_tables(); // fresh project: empty sidebar
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers"); // table appears in the sidebar
app.quit();
}
/// Flow 2 — create, quit, reopen the same project (`--resume`), identical
/// state. Persistence is per-command autosave; the kept temp is recorded
/// as the resume target on quit (ADR-0015).
#[test]
fn save_quit_and_reopen_restores_state() {
let dir = TempDir::new().expect("shared data dir");
let mut app = PtyApp::launch_in(dir.path(), &[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("add column to Customers: Name (text)");
app.wait_for("Name (text) ✓");
app.quit();
// Reopen the most-recently-used project from the same data root, then
// assert the *column* survived — not just the table name — so "identical
// state" means the schema, not merely that some table exists. In this
// fresh process "Name" appears only from the show-data header.
let mut reopened = PtyApp::launch_in(dir.path(), &["--resume"]);
reopened.wait_for_table("Customers");
reopened.submit("show data Customers");
reopened.wait_for("Name"); // the added column round-tripped through restart
reopened.quit();
}
/// Flow 3 — export a project, import it into a fresh project, confirm the
/// rebuilt database carries the schema and data.
#[test]
fn export_then_import_into_fresh_project() {
let zip_dir = TempDir::new().expect("zip dir");
let zip_path = zip_dir.path().join("export.zip");
let zip = zip_path.to_str().expect("utf-8 zip path");
// Source project: a table with one data row. `Name` is a regular
// column (added separately) — putting it in the `with pk` clause would
// make a compound PK and block the serial auto-fill.
let mut source = PtyApp::launch(&[]);
source.submit("create table Customers with pk id(serial)");
source.wait_for_table("Customers");
// Pace each command to completion before the next — driving a TUI, like
// a real user (or the cast driver), means waiting for each result. A
// command sent while the previous one's worker rebuild is still in
// flight can be misread against a stale schema cache (issue #39).
source.submit("add column to Customers: Name (text)");
source.wait_for("Name (text) ✓");
source.submit("insert into Customers values ('Alice')");
// The insert's OWN success echo (value + ✓) — not a bare "✓", which the
// create already painted — so export runs only once the row is in CSV.
source.wait_for("('Alice') ✓");
source.submit(&format!("export {zip}"));
source.wait_for("[ok] export");
source.quit();
// Fresh project (new data dir): import the zip. Import switches to the
// imported project, rebuilding its db from text since the export omits
// the .db (ADR-0004/0015). "Alice" is unambiguous here — it appears in
// no command typed into this instance, only in a rebuilt data row.
let mut target = PtyApp::launch(&[]);
target.submit(&format!("import {zip}"));
target.wait_for("now editing"); // switched to the imported project
target.wait_for_table("Customers"); // schema rebuilt
target.submit("show data Customers");
target.wait_for("Alice"); // data rebuilt from the CSV
target.quit();
}
/// Flow 4 — `undo` immediately after `DROP TABLE`, including the
/// confirmation modal.
#[test]
fn undo_after_drop_table_restores_it() {
let mut app = PtyApp::launch(&[]);
app.submit("create table Customers with pk id(serial)");
app.wait_for_table("Customers");
app.submit("drop table Customers");
app.wait_for_no_tables(); // gone from the sidebar
app.submit("undo");
app.wait_for("Restore that earlier state?"); // the confirm modal
app.send(b"y"); // confirm
app.wait_for_table("Customers"); // table restored in the sidebar
app.quit();
}
// ===================== NFR perf (measured, generous) ===================
//
// These run against the DEBUG binary, so the bounds are loose
// gross-regression catches, not the NFR targets. The real NFR-1 (startup
// < 500 ms) and NFR-3 (idle RSS < 50 MB) figures are measured on a
// --release build and recorded in the NFR verification doc (ADR-0057).
/// NFR-1 — startup to first rendered frame. Generous debug-binary bound.
#[test]
fn startup_to_first_frame_is_reasonable() {
let app = PtyApp::launch(&[]);
let ms = app.startup.as_millis();
eprintln!("NFR-1 startup (debug binary, under test harness): {ms} ms");
assert!(
app.startup < Duration::from_millis(2000),
"startup {ms} ms exceeds the generous 2000 ms debug bound — likely a real regression",
);
app.quit();
}
/// NFR-3 — idle resident memory. Generous debug-binary bound (Linux only;
/// reads the child's /proc VmRSS).
#[cfg(target_os = "linux")]
#[test]
fn idle_memory_footprint_is_reasonable() {
let app = PtyApp::launch(&[]);
// Already idle after the readiness wait; let it settle a moment.
thread::sleep(Duration::from_millis(200));
let rss = app.rss_kib().expect("read VmRSS");
eprintln!(
"NFR-3 idle RSS (debug binary): {} KiB ({:.1} MB)",
rss,
rss as f64 / 1024.0,
);
assert!(
rss < 150_000,
"idle RSS {rss} KiB exceeds the generous 150 MB debug bound — likely a real regression",
);
app.quit();
}