diff --git a/.claude/skills/termlens/SKILL.md b/.claude/skills/termlens/SKILL.md index 516115c..49394fa 100644 --- a/.claude/skills/termlens/SKILL.md +++ b/.claude/skills/termlens/SKILL.md @@ -5,7 +5,7 @@ description: Write, fix or review headless terminal tests for a Rust CLI or TUI # Testing terminal programs with termlens -Written against **termlens 0.9.0**. Every `rust` block below is a complete +Written against **termlens 0.10.1**. Every `rust` block below is a complete integration test that is compiled against the crate in CI, so the API it shows is the API that exists. The recipes spawn a binary called `myapp` that draws a list with a `> ` highlight, a status line ending in @@ -17,7 +17,10 @@ application's own texts where the comments say so. termlens spawns your **real binary** in a **real pseudo-terminal**, drains its output on a reader thread through a VT emulator into an in-memory **screen grid**, and lets a test wait on and assert against that grid — -Playwright for the terminal. Unix only (Linux, macOS). +Playwright for the terminal. Linux and macOS in full; on Windows (ConPTY) +screen assertions work and frame assertions do not — `wait_frame`, +`record`, graphics and mouse modes are Unix-only there, and a test that +needs one is `#[cfg_attr(windows, ignore = "…")]` with the reason. Use it for the things an in-process mock cannot see: @@ -109,9 +112,11 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─ 8. **`wait_frame` only works for applications that emit DEC 2026 synchronized updates.** Stock ratatui 0.30 with crossterm does **not** (measured: `repaints()` stays 0), so `wait_frame` times out against it - with a message saying exactly that. Default to `snapshot_after`. Use - `wait_frame` only if the application brackets its repaints in - `BeginSynchronizedUpdate` / `EndSynchronizedUpdate`. + with a message saying exactly that, and `Terminal::record()` refuses + for the same reason. Default to `snapshot_after`. Use `wait_frame` only + if the application brackets its repaints in `BeginSynchronizedUpdate` / + `EndSynchronizedUpdate` — then a `wait_frame` timeout also shows the + diff from the last frame it returned to the live screen. 9. **Return `termlens::Result<()>` from the test and use `?`.** The `Display` of every error carries the screen, so a failing wait prints @@ -120,9 +125,11 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─ 10. **Snapshot the `Screen`, not its text.** `insta::assert_snapshot!(screen)` records the header (`size: 80x24 cursor: 3,5` or `cursor: hidden`) and the grid; `screen.with_styles()` adds a `styles:` block that catches a - colour regression. `.text()` drops the header and `format!("{:?}")` is - the same as `Display`. Review changes with `cargo insta review`; never - blind-accept with `INSTA_UPDATE=always`. + colour regression. The one-liner that gets all three decisions right — + wait, settle, styles — is `termlens::assert_screen_snapshot!(t, after = + |s| s.contains("Ready"))`. `.text()` drops the header and + `format!("{:?}")` is the same as `Display`. Review changes with `cargo + insta review`; never blind-accept with `INSTA_UPDATE=always`. 11. **The environment is hermetic by default — set what the app reads.** Under `env_clear()` (which `bin!` applies) the child sees only @@ -136,26 +143,36 @@ your test ── send(Key) · click · paste · resize ──▶ PTY └─ (CJK, most emoji) occupies two cells: the leading one `is_wide()`, the next `is_wide_continuation()`. `find` reports real terminal columns. `contains` and `find` fold both sides to NFC and search the **visible - screen only** — text that scrolled off is in `full_text()`, and a line - that wrapped is two rows, so a needle spanning the wrap is not found. + screen only** — text that scrolled off is in `full_text()` (and + `locate` says which region holds a needle), and a line that wrapped is + two rows, so a needle spanning the wrap is found by `logical_text()`, + not by `contains`. `find_all` lists every match; a volatile cell is + masked in the grid with `mask_rect` / `mask_matching`, never edited in + the text. ## 4. Setup ```toml [dev-dependencies] -termlens = "0.9" +termlens = "0.10" insta = "1" # for the snapshot recipes; termlens also re-exports it as `termlens::insta` ``` +Features, all off by default except `insta`: `regex` (a pattern over a row +of the screen: `wait_until_matches`, `find_match`, `mask_matches`), +`serde` (a `Screen` as JSON and back, for `assert_json_snapshot!` or a CI +step), `decode` (the pixels of inline images). + - Put the tests in `tests/` **of the package that owns the `[[bin]]`**: Cargo sets `CARGO_BIN_EXE_` only there, and `bin!` needs it at compile time. For a binary in a sibling crate, build it and pass the path to `Terminal::builder().spawn(path)` instead. - The binary is built by `cargo test` before the tests run. Tests run in parallel by default; each spawns its own PTY, which is fine. -- Gate the test file with `#![cfg(unix)]` if the crate must also build on - Windows. -- `add --features decode` only if you assert on the pixels of inline images. +- The crate builds and runs on Windows over ConPTY. Do not gate whole files + with `#![cfg(unix)]`; mark the individual tests the platform cannot + honour — frames, graphics, mouse modes, signals — with + `#[cfg_attr(windows, ignore = "…")]` naming the reason. ## 5. Recipes @@ -318,6 +335,54 @@ fn cells_styles_and_wide_characters() -> termlens::Result<()> { } ``` +### Recipe E — the snapshot macro, a cell diff, and a masked clock + +```rust +use termlens::{Key, Screen}; + +#[test] +fn snapshot_diff_and_mask() -> termlens::Result<()> { + let mut t = termlens::bin!("myapp")?; + + // Wait for the marker, let the picture settle, snapshot WITH styles: + // the three decisions every TUI snapshot needs, in one line. `styles = + // false` for text only; `(&screen)` snapshots a Screen you already hold. + termlens::assert_screen_snapshot!(t, after = |s| s.contains("Ready")); + + // Two screens, and what changed between them — rows, columns, style + // runs — rendered in the assertion message rather than two whole grids. + let before = t.screen(); + t.send(Key::Char('j'))?; + let after = t.snapshot_after(|s| s.contains("> Beta"))?; + let diff = before.diff(&after); + assert!(!diff.is_empty(), "j should have moved the highlight:\n{diff}"); + assert!(diff.cells().any(|(row, _, _, _)| row == 1), "{diff}"); + + // A clock in the top-right corner breaks whole-screen snapshots. Mask + // it in the GRID — the mask keeps every column and style where it was, + // which a text filter over the rendering cannot. (cols, rows), like size(). + let masked: Screen = after.mask_rect(70..80, 0..1); + // `mask_matching` takes a LITERAL, not a set of characters: this hides + // the exact text "Counter: 1". To mask by shape, use `mask_cells` (or + // `mask_matches` with the `regex` feature) — a predicate per cell. + let _by_text = after.mask_matching("Counter: 1", '#'); + let _digits_hidden = after.mask_cells(|c| { + !c.contents().is_empty() && c.contents().chars().all(|ch| ch.is_ascii_digit()) + }); + termlens::assert_screen_snapshot!(&masked); + + // Every occurrence, not just the first; and a needle that spans a soft + // wrap, which contains() cannot see across rows. + let separators = after.find_all("│"); + assert!(separators.len() >= 2, "{after}"); + assert!(after.logical_text().contains("Ready: j/k move, q quits")); + + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} +``` + ## 6. Reading a failure Every error's `Display` ends with the screen, under a header that says @@ -330,7 +395,8 @@ Read the first line for the cause: | `… note: N rows have scrolled off the top` | the text went into history | assert with `full_text()` / `scrollback_text()` | | `… note: the application queried the terminal (^[[?u …) and received no answer` | the app is blocked on a probe termlens deliberately does not answer | the app needs a fallback; see the termlens README's Known limitations | | `terminal closed (EOF) while waiting for …` | the app exited before the predicate held | check `wait_exit()` first, or the app crashed — the final screen shows why | -| `the application never emitted a DEC 2026 synchronized update` | `wait_frame` against an app without synchronized output | use `snapshot_after` / `wait_until` (rule 8) | +| `the application never emitted a DEC 2026 synchronized update` | `wait_frame` or `record().stop()` against an app without synchronized output | use `snapshot_after` / `wait_until` (rule 8) | +| `--- last returned frame → live screen ---` under a `wait_frame` timeout | the app repainted, but never into the predicate | the diff shows what did change; the predicate is looking at the wrong thing | | `input not receivable: the application has not enabled mouse tracking` | `click`/`drag`/`scroll` before the app enabled the mouse | `wait_until(|s| s.mouse_mode() != MouseMode::None)` first | | `input not receivable: mouse at (50, 2) is outside the 20x5 grid` | coordinates swapped or out of range | rule 6 | | `failed to spawn \`sh\`: \`sh\` is a bare program name and env_clear() removed PATH` | bare program name under `env_clear` | absolute path, `bin!`, or `.env("PATH", …)` | @@ -350,6 +416,8 @@ Read the first line for the cause: | `.env(k, v)` / `.envs([..])` / `.env_clear()` | environment; `env_clear` keeps `TERM` and `SHELL` pinned and drops the rest | | `.current_dir(path)` | default: the test process's directory | | `.scrollback(rows)` | history retained (default 1000, text only) | +| `.scrollback_styles(true)` | retain scrolled rows as cells too, so `scrollback_cell` keeps a masked-field assertion alive after it scrolls (measured cost in the rustdoc) | +| `.record_budget(cells)` | how much `record()` retains before dropping the oldest frames | | `.spawn(program) -> Result` | program is a path or a name on `PATH` | **Wait** (all return `termlens::Result`, all embed the screen on failure, all have a `_for(…, timeout)` twin): @@ -362,6 +430,8 @@ Read the first line for the cause: | `wait_idle(quiet)` | `()` | no *bytes* for `quiet` — a weaker, older sibling of `wait_stable` | | `wait_frame(\|s\| bool)` | `Screen` | complete DEC 2026 frames only (rule 8) | | `wait_exit()` | `ExitStatus` | the child's exit; `success()`, `code() -> Option`, `signal() -> Option<&str>` | +| `wait_until_matches(&Regex)` | `Screen` | feature `regex`: a pattern over a row of the screen — the expect-style wait, on the grid | +| `record()` … `.stop()` | `Recording` | every complete DEC 2026 frame with its time; `frames()`, `write_asciicast(path)` for a file `asciinema` plays | **Drive**: `send(Key)`, `send_str("text")` (no Enter — send `Key::Enter` yourself; `"\n"` would send LF, not CR), `paste("text")` (bracketed if the @@ -379,17 +449,24 @@ from_r, to_c, to_r)`, `scroll(col, row, Scroll::Down)`, `resize(cols, rows)`, | Accessor | Returns | |---|---| -| `contains(&str)` / `find(&str)` | `bool` / `Option<(row, col)>` — visible grid, NFC-folded | +| `contains(&str)` / `find(&str)` / `find_all(&str)` | `bool` / `Option<(row, col)>` / `Vec<(row, col)>` — visible grid, NFC-folded | +| `locate(&str)` | `Option`: `Screen { row, col }` or `History { row, col }` | +| `logical_text()` / `row_wrapped(row)` | wrapped rows joined back into lines / where the backend wrapped | | `find_by(\|&Cell\| bool)` | `Option<(row, col)>` | | `cell(row, col)` | `Option<&Cell>`: `contents()`, `style()`, `is_wide()`, `is_wide_continuation()` | | `row_text(row)` / `text()` / `rect_text(cols, rows)` | `String` | | `full_text()` / `scrollback_text()` / `scrollback_rows()` | history + screen / history / count | +| `scrollback_cell(row, col)` / `styled_scrollback()` | history as cells, with `scrollback_styles(true)` | | `size()` / `cols()` / `rows()` | `(cols, rows)` | | `cursor()` | `(row, col, visible)`; `cursor_shape()`, `cursor_blink()` | | `alternate_screen()`, `bracketed_paste()`, `application_cursor()`, `focus_events()` | mode flags | | `mouse_mode()` / `mouse_modes()` | reporting protocol / the set the app enabled | | `title()`, `clipboard()`, `links()`, `bells()`, `repaints()`, `graphics()` | out-of-band state | +| `unsupported()` / `insert_mode()` | sequences the emulator did not implement (`^[[20h`…), so a plausible grid can be told from a right one / IRM left on | | `with_styles()` | `Display` with a `styles:` block; snapshot this to catch colour regressions | +| `diff(&other)` | `ScreenDiff`: `is_empty()`, `cells()`, and a `Display` of only the rows that changed | +| `mask_rect(cols, rows)` / `mask_matching(literal, fill)` / `mask_cells(pred)` | a new `Screen` with those cells replaced, styles and columns intact. `mask_matching` is a literal (rows included — it spans a wrap the way `find_all` does); `mask_cells` blanks by predicate | +| `to_ansi()` / `to_svg()` / `to_html()` | renderings a person can see; `Screen::parse(text)` reads the text format back | **Style** (`Copy`, public fields): `fg`, `bg` (`Color::Default` / `Color::Indexed(u8)` / `Color::Rgb(u8, u8, u8)`), `bold`, `dim`, `italic`, @@ -399,8 +476,10 @@ double underline are not modelled. **Errors** (`termlens::Error`, `#[non_exhaustive]`): `Timeout { waiting_for, timeout, screen }`, `Eof { waiting_for, screen }`, `Spawn { command, reason }`, `Size(String)`, `Input(String)`, `Write { what, screen }`, `Emulator { -detail, screen }`, `Pty(String)`, `Io(std::io::Error)`. `err.screen()` returns -the embedded screen when there is one. +detail, screen }`, `Parse(String)`, `Pty(String)`, `Io(std::io::Error)`. +`err.screen()` returns the embedded screen when there is one. With +`TERMLENS_ARTIFACT_DIR` set, every such screen is also written to that +directory (see §9b). ## 8. Pitfalls an agent falls into, and the fix @@ -431,9 +510,13 @@ the embedded screen when there is one. - `insta::assert_snapshot!("name", screen)` — several snapshots in one test. - Inline snapshots work: `insta::assert_snapshot!(screen, @"")`, then `cargo insta review` fills the literal. -- `termlens::assert_screen_snapshot!(screen)` is the same call through the - `insta` termlens re-exports, for crates that do not want their own `insta` - dev-dependency. +- `termlens::assert_screen_snapshot!(t)` settles the terminal (100 ms of + stillness) and snapshots **with styles**; `(t, after = |s| …)` waits for + the predicate first; `(t, styles = false)` for text only; `(&screen)` + for a `Screen` already in hand; `(t, @"…")` inline. Through the `insta` + termlens re-exports, so no separate `insta` dev-dependency is needed. +- With the `serde` feature, `insta::assert_json_snapshot!(screen)` records + the structured form and takes insta's redactions per field. - Volatile content (a clock, a PID, a spinner) breaks whole-screen snapshots. insta's text filters are not grid-aware — a shorter replacement shifts every column after it — so prefer asserting the stable region with @@ -442,6 +525,19 @@ the embedded screen when there is one. - Snapshot files live in `tests/snapshots/`; commit them. Review every change with `cargo insta review`; a diff you cannot explain is a bug. +## 9b. At a shell prompt, and in CI + +- `cargo install termlens-cli` gives the harness as a command: `termlens + inspect --size 120x40 myapp` prints what a program shows (`--ansi` for + colour), `termlens diff old.snap new.snap.new` prints the cell diff of two + saved screens and exits 1 if anything changed, `termlens render --svg + failing.snap` makes an image. A saved screen is any text termlens prints + — an insta `.snap`, the grid a wait error leaves in a log. +- In CI, set `TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens` on the + test step and add `uses: vyncint/termlens/.github/actions/report@v0.10.0` + with `if: failure()` after it: every screen a failing wait embedded, and + every `.snap.new` with its diff, lands in the pull request's step summary. + ## 10. A checklist before you finish - [ ] No `sleep` anywhere; every wait names what it waits for. diff --git a/.github/scripts/check-skill-version.sh b/.github/scripts/check-skill-version.sh new file mode 100755 index 0000000..d81c5bf --- /dev/null +++ b/.github/scripts/check-skill-version.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# The vendored termlens skill must name the version this repository depends on. +# +# `.claude/skills/termlens/SKILL.md` is a copy of the file termlens ships for +# coding agents, and AGENTS.md makes it normative: "PTY tests follow the +# termlens skill". It is refreshed by hand, and the failure mode is silent — +# the dependency gets bumped, the copy does not, and every agent writing a +# PTY test here is then handed guidance for a version that is no longer the +# one in the tree. The 0.9 copy documented no `unsupported()`, no `diff`, no +# `find_all`, which is exactly the surface the 0.10 tests are built on. +# +# Nothing can diff the copy against upstream: the published crate does not +# ship the skill, so there is no registry copy to compare with. What *is* +# checkable is that the two versions agree, which is the drift that happens. +# +# Compares major.minor only. A termlens patch release does not rewrite the +# skill, and demanding a re-copy for every one of them would make this noise. +# +# Usage: check-skill-version.sh [SKILL.md] [Cargo.toml] +# +# Portable to the macOS leg of `ci.yml`: bash 3.2, BSD sed, no GNU-only flags. +set -euo pipefail + +skill="${1:-.claude/skills/termlens/SKILL.md}" +manifest="${2:-crates/launchbound-tui/Cargo.toml}" + +[ -f "$skill" ] || { echo "::error::$skill does not exist"; exit 1; } +[ -f "$manifest" ] || { echo "::error::$manifest does not exist"; exit 1; } + +# "Written against **termlens 0.10.1**." -> 0.10.1 +skill_version="$(sed -n 's/.*Written against \*\*termlens \([0-9][0-9.]*\)\*\*.*/\1/p' "$skill" | head -1)" +[ -n "$skill_version" ] || { + echo "::error::$skill has no 'Written against **termlens X.Y.Z**' line to check" + exit 1 +} +skill_minor="$(echo "$skill_version" | cut -d. -f1,2)" + +# termlens = { version = "0.10", default-features = false, ... } -> 0.10 +dep_version="$(sed -n 's/^termlens = .*version = "\([0-9][0-9.]*\)".*/\1/p' "$manifest" | head -1)" +[ -n "$dep_version" ] || { + echo "::error::no termlens dependency with a version found in $manifest" + exit 1 +} +dep_minor="$(echo "$dep_version" | cut -d. -f1,2)" + +if [ "$skill_minor" != "$dep_minor" ]; then + echo "::error::the vendored termlens skill is written against ${skill_version} but this repository depends on ${dep_version}." + echo "::error::Refresh it: cp ../termlens/skills/termlens/SKILL.md ${skill}" + exit 1 +fi + +echo "the vendored termlens skill (${skill_version}) matches the dependency (${dep_version})" + +# The same drift, one layer out. The termlens `report` action takes the +# termlens-cli version as a literal in the workflow files, and a literal beside +# a dependency is a pin that goes stale silently: the suite would then be +# rendered by a tool from a different release than the library that produced +# the screens. Nothing else compares the two, so this does. +cli_pins="$(grep -rhoE 'cli-version: *"[0-9][0-9.]*"' .github/workflows/ 2>/dev/null | grep -oE '[0-9][0-9.]+' | sort -u)" +if [ -n "$cli_pins" ]; then + for pin in $cli_pins; do + pin_minor="$(echo "$pin" | cut -d. -f1,2)" + if [ "$pin_minor" != "$dep_minor" ]; then + echo "::error::a workflow pins termlens-cli ${pin} but this repository depends on termlens ${dep_version}." + echo "::error::Bump every 'cli-version:' under .github/workflows/ to match." + exit 1 + fi + done + echo "the termlens-cli pins in .github/workflows ($(echo "$cli_pins" | tr '\n' ' ')) match the dependency (${dep_version})" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ad4844..ba89d43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,23 @@ jobs: with: tool: just,cargo-deny - run: just ci + env: + # Every screen a failing termlens wait embeds is also written here, + # so the report step below can put the picture in the job summary + # instead of leaving it in a log someone has to scroll (termlens + # 0.10). The dependency carries the `serde` feature, so these are + # `.screen.json` rather than `.screen.txt`. + TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + # A PTY failure is a picture, and a log is the worst place to read one. + # This renders every screen the suite left behind into the job summary + # and uploads the SVG/HTML. It installs termlens-cli itself, pinned to + # the version the lockfile names so the renderer and the library that + # wrote the file are one release — the same rule tests/cli.rs enforces. + - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + if: failure() + with: + name: termlens-report-ci-${{ matrix.os }} + cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh # MSRV applies to the crates that do not need the pinned nightly # (CONTRIBUTING.md); checked against the committed lockfile. diff --git a/.github/workflows/stress.yml b/.github/workflows/stress.yml index ffdc559..3ff7a4b 100644 --- a/.github/workflows/stress.yml +++ b/.github/workflows/stress.yml @@ -19,7 +19,8 @@ # # `stress_100_runs_at_80x24` is skipped in the hunt. It is the same idea done # in-process, and running it inside an outer loop would multiply a hundred -# spawns by twenty for no new information. +# spawns by twenty for no new information. `tests/cli.rs` is skipped by both +# jobs without asking: it is `#[ignore]`d, and the gate runs it by name. name: stress on: @@ -56,7 +57,26 @@ jobs: - uses: actions/checkout@v4 - name: Install pinned toolchain run: rustup show - - run: cargo test -p launchbound-tui --test tui + # The PTY suites: the goldens, and the emulator invariants every one of + # them rests on. TERMLENS_ARTIFACT_DIR is where a failing wait leaves + # the screen it was looking at, for the report step at the end. + - run: cargo test -p launchbound-tui --test tui --test emulation + env: + TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + # The termlens-cli suite is `#[ignore]`d so a plain `cargo test` never + # `cargo install`s a binary behind a contributor's back — launchbound-tui + # is published, so `cargo test` is something other people run. CI is + # where it should run, so CI asks for it by name. The install is the + # version the lockfile names, so the tool and the library are one + # release. + - run: cargo test -p launchbound-tui --test cli -- --ignored + env: + TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens + - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + if: failure() + with: + name: termlens-report-stress-${{ matrix.os }} + cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh hunt: name: hunt (${{ matrix.os }}, ${{ matrix.threads }} threads) @@ -97,6 +117,13 @@ jobs: ITERS: ${{ inputs.iterations || '100' }} THREADS: ${{ matrix.threads }} WEIGHT: ${{ matrix.weight }} + # The whole point of the hunt is the once-in-fifty failure, and + # until now a flaked iteration left only whatever fitted in the + # log. Every screen an embedded wait error carries is written here + # instead, and the report step uploads them — one directory across + # all the iterations of a shard, so a flake at iteration 37 is + # still there at the end. + TERMLENS_ARTIFACT_DIR: ${{ runner.temp }}/termlens run: | per=$(( WEIGHT * ITERS / 100 )) # A small `iterations` must still exercise every concurrency, or a @@ -105,8 +132,13 @@ jobs: echo "${THREADS} thread(s): ${per} iterations of ${ITERS}" for i in $(seq "$per"); do echo "::group::${THREADS} threads, iteration ${i}/${per}" - cargo test -p launchbound-tui --release --test tui \ + cargo test -p launchbound-tui --release --test tui --test emulation \ -- --test-threads="$THREADS" --skip stress_100_runs \ || { echo "::error::suite flaked at ${THREADS} thread(s), iteration ${i}/${per}"; exit 1; } echo "::endgroup::" done + - uses: vyncint/termlens/.github/actions/report@e1b96c8203fd727fa3458af395719c429966ee82 # v0.10.1 + if: failure() + with: + name: termlens-report-hunt-${{ matrix.os }}-${{ matrix.threads }} + cli-version: "0.10.1" # checked against the manifest by check-skill-version.sh diff --git a/AGENTS.md b/AGENTS.md index 5efd03b..5e151a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,16 @@ MSRV 1.88 for everything that does not need it. satisfy both waits of a resize; `(cols, rows)` for a size and `(row, col)` for a cell. A readiness predicate has to hold at the width under test — `ready` looks for the footer's `q quit`, which is cut at - sixty columns. + sixty columns. The copy is normative, so it must name the version we + actually depend on: `just skill` checks that, and `just ci` runs it. + Refresh it with `cp ../termlens/skills/termlens/SKILL.md + .claude/skills/termlens/SKILL.md`. +- **The goldens rest on `tests/emulation.rs`.** Every frame assertion in + launchbound-tui reads a grid a VT emulator produced, and a sequence the + emulator drops makes that grid quietly wrong — a golden blessed from it + would record the fiction. `Screen::unsupported()` is pinned to exactly + `["^[[59m"]`. If that test fails, distrust the goldens until you know why + before touching anything else. - **The pins move together or not at all**, and `just pins` checks that the recorded sites agree before anything asks upstream. 2.0.0 moved four of six, which left `pins.yml` measuring drift from a version nothing diff --git a/CHANGELOG.md b/CHANGELOG.md index 96927e2..8650309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ change measured timings are marked `bench:`. ## [Unreleased] +### Changed + +- **The PTY test harness moves to termlens 0.10.1** (from 0.9). The upgrade + itself is small — `drag` takes four column-first arguments now instead of + two coordinate pairs — but it brings the accessor this suite was missing. + +- **`tests/emulation.rs` pins what the emulator drops.** Every screen + assertion in this crate reads a grid a VT emulator produced from the + binary's bytes: five golden files, two border scans and a styled banner. A + sequence that emulator does not implement makes the grid quietly wrong and + every one of those assertions a claim about a plausible-looking fiction. + 0.10 made it checkable, and the answer is one `SGR 59` — underline colour, + which changes no cell. Pinned exactly, so anything joining it has to be + read before the suite is trusted again. + +- **`tests/cli.rs` drives `termlens-cli`** against this crate's own saved + screens: rendered, diffed with its 0/1/2 exit codes, and `inspect` pointed + at the real binary. The tool is resolved at the version the lockfile names, + so it and the library are one release. + +- **The vendored agent skill (`.claude/skills/termlens/SKILL.md`) is + refreshed to 0.10.1**, and `check-skill-version.sh` now fails when that copy + and the dependency disagree on major.minor. It had drifted two releases + behind with nothing to notice. + +- **CI writes `TERMLENS_ARTIFACT_DIR` and renders failures into the job + summary** via termlens's `report` action, so a red PTY test arrives as a + picture rather than a grid in a log. + ## [2.1.0] - 2026-09-05 Thirteen findings, all reported against 2.0.0 with a measured reproduction. diff --git a/Cargo.lock b/Cargo.lock index b512204..5e227fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,6 +1243,7 @@ dependencies = [ "crossterm", "launchbound-report", "ratatui", + "serde_json", "termlens", ] @@ -2472,14 +2473,17 @@ dependencies = [ [[package]] name = "termlens" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fca989672430e13284b48504c44b499d88f5b39cb36b0e8dc5b45f06df50b09" +checksum = "c01f5cc1f410aa421987ca194b72620037422bddcfeb15b1f8da40e52b03b348" dependencies = [ "libc", "portable-pty", + "serde", + "serde_json", "thiserror 2.0.20", "unicode-normalization", + "unicode-width", "vt100", ] diff --git a/crates/launchbound-tui/Cargo.toml b/crates/launchbound-tui/Cargo.toml index 62abfe6..373b35e 100644 --- a/crates/launchbound-tui/Cargo.toml +++ b/crates/launchbound-tui/Cargo.toml @@ -22,4 +22,9 @@ crossterm = "0.29" ratatui = "0.30" [dev-dependencies] -termlens = { version = "0.9", default-features = false } +# `serde` for the Screen <-> JSON round trip in tests/emulation.rs, and so a +# failing wait under `TERMLENS_ARTIFACT_DIR` leaves `.screen.json` rather than +# `.screen.txt` — which is what the report action in CI renders. `insta` (the +# crate's default) stays off: goldens here go through LAUNCHBOUND_BLESS. +termlens = { version = "0.10", default-features = false, features = ["serde"] } +serde_json = { workspace = true } diff --git a/crates/launchbound-tui/tests/cli.rs b/crates/launchbound-tui/tests/cli.rs new file mode 100644 index 0000000..39d03f9 --- /dev/null +++ b/crates/launchbound-tui/tests/cli.rs @@ -0,0 +1,279 @@ +//! `termlens-cli` — the command that ships beside the harness this crate's +//! PTY tests already use. +//! +//! The rest of the suite asks whether the TUI draws the right thing. This +//! asks what a maintainer does when it draws the wrong one: point the tool +//! at the binary without writing a test, read a saved screen back, and diff +//! two of them. launchbound is a good case for that — five saved screens +//! live in `tests/golden/`, one of them carries an attribute (`REVERSED`) no +//! plain-text comparison can see, and the pair from +//! `resize_relayouts_the_frame` is two different geometries of the same +//! session. +//! +//! Ignored by default, and that is not a preference. launchbound-tui is +//! published on crates.io, so `cargo test` is something contributors and +//! downstream consumers run; a `cargo test` that quietly `cargo install`s a +//! binary is a surprise a published crate must not spring on anyone. CI asks +//! for them by name (stress.yml), which is also why they cost the `hunt` +//! shards nothing — `--ignored` tests are skipped by the plain runs. +//! +//! ```sh +//! just termlens-cli # cargo test -p launchbound-tui --test cli -- --ignored +//! ``` +//! +//! `TERMLENS_CLI` points at an existing binary and skips the install. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::OnceLock; + +/// The termlens version this crate is tested against, read from the +/// lockfile so the tool and the library can never be two different releases +/// — a `render` from a newer CLI than the `Screen` that wrote the file is +/// exactly the confusion this suite exists to rule out. +fn version_under_test() -> &'static str { + static VERSION: OnceLock = OnceLock::new(); + VERSION.get_or_init(|| { + // The workspace lockfile when run from a checkout; the one cargo + // puts beside the manifest when run from a published .crate. + let lock = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .map(|dir| dir.join("Cargo.lock")) + .find(|path| path.exists()) + .expect("Cargo.lock is committed at the workspace root"); + let text = + std::fs::read_to_string(&lock).unwrap_or_else(|e| panic!("{}: {e}", lock.display())); + let mut lines = text.lines(); + while let Some(line) = lines.next() { + if line.trim() == "name = \"termlens\"" { + for next in lines.by_ref() { + if let Some(rest) = next.trim().strip_prefix("version = \"") { + return rest.trim_end_matches('"').to_owned(); + } + } + } + } + panic!("no termlens version in {}", lock.display()); + }) +} + +/// The `termlens` binary: `$TERMLENS_CLI` if the environment provides one, +/// otherwise installed once under the target directory at the version under +/// test. +fn cli() -> &'static PathBuf { + static BIN: OnceLock = OnceLock::new(); + BIN.get_or_init(|| { + if let Some(given) = std::env::var_os("TERMLENS_CLI") { + return PathBuf::from(given); + } + // CARGO_TARGET_TMPDIR is inside the workspace target directory, so + // the install is cached between runs and `cargo clean` removes it. + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("termlens-cli"); + let bin = root + .join("bin") + .join(format!("termlens{}", std::env::consts::EXE_SUFFIX)); + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = Command::new(cargo) + .args(["install", "termlens-cli", "--version", version_under_test()]) + .args(["--locked", "--root"]) + .arg(&root) + .status() + .expect("cargo install termlens-cli"); + assert!( + status.success(), + "cargo install termlens-cli --version {} failed. It is published \ + alongside the library; if this version of termlens exists on \ + crates.io and termlens-cli does not, the two releases went out \ + of lockstep.", + version_under_test() + ); + bin + }) +} + +fn run(args: &[&str]) -> Output { + Command::new(cli()) + .args(args) + .output() + .expect("run termlens") +} + +fn golden(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/golden") + .join(name) +} + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +fn path(p: &Path) -> String { + p.to_str().expect("a UTF-8 path").to_owned() +} + +#[test] +#[ignore = "needs termlens-cli; run with --ignored (stress.yml does)"] +fn the_tool_and_the_library_are_one_release() { + let out = run(&["--version"]); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + format!("termlens {}", version_under_test()), + "the installed CLI is not the version this crate tests against" + ); +} + +/// `inspect` drives the binary without a test being written, which is the +/// first thing a contributor reaches for — and the first thing a bug report +/// should contain. +/// +/// Pointed at the gate=none run, because that is the frame the project most +/// needs to survive every path to a reader: the no-gate banner has to be +/// there when the TUI is driven by something that is not this suite. +#[test] +#[ignore = "needs termlens-cli; run with --ignored (stress.yml does)"] +fn inspect_drives_the_real_tui() { + let run_dir = path(&fixture("run-metal")); + let out = run(&[ + "inspect", + "--size", + "80x24", + "--idle", + "600", + env!("CARGO_BIN_EXE_launchbound-tui"), + &run_dir, + ]); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let screen = String::from_utf8_lossy(&out.stdout); + assert!(screen.contains("size: 80x24"), "{screen}"); + assert!( + screen.contains("NO convergence gate exists on the Metal path"), + "the no-gate banner reaches a reader who never wrote a test:\n{screen}" + ); + assert!( + screen.contains("1 overview · 2 ranking · 3 rejections · 4 progress"), + "and a whole frame, not a half-painted one:\n{screen}" + ); + assert!( + screen.contains("still running at the deadline"), + "launchbound-tui is a TUI, so inspect reports the deadline rather \ + than an exit status:\n{screen}" + ); +} + +/// `render` on this crate's own saved screens. +/// +/// The styled golden is the one worth rendering: `REVERSED` is invisible in +/// every text comparison the suite makes, so this is where the saved screen +/// proves it still carries the banner's emphasis into something a person can +/// paste into an issue. +#[test] +#[ignore = "needs termlens-cli; run with --ignored (stress.yml does)"] +fn render_carries_the_banner_out_of_a_saved_screen() { + let styled = path(&golden("overview-metal-80x24.styled.txt")); + + let html = run(&["render", "--html", &styled]); + assert!( + html.status.success(), + "{}", + String::from_utf8_lossy(&html.stderr) + ); + let body = String::from_utf8_lossy(&html.stdout); + let banner = body + .lines() + .find(|line| line.contains("NO convergence gate exists on the Metal path")) + .unwrap_or_else(|| panic!("the banner is not in the HTML:\n{body}")); + assert!( + banner.contains("font-weight:bold") && banner.contains("background:"), + "the banner lost its emphasis on the way out of the saved screen — \ + it renders as an ordinary line: {banner}" + ); + + let svg = run(&["render", "--svg", &styled]); + assert!( + svg.status.success(), + "{}", + String::from_utf8_lossy(&svg.stderr) + ); + let image = String::from_utf8_lossy(&svg.stdout); + assert!( + image.contains("NO convergence gate exists on the Metal path"), + "the banner is in the image:\n{image}" + ); + assert!( + image.matches("= 2, + "reverse video is a painted background, so the image needs a rect \ + besides the page's own:\n{image}" + ); + + // And the plain goldens, which are the format the other four tests save. + let text = run(&["render", "--text", &path(&golden("overview-80x24.txt"))]); + assert!(text.status.success()); + assert!( + String::from_utf8_lossy(&text.stdout).contains("CHOSEN c1-0000000000000009"), + "a plain golden reads back as a screen too" + ); +} + +/// `diff`, and the three exit codes a script reads. +/// +/// The pair is the one `resize_relayouts_the_frame` produces: the same +/// session at two geometries. What the tool says about it — compared over +/// the overlap, the rest clipped — is the answer that test's diff assertion +/// is making in-process, so a reader who reaches for the CLI after a golden +/// fails gets the same account of it. +#[test] +#[ignore = "needs termlens-cli; run with --ignored (stress.yml does)"] +fn diff_reports_the_three_exit_codes() { + let narrow = path(&golden("overview-80x24.txt")); + let wide = path(&golden("overview-110x32.txt")); + + // 0: a screen is the same picture as itself. + let same = run(&["diff", "--color", "never", &narrow, &narrow]); + assert_eq!(same.status.code(), Some(0), "a golden equals itself"); + assert!(String::from_utf8_lossy(&same.stdout).contains("no difference")); + + // 1: the screens differ. This is the signal a script reads. + let out = run(&["diff", "--color", "never", &narrow, &wide]); + assert_eq!( + out.status.code(), + Some(1), + "the resize re-laid out the frame" + ); + let rendered = String::from_utf8_lossy(&out.stdout); + assert!( + rendered.contains("size: 80x24 → 110x32"), + "the size delta:\n{rendered}" + ); + assert!( + rendered.contains("compared over the 80x24 overlap"), + "and what that means for the comparison:\n{rendered}" + ); + assert!( + rendered.contains("rows unchanged"), + "and a count of what did not move:\n{rendered}" + ); + + // 2: the tool itself could not run. A run directory is JSON, and JSON is + // one of the two formats a saved screen can be in — so this is the + // mistake that is easy to make, not a contrived one. + let bad = path(&fixture("run-flip").join("verdicts.json")); + let broken = run(&["diff", "--color", "never", &narrow, &bad]); + assert_eq!( + broken.status.code(), + Some(2), + "an unreadable file is the tool failing, not a difference" + ); + assert!( + String::from_utf8_lossy(&broken.stderr).contains("not a termlens Screen"), + "and it says so: {}", + String::from_utf8_lossy(&broken.stderr) + ); +} diff --git a/crates/launchbound-tui/tests/emulation.rs b/crates/launchbound-tui/tests/emulation.rs new file mode 100644 index 0000000..a559c9b --- /dev/null +++ b/crates/launchbound-tui/tests/emulation.rs @@ -0,0 +1,258 @@ +//! What the emulator can and cannot see of launchbound-tui — the assertion +//! the rest of the suite rests on. +//! +//! Every screen assertion in this crate reads a grid that a VT emulator +//! produced from the binary's bytes: five golden files, two border scans and +//! a styled banner. If the binary emits a sequence the emulator does not +//! implement, that grid is quietly wrong and all of them are being made +//! against a plausible-looking fiction — a golden blessed from it would +//! record the fiction and pass forever. termlens 0.10 made that checkable: +//! `Screen::unsupported` lists what was dropped. +//! +//! These are whole-suite invariants rather than feature tests. They are +//! cheap, and when one breaks the right response is to distrust `tui.rs` +//! until it is understood. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use termlens::{Key, Screen, Terminal}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +/// The complete set of sequences launchbound-tui emits that termlens does +/// not model. Pinned exactly, and measured rather than guessed: every +/// non-CUP sequence in the byte stream is `^[[?1049h/l` (alternate screen), +/// `^[[?25l/h` (cursor visibility), `^[[?2026h/l` (DEC 2026 synchronized +/// update), `^[[1m`/`^[[22m` (bold on/off), `^[[7m` (reverse — the Metal +/// banner only), `^[[39m`, `^[[49m`, `^[[59m` and `^[[0m`. +/// +/// Of those, `SGR 59` — "underline colour: default", which ratatui writes as +/// part of resetting a style — is the only one termlens drops. termlens +/// carries no underline colour, so it records the sequence and moves on, and +/// because the attribute changes no cell, nothing on the grid is wrong as a +/// result. That is what lets this list be pinned exactly: anything joining +/// it is a sequence that *might* change a cell, and would need reading +/// before the goldens are trusted again. +/// +/// None of these is a termlens#320 false positive (`^[[5m`/`^[[25m`/`^[[9m`/ +/// `^[[29m`, blink and strikethrough, reported unsupported although the +/// attribute shadow implements them). This application never blinks and +/// never strikes through — `app.rs` uses `Modifier::BOLD` and +/// `Modifier::BOLD | Modifier::REVERSED` and no other modifier — and none of +/// those four bytes appears in its stream, so the pin carries no +/// known-defect caveat. +const EXPECTED_UNSUPPORTED: [&str; 1] = ["^[[59m"]; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +fn spawn(run_dir: &str, size: (u16, u16)) -> termlens::Result { + let mut t = Terminal::builder() + .size(size.0, size.1) + .env_clear() + .timeout(TIMEOUT) + .arg(fixture(run_dir)) + .spawn(env!("CARGO_BIN_EXE_launchbound-tui"))?; + // A quiet PTY is not a painted PTY (see tui.rs): sync on content. The + // header is the one line every fixture and every width shows. + t.wait_until(|s| s.contains("launchbound"))?; + Ok(t) +} + +fn unsupported(screen: &Screen) -> Vec { + screen.unsupported().iter().map(|s| s.to_string()).collect() +} + +/// The views, as (key, a needle true only of that view). +/// +/// The panel's own top border, because it is the one marker that is unique +/// to a view at every width and on both fixtures — the body text is not +/// (`measured ` is in the header totals of every frame, and the gate=none +/// run words its rejection panel differently). A predicate true of frames +/// other than the one being waited for is the shape that already flaked +/// this suite once; see the comment in `resize_relayouts_the_frame`. +const VIEWS: [(char, &str); 3] = [ + ('2', "\u{250c}ranking"), + ('3', "\u{250c}rejections"), + ('4', "\u{250c}progress"), +]; + +fn check(label: &str, screen: &Screen) { + assert_eq!( + unsupported(screen), + EXPECTED_UNSUPPORTED, + "{label}: launchbound-tui emitted a sequence termlens does not \ + model. Until it is understood, every golden in this crate is being \ + held against a grid that may be wrong:\n{screen}" + ); + assert_eq!( + screen.unsupported_overflow(), + 0, + "{label}: the record is complete, not truncated" + ); +} + +/// The invariant, over both fixtures, all four views and both widths the +/// suite uses. +/// +/// The gate=none fixture is in here on purpose: it is the only run that +/// takes the `^[[7m` reverse-video path, so it is the only one whose bytes +/// would show a dropped SGR that the other four views never emit. +#[test] +fn the_emulator_drops_nothing_that_could_change_a_cell() -> termlens::Result<()> { + for (run, size) in [ + ("run-flip", (80u16, 24u16)), + ("run-flip", (60, 30)), + ("run-metal", (80, 24)), + ("run-metal", (60, 30)), + ] { + let mut t = spawn(run, size)?; + let first = t.wait_frame(|s| s.contains("candidates ·"))?; + check(&format!("{run} {}x{} overview", size.0, size.1), &first); + + for (key, needle) in VIEWS { + t.send(Key::Char(key))?; + let frame = t.wait_frame(|s| s.contains(needle))?; + check(&format!("{run} {}x{} view {key}", size.0, size.1), &frame); + } + + // And the teardown, which is bytes nothing else here looks at: the + // leave-alternate-screen and cursor-restore that run after the loop. + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + check( + &format!("{run} {}x{} after exit", size.0, size.1), + &t.screen(), + ); + } + Ok(()) +} + +/// Four smaller invariants that would each make the grid a lie, and that +/// nothing else in this crate would notice. +#[test] +fn the_tui_leaves_the_terminal_modes_alone() -> termlens::Result<()> { + let mut t = spawn("run-flip", (80, 24))?; + let screen = t.wait_frame(|s| s.contains("q quit"))?; + + // Insert mode pushes the rest of a row right. An application that left + // it on would draw a correct-looking panel with every row shifted, and + // the goldens would record the shift as the layout. + assert!(!screen.insert_mode(), "launchbound-tui never sets IRM"); + // A bell is a thing the grid cannot show, so a golden cannot catch one. + assert_eq!(screen.visual_bells(), 0, "no visual bell"); + assert_eq!(screen.bells(), 0, "and no audible one either"); + + // Nothing captures the mouse. This is the assertion that would catch a + // crossterm or ratatui upgrade quietly enabling mouse reporting: the + // frame would be identical, and the user would silently lose the ability + // to select text in the pane. + assert!( + screen.mouse_modes().is_empty(), + "the TUI enables no mouse reporting, got {:?}", + screen.mouse_modes() + ); + assert!(!screen.bracketed_paste(), "and no bracketed paste"); + assert!(!screen.focus_events(), "and no focus reporting"); + assert!( + !screen.application_cursor(), + "and no application cursor keys" + ); + assert!(!screen.cursor().2, "the cursor is hidden while drawing"); + + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// No row wraps — including at sixty columns, where the refusal reason is +/// visibly broken across five rows. +/// +/// That reads like a contradiction and is the point. ratatui's +/// `Wrap { trim: false }` does the breaking itself and emits each visual row +/// as its own line, so the terminal's own autowrap never fires and the wrap +/// flag is never set. The consequence is concrete: `logical_text()` will +/// *not* rejoin the refusal reason, which is why +/// `a_refusal_reason_survives_a_narrow_terminal_whole` joins the rows by +/// hand instead of reaching for it. +/// +/// Pinned so that if a future ratatui starts letting the terminal wrap, this +/// fails and names the hand-rolled join as the thing to revisit — rather +/// than the join silently producing doubled text. +#[test] +fn the_renderer_wraps_the_text_itself_so_no_terminal_row_is_wrapped() -> termlens::Result<()> { + let mut t = spawn("run-flip", (60, 30))?; + t.wait_frame(|s| s.contains("candidates ·"))?; + t.send(Key::Char('3'))?; + let screen = t.wait_frame(|s| s.contains("all refused configurations:"))?; + + let wrapped: Vec = (0..screen.rows()) + .filter(|row| screen.row_wrapped(*row)) + .collect(); + assert!( + wrapped.is_empty(), + "rows {wrapped:?} are terminal-wrapped. The renderer used to break \ + lines itself, and `a_refusal_reason_survives_a_narrow_terminal_whole` \ + rebuilds the prose on that assumption — reread it before changing \ + this:\n{screen}" + ); + // Deliberately not asserting that the sentence is absent from any single + // row. It is 58 characters inside a 60-column bordered frame, so it could + // not fit whatever the renderer did — that assertion is arithmetic + // dressed as a test. The claim worth making is the one above: no row is + // *terminal*-wrapped, so the breaking was the renderer's doing. + + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// A frame has to survive being saved and read back, because that is what a +/// bug report, a `TERMLENS_ARTIFACT_DIR` file and every golden in this crate +/// are. +/// +/// The gate=none frame is the one worth checking: it is the only one +/// carrying an attribute (`REVERSED`) that a text comparison cannot see, so +/// a round trip that dropped styles would pass against any other view. +#[test] +fn a_frame_survives_the_snapshot_format_and_json() -> termlens::Result<()> { + let mut t = spawn("run-metal", (80, 24))?; + let screen = t.wait_frame(|s| s.contains("q quit"))?; + + // The text format: a saved golden, or a block pasted out of a CI log. + let saved = screen.with_styles().to_string(); + let parsed = Screen::parse(&saved)?; + assert!(screen.diff(&parsed).is_empty(), "{}", screen.diff(&parsed)); + assert_eq!(parsed.with_styles().to_string(), saved, "byte for byte"); + + // And JSON, which is what `TERMLENS_ARTIFACT_DIR` writes in CI now that + // the dependency carries the `serde` feature — the file the report + // action renders into the job summary when a wait times out. + let json = serde_json::to_string(&screen).expect("a Screen serializes"); + let back: Screen = serde_json::from_str(&json).expect("and comes back"); + assert!(screen.diff(&back).is_empty(), "{}", screen.diff(&back)); + + // The banner's attributes specifically. Both round trips above compare + // pictures, and `diff` does see styles — but naming this cell says which + // property of the format is load-bearing here, and fails with the one + // sentence that matters if it ever stops holding. + let banner = screen.cell(1, 0).expect("the banner's first cell"); + assert!(banner.style().bold && banner.style().reverse); + for other in [&parsed, &back] { + let cell = other.cell(1, 0).expect("the banner's first cell, restored"); + assert_eq!( + cell.style(), + banner.style(), + "the no-gate banner came back unstyled: a saved screen would \ + read as an ordinary line of text" + ); + } + + t.send(Key::Char('q'))?; + assert!(t.wait_exit()?.success()); + Ok(()) +} diff --git a/crates/launchbound-tui/tests/fixtures/run-metal/results.json b/crates/launchbound-tui/tests/fixtures/run-metal/results.json new file mode 100644 index 0000000..8d04cfd --- /dev/null +++ b/crates/launchbound-tui/tests/fixtures/run-metal/results.json @@ -0,0 +1,1334 @@ +{ + "schema": "results.v1", + "kernel": "reduce-stable", + "entry": "reduce", + "plan_cc": "metal", + "device_name": "Apple M4 Pro", + "device_cc": "metal", + "driver_version": "macOS 26.6.1", + "candidates": [ + { + "id": "c1-f5b50bf81efd7621", + "config": "block_x=32 tile=128", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.41450001299381256, + 0.4147500148974359, + 0.4146666615270078, + 0.41450001299381256, + 0.4146249848417938, + 0.41437504114583135, + 0.4146249848417938, + 0.4148333100602031, + 0.4145416896790266, + 0.4146249848417938, + 0.4151250468567014, + 0.41458330815657973, + 0.41458336636424065, + 0.4144166596233845, + 0.41500001680105925, + 0.4148749867454171, + 0.4147500148974359, + 0.41520834201946855, + 0.41512498864904046, + 0.4148749867454171, + 0.4612083430401981, + 0.4146250430494547, + 0.41499995859339833, + 0.4146250430494547, + 4.583916626870632, + 0.41570834582671523, + 0.4148749867454171, + 0.4419999895617366, + 0.34362502628937364, + 0.15379168326035142, + 0.15224999515339732, + 0.1521249650977552, + 0.15199999324977398, + 0.15187496319413185, + 0.15220831846818328, + 0.15195837477222085, + 0.1520000514574349, + 0.15224999515339732, + 0.1520000514574349, + 0.1521250233054161, + 0.15225005336105824, + 0.1523333485238254, + 0.149125000461936, + 0.13324996689334512, + 0.13325002510100603, + 0.13349996879696846, + 0.13325002510100603, + 0.133208348415792, + 0.13299996498972178, + 0.13304164167493582, + 0.13316667173057795, + 0.13316667173057795, + 0.1331249950453639, + 0.1330000231973827, + 0.13329170178622007, + 0.1331249950453639, + 0.13316667173057795, + 0.13308331836014986, + 0.1519166980870068, + 0.1519166980870068, + 0.15174999134615064, + 0.1519166398793459, + 0.1519166980870068, + 0.15216669999063015, + 0.15199999324977398, + 0.15204166993498802, + 0.1521250233054161, + 0.15204166993498802, + 0.15187502140179276, + 0.15229167183861136, + 0.1521250233054161, + 0.1521249650977552, + 0.15224999515339732, + 0.133208348415792, + 0.13304169988259673, + 0.13299996498972178, + 0.1331249950453639, + 0.1331249950453639, + 0.1329166698269546, + 0.133208348415792, + 0.13295834651216865, + 0.1330000231973827, + 0.133208348415792, + 0.1331249950453639, + 0.13295834651216865, + 0.1330000231973827, + 0.13483333168551326, + 0.13287499314174056, + 0.13325002510100603, + 0.15204166993498802, + 0.15249999705702066, + 0.15174999134615064, + 0.1520000514574349, + 0.1519166980870068, + 0.15187502140179276, + 0.15208334662020206, + 0.15195831656455994, + 0.15199999324977398, + 0.1521250233054161, + 0.1521250233054161 + ], + "summary": { + "n": 99, + "outliers_rejected": 1, + "median_ms": 0.1520000514574349, + "ci95_lo_ms": 0.1519166980870068, + "ci95_hi_ms": 0.1521250233054161, + "min_ms": 0.13287499314174056, + "max_ms": 0.4612083430401981, + "mean_ms": 0.2204629697253683 + }, + "gpu_seconds": 0.20820375 + }, + { + "id": "c1-c8a587e903831a0f", + "config": "block_x=32 tile=256", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.28620834928005934, + 0.2637500292621553, + 0.25049998657777905, + 0.2708749962039292, + 0.2855833154171705, + 0.2857499639503658, + 0.28583331732079387, + 0.28624996775761247, + 0.2858749940060079, + 0.2858749940060079, + 0.2860833192244172, + 0.2859582891687751, + 0.28570834547281265, + 0.28579169884324074, + 0.2857499639503658, + 0.28600002406165004, + 0.28583331732079387, + 0.2862500259652734, + 0.2863749978132546, + 0.28612499590963125, + 0.2862916444428265, + 0.28604164253920317, + 0.3104583011008799, + 0.3315000212751329, + 0.28775003738701344, + 0.28583331732079387, + 0.2858749940060079, + 0.28600002406165004, + 0.28591667069122195, + 0.28591667069122195, + 0.2857500221580267, + 0.2862500259652734, + 0.28570834547281265, + 0.28583331732079387, + 0.28604164253920317, + 0.2856666687875986, + 0.28600002406165004, + 0.28600002406165004, + 0.2857916406355798, + 0.28591667069122195, + 0.2858749940060079, + 0.2860833192244172, + 0.33137499121949077, + 0.33137499121949077, + 0.3314166679047048, + 0.2925416920334101, + 0.2857499639503658, + 0.2856666687875986, + 0.2857500221580267, + 0.28583331732079387, + 0.2858749940060079, + 0.2860417007468641, + 0.2857500221580267, + 0.2857916406355798, + 0.285958347376436, + 0.28570834547281265, + 0.28562499210238457, + 0.2859582891687751, + 0.28600002406165004, + 0.2857500221580267, + 0.28570834547281265, + 0.31112501164898276, + 0.331499963067472, + 0.3316249931231141, + 0.3312916960567236, + 0.3317499649710953, + 0.2979583223350346, + 0.28570828726515174, + 0.285958347376436, + 0.2857500221580267, + 0.28579169884324074, + 0.28591667069122195, + 0.2856666687875986, + 0.2859999658539891, + 0.28600002406165004, + 0.2858333755284548, + 0.2857499639503658, + 0.28612499590963125, + 0.28604164253920317, + 0.28600002406165004, + 0.29150000773370266, + 0.33137499121949077, + 0.33137499121949077, + 0.33137499121949077, + 0.3314166679047048, + 0.33187499502673745, + 0.33208332024514675, + 0.2947499742731452, + 0.2857916406355798, + 0.2858749940060079, + 0.2857500221580267, + 0.28562499210238457, + 0.2858749940060079, + 0.2862917026504874, + 0.2858750522136688, + 0.28570828726515174, + 0.2859999658539891, + 0.28583331732079387, + 0.28579169884324074, + 0.28570834547281265 + ], + "summary": { + "n": 76, + "outliers_rejected": 24, + "median_ms": 0.2858749940060079, + "ci95_lo_ms": 0.28583331732079387, + "ci95_hi_ms": 0.28591667069122195, + "min_ms": 0.2855833154171705, + "max_ms": 0.2863749978132546, + "mean_ms": 0.2858930912273201 + }, + "gpu_seconds": 0.071849833 + }, + { + "id": "c1-10d6c6cc932d1d77", + "config": "block_x=32 tile=512", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.5450000171549618, + 0.544416718184948, + 0.544333306606859, + 0.5435416824184358, + 0.5447082803584635, + 0.4759999574162066, + 0.47666666796430945, + 0.476333312690258, + 0.48108334885910153, + 0.6590000120922923, + 0.47575001372024417, + 0.44425000669434667, + 0.7230000337585807, + 0.4258750122971833, + 0.42624998604878783, + 0.425624952185899, + 0.42566662887111306, + 0.42562501039355993, + 0.4248750046826899, + 0.4252500366419554, + 0.42520835995674133, + 0.4252917133271694, + 0.4253750084899366, + 0.4504583193920553, + 0.47666666796430945, + 0.6890000076964498, + 0.42512500658631325, + 0.4260833375155926, + 0.42604166083037853, + 0.42524997843429446, + 0.4253750084899366, + 0.424958358053118, + 0.42554165702313185, + 0.4259999841451645, + 0.4253750084899366, + 0.42512500658631325, + 0.4247499746270478, + 0.38362498162314296, + 0.40487502701580524, + 0.4714583628810942, + 0.3767500165849924, + 0.36462501157075167, + 0.36450003972277045, + 0.364875013474375, + 0.3645416582003236, + 0.3645833348855376, + 0.3647499834187329, + 0.36462501157075167, + 0.36512495717033744, + 0.36441668635234237, + 0.3644583630375564, + 0.3649999853223562, + 0.37516665179282427, + 0.38383336504921317, + 0.3837500116787851, + 0.38350000977516174, + 0.38370833499357104, + 0.38299994776025414, + 0.39837503572925925, + 0.364875013474375, + 0.3642499796114862, + 0.3649583668448031, + 0.3649999853223562, + 0.36470830673351884, + 0.36512501537799835, + 0.36500004353001714, + 0.3652500454336405, + 0.3647499834187329, + 0.3646666882559657, + 0.34366664476692677, + 0.34262496046721935, + 0.3422499867156148, + 0.3422500449232757, + 0.3186250105500221, + 0.351833354216069, + 0.3452500095590949, + 0.319208309520036, + 0.319208309520036, + 0.31924998620525, + 0.31937495805323124, + 0.3195000463165343, + 0.31908333767205477, + 0.3188333357684314, + 0.3193333395756781, + 0.31949998810887337, + 0.3197499900124967, + 0.3197499900124967, + 0.34220836823806167, + 0.3422500449232757, + 0.34262501867488027, + 0.3421666915528476, + 0.31887501245364547, + 0.3183750086463988, + 0.3833749797195196, + 0.3232500166632235, + 0.31937501626089215, + 0.3189166891388595, + 0.31825003679841757, + 0.3186250105500221, + 0.31908333767205477 + ], + "summary": { + "n": 97, + "outliers_rejected": 3, + "median_ms": 0.36512495717033744, + "ci95_lo_ms": 0.3646666882559657, + "ci95_hi_ms": 0.38383336504921317, + "min_ms": 0.31825003679841757, + "max_ms": 0.5450000171549618, + "mean_ms": 0.3877182169481344 + }, + "gpu_seconds": 0.089054042 + }, + { + "id": "c1-e4893bbe9e8d3379", + "config": "block_x=64 tile=128", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.09891670197248459, + 0.09912502719089389, + 0.09879167191684246, + 0.09899999713525176, + 0.09879167191684246, + 0.09887496707960963, + 0.09908335050567985, + 0.09908329229801893, + 0.0990416738204658, + 0.09912496898323298, + 0.09920832235366106, + 0.09908335050567985, + 0.09899999713525176, + 0.09887496707960963, + 0.09912502719089389, + 0.09920832235366106, + 0.09912502719089389, + 0.09887502528727055, + 0.09912496898323298, + 0.09891664376482368, + 0.09920832235366106, + 0.09891664376482368, + 0.09887496707960963, + 0.09887496707960963, + 0.09895832045003772, + 0.09887502528727055, + 0.09887496707960963, + 0.09879167191684246, + 0.09887496707960963, + 0.12245832476764917, + 0.12337497901171446, + 0.09887496707960963, + 0.09879167191684246, + 0.09887502528727055, + 0.09879167191684246, + 0.0990416738204658, + 0.09908329229801893, + 0.09883329039439559, + 0.0988333486020565, + 0.09887502528727055, + 0.09874999523162842, + 0.09895837865769863, + 0.09891670197248459, + 0.09908329229801893, + 0.09887496707960963, + 0.09908335050567985, + 0.09874999523162842, + 0.09899999713525176, + 0.09887502528727055, + 0.09891664376482368, + 0.09887502528727055, + 0.09912502719089389, + 0.09904161561280489, + 0.0990416738204658, + 0.09874999523162842, + 0.09908335050567985, + 0.09874999523162842, + 0.09899999713525176, + 0.09887496707960963, + 0.0988333486020565, + 0.09887496707960963, + 0.09900005534291267, + 0.0988333486020565, + 0.0988333486020565, + 0.09887496707960963, + 0.09887502528727055, + 0.09887496707960963, + 0.10300002759322524, + 0.15412498032674193, + 0.09895832045003772, + 0.09895832045003772, + 0.09899999713525176, + 0.09899999713525176, + 0.09887496707960963, + 0.09874999523162842, + 0.09887502528727055, + 0.09887502528727055, + 0.09899999713525176, + 0.09912502719089389, + 0.09887496707960963, + 0.0990416738204658, + 0.09908329229801893, + 0.09908335050567985, + 0.09895832045003772, + 0.09899999713525176, + 0.0990416738204658, + 0.09899999713525176, + 0.09899999713525176, + 0.0990416738204658, + 0.09899999713525176, + 0.09899999713525176, + 0.09866664186120033, + 0.09908335050567985, + 0.09899999713525176, + 0.09891664376482368, + 0.09887502528727055, + 0.09887496707960963, + 0.09866664186120033, + 0.09879167191684246, + 0.09879167191684246 + ], + "summary": { + "n": 96, + "outliers_rejected": 4, + "median_ms": 0.09891667286865413, + "ci95_lo_ms": 0.09887502528727055, + "ci95_hi_ms": 0.09899999713525176, + "min_ms": 0.09866664186120033, + "max_ms": 0.09920832235366106, + "mean_ms": 0.09894357329661337 + }, + "gpu_seconds": 0.037503917 + }, + { + "id": "c1-fb95e4ee0ec920f6", + "config": "block_x=64 tile=256", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.10083336383104324, + 0.1007916871458292, + 0.10091665899381042, + 0.1005833619274199, + 0.10075001046061516, + 0.10066671529784799, + 0.1010000123642385, + 0.10125001426786184, + 0.10070833377540112, + 0.10074995225295424, + 0.10062503861263394, + 0.10083330562338233, + 0.10070833377540112, + 0.10137498611584306, + 0.13162504183128476, + 0.1558333751745522, + 0.10054168524220586, + 0.1010000123642385, + 0.10087504051625729, + 0.10087498230859637, + 0.10050000855699182, + 0.10058330371975899, + 0.10062498040497303, + 0.10050000855699182, + 0.10087504051625729, + 0.10075001046061516, + 0.10087498230859637, + 0.10066665709018707, + 0.10099995415657759, + 0.10075001046061516, + 0.10066665709018707, + 0.10075001046061516, + 0.10137504432350397, + 0.10075001046061516, + 0.10125001426786184, + 0.1005833619274199, + 0.10120827937498689, + 0.10075001046061516, + 0.10112504241988063, + 0.10062503861263394, + 0.10079162893816829, + 0.10112498421221972, + 0.10095833567902446, + 0.10025000665336847, + 0.10070833377540112, + 0.10095833567902446, + 0.10112498421221972, + 0.10087498230859637, + 0.1007916871458292, + 0.10070833377540112, + 0.1010000123642385, + 0.10054168524220586, + 0.12349995085969567, + 0.10341661982238293, + 0.1315833069384098, + 0.15462498413398862, + 0.10074995225295424, + 0.10125001426786184, + 0.10087504051625729, + 0.1007916871458292, + 0.10112498421221972, + 0.10108336573466659, + 0.10129163274541497, + 0.10083330562338233, + 0.10095833567902446, + 0.10095833567902446, + 0.10112504241988063, + 0.10087504051625729, + 0.10087498230859637, + 0.10087504051625729, + 0.10087498230859637, + 0.10125001426786184, + 0.10075001046061516, + 0.10129163274541497, + 0.10091665899381042, + 0.10116666089743376, + 0.10075001046061516, + 0.10104168904945254, + 0.10099995415657759, + 0.10099995415657759, + 0.10062498040497303, + 0.10041671339422464, + 0.1010000123642385, + 0.1012083375826478, + 0.10062498040497303, + 0.10083336383104324, + 0.10150001617148519, + 0.12562499614432454, + 0.13649999164044857, + 0.10704167652875185, + 0.10041665518656373, + 0.10070833377540112, + 0.10062503861263394, + 0.10062498040497303, + 0.10062503861263394, + 0.10087504051625729, + 0.10162504622712731, + 0.10112498421221972, + 0.10029168333858252, + 0.10062498040497303 + ], + "summary": { + "n": 91, + "outliers_rejected": 9, + "median_ms": 0.10083336383104324, + "ci95_lo_ms": 0.10075001046061516, + "ci95_hi_ms": 0.10087504051625729, + "min_ms": 0.10025000665336847, + "max_ms": 0.10162504622712731, + "mean_ms": 0.10086355761594169 + }, + "gpu_seconds": 0.037074334 + }, + { + "id": "c1-9b07a475f71ad979", + "config": "block_x=64 tile=512", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.16825000056996942, + 0.1681249705143273, + 0.1678332919254899, + 0.1684167073108256, + 0.16825000056996942, + 0.16862497432157397, + 0.16825000056996942, + 0.16825000056996942, + 0.16804167535156012, + 0.16825000056996942, + 0.16816670540720224, + 0.1681249705143273, + 0.16870832769200206, + 0.16837497241795063, + 0.16816664719954133, + 0.16816664719954133, + 0.16850000247359276, + 0.16783335013315082, + 0.18854165682569146, + 0.24150003446266055, + 0.1681250287219882, + 0.16829167725518346, + 0.16816664719954133, + 0.16799999866634607, + 0.16825000056996942, + 0.1682083820924163, + 0.1681250287219882, + 0.16837497241795063, + 0.16766664339229465, + 0.16783335013315082, + 0.1681249705143273, + 0.1679167035035789, + 0.16795832198113203, + 0.16825000056996942, + 0.16862497432157397, + 0.16837497241795063, + 0.16799999866634607, + 0.16774999676272273, + 0.16754167154431343, + 0.16845832578837872, + 0.16795832198113203, + 0.16845832578837872, + 0.16762502491474152, + 0.16816664719954133, + 0.16787502681836486, + 0.16825000056996942, + 0.1680416171438992, + 0.16799999866634607, + 0.16799999866634607, + 0.19216665532439947, + 0.2003333647735417, + 0.16825000056996942, + 0.16816664719954133, + 0.16774999676272273, + 0.1676249667070806, + 0.16808329382911325, + 0.16820832388475537, + 0.16808329382911325, + 0.16808335203677416, + 0.16858329763635993, + 0.16804167535156012, + 0.16799999866634607, + 0.1681250287219882, + 0.1683333539403975, + 0.16858335584402084, + 0.16787496861070395, + 0.16774999676272273, + 0.16850000247359276, + 0.16816664719954133, + 0.16820832388475537, + 0.16825000056996942, + 0.16804167535156012, + 0.16799999866634607, + 0.1681249705143273, + 0.16825000056996942, + 0.16850000247359276, + 0.16804167535156012, + 0.16787496861070395, + 0.23237505229189992, + 0.17116667004302144, + 0.16820832388475537, + 0.16787502681836486, + 0.16829167725518346, + 0.16808335203677416, + 0.16758334822952747, + 0.16804167535156012, + 0.16845832578837872, + 0.16795832198113203, + 0.16837503062561154, + 0.16837497241795063, + 0.1676249667070806, + 0.16799999866634607, + 0.16774999676272273, + 0.16799999866634607, + 0.167916645295918, + 0.16841664910316467, + 0.16783335013315082, + 0.16779167344793677, + 0.16804167535156012, + 0.16825000056996942 + ], + "summary": { + "n": 93, + "outliers_rejected": 7, + "median_ms": 0.1681250287219882, + "ci95_lo_ms": 0.16804167535156012, + "ci95_hi_ms": 0.16820832388475537, + "min_ms": 0.16758334822952747, + "max_ms": 0.16870832769200206, + "mean_ms": 0.16812454866525786 + }, + "gpu_seconds": 0.044780625 + }, + { + "id": "c1-43e865c2e1620697", + "config": "block_x=128 tile=128", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.09691668674349785, + 0.09679165668785572, + 0.0968750100582838, + 0.09691662853583694, + 0.0968750100582838, + 0.09674998000264168, + 0.09695836342871189, + 0.09691662853583694, + 0.0968750100582838, + 0.09674998000264168, + 0.09704165859147906, + 0.09666662663221359, + 0.09670830331742764, + 0.09679171489551663, + 0.09670830331742764, + 0.0968750100582838, + 0.09674998000264168, + 0.10287499753758311, + 0.14370837016031146, + 0.13924995437264442, + 0.09704165859147906, + 0.09687495185062289, + 0.09679171489551663, + 0.09679165668785572, + 0.09683333337306976, + 0.09687495185062289, + 0.09675003821030259, + 0.09674998000264168, + 0.09674998000264168, + 0.09675003821030259, + 0.09695836342871189, + 0.09649997809901834, + 0.0968750100582838, + 0.09670830331742764, + 0.09695836342871189, + 0.09674998000264168, + 0.0968750100582838, + 0.09662500815466046, + 0.09687495185062289, + 0.09649997809901834, + 0.09691668674349785, + 0.09687495185062289, + 0.09695830522105098, + 0.09683333337306976, + 0.09700004011392593, + 0.09687495185062289, + 0.09683333337306976, + 0.09670830331742764, + 0.09679165668785572, + 0.09675003821030259, + 0.0968750100582838, + 0.09704165859147906, + 0.09674998000264168, + 0.09674998000264168, + 0.09674998000264168, + 0.0966666848398745, + 0.09666662663221359, + 0.10308338096365333, + 0.13399997260421515, + 0.12845831224694848, + 0.09683333337306976, + 0.09674998000264168, + 0.0968750100582838, + 0.09683333337306976, + 0.09695830522105098, + 0.09691662853583694, + 0.0968750100582838, + 0.09683333337306976, + 0.0968750100582838, + 0.09695836342871189, + 0.0968750100582838, + 0.09679165668785572, + 0.09670836152508855, + 0.09687495185062289, + 0.09679165668785572, + 0.09691668674349785, + 0.09666662663221359, + 0.09666662663221359, + 0.0968750100582838, + 0.0968750100582838, + 0.0968750100582838, + 0.09691668674349785, + 0.09674998000264168, + 0.09695836342871189, + 0.0968750100582838, + 0.09674998000264168, + 0.09674998000264168, + 0.0966666848398745, + 0.09683333337306976, + 0.09666662663221359, + 0.09670830331742764, + 0.09675003821030259, + 0.09674998000264168, + 0.12612499995157123, + 0.14474999625235796, + 0.12812495697289705, + 0.09691662853583694, + 0.0968750100582838, + 0.09683333337306976, + 0.09683333337306976 + ], + "summary": { + "n": 89, + "outliers_rejected": 11, + "median_ms": 0.09683333337306976, + "ci95_lo_ms": 0.09679165668785572, + "ci95_hi_ms": 0.0968750100582838, + "min_ms": 0.09662500815466046, + "max_ms": 0.09704165859147906, + "mean_ms": 0.09682583766518517 + }, + "gpu_seconds": 0.037032875 + }, + { + "id": "c1-845a62c8785e0a94", + "config": "block_x=128 tile=256", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.12287503341212869, + 0.15237496700137854, + 0.109999964479357, + 0.0992499990388751, + 0.0990416738204658, + 0.0992499990388751, + 0.09912502719089389, + 0.09929167572408915, + 0.0994583242572844, + 0.09950000094249845, + 0.09887502528727055, + 0.099874974694103, + 0.09912496898323298, + 0.09899999713525176, + 0.09912502719089389, + 0.09929167572408915, + 0.09937502909451723, + 0.09920832235366106, + 0.0992499990388751, + 0.0992499990388751, + 0.09908329229801893, + 0.09912496898323298, + 0.09920838056132197, + 0.09900005534291267, + 0.09941670577973127, + 0.0992499990388751, + 0.09962503099814057, + 0.0992499990388751, + 0.09962503099814057, + 0.09912496898323298, + 0.09958335431292653, + 0.09962497279047966, + 0.09912496898323298, + 0.0990416738204658, + 0.09912496898323298, + 0.09912502719089389, + 0.10612502228468657, + 0.14925003051757812, + 0.10004162322729826, + 0.11550000635907054, + 0.11841667583212256, + 0.10025000665336847, + 0.09958335431292653, + 0.09920832235366106, + 0.09975000284612179, + 0.09954167762771249, + 0.09920832235366106, + 0.09920832235366106, + 0.09929167572408915, + 0.0992499990388751, + 0.09962497279047966, + 0.09962497279047966, + 0.09899999713525176, + 0.0992499990388751, + 0.09933329420164227, + 0.0992499990388751, + 0.09895837865769863, + 0.09912502719089389, + 0.09937502909451723, + 0.09950000094249845, + 0.09954167762771249, + 0.09912496898323298, + 0.09912496898323298, + 0.09908335050567985, + 0.09929167572408915, + 0.09958329610526562, + 0.09916664566844702, + 0.09933335240930319, + 0.0994583242572844, + 0.09899999713525176, + 0.09929167572408915, + 0.09916670387610793, + 0.0992499990388751, + 0.0992499990388751, + 0.10570831364020705, + 0.09908329229801893, + 0.11912500485777855, + 0.09929167572408915, + 0.09912502719089389, + 0.09970832616090775, + 0.0992499990388751, + 0.09916664566844702, + 0.09958335431292653, + 0.09908335050567985, + 0.09916664566844702, + 0.09937497088685632, + 0.09912496898323298, + 0.09912496898323298, + 0.09962503099814057, + 0.09975000284612179, + 0.09937502909451723, + 0.0992499990388751, + 0.0992499990388751, + 0.09937497088685632, + 0.0992499990388751, + 0.0992499990388751, + 0.09912496898323298, + 0.09958335431292653, + 0.09891670197248459, + 0.09937497088685632 + ], + "summary": { + "n": 91, + "outliers_rejected": 9, + "median_ms": 0.0992499990388751, + "ci95_lo_ms": 0.09920838056132197, + "ci95_hi_ms": 0.09929167572408915, + "min_ms": 0.09887502528727055, + "max_ms": 0.10025000665336847, + "mean_ms": 0.09930631846885432 + }, + "gpu_seconds": 0.038001083 + }, + { + "id": "c1-b03f7ed3633c800d", + "config": "block_x=128 tile=512", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.10524998651817441, + 0.10474998271092772, + 0.10524998651817441, + 0.10462501086294651, + 0.10562496026977897, + 0.10487501276656985, + 0.10524998651817441, + 0.1051250146701932, + 0.10483333608135581, + 0.10525004472583532, + 0.10512495646253228, + 0.1044166274368763, + 0.10475004091858864, + 0.11262501357123256, + 0.10545836994424462, + 0.1047083642333746, + 0.10466662934049964, + 0.13954169116914272, + 0.14162500156089664, + 0.10499998461455107, + 0.10445830412209034, + 0.10604166891425848, + 0.10474998271092772, + 0.10454165749251842, + 0.10412500705569983, + 0.10437500895932317, + 0.10499998461455107, + 0.10462501086294651, + 0.10449998080730438, + 0.10483333608135581, + 0.10520830983296037, + 0.10483333608135581, + 0.10483333608135581, + 0.10529166320338845, + 0.10525004472583532, + 0.10495836613699794, + 0.10487501276656985, + 0.10504166129976511, + 0.1046249526552856, + 0.10487501276656985, + 0.10462501086294651, + 0.10445830412209034, + 0.10449998080730438, + 0.10474998271092772, + 0.10487501276656985, + 0.10495836613699794, + 0.10529166320338845, + 0.10487501276656985, + 0.10499998461455107, + 0.10474998271092772, + 0.10516663314774632, + 0.10500004282221198, + 0.10474998271092772, + 0.11125003220513463, + 0.12375001097097993, + 0.11558335972949862, + 0.10570831364020705, + 0.10583334369584918, + 0.10462501086294651, + 0.1047083642333746, + 0.10512495646253228, + 0.1051250146701932, + 0.10479165939614177, + 0.10499998461455107, + 0.1053333398886025, + 0.10466668754816055, + 0.10508333798497915, + 0.10449998080730438, + 0.10529166320338845, + 0.10483333608135581, + 0.1045000390149653, + 0.10458333417773247, + 0.10520830983296037, + 0.10550004662945867, + 0.10558334179222584, + 0.10433333227410913, + 0.10470830602571368, + 0.10479171760380268, + 0.10525004472583532, + 0.10512495646253228, + 0.10462501086294651, + 0.10454165749251842, + 0.10475004091858864, + 0.10549998842179775, + 0.10479165939614177, + 0.10562501847743988, + 0.10487501276656985, + 0.10483333608135581, + 0.10495830792933702, + 0.11437496868893504, + 0.10637502418830991, + 0.10462501086294651, + 0.1057499903254211, + 0.10474998271092772, + 0.1049166894517839, + 0.10512495646253228, + 0.10479165939614177, + 0.10499998461455107, + 0.10466668754816055, + 0.10520836804062128 + ], + "summary": { + "n": 92, + "outliers_rejected": 8, + "median_ms": 0.10487501276656985, + "ci95_lo_ms": 0.10479165939614177, + "ci95_hi_ms": 0.10499998461455107, + "min_ms": 0.10412500705569983, + "max_ms": 0.10604166891425848, + "mean_ms": 0.10494067037538828 + }, + "gpu_seconds": 0.038334541 + }, + { + "id": "c1-a0fdcb26d0491a52", + "config": "block_x=256 tile=256", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.09687495185062289, + 0.09675003821030259, + 0.09679165668785572, + 0.09695836342871189, + 0.09662500815466046, + 0.09695830522105098, + 0.09699998190626502, + 0.09675003821030259, + 0.09662500815466046, + 0.09674998000264168, + 0.09662494994699955, + 0.09699998190626502, + 0.09679165668785572, + 0.09674998000264168, + 0.09691668674349785, + 0.09712495375424623, + 0.0970833352766931, + 0.09691668674349785, + 0.09679171489551663, + 0.09691668674349785, + 0.09679165668785572, + 0.0968750100582838, + 0.09687495185062289, + 0.09679165668785572, + 0.09712501196190715, + 0.09700004011392593, + 0.09687495185062289, + 0.0968750100582838, + 0.09716663043946028, + 0.09674998000264168, + 0.09679165668785572, + 0.09687495185062289, + 0.09679165668785572, + 0.11820829240605235, + 0.13937498442828655, + 0.09737501386553049, + 0.09670830331742764, + 0.09691668674349785, + 0.0968750100582838, + 0.09699998190626502, + 0.09699998190626502, + 0.09687495185062289, + 0.09674998000264168, + 0.09674998000264168, + 0.09700004011392593, + 0.09695836342871189, + 0.09700004011392593, + 0.09674998000264168, + 0.0968750100582838, + 0.09675003821030259, + 0.09675003821030259, + 0.0966666848398745, + 0.0966666848398745, + 0.0968750100582838, + 0.09687495185062289, + 0.09699998190626502, + 0.09683333337306976, + 0.09699998190626502, + 0.09666662663221359, + 0.09687495185062289, + 0.09741663234308362, + 0.09799998952075839, + 0.09724998380988836, + 0.09724998380988836, + 0.09679171489551663, + 0.09691662853583694, + 0.0968750100582838, + 0.09712501196190715, + 0.09712501196190715, + 0.09724998380988836, + 0.09741663234308362, + 0.09745830902829766, + 0.09683333337306976, + 0.09699998190626502, + 0.10249996557831764, + 0.10116666089743376, + 0.1059583155438304, + 0.09712501196190715, + 0.09670836152508855, + 0.09675003821030259, + 0.0968750100582838, + 0.0968750100582838, + 0.09683333337306976, + 0.09675003821030259, + 0.09683333337306976, + 0.09654165478423238, + 0.09674998000264168, + 0.09675003821030259, + 0.09704171679913998, + 0.09683333337306976, + 0.09675003821030259, + 0.09683333337306976, + 0.0968750100582838, + 0.0968750100582838, + 0.09683333337306976, + 0.09674998000264168, + 0.09674998000264168, + 0.09674998000264168, + 0.09683333337306976, + 0.09679165668785572 + ], + "summary": { + "n": 90, + "outliers_rejected": 10, + "median_ms": 0.09687495185062289, + "ci95_lo_ms": 0.09679171489551663, + "ci95_hi_ms": 0.0968750100582838, + "min_ms": 0.09654165478423238, + "max_ms": 0.09724998380988836, + "mean_ms": 0.09686805424280465 + }, + "gpu_seconds": 0.0364195 + }, + { + "id": "c1-3fe30345938b783f", + "config": "block_x=256 tile=512", + "status": "ok", + "warmup": 20, + "repeats": 100, + "times_ms": [ + 0.10374997509643435, + 0.10404165368527174, + 0.1039999770000577, + 0.10387500515207648, + 0.10354164987802505, + 0.10374997509643435, + 0.10370835661888123, + 0.1033750013448298, + 0.10404165368527174, + 0.10474998271092772, + 0.10474998271092772, + 0.1039999770000577, + 0.10341667803004384, + 0.10345835471525788, + 0.1045000390149653, + 0.10383332846686244, + 0.10362500324845314, + 0.1031666761264205, + 0.1528333523310721, + 0.10387500515207648, + 0.1035833265632391, + 0.10424997890368104, + 0.10383332846686244, + 0.10354164987802505, + 0.10349997319281101, + 0.1039999770000577, + 0.10366667993366718, + 0.1053333398886025, + 0.10412500705569983, + 0.1039999770000577, + 0.1037916517816484, + 0.10350003140047193, + 0.1044166274368763, + 0.10349997319281101, + 0.1039999770000577, + 0.10474998271092772, + 0.10387500515207648, + 0.10374997509643435, + 0.10404165368527174, + 0.10412500705569983, + 0.1037916517816484, + 0.10350003140047193, + 0.10375003330409527, + 0.10375003330409527, + 0.10470830602571368, + 0.10300002759322524, + 0.10375003330409527, + 0.1033750013448298, + 0.10341661982238293, + 0.10400003520771861, + 0.10374997509643435, + 0.10383332846686244, + 0.10412494884803891, + 0.1039999770000577, + 0.10395835852250457, + 0.10433333227410913, + 0.10362500324845314, + 0.10404165368527174, + 0.1257082913070917, + 0.13562501408159733, + 0.13812497491016984, + 0.10449998080730438, + 0.10412500705569983, + 0.10437500895932317, + 0.1039999770000577, + 0.10387500515207648, + 0.10366667993366718, + 0.1044166274368763, + 0.10349997319281101, + 0.10404165368527174, + 0.10445836232975125, + 0.10349997319281101, + 0.10412500705569983, + 0.10375003330409527, + 0.10387500515207648, + 0.10362494504079223, + 0.10437500895932317, + 0.10445830412209034, + 0.10350003140047193, + 0.104208302218467, + 0.10429165558889508, + 0.10437500895932317, + 0.1037916517816484, + 0.10608328739181161, + 0.10500004282221198, + 0.10474998271092772, + 0.10687496978789568, + 0.10487495455890894, + 0.12525002239271998, + 0.13816665159538388, + 0.1370416721329093, + 0.10462501086294651, + 0.10404171189293265, + 0.10408333037048578, + 0.10404171189293265, + 0.10370829841122031, + 0.10387500515207648, + 0.10483333608135581, + 0.10341667803004384, + 0.104208302218467 + ], + "summary": { + "n": 91, + "outliers_rejected": 9, + "median_ms": 0.10395835852250457, + "ci95_lo_ms": 0.10383332846686244, + "ci95_hi_ms": 0.10404165368527174, + "min_ms": 0.10300002759322524, + "max_ms": 0.1053333398886025, + "mean_ms": 0.10397847598561874 + }, + "gpu_seconds": 0.038034375 + } + ], + "total_gpu_seconds": 0.676781875, + "strategy": "exhaustive", + "budget_exhausted": false +} \ No newline at end of file diff --git a/crates/launchbound-tui/tests/fixtures/run-metal/verdicts.json b/crates/launchbound-tui/tests/fixtures/run-metal/verdicts.json new file mode 100644 index 0000000..2488748 --- /dev/null +++ b/crates/launchbound-tui/tests/fixtures/run-metal/verdicts.json @@ -0,0 +1,74 @@ +{ + "candidates": [ + { + "block_threads": 32, + "config": "block_x=32 tile=128", + "id": "c1-f5b50bf81efd7621", + "verdict": "ungated" + }, + { + "block_threads": 32, + "config": "block_x=32 tile=256", + "id": "c1-c8a587e903831a0f", + "verdict": "ungated" + }, + { + "block_threads": 32, + "config": "block_x=32 tile=512", + "id": "c1-10d6c6cc932d1d77", + "verdict": "ungated" + }, + { + "block_threads": 64, + "config": "block_x=64 tile=128", + "id": "c1-e4893bbe9e8d3379", + "verdict": "ungated" + }, + { + "block_threads": 64, + "config": "block_x=64 tile=256", + "id": "c1-fb95e4ee0ec920f6", + "verdict": "ungated" + }, + { + "block_threads": 64, + "config": "block_x=64 tile=512", + "id": "c1-9b07a475f71ad979", + "verdict": "ungated" + }, + { + "block_threads": 128, + "config": "block_x=128 tile=128", + "id": "c1-43e865c2e1620697", + "verdict": "ungated" + }, + { + "block_threads": 128, + "config": "block_x=128 tile=256", + "id": "c1-845a62c8785e0a94", + "verdict": "ungated" + }, + { + "block_threads": 128, + "config": "block_x=128 tile=512", + "id": "c1-b03f7ed3633c800d", + "verdict": "ungated" + }, + { + "block_threads": 256, + "config": "block_x=256 tile=256", + "id": "c1-a0fdcb26d0491a52", + "verdict": "ungated" + }, + { + "block_threads": 256, + "config": "block_x=256 tile=512", + "id": "c1-3fe30345938b783f", + "verdict": "ungated" + } + ], + "cc": "metal", + "gate": "none", + "kernel": "reduce-stable", + "schema": "verdicts.v1" +} \ No newline at end of file diff --git a/crates/launchbound-tui/tests/golden/overview-metal-80x24.styled.txt b/crates/launchbound-tui/tests/golden/overview-metal-80x24.styled.txt new file mode 100644 index 0000000..7ee09c6 --- /dev/null +++ b/crates/launchbound-tui/tests/golden/overview-metal-80x24.styled.txt @@ -0,0 +1,31 @@ +size: 80x24 cursor: hidden +launchbound — reduce-stable · gate cc metal · measured · Apple M4 Pro +NO convergence gate exists on the Metal path: the same bug class is NOT checked +11 candidates · 11 admitted · 0 refused · 11 measured ok +┌overview──────────────────────────────────────────────────────────────────────┐ +│CHOSEN c1-43e865c2e1620697 block_x=128 tile=128 0.0968 ms [0.0968, 0.0969] │ +│indistinguishable: c1-a0fdcb26d0491a52 │ +│ │ +│GPU-seconds consumed: 0.7 │ +│ │ +│the field, fastest first │ +│» block_x=128 tile=128 0.0968 ms │ +│ block_x=256 tile=256 0.0969 ms +0.0% │ +│ block_x=64 tile=128 0.0989 ms +2.2% │ +│ block_x=128 tile=256 0.0992 ms +2.5% │ +│ block_x=64 tile=256 0.1008 ms +4.1% │ +│ block_x=256 tile=512 0.1040 ms +7.4% │ +│ block_x=128 tile=512 0.1049 ms +8.3% │ +│ block_x=32 tile=128 0.1520 ms +57.0% │ +│ block_x=64 tile=512 0.1681 ms +73.6% │ +│ block_x=32 tile=256 0.2859 ms +195.2% │ +│ block_x=32 tile=512 0.3651 ms +277.1% │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +1 overview · 2 ranking · 3 rejections · 4 progress · j/k scroll · q quit + +styles: +0: 0-11 bold +1: 0-78 bold reverse +4: 1-8 bold +9: 1-24 bold diff --git a/crates/launchbound-tui/tests/tui.rs b/crates/launchbound-tui/tests/tui.rs index 1c7c12a..348d4d4 100644 --- a/crates/launchbound-tui/tests/tui.rs +++ b/crates/launchbound-tui/tests/tui.rs @@ -3,6 +3,10 @@ //! search progress view, and the rejection view — plus the 100-iteration //! stress. No frame contains a clock or an animation. //! +//! Two sibling files carry the rest: `emulation.rs` asserts that the grid +//! every golden here rests on is one the emulator saw whole, and `cli.rs` +//! drives `termlens-cli` over these goldens and the real binary. +//! //! Sync policy: `wait_frame`, and the frame it returns is the one asserted //! on — never `wait_idle`, never sleep. //! @@ -29,8 +33,19 @@ use termlens::{Key, Terminal}; const TIMEOUT: Duration = Duration::from_secs(10); +/// A run directory shipped *inside* this crate. It has to be inside it: +/// `cargo package -p launchbound-tui --list` ships `src/`, `tests/` and +/// nothing above them, so a test reading `../../runs/...` would be published +/// with its data missing and fail for every downstream consumer — and +/// release.yml publishes with `--no-verify`, so nothing in CI would say so. +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + fn fixture_run() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/run-flip") + fixture("run-flip") } fn golden_path(name: &str) -> PathBuf { @@ -63,12 +78,40 @@ fn assert_golden(name: &str, screen: &str, context: &str) { ); } -fn spawn(size: (u16, u16)) -> Terminal { +/// A *styled* golden, compared verbatim. +/// +/// `with_styles()` writes three things a plain golden does not have: the +/// `size:`/`cursor:` header, the grid, and a `styles:` block naming the run +/// of columns each attribute covers. It goes through neither `normalize` +/// nor `to_string()`: this file is read back by `Screen::parse` in the same +/// test, and the round trip is only byte-exact against what `with_styles` +/// actually wrote. +/// +/// Blessed by the same `LAUNCHBOUND_BLESS=1` that blesses the plain +/// goldens, so the documented regeneration command covers this one too. +fn assert_styled_golden(name: &str, screen: &termlens::Screen, context: &str) -> String { + let path = golden_path(name); + let actual = screen.with_styles().to_string(); + if env::var_os("LAUNCHBOUND_BLESS").is_some() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{actual}\n")).unwrap(); + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("missing golden {name}; bless with LAUNCHBOUND_BLESS=1")); + assert_eq!( + expected.trim_end_matches('\n'), + actual, + "{context}: styled frame differs from golden {name}" + ); + expected +} + +fn spawn_in(run_dir: PathBuf, size: (u16, u16)) -> Terminal { let mut t = Terminal::builder() .size(size.0, size.1) .env_clear() .timeout(TIMEOUT) - .arg(fixture_run()) + .arg(run_dir) .spawn(env!("CARGO_BIN_EXE_launchbound-tui")) .expect("failed to spawn the TUI in a PTY"); // A quiet PTY is not a painted PTY: under parallel-test load the first @@ -78,10 +121,28 @@ fn spawn(size: (u16, u16)) -> Terminal { t } +fn spawn(size: (u16, u16)) -> Terminal { + spawn_in(fixture_run(), size) +} + fn quit(mut t: Terminal, context: &str) { t.send(Key::Char('q')).expect("send q"); let status = t.wait_exit().expect("TUI did not exit after q"); assert!(status.success(), "{context}: exited with {status:?}"); + // main.rs leaves the alternate screen *after* the event loop returns, + // on every path. Nothing asserted it, so a `?` that skipped the + // teardown, or a panic escaping the loop, would leave the user's shell + // painted with a dead frame and every test here still green — this is + // a published binary that takes the terminal over. It is the vendored + // skill's own rule, and it belongs in `quit` so every test that spawns + // makes it rather than one. + // + // Not a second instant to race against: the child has exited, so no + // further byte can arrive and this screen is final. + assert!( + !t.screen().alternate_screen(), + "{context}: the TUI exited without leaving the alternate screen" + ); } /// The overview has painted once its footer is on screen: it is drawn last, @@ -113,16 +174,29 @@ fn resize_relayouts_the_frame() { // one the resize produced, leaving nothing for the second wait and a // ten-second timeout. termlens says so in as many words ("has not // completed a repaint since the frame this terminal last returned"). - t.wait_frame(|s| { - let frame = s.to_string(); - frame.contains("q quit") && !frame.contains("[0.0398, 0.0402]") - }) - .expect("the 80-column frame"); + let before = t + .wait_frame(|s| { + let frame = s.to_string(); + frame.contains("q quit") && !frame.contains("[0.0398, 0.0402]") + }) + .expect("the 80-column frame"); t.resize(110, 32).expect("resize"); let frame = t .wait_frame(|s| s.to_string().contains("[0.0398, 0.0402]")) .expect("the relaid-out frame"); assert_golden("overview-110x32.txt", &frame.to_string(), "resized"); + // The subject of this test, said directly rather than inferred from two + // files agreeing with two other files: the frame *changed*. Counting + // changed cells rather than `!is_empty()` on purpose — a `ScreenDiff` + // between two different sizes is non-empty from the sizes alone, which + // would be true of an application that ignored SIGWINCH entirely. These + // are cells inside the eighty-column overlap, so they can only come from + // a re-layout. Its `Display` prints them when it fails. + let diff = before.diff(&frame); + assert!( + diff.cells().count() > 0, + "the 110-column frame re-laid out nothing inside the old width:\n{diff}" + ); quit(t, "resized"); } @@ -283,8 +357,29 @@ fn a_refusal_reason_survives_a_narrow_terminal_whole() { // sixty columns the footer itself is cut before it reaches those words. // A readiness marker has to hold at the width being tested, which is // the sort of thing only a narrow-terminal test finds out. - t.wait_frame(|s| s.to_string().contains("candidates ·")) + let first = t + .wait_frame(|s| s.to_string().contains("candidates ·")) .expect("the first complete frame"); + // And that is a measurement, not a note: the footer keeps four of its + // five separators here and loses the words `ready` waits for. Pinning it + // means the day the footer starts fitting at sixty columns, this comment + // stops being a fact and says so, rather than quietly misinforming the + // next person who writes a narrow-terminal test. + let footer = first.rows() - 1; + let separators = first + .find_all("·") + .into_iter() + .filter(|(row, _)| *row == footer) + .count(); + assert_eq!( + separators, 4, + "the sixty-column footer is cut mid-list:\n{first}" + ); + assert!( + first.locate("q quit").is_none(), + "`ready` would hold at sixty columns after all — reread the comment \ + above and the one in AGENTS.md:\n{first}" + ); t.send(Key::Char('3')).expect("send 3"); let frame = t .wait_frame(|s| s.to_string().contains("all refused configurations:")) @@ -329,3 +424,157 @@ fn a_refusal_reason_survives_a_narrow_terminal_whole() { quit(t, "narrow rejections"); } + +/// The Metal banner is bold *and* reverse-video, and nothing else on the +/// frame is. +/// +/// This is the project's central honesty claim reaching a screen. `app.rs` +/// paints the no-gate notice `BOLD | REVERSED` and its comment says the +/// banner "cannot be disabled (the same rule as the text renderer)" — and +/// until now that rule was enforced only for the *text* renderer, in +/// launchbound-report's `schema_and_golden`, as a plain substring. The TUI +/// had no gate=none frame at all, and a plain-text golden could not tell +/// `BOLD | REVERSED` from unstyled text if it had one: both render the same +/// characters. termlens 0.10's `with_styles()` is what makes the styling +/// assertable, so the claim is finally checked where the user meets it. +/// +/// The fixture is `runs/reduce-stable-metal` copied verbatim into the crate +/// (see `fixture`). +#[test] +fn the_metal_banner_is_bold_and_reversed_and_nothing_else_is() { + const BANNER: &str = + "NO convergence gate exists on the Metal path: the same bug class is NOT checked"; + + let mut t = spawn_in(fixture("run-metal"), (80, 24)); + let frame = t.wait_frame(ready).expect("the first complete frame"); + + let at = frame.find(BANNER).unwrap_or_else(|| { + panic!("the no-gate banner is not on the gate=none frame at all:\n{frame}") + }); + assert_eq!(at, (1, 0), "the banner is the second line of the header"); + + // Every cell of it, not the row: a banner that lost its styling halfway + // is the failure worth catching, and the row is wider than the text. + for col in 0..BANNER.chars().count() as u16 { + let cell = frame + .cell(1, col) + .unwrap_or_else(|| panic!("cell (1, {col}) is off the grid")); + let style = cell.style(); + assert!( + style.bold && style.reverse, + "banner cell (1, {col}) {:?} is not bold+reverse — the notice can \ + be read past:\n{frame}", + cell.contents() + ); + } + + // And it is the only reversed thing on the frame, so "reversed" still + // means "this one banner" to a reader who has seen the screen once. + for row in 0..frame.rows() { + if row == 1 { + continue; + } + for col in 0..frame.cols() { + let Some(cell) = frame.cell(row, col) else { + continue; + }; + assert!( + !cell.style().reverse, + "row {row} col {col} is also reversed, which dilutes the \ + banner:\n{frame}" + ); + } + } + + // The recording. A styled golden pins the rest of the frame's styling + // too — the bold `launchbound` and the bold section headings. + // + // Deliberately no `Screen::parse` round trip here. `assert_styled_golden` + // has just asserted the file equals this screen's `with_styles()` output, + // so parsing it back and diffing could only fail if termlens' own round + // trip were broken — a claim about the harness, not about launchbound, + // and one `emulation.rs::a_frame_survives_the_snapshot_format_and_json` + // makes properly against a live screen. + assert_styled_golden("overview-metal-80x24.styled.txt", &frame, "metal overview"); + + quit(t, "metal overview"); +} + +/// `wait_frame` works here *because* the binary brackets every repaint in +/// DEC 2026 synchronized updates. The module header at the top of this file +/// argues that at length; nothing asserted it. +/// +/// It is worth one test because of how it fails: drop the +/// `BeginSynchronizedUpdate` at main.rs:74 and the emulator never sees a +/// frame boundary, so every `wait_frame` in this file times out after ten +/// seconds and reports what the application was showing — which looks like +/// eight broken assertions about the UI rather than one missing mode. +/// `repaints()` names the cause in one line. +#[test] +fn every_repaint_is_bracketed_so_wait_frame_sees_whole_frames() { + let mut t = spawn((80, 24)); + let first = t.wait_frame(ready).expect("the first complete frame"); + assert_eq!( + first.repaints(), + 1, + "the first draw is one bracketed repaint, not zero (unbracketed) and \ + not several (bracketed per widget):\n{first}" + ); + + let mut expected = 1; + for (key, needle) in [ + ('2', "ranking ("), + ('3', "all refused configurations:"), + ('4', "measured 11 of"), + ] { + t.send(Key::Char(key)).expect("send a view key"); + let frame = t + .wait_frame(|s| s.to_string().contains(needle)) + .expect("the view's frame"); + expected += 1; + assert_eq!( + frame.repaints(), + expected, + "one keystroke is one whole frame ({needle}):\n{frame}" + ); + } + quit(t, "repaints"); +} + +/// The footer reaches the reader whole at eighty columns — every separator +/// and the last word of the last hint. +/// +/// The interesting half is that this is the same measurement the sixty-column +/// test makes and gets a different answer to. `ready` rests on `q quit` +/// being on the grid, so every test in this file rests on it; at sixty +/// columns it is not, and that difference has already cost a ten-second +/// timeout. Pinning both ends means a layout change that starts cutting the +/// footer at eighty is a named failure here rather than eight timeouts. +#[test] +fn the_footer_reaches_the_reader_whole_at_eighty_columns() { + let mut t = spawn((80, 24)); + let frame = t.wait_frame(ready).expect("the first complete frame"); + + let footer = frame.rows() - 1; + let separators: Vec<(u16, u16)> = frame + .find_all("·") + .into_iter() + .filter(|(row, _)| *row == footer) + .collect(); + assert_eq!( + separators.len(), + 5, + "the footer lists six hints separated by five `·`; it is cut:\n{frame}" + ); + + let Some(termlens::Location::Screen { row, col }) = frame.locate("q quit") else { + panic!("the last hint `q quit` is not on the grid:\n{frame}"); + }; + assert_eq!(row, footer, "and it is on the footer row"); + assert!( + col > separators.last().unwrap().1, + "after the last separator:\n{frame}" + ); + + quit(t, "footer"); +} diff --git a/justfile b/justfile index 5530ded..a7142d0 100644 --- a/justfile +++ b/justfile @@ -2,8 +2,11 @@ default: ci -# The full local gate. Never push a commit that fails this. -ci: fmt-check clippy test deny schemas pins +# The full local gate. Never push a commit that fails this. There is no +# "all required jobs green" aggregator job in ci.yml — this recipe is the +# aggregator, and the `ci` job runs it verbatim on both OSes — so a new gate +# becomes required by being listed here. +ci: fmt-check clippy test deny schemas pins skill # Cargo errors on a memberless virtual workspace, so the cargo recipes no-op # until the first crate lands in S1. `grep -c` prints 1 when packages is empty. @@ -48,3 +51,15 @@ pins: # Golden + JSON Schema validation of report documents (S4). schemas: cargo test -p launchbound-report --test schema_and_golden + +# The vendored termlens skill names the version we actually depend on. +# AGENTS.md makes that copy normative for PTY tests, so a stale one is a +# wrong contract, not a stale doc — and the staleness is silent. +skill: + ./.github/scripts/check-skill-version.sh + +# The termlens-cli suite. `#[ignore]`d so a plain `cargo test` never +# `cargo install`s a binary behind a contributor's back (launchbound-tui is +# published); CI asks for it by name. +termlens-cli: + cargo test -p launchbound-tui --test cli -- --ignored