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).
8.5 KiB
ADR-0008: Testing approach
Status
Accepted
Context
The project's working standards (CLAUDE.md, plus the user's
global testing rules) require:
- Test coverage established before changes.
- Bugs reproduced with failing tests before fixes.
- Integration tests that exercise the full stack a user touches.
- "All green, zero skips" as the only acceptable end state.
A TUI application needs a credible testing story for those rules to be enforceable. Naïvely, "TUI testing" sounds like an afterthought of manual screenshots. In practice, the Rust + Ratatui ecosystem supports automation across every level that matters, and we want to commit to that explicitly rather than discover it ad-hoc.
Decision
Testing is structured in four tiers. Tiers 1–3 run on every commit in CI; tier 4 runs on every commit for a focused subset of critical flows and on a nightly schedule for broader coverage.
Tier 1 — Pure-logic unit tests
Standard cargo test against modules with no terminal
dependency:
- DSL parser and command dispatcher
- Type mapping (user-facing → SQLite STRICT types)
- Project I/O (
project.yaml+data/<table>.csvround-tripping) - Database rebuild from authoritative text sources
- Snapshot ring buffer logic (ADR-0006)
- Replay log writer/reader
These are the bulk of the test count. Every behavioural unit has
unit tests; modules with non-trivial logic also have property
tests via proptest where the input space justifies it (parser
inputs, type coercion, CSV escaping, etc.).
Tier 2 — Render assertions via Ratatui TestBackend
Ratatui's in-tree TestBackend renders into a Buffer (a 2D
cell grid). Tests build an app state, render a frame, and assert
on the resulting buffer.
- Cell-level assertions for narrow tests
("the status bar shows mode label
Simplein the expected style"). - Snapshot tests via
instafor whole-frame coverage of representative views (default screen, query result table, schema view, undo confirmation prompt). Snapshots are checked in and reviewed on diff.
Snapshot discipline:
- Snapshots cover stable, intentional UI surfaces; they are not added reflexively to every component.
- A snapshot diff is treated as a real review item, not a rubber-stamp. Reviewers must confirm the change is intended.
Tier 3 — Synthetic event-loop integration tests
The application's update function consumes
crossterm::event::Event values. Tests feed sequences of
synthetic events (KeyEvent, MouseEvent, resize) to the update
function, then render via TestBackend and assert on both
state and buffer.
This tier is the equivalent of react-testing-library for our
TUI: it exercises the full input → state → render path without a
real terminal, and is where the most valuable behavioural tests
live. Examples:
- Typing a DSL command in simple mode, submitting with Ctrl-Enter, asserting the table list updates and the schema view re-renders.
- Triggering
undo, asserting the confirmation prompt appears with the expected timestamp and change summary, confirming, asserting state restoration. - Switching modes and verifying the prompt label and border colour change.
Tier 4 — PTY-based black-box end-to-end
A small number of critical flows are exercised against the actual built binary in a pseudo-terminal:
- Tooling:
portable-ptyfor the PTY,expectrlfor expect-style scripting,vt100to parse the terminal output stream into an inspectable cell grid. - These tests catch issues the lower tiers miss: TTY setup, signal handling, terminal mode transitions, real I/O timing.
Tier 4 is reserved for the highest-value flows, not blanket coverage. The initial scope is:
- Cold launch → first DDL command → graceful quit.
- Project save → process restart → reopen → identical state.
- Project export → import in a fresh project → rebuilt database matches the source.
undoimmediately after aDROP TABLE, including the confirmation prompt.
Tier 4 tests run in CI on every commit (the focused list above) and on a nightly schedule for any extended coverage.
Tooling commitments
cargo test— Tier 1, Tier 2, Tier 3.proptest— property-based testing for parser and conversion layers.- Ratatui's
TestBackend— frame rendering for tests. insta— snapshot testing of rendered buffers.portable-pty,expectrl,vt100— Tier 4 PTY-based tests.- CI matrix covers Linux, macOS, and Windows on stable Rust.
Honest limits
- No cross-terminal-emulator regression coverage. Tier 4 exercises a PTY but not real terminal emulators (xterm, Alacritty, Windows Terminal, etc.). Crossterm abstracts these well in practice; if a real-emulator regression ever surfaces, we will revisit.
- No visual aesthetic checks. Tests assert cell contents and styles, not "this layout is pretty". Visual polish is reviewed manually by humans.
- Snapshot brittleness is a known failure mode. We mitigate by being selective about what gets a snapshot and by treating snapshot diffs as real review items.
Consequences
- Test discipline from
CLAUDE.mdis enforceable on every layer: parser bugs caught at Tier 1, UI flow bugs caught at Tier 3, real-binary regressions caught at Tier 4. - Module boundaries are designed for testability from the start (pure logic modules separate from rendering, an explicit update function consuming events).
- CI cost is real but bounded: Tiers 1–3 are fast; Tier 4 is the only slow tier and is kept narrow.
- Adding a feature implies adding tests at the appropriate tier (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.