diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md index c4fd27de..3312faa1 100644 --- a/.agents/skills/tessera-contributing/SKILL.md +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -1,28 +1,34 @@ --- name: tessera-contributing -description: Use Tessera's own demos, scenario corpus, and developer tools while working in this repository. Use when running `mise run dev:*`, launching, picturing, or snapshot-testing a demo, adding a demo and its scenario, running `Examples/Scenarios`, or fixing a library defect a demo shows. Read with the `tessera` skill, which it extends. -version: 0.2.0 +description: Use Tessera's own demos, scenario corpus, and developer tools while working in this repository. Use when running `mise run dev:*`, launching, picturing, checking, tracing, or snapshot-testing a demo, adding a demo and its scenarios, running `Examples/Scenarios`, or fixing a library defect a demo shows. Read with the `tessera` skill, which it extends. +version: 0.3.0 --- # Contributing to Tessera with the developer tools -Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tessera` commands, the scenario grammar, goldens, the exit codes, and the pty recipe. This skill adds what only a Tessera contributor has: the `mise` tasks, the demos, the scenario corpus, and the demo snapshot tests. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design behind the tools. +Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tessera` commands, the scenario grammar, goldens, checks, traces, the exit codes, and the pty recipe. This skill adds what only a Tessera contributor has: the `mise` tasks, the demos, the scenario corpus, and the demo snapshot tests. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design behind the tools. ## Commands | mise | tessera | Does | | --- | --- | --- | | `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. | -| `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. | -| `mise run dev:launch`, `dev:shot`, or `dev:tree` | `tessera help COMMAND`, then `tessera apps` | With no name, print the command's help and the demos. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran and it exits 2, as `tessera` does. `dev:run` with no path prints its help the same way. | -| `mise run dev:run PATH...` | `tessera run PATH...` | Run scenario files or directories of them, with `--record`, `--repeat N`, and `--timing`. | +| `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. With `TESSERA_TRACE=FILE` it writes the session's trace. | +| `mise run dev:launch`, `dev:shot`, `dev:tree`, `dev:check`, or `dev:replay` | `tessera help COMMAND`, then `tessera apps` | With no name (or, for `dev:replay`, no file), print the command's help and the demos. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran and it exits 2, as `tessera` does. `dev:run` with no path prints its help the same way. | +| `mise run dev:run PATH...` | `tessera run PATH...` | Run scenario files or directories of them, with `--record`, `--repeat N`, `--timing`, and `--trace DIR`. | | `mise run dev:shot NAME [SIZE]` | `tessera shot NAME [SIZE]` | Print one demo's screen as text. | | `mise run dev:tree NAME [SIZE]` | `tessera tree NAME [SIZE]` | Print one demo's node tree. | +| `mise run dev:check NAME [SIZE]` | `tessera check NAME [SIZE]` | Run every invariant rule over one demo at a size, with no input. | +| `mise run dev:replay FILE --app NAME` | `tessera replay FILE --app NAME` | Run a recorded trace again against a demo and report what differs. | | `mise run dev:help [COMMAND]` | `tessera help [COMMAND]` | Print the commands, or one command's usage. | `mise run dev:*` builds the `tessera` executable once, quietly (`swift build --quiet --package-path Examples --product tessera`), and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors; the binary keeps the task's terminal, and `--json` and `--pretty` go through to it. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. -With mise's shell completions enabled, `mise run dev:launch ` offers the demo names, `dev:shot` and `dev:tree` offer them too and then the sizes, and `dev:run` offers `.scn` files and directories. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. +With mise's shell completions enabled, `mise run dev:launch ` offers the demo names, `dev:shot`, `dev:tree`, and `dev:check` offer them too and then the sizes, `dev:replay` offers them after `--app`, and `dev:run` offers `.scn` files and directories. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. + +## The loop + +The `tessera` skill's loop, through `mise`: `dev:launch NAME` or `dev:shot NAME mobile` to see the demo; `dev:run Examples/Scenarios/NAME-desktop.scn` to read the report; `dev:tree NAME mobile` and a `dump` statement for the nodes and the projection; `dev:run … --trace DIR` for why a step redrew; `dev:check NAME` for the broken invariant. Step 6 is to fix the library, never the demo, and rerun. `dev:run Examples/Scenarios/NAME-desktop.scn --trace /tmp/traces` writes `/tmp/traces/Examples/Scenarios/NAME-desktop.trace.jsonl`, and a live trace is `TESSERA_TRACE=/tmp/live.jsonl mise run dev:launch NAME`, which `dev:replay /tmp/live.jsonl --app NAME` runs again headless. ## Run a demo @@ -30,7 +36,7 @@ With mise's shell completions enabled, `mise run dev:launch ` offers the de ## The scenario corpus -`Examples/Scenarios` holds Tessera's scenarios, `NAME.scn` with its goldens in `NAME/` beside it. `mise run dev:run Examples/Scenarios --pretty` runs all of it, as CI does on macOS, Linux, and Windows, and `ScenarioCorpusTests` in `Examples/Tests/DemoSupportTests` runs the same files through `ScenarioRunner`. `Examples/Scenarios/README.md` is the grammar's source; the `tessera` skill carries the part an app author needs, so a change to the grammar updates both. A new `shot` needs one run with `--record`, and the golden it writes is reviewed and committed with the scenario. The corpus is the examples to copy: `counter-basics.scn` drives input, `expect screen`, and a `shot`. +`Examples/Scenarios` holds Tessera's scenarios, `NAME.scn` with its goldens (`NAME.txt` and `NAME.grid.txt` for each `shot`) in `NAME/` beside it. A demo has two scenarios, `NAME-desktop.scn` and `NAME-mobile.scn`, each taking a `shot` at its size. `mise run dev:run Examples/Scenarios --pretty` runs all of it, as CI does on macOS, Linux, and Windows, and `ScenarioCorpusTests` in `Examples/Tests/DemoSupportTests` runs the same files through `ScenarioRunner`. `Examples/Scenarios/README.md` is the grammar's source; the `tessera` skill carries the part an app author needs, so a change to the grammar updates both. A new `shot` needs one run with `--record`, and the goldens it writes are reviewed and committed with the scenario. Assert the numbers the catalog doc gives (its sizing tables and allocation fixtures), not the numbers you saw. The corpus is the examples to copy: `counter-basics.scn` drives input, `expect screen`, and a `shot`. ## Snapshot-test a demo @@ -46,19 +52,21 @@ func `the counter counts`() async throws { } ``` -`withHeadlessHost` opens a terminal session over a headless device, steps the demo through the same driver as `launch`, and feeds every frame to one Ghostty virtual terminal, so `host.screenRows` is the screen a terminal would show. `@testable import TesseraHarness` reaches it; it is not API for app authors, who run their scenarios through the public `ScenarioRunner`. The viewport names are the ones the `tessera` skill lists. +`withHeadlessHost` opens a terminal session over a headless device, steps the demo through the same driver as `launch`, and feeds every frame to one Ghostty virtual terminal, so `host.screenRows` is the screen a terminal would show. `host.projection` is the projection and `host.records` the trace. `@testable import TesseraHarness` reaches them; they are not API for app authors, who run their scenarios through the public `ScenarioRunner`. The viewport names are the ones the `tessera` skill lists. ## Add a demo -1. Write a `final class` conforming to `Demo` in `Examples/Sources/DemoSupport`: a kebab-case `name`, a `minimum` size, `viewports`, and `fixtures` (empty when there are none). None has a default. +1. Write a `final class` conforming to `Demo` in `Examples/Sources/DemoSupport`: a kebab-case `name`, a `minimum` size, `viewports`, and `fixtures` (empty when there are none). None has a default. Name the class so it does not shadow a library view (`TextDemo`, not `Text`). 2. Handle the quit key in `apply(_:)` with `isQuitKey`, and report it through `isFinished`. 3. Register it with one line in `Demos.registry()`. -4. Add its scenario to `Examples/Scenarios` and a snapshot test beside the counter's. +4. Add its two scenarios to `Examples/Scenarios` and a snapshot test beside the others. -A demo is interactive and shows every style or variant that can differ. It uses only what the library provides. +A demo is interactive, shows every style or variant that can differ, and reads well at 40 columns: no row is wider than the `mobile` viewport. It uses only what the library provides. A root that returns one view from a `@ViewBuilder` body is dirty after every update (the builder wraps it in a non-equatable tuple view), so a demo's draw count says nothing about whether its state changed; a body that returns its root explicitly does skip clean steps. ## Rules -- When a demo shows a library defect, fix the library and add a regression test. Never bend the demo to hide it. +- When a demo shows a library defect, fix the library and add a regression test. Never bend the demo to hide it, and never loosen a scenario until it passes. +- A failing check rule is the library's finding: fix the library and add a regression test. Some rules fire by contract: a fixed `frame(width:)` wider than its parent offered, a `Flex` child wider than its segment, and `Text`, which does not wrap, wider than the width it is offered. A scenario that shows that on purpose names the rules that must hold, such as `check no-dirty-after-pass siblings-disjoint requirements-aggregate`. - A name registered twice fails every command that reads the registry. Never rely on the later registration winning. - `--record` writes goldens from what the demo does now; it is for a new `shot` and a reviewed, intended change, never for a failing run you do not understand. +- The projection, the tree text, the trace, and the `expect cost` counts are free to change between releases; the scenario grammar, the rule ids, the exit codes, the report's fields, and the two golden formats are not. diff --git a/.agents/skills/tessera/SKILL.md b/.agents/skills/tessera/SKILL.md index 796bfc5f..35ae6447 100644 --- a/.agents/skills/tessera/SKILL.md +++ b/.agents/skills/tessera/SKILL.md @@ -1,7 +1,7 @@ --- name: tessera -description: Drive and check an app built on Tessera with the `tessera` command line. Use when writing a Tessera `Application`, registering it in an `ApplicationRegistry`, wiring the executable that calls `HarnessCLI.main(registry:)`, launching it, picturing its screen, writing or running a scenario with `tessera run`, or reading what the harness reports about it. -version: 0.2.0 +description: Drive and check an app built on Tessera with the `tessera` command line. Use when writing a Tessera `Application`, registering it in an `ApplicationRegistry`, wiring the executable that calls `HarnessCLI.main(registry:)`, launching it, picturing its screen or node tree, writing or running a scenario with `tessera run`, running invariant checks, or reading what the harness reports about it: its failures, its checks, its cost, or its trace. +version: 0.3.0 --- # Tessera for app authors @@ -103,16 +103,18 @@ The factory closure runs on the main actor once per run, so every run starts fro | tessera | Does | | --- | --- | | `tessera apps` | List the registered apps: name, minimum size, viewports. | -| `tessera launch NAME` | Run one app in this terminal until the app quits. | -| `tessera run PATH...` | Run scenario files (`.scn`) or directories of them, with `--record`, `--repeat N`, and `--timing`. | +| `tessera launch NAME` | Run one app in this terminal until the app quits. With `TESSERA_TRACE=FILE` it writes the session's trace. | +| `tessera run PATH...` | Run scenario files (`.scn`) or directories of them, with `--record`, `--repeat N`, `--timing`, and `--trace DIR`. | | `tessera shot NAME [SIZE]` | Print one app's screen as text. SIZE is `desktop` (the default), `mobile`, `mobileScreen`, or `WxH`. | -| `tessera tree NAME [SIZE]` | Print one app's node tree, one line per node: identity, type, frame, clip. | +| `tessera tree NAME [SIZE]` | Print one app's node tree, one line per node: identity, type, layout, frame, clip. | +| `tessera check NAME [SIZE]` | Run every invariant rule over one app at a size, with no input. | +| `tessera replay FILE --app NAME` | Run a recorded trace again against an app and report what differs. | | `tessera help [COMMAND]` | Print the commands, or one command's usage. `tessera COMMAND --help` prints the same. | | `tessera --generate-completion-script SHELL` | Print a completion script for `fish`, `zsh`, or `bash`. | Every command takes `--json` or `--pretty` to force the JSON object or the text form. Standard output is one JSON object when it is not a terminal, and text when it is. `apps` prints `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, sorted by name, where `minimum` is `null` for an app that declares none. A failed command prints `{"command", "exitCode", "message"}`. -Exit codes: 0 success, 1 an expectation failed, 2 a usage or parse error (an unknown command, app name, or flag, a missing or extra argument, a flag that takes a value given twice, `--json` with `--pretty`, a bad path, a scenario that does not parse; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, `launch` without a terminal, a golden that cannot be written). `--json` and `--pretty` go after the command or before it. +Exit codes: 0 success, 1 an expectation or check failed, 2 a usage or parse error (an unknown command, app name, or flag, a missing or extra argument, a flag that takes a value given twice, `--json` with `--pretty`, a bad path, a scenario that does not parse, a trace that is not one; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, `launch` without a terminal, a golden that cannot be written). `--json` and `--pretty` go after the command or before it. ## Complete commands and app names @@ -140,6 +142,17 @@ expect -c 'set timeout 20; spawn sh -c "stty rows 24 cols 80; exec swift run tes Read the screen from the bytes the run wrote: cursor addressing, then text. `tessera shot` and a scenario are the cheaper way to read a screen. +## The loop + +When an app looks wrong, work down this list and stop at the first step that names the cause: + +1. `tessera launch NAME` to see it, or `tessera shot NAME mobile` to read the screen without a terminal. +2. `tessera run Scenarios/NAME.scn` and read the report: each failure's `line`, `statement`, `message`, and `expected` against `actual`. +3. `tessera tree NAME mobile` for the nodes and their frames, and a `dump` statement for everything the projection holds. +4. `tessera run … --trace DIR` and read the trace: which step redrew, and the `reasons` the library gave. +5. `tessera check NAME` (or a `check` statement) for the broken invariant, with the rule and the node. +6. Fix the app and rerun, or, when the fault is Tessera's, report a Tessera bug. Never `--record` to make a failing run pass. + ## Picture a state `tessera shot counter mobile --pretty` prints the screen the way a golden holds it, and `tessera tree counter mobile --pretty` prints the nodes with their identities and frames. Both start a fresh app at that size and need no scenario, so they are the quickest way to see a failing state before writing the expectation that pins it. The JSON forms are `{"app", "command": "shot", "exitCode", "rows", "size"}` and `{"app", "command": "tree", "exitCode", "lines", "size"}`. An unknown app is a usage error that lists the registered names, and so is a size the app cannot take. The text `tree` prints is for reading; do not parse it. @@ -173,25 +186,28 @@ key q | `expect text [ROW] "text"` | With a ROW (from 0), that row without its trailing spaces is exactly the text. Without one, some row contains it. | | `expect screen <` completes the app names, and the sizes, from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. +- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for each command that takes one. `mise run dev:launch`, `dev:shot`, `dev:tree`, `dev:check`, and `dev:replay` with no name (or no trace file) print the command's help and the registered apps (`dev:run` with no path prints its help) instead of mise's missing-argument error (exit 0 at a terminal, 2 in a pipe or a script), and their `` completes the app names, and the sizes, from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. - Added two compile-fail fixtures that pin the isolation rules of `Application`, each with a positive control, to `mise run quality:architecture`: an actor host given an app with a main-actor isolated conformance, and a default-main-actor type adopted from another module without `@MainActor` on the conformance. -- Added the scenario language and its runner. `Examples/Scenarios/README.md` is the grammar (`app`, `size`, and `term` headers; `resize`, `key`, `type`, `paste`, and `bytes` input; `shot`, `tree`, `expect text`, `expect screen`, and `expect node … frame`; shell quoting and `#` comments), with a table of the forms it names but cannot honour yet (`wait`, `settle`, `focus`, `mouse`, `check`, `expect cell`, …), each a parse error that says what it waits for. A header-binding, fail-fast parser reports `FILE:LINE: error:` and reads CRLF files; a runner continues after a failed expectation; `shot` compares a text golden beside the scenario and fails on drift unless `--record` writes it. `TesseraHarness` gains `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure` as its public run entry point, and answers a session's colour queries (OSC 10, 11, and 4, and DA1) from the scenario's `term` header. -- Added `tessera run PATH...` (with `--record`, `--repeat N`, `--timing`), `tessera shot NAME [SIZE]`, and `tessera tree NAME [SIZE]` to `HarnessCLI`, each printing text on a terminal and one JSON object otherwise, with exit codes 0 pass, 1 an expectation failed, 2 a usage or parse error, and 3 a harness error. `help COMMAND` prints real text, and `mise run dev:run`, `dev:shot`, `dev:tree`, and `dev:help` mirror the commands. -- Added the scenario corpus, `Examples/Scenarios`, ported from the HeadlessRunner spike with a counter scenario, its text goldens, and the `stack` and `workspace` demos it runs against, and made CI run `tessera run Examples/Scenarios` on every leg of the test matrix, Windows included, with goldens checked out with LF. +- Added the scenario language and its runner. `Examples/Scenarios/README.md` is the grammar (`app`, `size`, and `term` headers; `resize`, `key`, `type`, `paste`, and `bytes` input; `shot`, `tree`, `expect text`, `expect screen`, and `expect node … frame`; shell quoting and `#` comments), with a table of the forms it names but cannot honour yet (`wait`, `settle`, `focus`, `mouse`, `expect cell`, …), each a parse error that says what it waits for. A header-binding, fail-fast parser reports `FILE:LINE: error:` and reads CRLF files; a runner continues after a failed expectation; `shot` compares a text golden beside the scenario and fails on drift unless `--record` writes it. `TesseraHarness` gains `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure` as its public run entry point, and answers a session's colour queries (OSC 10, 11, and 4, and DA1) from the scenario's `term` header. +- Added `tessera run PATH...` (with `--record`, `--repeat N`, `--timing`), `tessera shot NAME [SIZE]`, and `tessera tree NAME [SIZE]` to `HarnessCLI`, each printing text on a terminal and one JSON object otherwise, with exit codes 0 pass, 1 an expectation failed, 2 a usage or parse error, and 3 a harness error. `help COMMAND` prints real text, and `mise run dev:run`, `dev:shot`, `dev:tree`, `dev:check`, `dev:replay`, and `dev:help` mirror the commands. +- Added the scenario corpus, `Examples/Scenarios`, ported from the HeadlessRunner spike with a counter scenario, its text goldens, and the `workspace` demo it runs against, and made CI run `tessera run Examples/Scenarios` on every leg of the test matrix, Windows included, with goldens checked out with LF. +- Added the harness projection, `check`, `expect cost`, and the trace to `TesseraHarness`. A `Projection` is the view graph's diagnostics as sorted-key JSON (nodes with short `type` and `layout` names, frames, clips, proposals, measured sizes, requirements, and invalidation reasons, plus the statistics and the screen's rows), with a `version` and no promise of stability. Five rules read it, and their ids are stable: `no-dirty-after-pass`, `frame-inside-clip`, `measured-within-proposal`, `siblings-disjoint`, and `requirements-aggregate`. The scenario language gains `dump`, `check [RULE...]` (an error fails the step and names the rule and the node; a warning is listed in the observation and fails only a rule you name), `expect cost NAME<=N` over `bodies`, `bytes`, `measures`, `placements`, and `renders`, and the locators `layout=` and `id=` (a user key or a full path) beside `type=`, `text=`, and `nth=`. +- Added `tessera check NAME [SIZE]` (every rule over one app at one size, exit 1 on an error) and `tessera replay FILE --app NAME`, and made `tessera run --trace DIR` and `TESSERA_TRACE` (for `launch`) write a JSONL trace, one record per step with the event, `drew`, the invalidation `reasons`, the changed nodes and screen rows, and the `cost`. `replay` delivers a trace's events to its app (keys, pastes, resizes, and the terminal's focus changes; a trace with a mouse event or a terminal reply is refused by name) and compares reasons, nodes, rows, and cost (bytes excepted), so a library change that moves work shows as a diff. `shot` now writes `NAME.grid.txt` beside `NAME.txt`: one row of text per screen row, then one row of styles, in the design catalog's grid dialect, and `--record` rewrites both; a screen with more than 62 distinct styles fails the `shot` as a harness error. `NAME.txt` itself now reads a wide grapheme as one character (`東京` is two characters over four columns), where the first scenario runner kept the blank cell after it, so a golden with wide text needs one `--record`. +- Added `TerminalSession.bytesDrawn`, a `package` running total of the bytes the session's frames have written (the harness takes the difference across a step), so the harness measures a frame's cost without a lock or a tap. +- Added demos for the landed primitives to `DemoSupport`, each with a desktop and a mobile scenario that shots both sizes and asserts a node's frame, and most of them run `check`: `text` (with the archived Unicode samples), `stacks` (`Spacer`, `Spacer(minLength:)`, a `Spacer` in a `VStack`, and `layoutPriority`; it absorbs the `stack` demo), `frame-padding`, `zstack` (layers from a `ForEach` and from an `if` group too), `flex` (two pages: the constraints, then a `layoutPriority` tier, a child with no `.flex` value, and cross-axis placement), and `foreach` (`siblings-disjoint` passes over `ForEach` items that are added, removed, and reversed). The corpus is 16 scenarios. - Added Windows Ghostty snapshot support with a static library, reproducible Zig build and cache tooling, Frost VM recipes, console mode setup and restoration, live terminal device I/O, focused cross-platform test recipes, and hosted CI coverage. - Added public issue, Discussion, and pull-request forms, private security and conduct reporting, solo-maintainer ownership metadata, and a fork-safe reviewed contributor vouch gate with tested trust parsing. - Added read-only DocC pull-request validation plus main-branch and manual GitHub Pages deployment for the combined static documentation site. @@ -68,3 +72,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed Windows terminal input shutdown so queued resize notifications drain before the event stream finishes, and fixed text snapshots to omit wide-cell continuation placeholders. - Fixed POSIX input cancellation to break retention cycles, wake blocked polls, prevent empty-read CPU spins, and keep active-probe tests deterministic; Linux Lima test arguments now support an empty forwarded argument list. - Fixed CI and documentation cache restoration and made Windows Frost SSH prefer the configured key without exhausting authentication attempts during password fallback. +- Fixed `ViewGraph` diagnostics so a viewport change keeps its `layoutViewportChanged` and `renderViewportChanged` reasons through the `update()` that follows it, where they were dropped and a resize redraw reported none. diff --git a/Examples/Scenarios/README.md b/Examples/Scenarios/README.md index ab18810f..6a870772 100644 --- a/Examples/Scenarios/README.md +++ b/Examples/Scenarios/README.md @@ -48,11 +48,14 @@ One statement per line. Blank lines are ignored. | Input | `type "text"` | One key press per character. | | Input | `paste "text"` | One paste event holding the whole text. | | Input | `bytes "\x1b[A"` | Raw terminal input, parsed as a terminal's input would be. | -| Observe | `shot NAME` | Takes the screen as a golden (see [Goldens](#goldens)). | +| Observe | `shot NAME` | Takes the screen as two goldens (see [Goldens](#goldens)). | | Observe | `tree` | Adds the node tree to the report's observations. | +| Observe | `dump` | Adds the whole [projection](#the-projection) to the report's observations, as a JSON object. | | Assert | `expect text [ROW] "text"` | With a ROW (counted from 0), that row, without its trailing spaces, is exactly the text. Without one, some row contains the text. | | Assert | `expect screen < ApplicationRegistry { var registry = ApplicationRegistry() register(Counter.self, into: ®istry) - register(Stack.self, into: ®istry) + register(FlexDemo.self, into: ®istry) + register(ForEachDemo.self, into: ®istry) + register(FramePaddingDemo.self, into: ®istry) + register(StacksDemo.self, into: ®istry) + register(TextDemo.self, into: ®istry) register(Workspace.self, into: ®istry) + register(ZStackDemo.self, into: ®istry) return registry } diff --git a/Examples/Sources/DemoSupport/FlexDemo.swift b/Examples/Sources/DemoSupport/FlexDemo.swift new file mode 100644 index 00000000..e96714d2 --- /dev/null +++ b/Examples/Sources/DemoSupport/FlexDemo.swift @@ -0,0 +1,166 @@ +import Tessera +import TesseraHarness + +/// `Flex`: explicit integer constraints on one axis, in two pages (`n` switches them). +/// +/// Page one shows the constraint vocabulary (`length`, `fill`, `max`, `percentage`, `ratio`, +/// and `min`) as bars whose width is the allocation the constraint resolved to. Most bars are +/// runs of one letter far wider than their allocation, so what shows is exactly the cells +/// `Flex` gave them: `A` runs for the first child, `B` for the second. Two rows use short +/// letters instead, because an allocation can only be seen through where the next child +/// starts: the fill weights (`w` cycles 1:1, 1:2, 1:3, and 2:1 and moves where `B` starts), +/// and a `min` row whose first child grows into the space left. The last sample is a +/// vertical `Flex` in a seven-row frame. +/// +/// Page two shows what the constraint rows do not: three equal fills against the same three +/// with a `layoutPriority` tier on the last (the higher tier takes the whole remainder), a +/// child with no `.flex` value beside a `fill(1)`, and where a child smaller than the cross +/// axis sits, which is the leading edge for both axes because `Flex` takes no alignment. +/// `s` cycles the spacing of every row between 0, 1, and 2. `q` quits. +final class FlexDemo: Demo { + static let name = "flex" + static let minimum = TerminalSize(columns: 40, rows: 22) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let weights: [(first: Int, second: Int)] = [ + (1, 1), (1, 2), (1, 3), (2, 1), + ] + + private var weightIndex = 0 + private var spacing = 1 + private var page = 0 + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + private var weight: (first: Int, second: Int) { + Self.weights[weightIndex] + } + + var body: some View { + VStack(spacing: 0) { + Text(page == 0 ? "Flex: constraints" : "Flex: priority, defaults, cross axis") + Divider() + if page == 0 { + constraints + } else { + variants + } + Divider() + Text("spacing \(spacing)") + Text("w weights s spacing n page q quit") + } + } + + /// Page one: the constraint vocabulary, each constraint on its own row. + @ViewBuilder private var constraints: some View { + row("length(4) fill(1) max(4)") { + bar("A").flex(.length(4)) + bar("B").flex(.fill(1)) + bar("C").flex(.max(4)) + } + row("percentage(25) percentage(75)") { + bar("A").flex(.percentage(25)) + bar("B").flex(.percentage(75)) + } + row("ratio(1, 3) fill(1)") { + bar("A").flex(.ratio(1, 3)) + bar("B").flex(.fill(1)) + } + row("fill(\(weight.first)) fill(\(weight.second))") { + Text("A").flex(.fill(weight.first)) + Text("B").flex(.fill(weight.second)) + } + row("min(10) length(1)") { + Text("A").flex(.min(10)) + Text("B").flex(.length(1)) + } + HStack(spacing: 2) { + Flex(.vertical, spacing: spacing) { + column("A").flex(.length(1)) + column("B").flex(.fill(1)) + column("C").flex(.length(1)) + } + .frame(width: 1, height: 7) + Text("vertical: length(1) fill(1) length(1)") + } + } + + /// Page two: what priority and a missing constraint do, and where a child smaller than the + /// cross axis sits. Each row has short children, because the allocation shows only in where + /// the next child starts. + @ViewBuilder private var variants: some View { + row("fill(1) fill(1) fill(1)") { + Text("D").flex(.fill(1)) + Text("E").flex(.fill(1)) + Text("F").flex(.fill(1)) + } + row("the same, the third layoutPriority(1)") { + Text("G").flex(.fill(1)) + Text("H").flex(.fill(1)) + Text("I").flex(.fill(1)).layoutPriority(1) + } + row("no flex value, fill(1)") { + Text("J") + Text("K").flex(.fill(1)) + } + VStack(alignment: .leading, spacing: 0) { + Text("cross axis: 3-row child, 1-row child") + Flex(.horizontal, spacing: spacing) { + Text("┃\n┃\n┃").flex(.length(1)) + Text("•").flex(.length(1)) + } + } + VStack(alignment: .leading, spacing: 0) { + Text("cross axis: 8-column child, 1-column") + Flex(.vertical, spacing: spacing) { + Text("########").flex(.length(1)) + Text("◦").flex(.length(1)) + } + } + } + + init() {} + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("w"): weightIndex = (weightIndex + 1) % Self.weights.count + case .character("s"): spacing = (spacing + 1) % 3 + case .character("n"): page = (page + 1) % 2 + default: break + } + } + + /// A caption over one horizontal `Flex`. + private func row( + _ caption: String, @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(caption) + Flex(.horizontal, spacing: spacing, content: content) + } + } + + /// A run of `letter` wider than any allocation the demo's rows can give it. + private func bar(_ letter: String) -> Text { + Text(String(repeating: letter, count: 80)) + } + + /// A column of `letter` taller than the vertical sample's frame. + private func column(_ letter: String) -> Text { + Text(Array(repeating: letter, count: 12).joined(separator: "\n")) + } +} diff --git a/Examples/Sources/DemoSupport/ForEachDemo.swift b/Examples/Sources/DemoSupport/ForEachDemo.swift new file mode 100644 index 00000000..174b7349 --- /dev/null +++ b/Examples/Sources/DemoSupport/ForEachDemo.swift @@ -0,0 +1,88 @@ +import Tessera +import TesseraHarness + +/// `ForEach` inside stacks: a keyed list beside static children, in both axes. +/// +/// A row of tags is a `ForEach` inside an `HStack` after a label, and the items below are a +/// `ForEach` inside a `VStack` between static rows. Both are the shape that once laid every +/// item at the same frame, so the scenario for this demo runs a bare `check` after each +/// change, and `siblings-disjoint` is the rule among its five that would catch it. `+` adds an +/// item and `-` removes the last, up to eight, and `r` reverses the order: an item keeps its +/// identity, so `id=` finds it where it moved to. `q` quits. +final class ForEachDemo: Demo { + private struct Item: Identifiable { + var id: String + var title: String + } + + static let name = "foreach" + static let minimum = TerminalSize(columns: 40, rows: 10) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let pool = [ + Item(id: "a", title: "alpha"), Item(id: "b", title: "bravo"), + Item(id: "c", title: "charlie"), Item(id: "d", title: "delta"), + Item(id: "e", title: "echo"), Item(id: "f", title: "foxtrot"), + Item(id: "g", title: "golf"), Item(id: "h", title: "hotel"), + ] + + private static let tags = ["swift", "tui", "views"] + + private var count = 3 + private var reversed = false + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + private var items: [Item] { + let shown = Array(Self.pool.prefix(count)) + return reversed ? shown.reversed() : shown + } + + var body: some View { + VStack(spacing: 0) { + Text("ForEach") + Divider() + HStack(spacing: 1) { + Text("tags") + ForEach(Self.tags, id: \.self) { tag in + Text("#\(tag)") + } + } + Divider() + ForEach(items) { item in + HStack(spacing: 1) { + Text(item.id) + Text(item.title) + } + } + Divider() + Text("\(count) items\(reversed ? ", reversed" : "")") + Text("+ add - remove r reverse q quit") + } + } + + init() {} + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("+"): count = min(count + 1, Self.pool.count) + case .character("-"): count = max(count - 1, 0) + case .character("r"): reversed.toggle() + default: break + } + } +} diff --git a/Examples/Sources/DemoSupport/FramePaddingDemo.swift b/Examples/Sources/DemoSupport/FramePaddingDemo.swift new file mode 100644 index 00000000..76b7843e --- /dev/null +++ b/Examples/Sources/DemoSupport/FramePaddingDemo.swift @@ -0,0 +1,113 @@ +import Tessera +import TesseraHarness + +/// `frame` and `padding`: fixed and bounded frames, every alignment, uniform and per-edge +/// padding, and the order the two compose in. +/// +/// Neither modifier draws anything, so each sample sits over a backdrop of dots that is the +/// size the modified view should report: the dots a `Text("Go")` does not cover are the cells +/// the modifier reserved. The first sample is a fixed 8x3 frame whose alignment `a` cycles +/// through the nine placements; `+` and `-` change the uniform padding of the `pad` sample +/// from 0 to 3 cells; the last two samples put the same `padding(1)` and `frame(width:)` in +/// both orders, which differ by two columns. `q` quits. +final class FramePaddingDemo: Demo { + static let name = "frame-padding" + static let minimum = TerminalSize(columns: 40, rows: 13) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let alignments: [(name: String, value: Alignment)] = [ + ("topLeading", .topLeading), ("top", .top), ("topTrailing", .topTrailing), + ("leading", .leading), ("center", .center), ("trailing", .trailing), + ("bottomLeading", .bottomLeading), ("bottom", .bottom), + ("bottomTrailing", .bottomTrailing), + ] + + private static let maximumPadding = 3 + + private var alignmentIndex = 0 + private var padding = 1 + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + private var alignment: (name: String, value: Alignment) { + Self.alignments[alignmentIndex] + } + + var body: some View { + VStack(spacing: 0) { + Text("Frame and padding") + Divider() + HStack(spacing: 2) { + sample("fixed 8x3", columns: 8, rows: 3) { + Text("Go").frame(width: 8, height: 3, alignment: alignment.value) + } + sample("min 6", columns: 6, rows: 1) { + Text("Go").frame(minWidth: 6) + } + sample("max 4", columns: 4, rows: 1) { + Divider().frame(width: 12).frame(maxWidth: 4) + } + } + HStack(spacing: 2) { + sample("pad \(padding)", columns: 2 + 2 * padding, rows: 1 + 2 * padding) { + Text("Go").padding(padding) + } + sample("insets", columns: 5, rows: 2) { + Text("Go").padding(EdgeInsets(top: 0, leading: 3, bottom: 1, trailing: 0)) + } + sample("pad+frame", columns: 8, rows: 3) { + Text("Go").padding(1).frame(width: 8) + } + sample("frame+pad", columns: 10, rows: 3) { + Text("Go").frame(width: 8).padding(1) + } + } + Divider() + Text("alignment \(alignment.name)") + Text("a alignment + - padding q quit") + } + } + + init() {} + + private static func backdrop(columns: Int, rows: Int) -> String { + Array(repeating: String(repeating: "·", count: columns), count: rows) + .joined(separator: "\n") + } + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("a"): alignmentIndex = (alignmentIndex + 1) % Self.alignments.count + case .character("+"): padding = min(padding + 1, Self.maximumPadding) + case .character("-"): padding = max(padding - 1, 0) + default: break + } + } + + /// A name over a modified view that sits on a backdrop of the size it should report. + private func sample( + _ name: String, columns: Int, rows: Int, @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(name) + ZStack(alignment: .topLeading) { + Text(Self.backdrop(columns: columns, rows: rows)) + content() + } + } + } +} diff --git a/Examples/Sources/DemoSupport/Stack.swift b/Examples/Sources/DemoSupport/Stack.swift deleted file mode 100644 index a7aa8e9a..00000000 --- a/Examples/Sources/DemoSupport/Stack.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Tessera -import TesseraHarness - -/// Stacks of natural-height content: the demo whose root has a height of its own, so -/// `size mobile` has one to use. -/// -/// Typed characters appear after `Name:`, `Backspace` removes one, and the last line shows the -/// width the demo was last told it has. -final class Stack: Demo { - static let name = "stack" - static let minimum = TerminalSize(columns: 20, rows: 6) - static let viewports: [Viewport] = [.desktop, .mobile] - static let fixtures: [DemoFixture] = [] - - private var size = TerminalSize(columns: 80, rows: 24) - private var typed = "" - - var body: some View { - VStack(spacing: 0) { - Text("Stack demo") - Divider() - HStack(spacing: 1) { - Text("Name:") - Text(typed.isEmpty ? "-" : typed) - } - HStack(spacing: 0) { - Text("left") - Spacer() - Text("right") - } - Divider() - Text("\(size.columns) columns") - } - } - - init() {} - - func apply(_ event: InputEvent) { - switch event { - case .resize(let newSize): - size = newSize - case .key(let key): - switch key.code { - case .character(let character): - typed.append(character) - case .backspace: - _ = typed.popLast() - default: - break - } - default: - break - } - } -} diff --git a/Examples/Sources/DemoSupport/StacksDemo.swift b/Examples/Sources/DemoSupport/StacksDemo.swift new file mode 100644 index 00000000..1b6a0736 --- /dev/null +++ b/Examples/Sources/DemoSupport/StacksDemo.swift @@ -0,0 +1,143 @@ +import Tessera +import TesseraHarness + +/// `HStack`, `VStack`, and `Spacer`: every alignment, spacing, and way a `Spacer` shares the +/// space left over. +/// +/// The first row is an `HStack` whose spacing `s` cycles from 0 to 3. Below it, three `VStack`s +/// show `.leading`, `.center`, and `.trailing` over the same three lines, three `HStack`s show +/// `.top`, `.center`, and `.bottom` beside a three-line neighbour, a `Spacer` fills the middle +/// of a three-row `VStack`, and `v` switches the first `VStack`s between no spacing and one +/// blank row. The last block is the `Spacer` family in a row: one `Spacer` takes everything, +/// two share it and the earlier takes the odd cell, a `minLength` floor shows where it binds +/// (at 40 columns, not at 80), and a `layoutPriority` hands all of it to the higher +/// priority. `q` quits. +final class StacksDemo: Demo { + static let name = "stacks" + static let minimum = TerminalSize(columns: 40, rows: 22) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let maximumSpacing = 3 + + private var spacing = 1 + private var rowsApart = false + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + var body: some View { + VStack(spacing: 0) { + Text("Stacks") + Divider() + HStack(spacing: spacing) { + Text("A") + Text("B") + Text("C") + Text("spacing \(spacing)") + } + HStack(spacing: 3) { + columns(.leading, "leading") + columns(.center, "center") + columns(.trailing, "trailing") + } + HStack(spacing: 3) { + beside(.top, "top") + beside(.center, "center") + beside(.bottom, "bottom") + between() + } + Divider() + Text("one Spacer") + HStack(spacing: 0) { + Text("L") + Spacer() + Text("R") + } + Text("two Spacers: earlier gets the odd cell") + HStack(spacing: 0) { + Text("L") + Spacer() + Text("M") + Spacer() + Text("R") + } + Text("Spacer(minLength: 20), Spacer()") + HStack(spacing: 0) { + Text("L") + Spacer(minLength: 20) + Text("M") + Spacer() + Text("R") + } + Text("priority 1: first Spacer and neighbours") + HStack(spacing: 0) { + Text("L").layoutPriority(1) + Spacer().layoutPriority(1) + Text("M").layoutPriority(1) + Spacer() + Text("R").layoutPriority(1) + } + Divider() + Text("s spacing v rows apart q quit") + } + } + + init() {} + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("s"): spacing = (spacing + 1) % (Self.maximumSpacing + 1) + case .character("v"): rowsApart.toggle() + default: break + } + } + + /// Three lines of different widths stacked with one horizontal alignment, under its name. + private func columns(_ alignment: HorizontalAlignment, _ name: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(name) + VStack(alignment: alignment, spacing: rowsApart ? 1 : 0) { + Text("a") + Text("bbbbbbb") + Text("cc") + } + } + } + + /// A one-character marker beside a three-line neighbour, placed with one vertical alignment. + private func beside(_ alignment: VerticalAlignment, _ name: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(name) + HStack(alignment: alignment, spacing: 1) { + Text("M") + Text("x\ny\nz") + } + } + } + + /// A `Spacer` in a `VStack`: it takes the rows left in a three-row frame between two lines. + private func between() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("Spacer") + VStack(spacing: 0) { + Text("A") + Spacer() + Text("B") + } + .frame(height: 3) + } + } +} diff --git a/Examples/Sources/DemoSupport/TextDemo.swift b/Examples/Sources/DemoSupport/TextDemo.swift new file mode 100644 index 00000000..27cb4953 --- /dev/null +++ b/Examples/Sources/DemoSupport/TextDemo.swift @@ -0,0 +1,102 @@ +import Tessera +import TesseraHarness + +/// `Text`: what it measures and draws today, with the archived Unicode demo's transcript. +/// +/// The sheet shows every kind of content `Text` takes: ASCII, wide CJK, emoji clusters, +/// combining marks, source lines, and empty content. Each is measured in terminal cells at +/// grapheme boundaries, so `東京` is four columns and a combining mark never gains one. Below +/// them is the Unicode demo's chat transcript, a ruler that counts the 40 columns of the +/// `mobile` viewport, and a probe that `n` and `p` step through one sample at a time with the +/// size it should measure. +/// +/// The demo shows measurement and placement only, with no wrapping, truncation, or styled runs. +/// `q` quits. +final class TextDemo: Demo { + /// One probe sample and the size it measures. + private struct Sample { + var name: String + var text: String + var measures: String + } + + static let name = "text" + static let minimum = TerminalSize(columns: 40, rows: 20) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let samples = [ + Sample(name: "ascii", text: "Hello", measures: "5x1"), + Sample(name: "wide", text: "東京", measures: "4x1"), + Sample(name: "emoji", text: "👩🏽‍💻", measures: "2x1"), + Sample(name: "combining", text: "e\u{0301}", measures: "1x1"), + Sample(name: "halfwidth", text: "アイウ", measures: "3x1"), + Sample(name: "lines", text: "release\nnotes", measures: "7x2"), + ] + + private static let ruler = String( + repeating: "0123456789", count: 4) + + private var sampleIndex = 0 + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + private var sample: Sample { + Self.samples[sampleIndex] + } + + var body: some View { + VStack(spacing: 0) { + Text("Text") + Divider() + row("ascii", "Hello, Tessera") + row("wide", "東京 中文 한글") + row("emoji", "👩🏽‍💻 🧑🏿‍🚀 🇯🇵") + row("marks", "Ti\u{0065}\u{0302}\u{0301}ng Vi\u{0065}\u{0302}\u{0323}t") + row("lines", "first source line\nsecond source line") + row("empty", "") + Divider() + Text("Ari 👩🏽‍💻: I’m building a diff tool") + Text("Chen 陈: 我在做终端布局探针") + Text("Minh: Ti\u{0065}\u{0302}\u{0301}ng Vi\u{0065}\u{0302}\u{0323}t — cảm ơn!") + Text("Bo 🧑🏿‍🚀: https://tessera.dev/labs") + Divider() + Text(sample.text).frame(height: 2) + Text("\(sample.name): measures \(sample.measures)") + Text(Self.ruler) + Text("n next p previous q quit") + } + } + + init() {} + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("n"): sampleIndex = (sampleIndex + 1) % Self.samples.count + case .character("p"): + sampleIndex = (sampleIndex + Self.samples.count - 1) % Self.samples.count + default: break + } + } + + /// A label in an eight-column gutter and the content beside it, top-aligned. + private func row(_ label: String, _ content: String) -> some View { + HStack(spacing: 1) { + Text(label).frame(width: 8) + Text(content) + } + } +} diff --git a/Examples/Sources/DemoSupport/ZStackDemo.swift b/Examples/Sources/DemoSupport/ZStackDemo.swift new file mode 100644 index 00000000..7fb4baa0 --- /dev/null +++ b/Examples/Sources/DemoSupport/ZStackDemo.swift @@ -0,0 +1,142 @@ +import Tessera +import TesseraHarness + +/// `ZStack`: layers that share bounds, paint in source order, and place with one alignment. +/// +/// The first sample is the catalog's anatomy, `Text("OK")` over `Text("status")`. The second +/// places a `Text("Go")` in an 8x3 backdrop of dots with the alignment `a` cycles through. The +/// third overlaps two blocks, and `s` swaps their source order: the later layer is the one +/// that shows. The last four show an empty `ZStack`, which reports no size and paints nothing, +/// a `layoutPriority` that a `ZStack` ignores, layers built by a `ForEach`, and a group of +/// layers that `g` adds with an `if`; the layers of a list are still layers, so the later +/// one shows. `q` quits. +final class ZStackDemo: Demo { + static let name = "zstack" + static let minimum = TerminalSize(columns: 40, rows: 11) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private static let alignments: [(name: String, value: Alignment)] = [ + ("topLeading", .topLeading), ("top", .top), ("topTrailing", .topTrailing), + ("leading", .leading), ("center", .center), ("trailing", .trailing), + ("bottomLeading", .bottomLeading), ("bottom", .bottom), + ("bottomTrailing", .bottomTrailing), + ] + + private static let backdrop = Array(repeating: "········", count: 3).joined( + separator: "\n") + + private static let layers = ["abcd", "ef"] + + private var alignmentIndex = 0 + private var swapped = false + private var grouped = false + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + private var alignment: (name: String, value: Alignment) { + Self.alignments[alignmentIndex] + } + + var body: some View { + VStack(spacing: 0) { + Text("ZStack") + Divider() + HStack(spacing: 2) { + sample("anatomy") { + ZStack { + Text("status") + Text("OK").frame(width: 2) + } + } + sample("alignment") { + ZStack(alignment: alignment.value) { + Text(Self.backdrop) + Text("Go") + } + } + sample("order") { + if swapped { + ZStack { + Text("bb") + Text("AAAA\nAAAA") + } + } else { + ZStack { + Text("AAAA\nAAAA") + Text("bb") + } + } + } + } + HStack(spacing: 2) { + sample("empty") { + HStack(spacing: 0) { + Text("[") + ZStack {} + Text("]") + } + } + sample("priority") { + ZStack { + Text("A").layoutPriority(9) + Text("B") + } + } + sample("layers") { + ZStack { + ForEach(Self.layers, id: \.self) { layer in + Text(layer) + } + } + } + sample("group") { + ZStack { + Text("base") + if grouped { + Text("g") + Text("h") + } + } + } + } + Divider() + Text("alignment \(alignment.name), \(swapped ? "A on top" : "bb on top")") + Text("a alignment s swap g group q quit") + } + } + + init() {} + + func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind != .release else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.modifiers.isEmpty else { + return + } + switch key.code { + case .character("a"): alignmentIndex = (alignmentIndex + 1) % Self.alignments.count + case .character("s"): swapped.toggle() + case .character("g"): grouped.toggle() + default: break + } + } + + /// A name over a sample. + private func sample( + _ name: String, @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(name) + content() + } + } +} diff --git a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift index 9ff6ce40..f1f5319f 100644 --- a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift +++ b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift @@ -121,6 +121,12 @@ struct CounterDemoTests { func `the demo registry has no duplicate names`() throws { let names = try Demos.registry().names() - expectNoDifference(names, ["counter", "counter-high", "stack", "workspace"]) + expectNoDifference( + names, + [ + "counter", "counter-high", "flex", "foreach", "frame-padding", "stacks", "text", + "workspace", "zstack", + ] + ) } } diff --git a/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift new file mode 100644 index 00000000..a9625f40 --- /dev/null +++ b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift @@ -0,0 +1,265 @@ +import CustomDump +import Tessera +import Testing + +@testable import DemoSupport +@testable import TesseraHarness + +/// The demos of the layout primitives: each renders at both its viewports, takes its keys, quits +/// on `q`, and keeps the invariants that hold for it. +@MainActor +struct PrimitiveDemoTests { + nonisolated static let demos = [ + "flex", "foreach", "frame-padding", "stacks", "text", "zstack", + ] + + /// The rules a demo keeps with no help: every rule, except for the demos that overflow or clip + /// on purpose, where a fixed frame or a bar is wider than what it was offered by design and so + /// `measured-within-proposal` and `frame-inside-clip` would flag the demonstration itself. + private static func rules(for demo: String) -> [CheckRule] { + ["flex", "frame-padding"].contains(demo) + ? [.noDirtyAfterPass, .siblingsDisjoint, .requirementsAggregate] + : CheckRule.allCases + } + + private func key(_ character: Character) -> InputEvent { + .key(Key(code: .character(character))) + } + + @Test(arguments: Self.demos) + func `a demo renders at each viewport it declares and passes its rules`(demo: String) + async throws + { + let registry = Demos.registry() + let entry = try registry.entry(named: demo) + + #expect(entry.viewports == [.desktop, .mobile]) + for viewport in entry.viewports { + try await registry.withHeadlessHost(demo, at: viewport) { host in + #expect(host.screenRows.contains { !$0.isEmpty }, "\(demo) at \(viewport.name)") + let violations = Checker.run(Self.rules(for: demo), on: host.projection) + #expect( + violations.isEmpty, "\(demo) at \(viewport.name): \(violations.map(\.text))") + } + } + } + + @Test(arguments: Self.demos) + func `a demo fits the minimum it declares and its mobile height is its natural height`( + demo: String + ) async throws { + let registry = Demos.registry() + let minimum = try #require(try registry.entry(named: demo).minimum) + + try await registry.withHeadlessHost(demo, at: .mobile) { host in + #expect(host.size.columns == 40) + #expect( + host.size.rows == minimum.rows, "\(demo)'s natural height is its minimum rows") + #expect(minimum.columns == 40) + } + } + + @Test(arguments: Self.demos) + func `q finishes a demo and a modified q does not`(demo: String) async throws { + try await Demos.registry().withHeadlessHost(demo, at: .desktop) { host in + try await host.send(.key(Key(code: .character("q"), modifiers: .control))) + #expect(!host.isFinished) + try await host.send(key("q")) + #expect(host.isFinished) + } + } + + @Test(arguments: Self.demos) + func `a demo's key changes its screen and leaves no node dirty`(demo: String) + async throws + { + let keys: [String: Character] = [ + "flex": "w", "foreach": "r", "frame-padding": "a", "stacks": "s", "text": "n", + "zstack": "s", + ] + try await Demos.registry().withHeadlessHost(demo, at: .desktop) { host in + let before = host.screenRows + try await host.send(key(try #require(keys[demo]))) + #expect(host.screenRows != before, "\(demo)") + #expect(host.projection.nodes.allSatisfy { !$0.needsLayout && !$0.needsRender }) + #expect(host.projection.pass == 1) + } + } + + // MARK: What each demo shows + + @Test + func `text measures wide and clustered content in cells`() async throws { + try await Demos.registry().withHeadlessHost("text", at: .desktop) { host in + let wide = host.projection.nodes.first { $0.text == "東京 中文 한글" } + expectNoDifference(wide?.frame, Projection.Box(column: 9, row: 3, columns: 14, rows: 1)) + #expect(host.screenRows[3] == "wide 東京 中文 한글") + var probes: [[Int]] = [] + for _ in ["wide", "emoji", "combining", "halfwidth"] { + try await host.send(key("n")) + let probe = host.projection.nodes.filter { $0.layout == "_FrameLayout" }[6] + probes.append([probe.frame.columns, probe.frame.rows]) + } + expectNoDifference(probes, [[4, 2], [2, 2], [1, 2], [3, 2]]) + } + } + + @Test + func `frame and padding report the sizes the catalog gives them`() async throws { + try await Demos.registry().withHeadlessHost("frame-padding", at: .desktop) { host in + let projection = host.projection + let frames = projection.nodes.filter { $0.layout == "_FrameLayout" }.map(\.frame) + let paddings = projection.nodes.filter { $0.layout == "_PaddingLayout" }.map( + \.frame) + + expectNoDifference(frames[0], Projection.Box(column: 0, row: 3, columns: 8, rows: 3)) + expectNoDifference(frames[1...3].map(\.columns), [6, 4, 12]) + expectNoDifference(paddings.map(\.columns), [4, 5, 4, 10]) + expectNoDifference(paddings.map(\.rows), [3, 2, 3, 3]) + } + } + + @Test + func `the padding keys change the reserved cells from none to three on every edge`() + async throws + { + try await Demos.registry().withHeadlessHost("frame-padding", at: .desktop) { host in + // `+` and `-` step the uniform padding of the `pad` sample, which starts at one cell and + // stops at zero and at three: 2n + 2 columns by 2n + 1 rows around `Go`. + var sizes: [[Int?]] = [] + for pressed in ["+", "+", "+", "-"] { + try await host.send(key(Character(pressed))) + let padding = host.projection.nodes.first { $0.layout == "_PaddingLayout" } + sizes.append([padding?.frame.columns, padding?.frame.rows]) + } + expectNoDifference(sizes, [[6, 5], [8, 7], [8, 7], [6, 5]]) + for _ in 0..<5 { + try await host.send(key("-")) + } + let none = host.projection.nodes.first { $0.layout == "_PaddingLayout" } + expectNoDifference(none?.frame, Projection.Box(column: 0, row: 7, columns: 2, rows: 1)) + } + } + + @Test + func `a zstack's later layer shows and an empty zstack has no size`() async throws { + try await Demos.registry().withHeadlessHost("zstack", at: .desktop) { host in + #expect(host.screenRows[3].hasPrefix("OKatus Go······ bbAA")) + let zstacks = host.projection.nodes.filter { $0.type == "ZStack" } + expectNoDifference( + zstacks[3].frame, Projection.Box(column: 1, row: 7, columns: 0, rows: 0)) + try await host.send(key("s")) + #expect(host.screenRows[3].hasSuffix("AAAA")) + #expect(host.screenRows[9].hasPrefix("alignment topLeading, A on top")) + } + } + + @Test + func `layers a ForEach and an if group build are layers that check does not flag`() + async throws + { + try await Demos.registry().withHeadlessHost("zstack", at: .desktop) { host in + // The two ForEach items share one frame under the ZStack, and `efcd` is that overlap. + let list = try #require(host.projection.nodes.first { $0.type == "ForEach" }) + let frames = list.children.compactMap { host.projection.node(at: $0)?.frame } + #expect(frames.count == 2) + #expect(frames[0].column == frames[1].column && frames[0].row == frames[1].row) + #expect(host.screenRows[7].hasSuffix("efcd base")) + #expect(Checker.run(CheckRule.allCases, on: host.projection).isEmpty) + + // `g` adds an `if` group of two texts on one frame, over `base`. + try await host.send(key("g")) + #expect(host.screenRows[7].hasSuffix("efcd hase")) + let group = host.projection.nodes.last { $0.type == "TupleView" } + #expect(group?.children.count == 2) + #expect(Checker.run(CheckRule.allCases, on: host.projection).isEmpty) + } + } + + @Test + func `the spacers of the stacks demo share what is left by the rules of the catalog`() + async throws + { + // The vertical Spacer is 0 columns wide. Then one spacer takes 78 cells, two share 77 as 39 + // and 38, `minLength: 20` changes nothing at 80 columns, and the priority spacer takes 77 + // while the unprioritised one gets nothing. + try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in + let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( + \.frame.columns) + expectNoDifference(spacers, [0, 78, 39, 38, 39, 38, 77, 0]) + } + // At 40 columns the share would be 19, so the floor of 20 binds and the plain Spacer is + // left 17. + try await Demos.registry().withHeadlessHost("stacks", at: .mobile) { host in + let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( + \.frame.columns) + expectNoDifference(spacers, [0, 38, 19, 18, 20, 17, 37, 0]) + } + } + + @Test + func `a spacer in a vstack takes the rows between its neighbours`() async throws { + try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in + let vertical = host.projection.nodes.first { $0.type == "Spacer" } + expectNoDifference( + vertical?.frame, Projection.Box(column: 24, row: 9, columns: 0, rows: 1)) + #expect(host.screenRows[8].hasSuffix("A")) + #expect(host.screenRows[10].hasSuffix("B")) + } + } + + @Test + func `flex page two shows a priority tier, an unflexed child, and the cross axis`() + async throws + { + try await Demos.registry().withHeadlessHost("flex", at: .desktop) { host in + try await host.send(key("n")) + let frames = Dictionary( + host.projection.nodes.compactMap { node in node.text.map { ($0, node.frame) } } + ) { first, _ in first } + #expect(host.screenRows[0] == "Flex: priority, defaults, cross axis") + // Three equal fills split 75 cells; with a priority tier the third takes them all. + // No flex value is weight 1 beside a fill(1): 38 cells each, the odd one to the first. + expectNoDifference(["E", "H", "I", "K", "◦"].map { frames[$0]?.column }, [27, 2, 4, 41, 0]) + // A child smaller than the cross axis sits at the leading edge. + expectNoDifference(frames["•"], Projection.Box(column: 2, row: 9, columns: 1, rows: 1)) + let violations = Checker.run(CheckRule.allCases, on: host.projection) + #expect(violations.isEmpty, "\(violations.map(\.text))") + try await host.send(key("n")) + #expect(host.screenRows[0] == "Flex: constraints") + } + } + + @Test + func `flex gives each constraint the allocation its rule resolves to`() async throws { + try await Demos.registry().withHeadlessHost("flex", at: .mobile) { host in + let modifiers = host.projection.nodes.filter { $0.type == "_LayoutValueModifier" } + // Children's origins after their predecessors' allocations: 4, 30, and 4 around two + // spacing cells; 9 and 29 for the percentages of the 39 cells after the spacing; 13 and + // 26; the equal fills 20 and 19; and the min child growing into the space left. + expectNoDifference( + Array(modifiers.map(\.frame.column).prefix(11)), + [0, 5, 36, 0, 10, 0, 14, 0, 21, 0, 39] + ) + } + } + + @Test + func `foreach keeps each item's identity when the order is reversed`() async throws { + func rows(_ projection: Projection) -> [Int?] { + ["a", "b", "c"].map { key in + projection.nodes.first { + $0.path.hasSuffix("id(\(key))") && $0.type == "TupleView" + } + .map(\.frame.row) + } + } + try await Demos.registry().withHeadlessHost("foreach", at: .desktop) { host in + expectNoDifference(rows(host.projection), [4, 5, 6]) + try await host.send(key("r")) + expectNoDifference(rows(host.projection), [6, 5, 4]) + let violations = Checker.run([.siblingsDisjoint], on: host.projection) + #expect(violations.isEmpty) + } + } +} diff --git a/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift index 4c346903..c83a0224 100644 --- a/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift +++ b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift @@ -20,7 +20,7 @@ struct ScenarioCorpusTests { let reports = await ScenarioRunner(registry: Demos.registry()).run(Self.corpus) expectNoDifference(reports.filter { !$0.passed }, []) - #expect(reports.count >= 5) + #expect(reports.count >= 16) } @Test diff --git a/Sources/TesseraCore/Runtime/ViewGraph.swift b/Sources/TesseraCore/Runtime/ViewGraph.swift index 2127ba29..c0dd454d 100644 --- a/Sources/TesseraCore/Runtime/ViewGraph.swift +++ b/Sources/TesseraCore/Runtime/ViewGraph.swift @@ -32,6 +32,9 @@ public final class ViewGraph { ) private var size: TerminalSize private var layoutMeasurements: [_LayoutMeasurementKey: TerminalSize] = [:] + /// Whether a resize has changed the size since the last render pass drew it, so the update + /// that starts the next pass still names the viewport as a cause. + private var viewportChangedSinceRender = false /// Work recorded by the most recently completed graph passes. public private(set) var statistics = GraphStatistics() @@ -90,6 +93,10 @@ extension ViewGraph { public func update() { statistics = GraphStatistics() statistics.record(.updateRequested) + if viewportChangedSinceRender { + statistics.record(.layoutViewportChanged) + statistics.record(.renderViewportChanged) + } let start = now() rootNode = reconcile( node: rootNode, @@ -130,6 +137,7 @@ extension ViewGraph { render(rootNode, into: frame) clearRenderFlags(rootNode) statistics.renderDuration = start.duration(to: now()) + viewportChangedSinceRender = false } /// Changes the graph viewport and invalidates layout only when the size changed. @@ -139,6 +147,7 @@ extension ViewGraph { return } self.size = size + viewportChangedSinceRender = true statistics.record(.layoutViewportChanged) statistics.record(.renderViewportChanged) markSubtreeNeedsLayout(rootNode) diff --git a/Sources/TesseraHarness/ApplicationRegistry.swift b/Sources/TesseraHarness/ApplicationRegistry.swift index 64887d86..e4d37123 100644 --- a/Sources/TesseraHarness/ApplicationRegistry.swift +++ b/Sources/TesseraHarness/ApplicationRegistry.swift @@ -29,7 +29,8 @@ public struct ApplicationRegistry { Viewport, TerminalProfile, @escaping @MainActor (any ScenarioHost) async throws -> Void ) async throws -> Void - let live: @MainActor (TerminalApplicationConfiguration) async throws -> Void + let live: + @MainActor (TerminalApplicationConfiguration, TraceWriter?) async throws -> Void } package private(set) var entries: [Entry] = [] @@ -61,8 +62,8 @@ public struct ApplicationRegistry { try await HeadlessHost.run( name: name, viewport: viewport, profile: profile, make: make, body: body) }, - live: { configuration in - try await LiveHost.run(make: make, configuration: configuration) + live: { configuration, trace in + try await LiveHost.run(make: make, configuration: configuration, trace: trace) } ) ) diff --git a/Sources/TesseraHarness/Driver.swift b/Sources/TesseraHarness/Driver.swift index 0fb1fec0..ad3b5270 100644 --- a/Sources/TesseraHarness/Driver.swift +++ b/Sources/TesseraHarness/Driver.swift @@ -1,6 +1,7 @@ import TesseraCore import TesseraRuntime import TesseraTerminal +import TesseraTerminalBuffer import TesseraTerminalCore import TesseraTerminalInput @@ -13,6 +14,11 @@ import TesseraTerminalInput /// shows. The session is an actor that receives finished frames; the frame itself is drawn /// here, in the main actor's isolation, because the graph lives here. /// +/// After every step the driver records one ``TraceRecord``: the event, why the frame was drawn, +/// which nodes and rows changed, and what the step cost. It hands the record to `onRecord` and +/// keeps the latest, so a host decides whether to keep every record (a scenario run), write +/// each to a file (a live session), or drop them. +/// /// The driver has no quit key and no focus policy. It stops being useful when the application /// reports ``isFinished``, and the host decides what to do about that. @MainActor @@ -26,7 +32,15 @@ final class Driver { /// The size the graph was last laid out at. private(set) var size: TerminalSize + /// The record of the latest step, or `nil` before ``start()`` has drawn the first frame. + private(set) var lastRecord: TraceRecord? + private let session: TerminalSession + private let onRecord: (@MainActor (TraceRecord) -> Void)? + private var steps = 0 + private var nodesBefore: [NodeDiagnostics] = [] + private var rowsBefore: [String] = [] + private var bytesBefore = 0 /// Whether the application has asked its host to stop. var isFinished: Bool { @@ -34,15 +48,20 @@ final class Driver { } /// Creates a driver whose graph starts at `size` and draws through `session`. + /// + /// - Parameter onRecord: Called with each step's record, on the main actor, before the step + /// returns. init( app: App, size: TerminalSize, session: TerminalSession, - environment: EnvironmentValues = EnvironmentValues() + environment: EnvironmentValues = EnvironmentValues(), + onRecord: (@MainActor (TraceRecord) -> Void)? = nil ) { self.app = app self.size = size self.session = session + self.onRecord = onRecord graph = ViewGraph(root: { app.body }, size: size, environment: environment) } @@ -51,8 +70,10 @@ final class Driver { /// The size reaches the application as a resize event, through the same step as every /// later event, because an application has no other way to learn the size it starts at. func start() async throws { - Step.run(app, graph: graph, event: .resize(size), isolation: MainActor.shared) + let event = InputEvent.resize(size) + Step.run(app, graph: graph, event: event, isolation: MainActor.shared) try await present() + await record(event, drew: true) } /// Delivers one event, then draws a frame if anything changed. @@ -66,9 +87,11 @@ final class Driver { } let disposition = Step.run( app, graph: graph, event: event, isolation: MainActor.shared) - if graph.needsRender { + let drew = graph.needsRender + if drew { try await present() } + await record(event, drew: drew) return disposition } @@ -78,4 +101,51 @@ final class Driver { graph.render(into: frame) } } + + /// Records the step that has just finished: its statistics are the pass's, since the graph + /// resets them at every update. + private func record(_ event: InputEvent, drew: Bool) async { + let nodes = graph.diagnostics.nodes + var rows = rowsBefore + if drew, let frame = await session.lastDrawn { + rows = frame.buffer.textRows + } + let bytesDrawn = await session.bytesDrawn + let record = TraceRecord( + seq: steps, + event: event, + drew: drew, + statistics: graph.statistics, + bytes: bytesDrawn - bytesBefore, + nodes: TraceRecord.NodeChanges(from: nodesBefore, to: nodes), + rowsBefore: rowsBefore, + rowsAfter: rows) + nodesBefore = nodes + rowsBefore = rows + bytesBefore = bytesDrawn + steps += 1 + lastRecord = record + onRecord?(record) + } +} + +extension Buffer { + /// The text of every row, as the cells hold it: a continuation cell adds nothing, a cell + /// with no text adds a space, and a row loses its trailing spaces. + fileprivate var textRows: [String] { + (0.. CommandOutput { + let picture: ScenarioPlayer.Picture + switch await HarnessCLI.loadPicture("check", target, in: context) { + case .refused(let refusal): + return refusal + case .picture(let loaded): + picture = loaded + } + let name = target.name + let rules = CheckRule.allCases + let violations = Checker.run(rules, on: picture.projection) + let errors = violations.filter { $0.severity == CheckRule.Severity.error.rawValue } + let code: Int32 = errors.isEmpty ? 0 : 1 + let size = "\(picture.size.columns)x\(picture.size.rows)" + if context.asJSON { + return CommandOutput( + exitCode: code, + stdout: HarnessCLI.encode( + Result( + app: name, exitCode: code, rules: rules.map(\.rawValue), size: size, + violations: violations)) + "\n") + } + var lines = [ + "check \(name) at \(size) against \(rules.count) rules: " + + rules.map(\.rawValue).joined(separator: ", ") + ] + lines += violations.map { " \($0.text)" } + let warnings = violations.count - errors.count + let warningText = "\(warnings) \(warnings == 1 ? "warning" : "warnings")" + if errors.isEmpty { + lines.append("PASS \(name)" + (warnings > 0 ? ": \(warningText)" : "")) + } else { + lines.append( + "FAIL \(name): \(errors.count) \(errors.count == 1 ? "error" : "errors")" + + (warnings > 0 ? ", \(warningText)" : "")) + } + return CommandOutput(exitCode: code, stdout: lines.joined(separator: "\n") + "\n") + } +} + +struct ReplayCommand: HarnessCommand { + static let configuration = CommandConfiguration( + commandName: "replay", + abstract: "Run a recorded trace again against an application.", + discussion: """ + Runs the trace FILE, written by `launch` under TESSERA_TRACE or by `run --trace`, \ + again against the registered application: the same events, in order, at the size \ + the recording started at. After every step the replay is compared with the \ + recording on why the frame was drawn, the nodes and rows that changed, and the \ + cost, which are the same on any terminal; the bytes a frame wrote are not \ + compared. A step that differs is a `replay` failure (exit 1) that shows both. Keys, \ + pastes, resizes, and the terminal's focus changes replay; a trace that holds any \ + other event (a mouse event, a terminal reply), or that is not a trace, is a usage \ + error (exit 2). The output is a run report, {"command": "replay", "exitCode", \ + "scenarios": [REPORT]}, whose observation holds the final screen. + """) + + static let singleValueOptions = ["--app"] + + @OptionGroup var output: OutputOptions + + @Argument(help: ArgumentHelp("The trace to run again.", valueName: "file")) + var file: String + + @Option( + name: .customLong("app"), + help: ArgumentHelp( + "The application to replay it against, as `apps` lists it.", valueName: "name"), + completion: HarnessCLI.appNameCompletion) + var app: String + + func validate() throws { + guard !file.isEmpty else { + throw ValidationError(emptyArgumentMessage) + } + guard !app.isEmpty else { + throw ValidationError("`--app` needs a value that is not empty") + } + } + + /// Reports what differs from the recording, as `run` reports a scenario. + @MainActor + func execute(in context: HarnessCLI.Context) async -> CommandOutput { + do { + _ = try context.registry.entry(named: app) + } catch { + return HarnessCLI.registryFailure(error, command: "replay", json: context.asJSON) + } + guard let data = FileManager.default.contents(atPath: file) else { + return HarnessCLI.usage( + "cannot read the trace `\(file)`", command: "replay", json: context.asJSON) + } + guard let text = String(validating: data, as: UTF8.self) else { + return HarnessCLI.usage( + "the trace `\(file)` is not UTF-8 text", command: "replay", json: context.asJSON) + } + + let report = await Replayer.replay( + text: text, file: file, app: app, registry: context.registry) + let code = HarnessCLI.exitCode(for: [report]) + if context.asJSON { + return CommandOutput( + exitCode: code, + stdout: HarnessCLI.encode( + HarnessCLI.RunResult( + command: "replay", exitCode: code, scenarios: [ReportBody(report)])) + "\n") + } + return CommandOutput(exitCode: code, stdout: report.description + "\n") + } +} diff --git a/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift index 0c0bd19b..90ce0cd7 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -13,9 +13,10 @@ import TesseraTerminal /// ``` /// /// The commands are `apps` (list the registry), `launch NAME` (run one application in this -/// terminal), `run PATH...` (run scenario files, with `--record`, `--repeat N`, and -/// `--timing`), `shot NAME [SIZE]` and `tree NAME [SIZE]` (print one application's screen or -/// node tree), and `help [COMMAND]`. +/// terminal), `run PATH...` (run scenario files, with `--record`, `--repeat N`, `--timing`, +/// and `--trace DIR`), `shot NAME [SIZE]`, `tree NAME [SIZE]`, and `check NAME [SIZE]` (print +/// one application's screen or node tree, or check it against the invariant rules), +/// `replay FILE --app NAME` (run a recorded trace again), and `help [COMMAND]`. /// /// Exit codes are 0 for success, 1 when an expectation failed, 2 for a usage or parse error, /// and 3 for a harness error. When standard output is not a terminal, each command writes one @@ -26,7 +27,8 @@ import TesseraTerminal public enum HarnessCLI { /// Runs the command in the process's arguments and exits with its status. /// - /// - Parameter registry: The applications the commands can list and launch. + /// - Parameter registry: The applications the commands can list, launch, and run + /// scenarios against. @MainActor public static func main(registry: ApplicationRegistry) async -> Never { let output = await run( @@ -35,6 +37,7 @@ public enum HarnessCLI { inputIsTerminal: StandardStreams.inputIsTerminal, outputIsTerminal: StandardStreams.outputIsTerminal, configuration: .default, + environment: ProcessInfo.processInfo.environment, executableName: ProcessInfo.processInfo.processName, columns: nil ) @@ -88,7 +91,7 @@ extension HarnessCLI { static let commands: [any HarnessCommand.Type] = [ AppsCommand.self, LaunchCommand.self, RunCommand.self, ShotCommand.self, - TreeCommand.self, + TreeCommand.self, CheckCommand.self, ReplayCommand.self, ] /// The words that name a command: the commands, then ArgumentParser's `help`. @@ -105,6 +108,7 @@ extension HarnessCLI { /// live session, and fails before the terminal changes mode rather than after. /// /// - Parameters: + /// - environment: The process's environment, which `launch` reads `TESSERA_TRACE` from. /// - executableName: What usage and completion scripts call the executable. /// - columns: The width help text wraps at, or `nil` for the terminal's width. @MainActor @@ -114,6 +118,7 @@ extension HarnessCLI { inputIsTerminal: Bool, outputIsTerminal: Bool, configuration: TerminalApplicationConfiguration, + environment: [String: String] = [:], executableName: String = "tessera", columns: Int? = 80 ) async -> CommandOutput { @@ -148,6 +153,7 @@ extension HarnessCLI { in: Context( asJSON: asJSON, configuration: configuration, + environment: environment, registry: registry, terminals: (input: inputIsTerminal, output: outputIsTerminal))) case is Root: diff --git a/Sources/TesseraHarness/HeadlessHost.swift b/Sources/TesseraHarness/HeadlessHost.swift index d7d358f2..dda08619 100644 --- a/Sources/TesseraHarness/HeadlessHost.swift +++ b/Sources/TesseraHarness/HeadlessHost.swift @@ -19,26 +19,39 @@ package protocol ScenarioHost: AnyObject { /// The visible screen, one string per row, with trailing blanks trimmed. var screenRows: [String] { get } + /// The visible screen as cells, for the goldens and the projection. + var screen: ScreenGrid { get } + /// The view graph's current diagnostics. var diagnostics: GraphDiagnostics { get } + /// The graph's diagnostics and the screen, joined as of the last completed step. + var projection: Projection { get } + + /// The record of the last step, which is the first frame until an event has been sent. + var lastRecord: TraceRecord? { get } + + /// The record of every step so far, the first frame first. + var records: [TraceRecord] { get } + /// Delivers one event and returns once the resulting frame is committed. func send(_ event: InputEvent) async throws } extension ScreenSnapshot { - /// One string per row, with trailing blanks trimmed. + /// One string per row, with trailing blanks trimmed and a wide grapheme's continuation cell + /// left out. var trimmedRows: [String] { - cells.map { row in - var text = String(row.map(\.character)) - while text.last == " " { - text.removeLast() - } - return text - } + ScreenGrid(self).textRows } } +/// The records of a run, kept in step order. +@MainActor +private final class RecordLog { + var records: [TraceRecord] = [] +} + /// Runs an application on a terminal session over a headless device and reads the result from /// one persistent virtual terminal. /// @@ -50,6 +63,7 @@ final class HeadlessHost: ScenarioHost { private let driver: Driver private let device: HeadlessTerminalDevice private let terminal: VirtualTerminal + private let log: RecordLog var size: TerminalSize { driver.size @@ -60,21 +74,41 @@ final class HeadlessHost: ScenarioHost { } var screenRows: [String] { - terminal.snapshot().trimmedRows + screen.textRows + } + + var screen: ScreenGrid { + ScreenGrid(terminal.snapshot()) } var diagnostics: GraphDiagnostics { driver.graph.diagnostics } + var projection: Projection { + Projection( + diagnostics: driver.graph.diagnostics, screen: screen, + pass: driver.lastRecord?.seq ?? 0) + } + + var lastRecord: TraceRecord? { + driver.lastRecord + } + + var records: [TraceRecord] { + log.records + } + private init( driver: Driver, device: HeadlessTerminalDevice, - terminal: VirtualTerminal + terminal: VirtualTerminal, + log: RecordLog ) { self.driver = driver self.device = device self.terminal = terminal + self.log = log } /// Builds the application, opens a headless session at `viewport`, and runs `body` against @@ -105,9 +139,13 @@ final class HeadlessHost: ScenarioHost { // The session's scope hands this function value, not the generic driver, to the main // actor, so the application's conformance never crosses out of it. let scope: @MainActor (TerminalSession) async throws -> Void = { session in - let driver = Driver(app: app, size: size, session: session) + let log = RecordLog() + let driver = Driver( + app: app, size: size, session: session + ) { log.records.append($0) } try await driver.start() - try await body(HeadlessHost(driver: driver, device: device, terminal: terminal)) + try await body( + HeadlessHost(driver: driver, device: device, terminal: terminal, log: log)) } do { try await TerminalSession.withApplicationTerminal( diff --git a/Sources/TesseraHarness/LiveHost.swift b/Sources/TesseraHarness/LiveHost.swift index bf536e30..d1b93c7f 100644 --- a/Sources/TesseraHarness/LiveHost.swift +++ b/Sources/TesseraHarness/LiveHost.swift @@ -10,17 +10,21 @@ import TesseraTerminal enum LiveHost { /// Builds the application, opens the terminal, and runs until the application finishes. /// - /// The factory runs once, on the main actor, before the terminal changes mode. + /// The factory runs once, on the main actor, before the terminal changes mode. When `trace` + /// is given, the driver writes it one record per step, so the session can be replayed. static func run( make: @MainActor () -> App, - configuration: TerminalApplicationConfiguration + configuration: TerminalApplicationConfiguration, + trace: TraceWriter? = nil ) async throws { let app = make() // The session's scope hands this function value, not the generic driver, to the main // actor, so the application's conformance never crosses out of it. let loop: @MainActor (TerminalSession) async throws -> Void = { session in - let driver = Driver(app: app, size: try await session.size, session: session) + let driver = Driver( + app: app, size: try await session.size, session: session + ) { trace?.append($0) } try await driver.start() guard !driver.isFinished else { return diff --git a/Sources/TesseraHarness/Projection/Checks.swift b/Sources/TesseraHarness/Projection/Checks.swift new file mode 100644 index 00000000..11b6e570 --- /dev/null +++ b/Sources/TesseraHarness/Projection/Checks.swift @@ -0,0 +1,286 @@ +import TesseraTerminalCore + +/// A named invariant over a ``Projection``. +/// +/// Every rule is a pure function of the projection: it reads no graph and no screen beyond what +/// the projection holds, so it runs on a crafted value as well as on a live run. The rule ids +/// are stable kebab-case names that a scenario's `check` statement writes. A rule added in a +/// minor release runs only when it is named and joins bare `check` at the next major, so a +/// release cannot fail an app's CI with a rule the app never asked for. +package enum CheckRule: String, CaseIterable, Sendable { + /// A leaf's content is placed outside its clip. + case frameInsideClip = "frame-inside-clip" + + /// A view measured more than the size it was offered on a constrained axis. + case measuredWithinProposal = "measured-within-proposal" + + /// A node is still dirty after a completed pass. + case noDirtyAfterPass = "no-dirty-after-pass" + + /// A terminal requirement does not roll up from the node that asks for it to the graph. + case requirementsAggregate = "requirements-aggregate" + + /// Two children of one container show a cell in common. + case siblingsDisjoint = "siblings-disjoint" + + /// How a violation of a rule counts. + package enum Severity: String, Sendable { + case error + case warning + } + + /// Every rule, in the order bare `check` runs them. The cases are declared in alphabetical + /// order, so this list is what `allCases` is. + package static let allCases: [Self] = [ + .noDirtyAfterPass, .frameInsideClip, .measuredWithinProposal, .siblingsDisjoint, + .requirementsAggregate, + ] + + /// The rule ids, in the order bare `check` runs them, for the error that names the + /// vocabulary. + package static var accepted: String { + allCases.map(\.rawValue).joined(separator: ", ") + } + + /// How a violation of the rule counts. + /// + /// An error fails the `check` statement. A warning is reported and does not fail a bare + /// `check`, because the content it flags can be clipped on purpose; naming the rule asks for + /// it to hold, so a named rule fails on a warning too. + package var severity: Severity { + switch self { + case .frameInsideClip: .warning + case .measuredWithinProposal, .noDirtyAfterPass, .requirementsAggregate, + .siblingsDisjoint: + .error + } + } +} + +/// One invariant that does not hold, on one node. +package struct Violation: Encodable, Equatable, Sendable { + /// The id of the rule, such as `siblings-disjoint`. + package var rule: String + + /// `error` or `warning`. + package var severity: String + + /// The identity path of the node the rule found at fault. + package var node: String + + /// What is wrong, with the numbers that show it. + package var detail: String + + /// The line a human reads: the severity, the rule, the node, and the detail. + package var text: String { + "\(severity) \(rule) \(node): \(detail)" + } +} + +/// The container layouts whose children overlap by contract. +/// +/// A container is matched by its layout algorithm when it has one and by its type otherwise. +/// Every other container is held to disjoint children, so a new overlay, popover, or modal +/// container must join this list or `siblings-disjoint` fails loudly on it. +private let overlappingContainers: Set = ["ZStack"] + +/// The containers whose descendants are clipped on purpose, so a clipped leaf under one is the +/// container doing its job. +private let scrollingContainers: Set = ["ScrollView"] + +/// One projection, indexed for the rules. +private struct CheckContext { + let projection: Projection + let byPath: [String: Projection.Node] + + init(_ projection: Projection) { + self.projection = projection + byPath = Dictionary(projection.nodes.map { ($0.path, $0) }) { first, _ in first } + } + + func violation(_ rule: CheckRule, _ node: Projection.Node, _ detail: String) + -> Violation + { + Violation( + rule: rule.rawValue, severity: rule.severity.rawValue, node: node.path, + detail: detail) + } + + /// The node whose layout places `node`'s children: `node` itself, or the nearest ancestor + /// that is not a view list when `node` is one. + func placingContainer(of node: Projection.Node) -> Projection.Node { + var current = node + while current.isList, let parent = current.parent.flatMap({ byPath[$0] }) { + current = parent + } + return current + } + + /// A node still marked for layout or rendering once the pass that drew the screen is over. + func noDirtyAfterPass() -> [Violation] { + projection.nodes.compactMap { node in + let flags = [ + node.needsLayout ? "needsLayout" : nil, node.needsRender ? "needsRender" : nil, + ].compactMap(\.self) + guard !flags.isEmpty else { + return nil + } + return violation( + .noDirtyAfterPass, node, + "still dirty after pass \(projection.pass): \(flags.joined(separator: ", "))") + } + } + + /// A leaf with cells whose frame reaches past its clip: some of its content is not drawn. + func frameInsideClip() -> [Violation] { + projection.nodes.compactMap { node in + guard node.isLeaf, !node.frame.isEmpty, !isScrolled(node) else { + return nil + } + let visible = node.frame.rect.intersection(node.clip.rect) + guard visible != node.frame.rect else { + return nil + } + let shown = visible.map { Projection.Box($0).text } ?? "nothing" + let content = node.text.map { " (visible text `\($0.firstLine)`)" } ?? "" + return violation( + .frameInsideClip, node, + "frame \(node.frame.text) is clipped to \(shown)\(content)") + } + } + + /// A node that measured wider or taller than the axis it was offered. A node that was never + /// placed keeps a stale probe measurement, so one whose frame is empty and differs from its + /// measurement is skipped. + func measuredWithinProposal() -> [Violation] { + projection.nodes.compactMap { node in + guard let proposal = node.proposal, let measured = node.measured else { + return nil + } + if node.frame.isEmpty, + node.frame.columns != measured.columns || node.frame.rows != measured.rows + { + return nil + } + var overflows: [String] = [] + if let width = proposal.width, measured.columns > width { + overflows.append("\(measured.columns) columns where \(width) were offered") + } + if let height = proposal.height, measured.rows > height { + overflows.append("\(measured.rows) rows where \(height) were offered") + } + guard !overflows.isEmpty else { + return nil + } + return violation( + .measuredWithinProposal, node, "measured \(overflows.joined(separator: " and "))") + } + } + + /// Two children of one container that show a cell in common, unless the container overlaps + /// its children by contract. + /// + /// A child shows its frame clipped to its clip, so a child wider than its segment that is + /// clipped to it (a `Flex` child whose content overflows) does not overlap its neighbour; + /// two children placed on one frame (the `ForEach` defect) do. Layout looks through a view + /// list (`ForEach`, `if`, a group of views) to its elements, so the container that decides + /// whether they overlap is the nearest ancestor that is not a list: the elements of a + /// `ForEach` under a `ZStack` are layers, and under a `VStack` they are not. + func siblingsDisjoint() -> [Violation] { + var violations: [Violation] = [] + for parent in projection.nodes where parent.children.count > 1 { + let name = parent.layout ?? parent.type + let container = placingContainer(of: parent) + guard !overlappingContainers.contains(container.layout ?? container.type) else { + continue + } + let children = parent.children.compactMap { byPath[$0] } + for (index, later) in children.enumerated() { + guard let shown = visible(later) else { + continue + } + for earlier in children[.. [Violation] { + guard let root = projection.nodes.first else { + return [] + } + var violations: [Violation] = [] + let requested = Set(projection.requirements.requested) + let names = Set(projection.nodes.flatMap(\.requirements)).union(requested).sorted() + for name in names { + let sources = projection.nodes.filter { $0.requirements.contains(name) } + if let source = sources.first, !requested.contains(name) { + violations.append( + violation( + .requirementsAggregate, source, + "asks for `\(name)`, which the graph does not request")) + } else if sources.isEmpty { + violations.append( + violation( + .requirementsAggregate, root, + "the graph requests `\(name)` and no node asks for it")) + } + } + return violations + } + + /// The cells a node shows: its frame clipped to its clip, or `nil` when it shows none. + private func visible(_ node: Projection.Node) -> Rect? { + node.frame.rect.intersection(node.clip.rect) + } + + private func isScrolled(_ node: Projection.Node) -> Bool { + var cursor = node.parent.flatMap { byPath[$0] } + while let ancestor = cursor { + if scrollingContainers.contains(ancestor.layout ?? ancestor.type) { + return true + } + cursor = ancestor.parent.flatMap { byPath[$0] } + } + return false + } +} + +/// Runs invariants over a projection. +package enum Checker { + /// The violations of `rules` in `projection`, rule by rule in the order given and node by + /// node in tree order, so the same projection always yields the same list. + package static func run(_ rules: [CheckRule], on projection: Projection) -> [Violation] + { + let context = CheckContext(projection) + return rules.flatMap { rule -> [Violation] in + switch rule { + case .noDirtyAfterPass: context.noDirtyAfterPass() + case .frameInsideClip: context.frameInsideClip() + case .measuredWithinProposal: context.measuredWithinProposal() + case .siblingsDisjoint: context.siblingsDisjoint() + case .requirementsAggregate: context.requirementsAggregate() + } + } + } +} + +extension String { + /// The first line, for a detail that quotes text. + fileprivate var firstLine: String { + split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false).first.map( + Self.init) + ?? self + } +} diff --git a/Sources/TesseraHarness/Projection/Projection.swift b/Sources/TesseraHarness/Projection/Projection.swift new file mode 100644 index 00000000..573bf1f8 --- /dev/null +++ b/Sources/TesseraHarness/Projection/Projection.swift @@ -0,0 +1,346 @@ +import TesseraCore +import TesseraTerminalCore + +/// What a locator, a check, `dump`, and `tree` read: the graph's diagnostics after one +/// completed pass, joined to the screen that pass drew. +/// +/// A projection is a value. It holds no view value, closure, or reflected type name, so it +/// serializes as sorted-key JSON and two runs of one scenario project to the same bytes. The +/// runtime returns the facts (`GraphDiagnostics`) and the harness judges them here: locators +/// pick nodes from it, and every invariant is a pure function of it. +/// +/// The JSON carries a `version` and is not part of the 1.0 promise: a field may be added, +/// renamed, or removed in any release, and the version says when. +package struct Projection: Encodable, Equatable, Sendable { + /// A cell rectangle, half-open like `Rect`. + struct Box: Encodable, Equatable, Sendable { + var column: Int + var row: Int + var columns: Int + var rows: Int + + var rect: Rect { + Rect(column: column, row: row, columns: columns, rows: rows) + } + + var isEmpty: Bool { + rect.isEmpty + } + + /// `X,Y,WxH`, the notation `expect node … frame` and `tree` use. + var text: String { + "\(column),\(row),\(columns)x\(rows)" + } + + init(_ rect: Rect) { + column = rect.origin.column + row = rect.origin.row + columns = rect.size.columns + rows = rect.size.rows + } + + init(column: Int, row: Int, columns: Int, rows: Int) { + self.column = column + self.row = row + self.columns = columns + self.rows = rows + } + } + + /// A size in cells. + struct Extent: Encodable, Equatable, Sendable { + var columns: Int + var rows: Int + } + + /// The size a node was offered; a missing axis was unconstrained. + struct Proposal: Encodable, Equatable, Sendable { + var width: Int? + var height: Int? + } + + /// One node of the view graph. + struct Node: Encodable, Equatable, Sendable { + /// The identity path, such as `root/index(2)/id(catalog)`; unique per node. + var path: String + var parent: String? + var children: [String] + var depth: Int + + /// The zero-based position among the parent's children, which is the order they paint in + /// (the root is 0). + var paintOrder: Int + + /// The module-free, generic-free type name: `Text`, `VStack`, `_LayoutContainer`. + var type: String + + /// For a `_LayoutContainer` (`frame`, `padding`, an app's own `Layout`), the short name of + /// its algorithm; absent for every other node. + var layout: String? + + /// Whether this is one of the library's view lists (`ForEach`, `if`, a group of views), + /// which layout looks through to place the elements as the list's parent's own children. + /// It is decided from the module-qualified type, so an app's own view named `ForEach` is + /// not one. + var isList: Bool + var proposal: Proposal? + var measured: Extent? + var frame: Box + var clip: Box + var needsLayout: Bool + var needsRender: Bool + var environmentOverrides: [String] + var handlerKinds: [String] + var requirements: [String] + + /// The screen text attributed to this node, which only a leaf has: the characters of the + /// cells it owns, where a cell belongs to the last leaf in paint order that covers it, so + /// text that another node paints over is not its text. Absent when it owns no text. + var text: String? + + var isLeaf: Bool { + children.isEmpty + } + } + + /// The terminal features the graph asks for. + struct Requirements: Encodable, Equatable, Sendable { + var effective: [String]? // swiftlint:disable:this discouraged_optional_collection + var requested: [String] + } + + /// The screen the pass drew. + struct Screen: Encodable, Equatable, Sendable { + /// Where the cursor is. + struct Cursor: Encodable, Equatable, Sendable { + var column: Int + var row: Int + } + + /// A run of non-default style. + struct StyleRun: Encodable, Equatable, Sendable { + var row: Int + var column: Int + var columns: Int + var style: String + } + + var columns: Int + var cursor: Cursor + + /// The text of each row with trailing spaces removed; a wide grapheme is one character, + /// so a character index is not always a column. + var rows: [String] + var styles: [StyleRun] + } + + /// The pass counters. Durations are left out: they are not deterministic. + struct Statistics: Encodable, Equatable, Sendable { + var bodyEvaluations: Int + var equatableSkips: Int + var focusChanges: Int + var invalidationReasons: [String] + var leafUpdates: Int + var measurements: Int + var nodesCreated: Int + var nodesDestroyed: Int + var nodesUpdated: Int + var placements: Int + var renderedNodes: Int + var terminalRequirementChanges: Int + } + + /// The schema version the JSON carries. + package static let currentVersion = 1 + + var version = Self.currentVersion + + /// The step whose completed pass this projects: 0 is the first frame, and each event the + /// scenario delivers adds one. A projection is only taken between steps, after the pass has + /// laid out and drawn, which is why a node that is still dirty is a finding. + var pass: Int + var nodes: [Node] + var requirements: Requirements + var screen: Screen + var statistics: Statistics + + /// Memberwise creation, for tests that craft a projection a check should judge. + init( + pass: Int, nodes: [Node], requirements: Requirements, screen: Screen, + statistics: Statistics + ) { + self.pass = pass + self.nodes = nodes + self.requirements = requirements + self.screen = screen + self.statistics = statistics + } + + /// Projects `diagnostics` and the screen drawn by the same pass. + init(diagnostics: GraphDiagnostics, screen grid: ScreenGrid, pass: Int) { + let depths = Self.depths(of: diagnostics.nodes) + let orders = Self.paintOrders(of: diagnostics.nodes) + var nodes = diagnostics.nodes.enumerated().map { index, node in + Node(node, depth: depths[index], paintOrder: orders[index]) + } + ScreenText(grid: grid).attribute(to: &nodes) + self.init( + pass: pass, + nodes: nodes, + requirements: Requirements( + effective: diagnostics.effectiveTerminalRequirements.map(Self.names), + requested: Self.names(diagnostics.requestedTerminalRequirements)), + screen: Screen( + columns: grid.columns, + cursor: Screen.Cursor(column: grid.cursor.column, row: grid.cursor.row), + rows: grid.textRows, + styles: grid.styleRuns.map { + Screen.StyleRun( + row: $0.row, column: $0.column, columns: $0.columns, style: $0.style) + }), + statistics: Statistics(diagnostics.statistics)) + } + + private static func depths(of nodes: [NodeDiagnostics]) -> [Int] { + var depths: [String: Int] = [:] + return nodes.map { node in + let depth = node.parentIdentity.map { (depths[$0.description] ?? -1) + 1 } ?? 0 + depths[node.identity.description] = depth + return depth + } + } + + /// Each node's position among its parent's children, in node order. + private static func paintOrders(of nodes: [NodeDiagnostics]) -> [Int] { + var positions: [String: Int] = [:] + for node in nodes { + for (position, child) in node.childIdentities.enumerated() { + positions[child.description] = position + } + } + return nodes.map { positions[$0.identity.description] ?? 0 } + } + + /// The names a terminal requirement set spells. + static func names(_ requirements: TerminalRequirements) -> [String] { + var names: [String] = [] + if requirements.wantsBracketedPaste { + names.append("bracketedPaste") + } + if requirements.wantsFocusReporting { + names.append("focus") + } + if requirements.wantsKeyboardEnhancement { + names.append("keyboard") + } + if requirements.wantsMouse { + names.append("mouse") + } + return names + } + + /// The node whose identity path is `path`. + func node(at path: String) -> Node? { + nodes.first { $0.path == path } + } +} + +extension Projection.Node { + init(_ node: NodeDiagnostics, depth: Int, paintOrder: Int) { + path = node.identity.description + parent = node.parentIdentity?.description + children = node.childIdentities.map(\.description) + self.depth = depth + self.paintOrder = paintOrder + type = TypeName.short(node.viewType) + layout = TypeName.layout(of: node.viewType) + isList = TypeName.isViewList(node.viewType) + proposal = node.proposal.map { + Projection.Proposal(width: $0.width, height: $0.height) + } + measured = node.measuredSize.map { + Projection.Extent(columns: $0.columns, rows: $0.rows) + } + frame = Projection.Box(node.frame) + clip = Projection.Box(node.clip) + needsLayout = node.needsLayout + needsRender = node.needsRender + environmentOverrides = node.environmentOverrides + handlerKinds = node.handlerKinds + requirements = Projection.names(node.requestedTerminalRequirements) + text = nil + } +} + +extension Projection.Statistics { + init(_ statistics: GraphStatistics) { + bodyEvaluations = statistics.bodyEvaluations + equatableSkips = statistics.equatableSkips + focusChanges = statistics.focusChanges + invalidationReasons = statistics.invalidationReasons.values.map(\.rawValue) + leafUpdates = statistics.leafUpdates + measurements = statistics.measurements + nodesCreated = statistics.nodesCreated + nodesDestroyed = statistics.nodesDestroyed + nodesUpdated = statistics.nodesUpdated + placements = statistics.placements + renderedNodes = statistics.renderedNodes + terminalRequirementChanges = statistics.terminalRequirementChanges + } +} + +/// Attributes the screen's text to the leaf nodes that painted it. +/// +/// `GraphDiagnostics` holds no view values, so a node's text can only come from the screen. +/// Leaves paint in node order (a pre-order walk, the order `ViewGraph.render` visits them), so +/// the last leaf whose clip covers a cell is the one whose content shows there. A leaf's text +/// is the characters of the cells it owns, with the cells a later leaf owns blanked, so text +/// that is drawn over does not match a locator. The model is geometric: a leaf is assumed to +/// paint every cell of its clip, and a leaf that covers cells without painting them (a +/// `Spacer` that has an extent) still takes them from earlier leaves. +private struct ScreenText { + let grid: ScreenGrid + + func attribute(to nodes: inout [Projection.Node]) { + let screen = Rect(column: 0, row: 0, columns: grid.columns, rows: grid.rows.count) + var owner = Array( + repeating: Array(repeating: -1, count: grid.columns), count: grid.rows.count) + var regions: [Int: Rect] = [:] + for (index, node) in nodes.enumerated() where node.isLeaf { + guard let region = node.clip.rect.intersection(screen) else { + continue + } + regions[index] = region + for row in region.rowRange { + for column in region.columnRange { + owner[row][column] = index + } + } + } + + for (index, region) in regions { + var lines: [String] = [] + for row in region.rowRange { + var line = "" + for column in region.columnRange where grid.rows[row].indices.contains(column) { + let gridCell = grid.rows[row][column] + guard !gridCell.isContinuation else { + continue + } + line.append(owner[row][column] == index ? gridCell.cell.character : " ") + } + while line.last == " " { + line.removeLast() + } + lines.append(line) + } + while lines.last?.isEmpty == true { + lines.removeLast() + } + while lines.first?.isEmpty == true { + lines.removeFirst() + } + nodes[index].text = lines.isEmpty ? nil : lines.joined(separator: "\n") + } + } +} diff --git a/Sources/TesseraHarness/Projection/ScreenGrid.swift b/Sources/TesseraHarness/Projection/ScreenGrid.swift new file mode 100644 index 00000000..d7313793 --- /dev/null +++ b/Sources/TesseraHarness/Projection/ScreenGrid.swift @@ -0,0 +1,246 @@ +import TesseraTerminal +import TesseraTerminalCore +import TesseraTerminalSnapshotSupport + +/// The screen a terminal shows, one cell per column. +/// +/// The virtual terminal reports a wide grapheme in its first cell and a blank in the next, and +/// carries no flag that says which blank belongs to the grapheme. The grid marks that second +/// cell as a continuation from the width table the library draws with, so the text of a row +/// reads `東京` and not `東 京 `, and a column still maps to one cell. Once the terminal +/// reports its own wide flag this derivation goes away. +package struct ScreenGrid: Equatable, Sendable { + /// One terminal cell and whether it only continues the wide grapheme before it. + struct GridCell: Equatable, Sendable { + var cell: RenderedCell + var isContinuation: Bool + } + + /// One run of cells that share a non-default style, in one row. + struct StyleRun: Equatable, Sendable { + var row: Int + var column: Int + var columns: Int + var style: String + } + + /// A screen that cannot be written as a grid. + package struct GridError: CustomStringConvertible, Equatable, Error { + /// How many distinct styles the screen has. + var styles: Int + + package var description: String { + "the screen has \(styles) distinct styles, and a grid golden names at most " + + "\(ScreenGrid.alphabet.count) with one letter each; take the shot where fewer " + + "styles show, or compare the screen with `expect text` instead" + } + } + + /// The style letters, in the order a grid hands them out. + private static let alphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + + /// The cells, row by row. + var rows: [[GridCell]] + + /// Where the cursor is, in cells. + var cursor: TerminalPosition + + /// The width of the widest row. + var columns: Int { + rows.map(\.count).max() ?? 0 + } + + /// The visible text of every row: continuation cells elided and trailing spaces removed. + package var textRows: [String] { + rows.indices.map(text(ofRow:)) + } + + /// The runs of non-default style in reading order, as the projection lists them. + var styleRuns: [StyleRun] { + var runs: [StyleRun] = [] + for (row, cells) in rows.enumerated() { + var current: StyleRun? + for (column, style) in Self.columnStyles(of: cells).enumerated() { + if let run = current, run.style == style, !style.isEmpty { + current?.columns = run.columns + 1 + continue + } + if let run = current { + runs.append(run) + } + current = + style.isEmpty + ? nil : StyleRun(row: row, column: column, columns: 1, style: style) + } + if let current { + runs.append(current) + } + } + return runs + } + + package init(_ snapshot: ScreenSnapshot) { + rows = snapshot.cells.map(Self.cells(of:)) + cursor = snapshot.cursor + } + + /// The cell's style as the legend spells it, or an empty string for the default style. + /// + /// Colours come first, then the attributes in the catalog's order. Hyperlinks are not part + /// of the grid. + static func style(of cell: RenderedCell) -> String { + var tokens: [String] = [] + if cell.foreground != .default { + tokens.append("fg=\(colorName(cell.foreground))") + } + if cell.background != .default { + tokens.append("bg=\(colorName(cell.background))") + } + if cell.bold { + tokens.append("bold") + } + if cell.dim { + tokens.append("dim") + } + if cell.italic { + tokens.append("italic") + } + switch cell.underlineStyle { + case .none: break + case .single: tokens.append("underline") + case .double: tokens.append("underline=double") + case .curly: tokens.append("underline=curly") + case .dotted: tokens.append("underline=dotted") + case .dashed: tokens.append("underline=dashed") + } + if cell.underlineStyle != .none, cell.underlineColor != .default { + tokens.append("underline-color=\(colorName(cell.underlineColor))") + } + if cell.reverse { + tokens.append("reverse") + } + if cell.strikethrough { + tokens.append("strikethrough") + } + return tokens.joined(separator: " ") + } + + private static func cells(of row: [RenderedCell]) -> [GridCell] { + var result: [GridCell] = [] + result.reserveCapacity(row.count) + var follows = false + for cell in row { + // Only a blank cell right after a wide grapheme is its continuation. The library's + // width table and the terminal's can disagree about a cluster, and then the cell after + // it holds real content that must not be dropped. + let isContinuation = follows && cell.character == " " + result.append(GridCell(cell: cell, isContinuation: isContinuation)) + follows = !isContinuation && Cell(character: cell.character).width == 2 + } + return result + } + + /// The style of every column of a row; a continuation cell takes the style of the wide + /// grapheme it continues, so a style letter sits under every cell the grapheme covers. + private static func columnStyles(of cells: [GridCell]) -> [String] { + var styles: [String] = [] + styles.reserveCapacity(cells.count) + for gridCell in cells { + if gridCell.isContinuation, let previous = styles.last { + styles.append(previous) + } else { + styles.append(style(of: gridCell.cell)) + } + } + return styles + } + + private static func colorName(_ color: RenderedColor) -> String { + switch color { + case .default: + "default" + case .indexed(let index): + "indexed(\(index))" + case .rgb(let red, let green, let blue): + "#" + [red, green, blue].map(hex).joined() + } + } + + private static func hex(_ byte: UInt8) -> String { + let digits = String(byte, radix: 16) + return digits.count == 1 ? "0" + digits : digits + } + + /// The grid as `NAME.grid.txt` holds it: the characters, an aligned row of style letters + /// for every character row, and the legend that names each letter. + /// + /// This is the catalog's styled-grid dialect (`docs/design.md`, "Styled grids"): three + /// sections in this order, one style letter per cell, `.` for the default style. The rows + /// follow one rule, so the dialect's "no more style rows than chars rows" always holds: a + /// character row loses its trailing spaces, the chars section ends at the last row that has + /// text or a style (a blank row with a background keeps its place as an empty line, and + /// `NAME.txt` still ends at the last row with text), and the style section ends at the last + /// row that has a style, each style row losing its trailing default cells. A screen with no + /// styled cell has no style rows at all. The dialect names a colour only by role, so a + /// colour that no role owns is spelled `#rrggbb` or `indexed(N)`, which + /// `docs/architecture/developer-tooling.md` ("Shots and goldens") fixes. + /// + /// - Throws: ``GridError`` when the screen has more distinct styles than the 62 letters + /// name, since a letter that stands for two styles would make the golden ambiguous. + package func styledGridText() throws(GridError) -> String { + let styled = rows.map(Self.columnStyles(of:)) + let distinct = Set(styled.joined().filter { !$0.isEmpty }) + guard distinct.count <= Self.alphabet.count else { + throw GridError(styles: distinct.count) + } + + var letters: [String: Character] = [:] + var order: [String] = [] + var styleRows: [String] = [] + for styles in styled { + var line = "" + for style in styles { + if style.isEmpty { + line.append(".") + continue + } + let letter = letters[style] ?? Self.alphabet[order.count] + if letters[style] == nil { + letters[style] = letter + order.append(style) + } + line.append(letter) + } + while line.last == "." { + line.removeLast() + } + styleRows.append(line) + } + + let text = textRows + let styledRows = styleRows.lastIndex { !$0.isEmpty }.map { $0 + 1 } ?? 0 + let shownRows = max(text.dropTrailingEmptyRows().count, styledRows) + var lines = ["── chars ──"] + text.prefix(shownRows) + lines.append("── style ──") + lines += styleRows.prefix(styledRows) + lines.append("── legend ──") + lines.append(". default") + for (index, style) in order.enumerated() { + lines.append("\(Self.alphabet[index]) \(style)") + } + return lines.joined(separator: "\n") + "\n" + } + + /// The text of one row, without its trailing spaces. + func text(ofRow row: Int) -> String { + guard rows.indices.contains(row) else { + return "" + } + var text = String(rows[row].filter { !$0.isContinuation }.map(\.cell.character)) + while text.last == " " { + text.removeLast() + } + return text + } +} diff --git a/Sources/TesseraHarness/Projection/TypeName.swift b/Sources/TesseraHarness/Projection/TypeName.swift new file mode 100644 index 00000000..b03b1b57 --- /dev/null +++ b/Sources/TesseraHarness/Projection/TypeName.swift @@ -0,0 +1,124 @@ +/// Short, stable names for the reflected type names a node reports. +/// +/// `NodeDiagnostics.viewType` is `String(reflecting:)` of the view type, which is right for a +/// debugger and wrong for a golden: `TesseraLayout.(unknown context at $104f0c1a8)._FrameLayout` +/// names a private type by a build-specific address, and a generic view carries all its +/// arguments. The projection reports the short `type` (`Text`, `VStack`, `_LayoutContainer`) +/// and, for a `_LayoutContainer`, the short `layout` of its algorithm (`_FrameLayout`, +/// `_PaddingLayout`, or the name of an app's own `Layout`), and never the reflected name. +enum TypeName { + /// The generic-free, module-qualified names of the view lists: the structural views that + /// `_ViewList` marks in `TesseraCore`, and the `Optional` an `if` without an `else` builds. + private static let viewLists: Set = [ + "TesseraCore.ConditionalView", "TesseraCore.EmptyView", "TesseraCore.ForEach", + "TesseraCore.TupleView", "Swift.Optional", + ] + + /// `TesseraLayout.VStack>` becomes `VStack`. + static func short(_ reflected: String) -> String { + lastComponent(of: genericFree(reflected)) + } + + /// Whether the reflected type is one of the library's view lists, which layout looks through. + /// + /// The module-qualified name decides, before it is shortened: a view of an app's own that + /// happens to be called `ForEach` or `TupleView` is a composite with one child, not a list. + static func isViewList(_ reflected: String) -> Bool { + viewLists.contains(genericFree(reflected)) + } + + /// The short name of the algorithm of a `_LayoutContainer`, or `nil` + /// for any other type. + static func layout(of reflected: String) -> String? { + guard short(reflected) == "_LayoutContainer", let open = reflected.firstIndex(of: "<") + else { + return nil + } + var depth = 0 + var argument = "" + for character in reflected[reflected.index(after: open)...] { + switch character { + case "<", "(": + depth += 1 + case ">", ")": + if depth == 0 { + return finish(argument) + } + depth -= 1 + case "," where depth == 0: + return finish(argument) + default: + break + } + argument.append(character) + } + return finish(argument) + } + + private static func finish(_ argument: String) -> String? { + let name = short(argument.trimmingSpaces()) + return name.isEmpty ? nil : name + } + + /// The name without its generic argument lists, so `A.B.D` is `A.B.D`. Angle brackets + /// inside a parenthesised context stay with it. + private static func genericFree(_ name: String) -> String { + var result = "" + var parentheses = 0 + var angles = 0 + for character in name { + if character == "(" { + parentheses += 1 + } else if character == ")" { + parentheses -= 1 + } else if character == "<", parentheses == 0 { + angles += 1 + continue + } else if character == ">", parentheses == 0, angles > 0 { + angles -= 1 + continue + } + if angles == 0 { + result.append(character) + } + } + return result + } + + /// The last `.`-separated component, where a parenthesised context such as + /// `(unknown context at $…)` is one component whatever it contains. + private static func lastComponent(of name: String) -> String { + var components: [String] = [] + var current = "" + var parentheses = 0 + for character in name { + if character == "(" { + parentheses += 1 + } else if character == ")" { + parentheses -= 1 + } + if character == ".", parentheses == 0 { + components.append(current) + current = "" + } else { + current.append(character) + } + } + components.append(current) + return components.last { !$0.isEmpty } ?? name + } +} + +extension String { + /// The string without leading or trailing spaces. + fileprivate func trimmingSpaces() -> String { + var scalars = Substring(self) + while scalars.first == " " { + scalars.removeFirst() + } + while scalars.last == " " { + scalars.removeLast() + } + return String(scalars) + } +} diff --git a/Sources/TesseraHarness/Scenarios/Goldens.swift b/Sources/TesseraHarness/Scenarios/Goldens.swift index 2239b020..72881933 100644 --- a/Sources/TesseraHarness/Scenarios/Goldens.swift +++ b/Sources/TesseraHarness/Scenarios/Goldens.swift @@ -1,29 +1,30 @@ import Foundation -/// The committed text goldens of one scenario: a `NAME.txt` file per `shot`, in a directory -/// named like the scenario file without its extension (`counter.scn` keeps its goldens in -/// `counter/`). +/// The committed text goldens of one scenario: for each `shot`, `NAME.txt` and `NAME.grid.txt` +/// in a directory named like the scenario file without its extension (`counter.scn` keeps its +/// goldens in `counter/`). /// -/// A golden is the screen as text: every row with its trailing spaces removed, trailing empty -/// rows dropped, rows joined by `\n`, and one `\n` at the end. Comparison is byte for byte, -/// so drift fails the `shot` statement; recording rewrites the files and says which changed. +/// `NAME.txt` is the screen as text: every row with its trailing spaces removed, trailing empty +/// rows dropped, rows joined by `\n`, and one `\n` at the end. `NAME.grid.txt` is the same +/// screen as the catalog's styled grid (``ScreenGrid/styledGridText()``): the characters, one +/// style letter per cell, and the legend. Comparison is byte for byte, so drift in either file +/// fails the `shot` statement; recording rewrites the files and says which changed. struct GoldenStore: Equatable, Sendable { - /// What checking one screen against its golden decided. + /// What checking one screen against its goldens decided. enum Outcome: Equatable { - /// The golden holds this screen. + /// Both goldens hold this screen. case matches - /// `record` wrote the golden, which was missing or different. - case recorded(GoldenChange) + /// `record` wrote the goldens that were missing or different. + case recorded([GoldenChange]) - /// The screen differs from the golden. + /// The screen differs from a golden. case drifted(message: String, expected: String, actual: String) - /// There is nothing to compare the screen with: the golden is missing or is not an LF - /// file. + /// There is nothing to compare the screen with: a golden is missing or is not an LF file. case unusable(message: String) - /// The golden could not be written. + /// A golden could not be written. case failed(message: String) } @@ -39,41 +40,81 @@ struct GoldenStore: Equatable, Sendable { self.record = record } - /// The text of a screen as a golden holds it. + /// The text of a screen as `NAME.txt` holds it. static func text(of rows: [String]) -> String { rows.dropTrailingEmptyRows().joined(separator: "\n") + "\n" } + /// The text golden of the shot `name`. func path(for name: String) -> String { "\(directory)/\(name).txt" } - /// Checks `rows`, the screen after statement `name`, against its golden, or records it. - func check(shot name: String, rows: [String]) -> Outcome { - let path = path(for: name) - let text = Self.text(of: rows) - let bytes = Data(text.utf8) - let existing = FileManager.default.contents(atPath: path) - if existing == bytes { - return .matches + /// The styled-grid golden of the shot `name`. + func gridPath(for name: String) -> String { + "\(directory)/\(name).grid.txt" + } + + /// Checks `screen`, the screen after statement `name`, against its goldens, or records them. + /// + /// The text golden is compared first, and the first golden that differs or is missing is + /// the one reported. + func check(shot name: String, screen: ScreenGrid) -> Outcome { + let grid: String + do { + grid = try screen.styledGridText() + } catch { + return .failed( + message: "shot `\(name)` cannot be compared with a grid golden: \(error)") } + let files = [ + (path: path(for: name), text: Self.text(of: screen.textRows)), + (path: gridPath(for: name), text: grid), + ] if record { + return write(files) + } + for file in files { + let outcome = compare(shot: name, path: file.path, text: file.text) + if outcome != .matches { + return outcome + } + } + return .matches + } + + /// Writes each file that is missing or different, and lists what it changed. + private func write(_ files: [(path: String, text: String)]) -> Outcome { + var changes: [GoldenChange] = [] + for file in files { + let bytes = Data(file.text.utf8) + let existing = FileManager.default.contents(atPath: file.path) + if existing == bytes { + continue + } do { try FileManager.default.createDirectory( atPath: directory, withIntermediateDirectories: true) - try bytes.write(to: URL(fileURLWithPath: path), options: .atomic) + try bytes.write(to: URL(fileURLWithPath: file.path), options: .atomic) } catch { - return .failed(message: "cannot write the golden `\(path)`: \(error)") + return .failed(message: "cannot write the golden `\(file.path)`: \(error)") } - return .recorded( - GoldenChange(path: path, change: existing == nil ? .created : .updated)) + changes.append( + GoldenChange(path: file.path, change: existing == nil ? .created : .updated)) } - guard let existing else { + return changes.isEmpty ? .matches : .recorded(changes) + } + + private func compare(shot name: String, path: String, text: String) -> Outcome { + guard let existing = FileManager.default.contents(atPath: path) else { return .unusable( message: "shot `\(name)` has no golden at `\(path)`; " + "run with --record to create it, and review the file") } + if existing == Data(text.utf8) { + return .matches + } guard let goldenText = String(validating: existing, as: UTF8.self) else { return .unusable(message: "golden `\(path)` is not UTF-8 text") } diff --git a/Sources/TesseraHarness/Scenarios/Grammar.swift b/Sources/TesseraHarness/Scenarios/Grammar.swift index 08137b80..a1180140 100644 --- a/Sources/TesseraHarness/Scenarios/Grammar.swift +++ b/Sources/TesseraHarness/Scenarios/Grammar.swift @@ -3,23 +3,20 @@ /// The grammar's source document keeps a table of the forms a scenario may not use; a test /// holds it to ``notYetExpressible``, so the two cannot drift. package enum ScenarioGrammar { - /// A form the grammar names that the harness cannot honour yet. + /// A form the grammar names and a scenario may not use. package struct Deferred: Equatable, Sendable { /// The form as the README's table writes it. package var form: String } - /// Every statement form that parses as "not yet expressible", never as a silent no-op. + /// Every named statement form that parses as a refusal, never as a silent no-op. package static let notYetExpressible: [Deferred] = [ Deferred(form: "wait"), Deferred(form: "settle"), Deferred(form: "focus"), Deferred(form: "mouse"), Deferred(form: "hit"), - Deferred(form: "dump"), - Deferred(form: "check"), Deferred(form: "expect cell"), - Deferred(form: "expect cost"), Deferred(form: "expect focused"), Deferred(form: "expect wireframe"), Deferred(form: "expect node SEL visible"), @@ -34,8 +31,8 @@ package enum ScenarioGrammar { /// The statements a scenario may use, for the error that names the vocabulary. static let acceptedStatements = - "app, size, term, resize, key, type, paste, bytes, shot, tree, " - + "expect text, expect screen, expect node" + "app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, " + + "expect text, expect screen, expect node, expect cost" /// The refusal for a form that is named and not supported. static func refusal(_ form: String) -> String { diff --git a/Sources/TesseraHarness/Scenarios/KeyName.swift b/Sources/TesseraHarness/Scenarios/KeyName.swift index 43552c6d..4379325a 100644 --- a/Sources/TesseraHarness/Scenarios/KeyName.swift +++ b/Sources/TesseraHarness/Scenarios/KeyName.swift @@ -98,4 +98,44 @@ enum KeyName { } return parts.joined(separator: "+") } + + /// The name a scenario's `key` statement spells `key` with, or `nil` when the grammar cannot + /// say it: a modifier it has no name for (Caps Lock, Num Lock, Hyper), or a key code it has + /// none for. The name is checked by parsing it back to the same code and modifiers, so a + /// name that is returned replays as the key it came from. + static func name(of key: Key) -> String? { + var parts: [String] = [] + for (name, flag) in [ + ("Ctrl", Modifiers.control), ("Alt", .alt), ("Meta", .meta), ("Shift", .shift), + ("Super", .super), + ] where key.modifiers.contains(flag) { + parts.append(name) + } + switch key.code { + case .character(let character): + parts.append(character == " " ? "Space" : String(character)) + case .function(let number): parts.append("F\(number)") + case .backspace: parts.append("Backspace") + case .delete: parts.append("Delete") + case .down: parts.append("ArrowDown") + case .end: parts.append("End") + case .enter: parts.append("Enter") + case .escape: parts.append("Escape") + case .home: parts.append("Home") + case .left: parts.append("ArrowLeft") + case .pageDown: parts.append("PageDown") + case .pageUp: parts.append("PageUp") + case .right: parts.append("ArrowRight") + case .tab: parts.append("Tab") + case .up: parts.append("ArrowUp") + default: return nil + } + let name = parts.joined(separator: "+") + guard let parsed = try? parse(name), parsed.code == key.code, + parsed.modifiers == key.modifiers + else { + return nil + } + return name + } } diff --git a/Sources/TesseraHarness/Scenarios/Locator.swift b/Sources/TesseraHarness/Scenarios/Locator.swift index eebbde3d..895b778c 100644 --- a/Sources/TesseraHarness/Scenarios/Locator.swift +++ b/Sources/TesseraHarness/Scenarios/Locator.swift @@ -1,24 +1,24 @@ -import TesseraCore -import TesseraTerminalCore - -/// `key=value` node selector: `type=Text id=root/1/0 text="Submit" nth=2`. +/// `key=value` node selector: `type=Text id=root/index(1) text="Submit" nth=2`. /// -/// Every field filters; `nth` then picks one match by zero-based preorder position. -/// Without `nth` the locator must match exactly one node. +/// A selector addresses nodes by meaning and is re-resolved against a fresh ``Projection`` at +/// every statement. Every key filters; `nth` then picks one match by zero-based position in tree +/// order. Without `nth` the selector must match exactly one node, and a selector that matches +/// nothing is an error, never an empty result. package struct Locator: CustomStringConvertible, Equatable, Sendable { struct Failure: Error { var message: String } enum Resolution { - case node(NodeDiagnostics) + case node(Projection.Node) case none - case ambiguous([NodeDiagnostics]) + case ambiguous([Projection.Node]) } - static let accepted = "type, id, text, nth" + static let accepted = "type, layout, id, text, nth" package var type: String? + package var layout: String? package var id: String? package var text: String? package var nth: Int? @@ -26,6 +26,7 @@ package struct Locator: CustomStringConvertible, Equatable, Sendable { package var description: String { var terms: [String] = [] if let type { terms.append("type=\(type)") } + if let layout { terms.append("layout=\(layout)") } if let id { terms.append("id=\(id)") } if let text { terms.append("text=\"\(text)\"") } if let nth { terms.append("nth=\(nth)") } @@ -41,8 +42,14 @@ package struct Locator: CustomStringConvertible, Equatable, Sendable { } let key = String(token[..` → `ScrollView`. - static func baseTypeName(_ viewType: String) -> String { - let withoutGenerics = viewType.prefix { $0 != "<" } - return String(withoutGenerics.split(separator: ".").last ?? withoutGenerics) - } - - /// Screen text inside `frame`, rows joined by newline. - static func renderedText(in frame: Rect, rows: [String]) -> String { - var lines: [String] = [] - for row in frame.rowRange where rows.indices.contains(row) { - let characters = Array(rows[row]) - let lower = max(frame.origin.column, 0) - let upper = min(frame.maxColumn, characters.count) - lines.append(lower < upper ? String(characters[lower.. String? { + guard let last = path.split(separator: "/", omittingEmptySubsequences: true).last + else { + return nil } - return lines.joined(separator: "\n") + for prefix in ["explicit(", "id("] where last.hasPrefix(prefix) && last.hasSuffix(")") + { + return String(last.dropFirst(prefix.count).dropLast()) + } + return nil } - /// Re-resolves against a fresh projection; `screenRows` are untrimmed screen rows. - func resolve(in nodes: [NodeDiagnostics], screenRows: [String]) -> Resolution { - let matches = nodes.filter { node in - if let type, Self.baseTypeName(node.viewType) != type { + /// Re-resolves against a fresh projection. + /// + /// - `type` is the short type name (`Text`, `_LayoutContainer`), `layout` the short name of a + /// `_LayoutContainer`'s algorithm (`_FrameLayout`, `_PaddingLayout`). + /// - `id` is a node's full identity path, or the id the app gave it: the `X` of `.id(X)` or of + /// a `ForEach` key. + /// - `text` is a substring of the text the node's own cells show, so a container has none and + /// text that another node paints over does not match: `text="atus"` finds `status` under + /// `OK`, whose own cells show only `atus`, and `text="OKatus"` finds nothing. + func resolve(in projection: Projection) -> Resolution { + let matches = projection.nodes.filter { node in + if let type, node.type != type { + return false + } + if let layout, node.layout != layout { return false } - if let id, node.identity.description != id { + if let id, node.path != id, Self.userID(ofPath: node.path) != id { return false } - if let text, !Self.renderedText(in: node.frame, rows: screenRows).contains(text) { + if let text, node.text?.contains(text) != true { return false } return true diff --git a/Sources/TesseraHarness/Scenarios/Report.swift b/Sources/TesseraHarness/Scenarios/Report.swift index f99d446e..43400a61 100644 --- a/Sources/TesseraHarness/Scenarios/Report.swift +++ b/Sources/TesseraHarness/Scenarios/Report.swift @@ -29,6 +29,12 @@ public struct ScenarioFailure: Equatable, Sendable { /// The screen at the failure, trailing blanks trimmed and trailing empty rows dropped. package let screen: [String]? // swiftlint:disable:this discouraged_optional_collection + /// For a `check` failure, the id of the rule that did not hold. + package let rule: String? + + /// For a `check` failure, the identity path of the node at fault. + package let node: String? + // swiftlint:disable discouraged_optional_collection package init( line: Int, @@ -37,7 +43,9 @@ public struct ScenarioFailure: Equatable, Sendable { message: String, expected: String? = nil, actual: String? = nil, - screen: [String]? = nil + screen: [String]? = nil, + rule: String? = nil, + node: String? = nil ) { self.line = line self.statement = statement @@ -46,16 +54,21 @@ public struct ScenarioFailure: Equatable, Sendable { self.expected = expected self.actual = actual self.screen = screen + self.rule = rule + self.node = node } // swiftlint:enable discouraged_optional_collection } -/// Output of an observation statement (`shot`, `tree`). +/// Output of an observation statement (`shot`, `tree`, `dump`, `check`). package struct ScenarioObservation: Encodable, Equatable, Sendable { package var line: Int package var verb: String package var name: String? package var lines: [String] + + /// For `dump`, the projection the statement saw. + package var projection: Projection? } /// A golden file `--record` wrote. @@ -79,7 +92,7 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { /// one run reports them all. public let failures: [ScenarioFailure] - /// What the `shot` and `tree` statements saw, in order. + /// What the `shot`, `tree`, `dump`, and `check` statements saw, in order. package let observations: [ScenarioObservation] /// The goldens `--record` wrote. @@ -88,13 +101,18 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { /// Wall-clock seconds, one per run, when `--timing` asked for them, and empty otherwise. package var seconds: [Double] + /// One record per step of the run, the first frame first. It is not part of the JSON + /// report: `run --trace` writes it as a file. + package var trace: [TraceRecord] + /// Whether every statement held. public var passed: Bool { failures.isEmpty } - /// The human form: `PASS` or `FAIL` and the scenario's path, then each failure with the - /// statement, what was expected, what was seen, and the screen. + /// The human form: `PASS` or `FAIL` and the scenario's path, then the warnings a bare + /// `check` listed, then each failure with the statement, what was expected, what was seen, + /// and the screen. /// /// A `parse` failure prints as `FILE:LINE: error: message`, the form editors and CI logs /// link. @@ -103,6 +121,13 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { for change in recorded { lines.append(" recorded \(change.path) (\(change.change.rawValue))") } + // A warning does not fail the scenario, so a person who reads only this form would never + // see it; the JSON form carries it in the observation. + for observation in observations where observation.verb == "check" { + for warning in observation.lines where warning.hasPrefix("warning ") { + lines.append(" line \(observation.line): \(warning)") + } + } for failure in failures { lines += humanLines(for: failure) } @@ -116,7 +141,8 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { /// The report with everything that varies between two runs of the same scenario removed. var comparable: Self { - Self(scenario: scenario, failures: failures, observations: observations) + Self( + scenario: scenario, failures: failures, observations: observations, trace: trace) } private var timingText: String { @@ -131,13 +157,15 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { failures: [ScenarioFailure], observations: [ScenarioObservation] = [], recorded: [GoldenChange] = [], - seconds: [Double] = [] + seconds: [Double] = [], + trace: [TraceRecord] = [] ) { self.scenario = scenario self.failures = failures self.observations = observations self.recorded = recorded self.seconds = seconds + self.trace = trace } /// A report for a scenario that could not be parsed. @@ -198,6 +226,8 @@ struct ReportBody: Encodable { var expected: String? var actual: String? var screen: [String]? // swiftlint:disable:this discouraged_optional_collection + var rule: String? + var node: String? init(_ failure: ScenarioFailure) { line = failure.line @@ -207,6 +237,8 @@ struct ReportBody: Encodable { expected = failure.expected actual = failure.actual screen = failure.screen + rule = failure.rule + node = failure.node } } diff --git a/Sources/TesseraHarness/Scenarios/ScenarioParser.swift b/Sources/TesseraHarness/Scenarios/ScenarioParser.swift index ac5c9705..a729551f 100644 --- a/Sources/TesseraHarness/Scenarios/ScenarioParser.swift +++ b/Sources/TesseraHarness/Scenarios/ScenarioParser.swift @@ -295,6 +295,15 @@ private struct Parse { } return .tree + case "dump": + guard arguments.isEmpty else { + throw fail("usage: dump (the whole projection; it takes no argument)") + } + return .dump + + case "check": + return .check(rules: try parseCheckRules(arguments, fail: fail)) + case "expect": return try parseExpect(arguments, fail: fail) @@ -313,7 +322,8 @@ private struct Parse { ) throws -> Statement { guard let kind = arguments.first else { throw fail( - "usage: expect text … | expect screen < ScenarioParseError + ) throws -> [CostBound] { + let counters = StepCost.names.joined(separator: ", ") + guard !words.isEmpty else { + throw fail("usage: expect cost NAME<=N… (counters: \(counters))") + } + var bounds: [CostBound] = [] + for word in words { + guard let operatorRange = word.range(of: "<=") else { + throw fail( + "cost bound `\(word)` is not NAME<=N, written without spaces; only `<=` is " + + "accepted, because the counts are the library's work and change with it " + + "(counters: \(counters))") + } + let counter = String(word[..= 0 else { + throw fail("cost bound `\(word)` needs a non-negative whole number after `<=`") + } + guard !bounds.contains(where: { $0.counter == counter }) else { + throw fail("cost counter `\(counter)` is bounded twice") + } + bounds.append(CostBound(counter: counter, limit: limit)) + } + return bounds + } + + /// The rules a `check` statement names, or none for every rule. + private func parseCheckRules( + _ words: [String], + fail: (String) -> ScenarioParseError + ) throws -> [CheckRule] { + var rules: [CheckRule] = [] + for word in words { + guard let rule = CheckRule(rawValue: word) else { + throw fail("unknown check rule `\(word)`; accepted: \(CheckRule.accepted)") + } + guard !rules.contains(rule) else { + throw fail("check rule `\(word)` is named twice") + } + rules.append(rule) + } + return rules + } + /// `expect screen < ScenarioFailure { ScenarioFailure( line: line.number, @@ -120,7 +130,9 @@ enum ScenarioPlayer { message: message, expected: expected, actual: actual, - screen: showsScreen ? screenForReport(host.screenRows) : nil + screen: showsScreen ? screenForReport(host.screenRows) : nil, + rule: rule, + node: node ) } @@ -182,19 +194,19 @@ enum ScenarioPlayer { } case .shot(let shotName): - let rows = host.screenRows + let screen = host.screen outcome.observations.append( ScenarioObservation( line: line.number, verb: "shot", name: shotName, - lines: screenForReport(rows))) + lines: screenForReport(screen.textRows))) guard let goldens else { continue } - switch goldens.check(shot: shotName, rows: rows) { + switch goldens.check(shot: shotName, screen: screen) { case .matches: break - case .recorded(let change): - outcome.recorded.append(change) + case .recorded(let changes): + outcome.recorded.append(contentsOf: changes) case .drifted(let message, let expected, let actual): outcome.failures.append(failure(message, expected: expected, actual: actual)) case .unusable(let message): @@ -207,7 +219,35 @@ enum ScenarioPlayer { outcome.observations.append( ScenarioObservation( line: line.number, verb: "tree", name: nil, - lines: treeLines(host.diagnostics.nodes))) + lines: treeLines(host.projection.nodes))) + + case .dump: + outcome.observations.append( + ScenarioObservation( + line: line.number, verb: "dump", name: nil, lines: [], + projection: host.projection)) + + case .check(let rules): + let ran = rules.isEmpty ? CheckRule.allCases : rules + let violations = Checker.run(ran, on: host.projection) + // A named rule is asked to hold, so a warning of it fails; a bare `check` reports + // warnings and fails only on errors. + let named = !rules.isEmpty + var warnings: [String] = [] + for violation in violations { + if violation.severity == CheckRule.Severity.warning.rawValue, !named { + warnings.append(violation.text) + continue + } + outcome.failures.append( + failure( + "\(violation.rule) at \(violation.node): \(violation.detail)", + rule: violation.rule, node: violation.node)) + } + outcome.observations.append( + ScenarioObservation( + line: line.number, verb: "check", name: nil, + lines: ["ran \(ran.map(\.rawValue).joined(separator: ", "))"] + warnings)) case .expectText(let row, let text): if let message = expectText(row: row, text: text, rows: host.screenRows) { @@ -226,13 +266,20 @@ enum ScenarioPlayer { } case .expectNode(let locator, let frame): - if let difference = nodeDifference( - locator: locator, frame: frame, host: host) - { + if let difference = nodeDifference(locator: locator, frame: frame, host: host) { outcome.failures.append( failure( difference.message, expected: frame.description, actual: difference.actual)) } + + case .expectCost(let bounds): + if let difference = costDifference(bounds, record: host.lastRecord) { + outcome.failures.append( + failure( + difference.message, + expected: bounds.map(\.description).joined(separator: " "), + actual: difference.actual, showsScreen: false)) + } } } } @@ -283,27 +330,49 @@ enum ScenarioPlayer { private static func nodeDifference( locator: Locator, frame: FrameExpectation, host: any ScenarioHost ) -> (message: String, actual: String)? { - let nodes = host.diagnostics.nodes - switch locator.resolve(in: nodes, screenRows: host.screenRows) { + let projection = host.projection + switch locator.resolve(in: projection) { case .node(let node): let actual = FrameExpectation( - column: node.frame.origin.column, row: node.frame.origin.row, - columns: node.frame.size.columns, rows: node.frame.size.rows) + column: node.frame.column, row: node.frame.row, + columns: node.frame.columns, rows: node.frame.rows) return actual == frame ? nil : ("frame of `\(locator.description)` differs", actual.description) case .none: return ( "locator `\(locator.description)` matches no node", - "(no node; types: \(distinctTypes(nodes)))" + "(no node; types: \(distinctTypes(projection.nodes))" + + "\(layoutsSuffix(projection.nodes)))" ) case .ambiguous(let matches): return ( "locator `\(locator.description)` matches \(matches.count) nodes; add nth=", - matches.map(\.identity.description).joined(separator: ", ") + matches.map(\.path).joined(separator: ", ") ) } } + /// The first bound the step's cost exceeds, with every count so the next bound can be + /// written from what the step took. + private static func costDifference(_ bounds: [CostBound], record: TraceRecord?) + -> (message: String, actual: String)? + { + guard let record else { + return ("no step has run to take the cost of", "(no step)") + } + for bound in bounds { + let count = record.cost.count(named: bound.counter) ?? 0 + if count > bound.limit { + return ( + "step \(record.seq) (\(record.event.summary)) took \(count) \(bound.counter), " + + "above the bound \(bound.limit)", + record.cost.text + ) + } + } + return nil + } + // MARK: Output /// Rows with trailing empty rows dropped, so a 500-row probe stays readable. @@ -311,34 +380,36 @@ enum ScenarioPlayer { rows.dropTrailingEmptyRows() } - static func distinctTypes(_ nodes: [NodeDiagnostics]) -> String { + static func distinctTypes(_ nodes: [Projection.Node]) -> String { var seen: [String] = [] - for node in nodes { - let type = Locator.baseTypeName(node.viewType) - if !seen.contains(type) { - seen.append(type) - } + for node in nodes where !seen.contains(node.type) { + seen.append(node.type) } return seen.joined(separator: ", ") } - /// One line per node: indentation by depth, identity, base type, frame (and clip if it differs). - static func treeLines(_ nodes: [NodeDiagnostics]) -> [String] { + /// `; layouts: …` when some node has a layout algorithm, so a `layout=` locator that + /// matched nothing shows what it could have named. + private static func layoutsSuffix(_ nodes: [Projection.Node]) -> String { + var seen: [String] = [] + for layout in nodes.compactMap(\.layout) where !seen.contains(layout) { + seen.append(layout) + } + return seen.isEmpty ? "" : "; layouts: \(seen.joined(separator: ", "))" + } + + /// One line per node: indentation by depth, identity, type (with the layout algorithm of a + /// container that has one), frame, and clip if it differs from the frame. + static func treeLines(_ nodes: [Projection.Node]) -> [String] { nodes.map { node in - let identity = node.identity.description - let depth = identity.split(separator: "/").count - 1 + let type = node.layout.map { "\(node.type)(\($0))" } ?? node.type var line = - String(repeating: " ", count: depth) - + "\(identity) \(Locator.baseTypeName(node.viewType)) " - + "frame=\(rectText(node.frame))" + String(repeating: " ", count: node.depth) + + "\(node.path) \(type) frame=\(node.frame.text)" if node.clip != node.frame { - line += " clip=\(rectText(node.clip))" + line += " clip=\(node.clip.text)" } return line } } - - private static func rectText(_ rect: Rect) -> String { - "\(rect.origin.column),\(rect.origin.row),\(rect.size.columns)x\(rect.size.rows)" - } } diff --git a/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift index 0017d72b..dd8e3f49 100644 --- a/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift +++ b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift @@ -30,6 +30,9 @@ public struct ScenarioRunner { /// Whether each report carries the wall-clock time of its runs. package var measuresTime = false + /// The directory `run --trace` writes each scenario's trace into, or `nil` to write none. + package var traceDirectory: String? + /// Creates a runner over `registry`. /// /// - Parameters: @@ -61,6 +64,12 @@ public struct ScenarioRunner { if let index = first.observations.firstDifference(from: run.observations) { return "observation \(index + 1), at line \(run.observations[index].line), differs" } + if run.trace.count != first.trace.count { + return "it took \(run.trace.count) steps where run 1 took \(first.trace.count)" + } + if let index = first.trace.firstDifference(from: run.trace) { + return "step \(index) of the trace differs" + } return "the reports differ" } @@ -149,12 +158,24 @@ public struct ScenarioRunner { seconds.append(Self.seconds(started.duration(to: clock.now))) reports.append(report) } - return combined(reports, seconds: seconds) + var traceFailure: ScenarioFailure? + if let traceDirectory { + do { + try TraceFile.write(reports[0].trace, forScenario: path, into: traceDirectory) + } catch { + traceFailure = ScenarioFailure( + line: 0, statement: "", verb: "harness", + message: + "cannot write the trace of `\(path)` into `\(traceDirectory)`: \(error)") + } + } + return combined(reports, seconds: seconds, traceFailure: traceFailure) } /// The first run's report, with a `repeat` failure for each later run that differs. - private func combined(_ reports: [ScenarioReport], seconds: [Double]) -> ScenarioReport - { + private func combined( + _ reports: [ScenarioReport], seconds: [Double], traceFailure: ScenarioFailure? + ) -> ScenarioReport { let first = reports[0] var failures = first.failures for (offset, report) in reports.enumerated().dropFirst() @@ -166,12 +187,16 @@ public struct ScenarioRunner { "run \(offset + 1) of \(reports.count) differs from run 1: " + Self.divergence(of: report, from: first))) } + if let traceFailure { + failures.append(traceFailure) + } return ScenarioReport( scenario: first.scenario, failures: failures, observations: first.observations, recorded: first.recorded, - seconds: measuresTime ? seconds : [] + seconds: measuresTime ? seconds : [], + trace: first.trace ) } } diff --git a/Sources/TesseraHarness/Scenarios/Statement.swift b/Sources/TesseraHarness/Scenarios/Statement.swift index 36560b9a..79dc6f92 100644 --- a/Sources/TesseraHarness/Scenarios/Statement.swift +++ b/Sources/TesseraHarness/Scenarios/Statement.swift @@ -13,9 +13,27 @@ package struct FrameExpectation: CustomStringConvertible, Equatable, Sendable { } } +/// One bound of an `expect cost` statement: `bodies<=5`. +package struct CostBound: CustomStringConvertible, Equatable, Sendable { + /// The counter, one of ``StepCost/names``. + package var counter: String + + /// The most the counter may be. + package var limit: Int + + package var description: String { + "\(counter)<=\(limit)" + } +} + /// One executable scenario statement. package enum Statement: Equatable, Sendable { case bytes([UInt8]) + + /// `check [RULE...]`; an empty list runs every rule. + case check(rules: [CheckRule]) + case dump + case expectCost([CostBound]) case expectNode(Locator, FrameExpectation) case expectScreen(rows: [String]) case expectText(row: Int?, text: String) @@ -30,6 +48,9 @@ package enum Statement: Equatable, Sendable { package var verb: String { switch self { case .bytes: "bytes" + case .check: "check" + case .dump: "dump" + case .expectCost: "expect cost" case .expectNode: "expect node" case .expectScreen: "expect screen" case .expectText: "expect text" diff --git a/Sources/TesseraHarness/Trace/Replay.swift b/Sources/TesseraHarness/Trace/Replay.swift new file mode 100644 index 00000000..195df472 --- /dev/null +++ b/Sources/TesseraHarness/Trace/Replay.swift @@ -0,0 +1,199 @@ +import Foundation +import TesseraTerminalCore + +/// Reruns a recorded trace as a scenario. +/// +/// The trace names the events, so replaying it is delivering them again, in order, to a fresh +/// application at the size the recording started at. After every step the replay compares its +/// own record with the recorded one on what the recording promises to hold on any terminal: why +/// the frame was drawn, which nodes and rows changed, and what the step cost. The bytes a frame +/// wrote are left out, because they depend on the terminal the recording ran on. A step that +/// differs is a `replay` failure, and the run goes on to report the rest. +/// +/// A trace that holds an event the grammar cannot say (a mouse event before pointer input +/// exists) cannot be replayed: it is refused before anything runs, naming the step. +@MainActor +enum Replayer { + /// What a replay collected. + private final class Outcome { + var failures: [ScenarioFailure] = [] + var observations: [ScenarioObservation] = [] + var steps: [TraceRecord] = [] + } + + /// Replays the trace in `text`, read from `file`, against the application `name`. + static func replay( + text: String, file: String, app name: String, registry: ApplicationRegistry + ) async -> ScenarioReport { + let recorded: [TraceRecord] + do { + recorded = try TraceFile.records(in: text) + } catch let error as TraceFile.ReadError { + return ScenarioReport( + scenario: file, + failures: [ + ScenarioFailure( + line: error.line, statement: "", verb: "usage", message: error.message) + ]) + } catch { + return .failure(scenario: file, verb: "usage", message: "\(error)") + } + if let refusal = refusal(recorded) { + return ScenarioReport(scenario: file, failures: [refusal]) + } + + let entry: ApplicationRegistry.Entry + do { + entry = try registry.entry(named: name) + } catch { + return .failure(scenario: file, verb: "harness", message: error.description) + } + guard let start = recorded.first?.event.inputEvent, case .resize(let size) = start + else { + return .failure(scenario: file, verb: "usage", message: "the trace holds no steps") + } + + let outcome = Outcome() + do { + try await entry.headless(.custom(size), TerminalProfile()) { host in + await play(recorded, on: host, into: outcome) + } + } catch let error as HostError { + return .failure(scenario: file, verb: error.verb, message: error.message) + } catch { + return .failure(scenario: file, verb: "harness", message: "\(error)") + } + return ScenarioReport( + scenario: file, failures: outcome.failures, observations: outcome.observations, + trace: outcome.steps) + } + + /// Delivers each recorded event to `host` and compares the step it took with the recording. + private static func play( + _ recorded: [TraceRecord], on host: any ScenarioHost, into outcome: Outcome + ) async { + defer { + outcome.steps = host.records + } + func compare(_ replayed: TraceRecord?, with expected: TraceRecord) { + guard let replayed, + let difference = difference(expected: expected, replayed: replayed) + else { + return + } + outcome.failures.append( + ScenarioFailure( + line: expected.seq + 1, + statement: "step \(expected.seq): \(expected.event.summary)", + verb: "replay", + message: difference.message, + expected: difference.expected, actual: difference.actual, + screen: host.screenRows.dropTrailingEmptyRows())) + } + + compare(host.lastRecord, with: recorded[0]) + for expected in recorded.dropFirst() { + if host.isFinished { + outcome.failures.append( + ScenarioFailure( + line: expected.seq + 1, + statement: "step \(expected.seq): \(expected.event.summary)", + verb: "replay", + message: + "the application finished before step \(expected.seq), where the recording " + + "kept going")) + return + } + guard let event = expected.event.inputEvent else { + return + } + do { + try await host.send(event) + } catch { + outcome.failures.append( + ScenarioFailure( + line: expected.seq + 1, statement: "step \(expected.seq)", verb: "harness", + message: "the host could not deliver the event: \(error)")) + return + } + compare(host.lastRecord, with: expected) + } + outcome.observations.append( + ScenarioObservation( + line: 0, verb: "replay", name: nil, + lines: host.screenRows.dropTrailingEmptyRows())) + } + + /// The failure for a trace that cannot be replayed, or `nil` when every step can. + private static func refusal(_ records: [TraceRecord]) -> ScenarioFailure? { + guard let first = records.first else { + return ScenarioFailure( + line: 0, statement: "", verb: "usage", message: "the trace holds no steps") + } + guard first.seq == 0, case .resize? = first.event.inputEvent else { + return ScenarioFailure( + line: 1, statement: "", verb: "usage", + message: + "the first record must be step 0, the resize that tells the application its " + + "starting size; it is step \(first.seq), \(first.event.summary)") + } + for (offset, record) in records.enumerated() { + if record.seq != offset { + return ScenarioFailure( + line: offset + 1, statement: "", verb: "usage", + message: "the steps are numbered 0, 1, 2, …; this one is \(record.seq)") + } + if record.event.inputEvent == nil { + return ScenarioFailure( + line: offset + 1, statement: "step \(record.seq)", verb: "usage", + message: + "step \(record.seq) holds an event replay cannot deliver: " + + "\(record.event.summary); replay delivers keys, pastes, resizes, and focus changes" + ) + } + } + return nil + } + + /// The first way a replayed step differs from the recorded one, with both values. + private static func difference(expected: TraceRecord, replayed: TraceRecord) + -> (message: String, expected: String, actual: String)? + { + if expected.drew != replayed.drew { + return ( + "the recording \(expected.drew ? "drew a frame" : "drew none") and the replay " + + "\(replayed.drew ? "drew a frame" : "drew none")", + "drew \(expected.drew)", "drew \(replayed.drew)" + ) + } + if expected.reasons != replayed.reasons { + return ( + "the reasons differ from the recording", expected.reasons.joined(separator: ", "), + replayed.reasons.joined(separator: ", ") + ) + } + if expected.nodes != replayed.nodes { + return ( + "the changed nodes differ from the recording", + JSONText.string(for: expected.nodes), + JSONText.string(for: replayed.nodes) + ) + } + if expected.rows != replayed.rows { + return ( + "the changed rows differ from the recording", "\(expected.rows)", + "\(replayed.rows)" + ) + } + var recordedCost = expected.cost + var replayedCost = replayed.cost + recordedCost.bytes = 0 + replayedCost.bytes = 0 + if recordedCost != replayedCost { + return ( + "the costs differ from the recording", expected.cost.text, replayed.cost.text + ) + } + return nil + } +} diff --git a/Sources/TesseraHarness/Trace/Trace.swift b/Sources/TesseraHarness/Trace/Trace.swift new file mode 100644 index 00000000..5a122b69 --- /dev/null +++ b/Sources/TesseraHarness/Trace/Trace.swift @@ -0,0 +1,280 @@ +import Foundation +import TesseraCore +import TesseraTerminalCore +import TesseraTerminalInput + +/// What one step cost: the work the library did to take one event to one frame. +/// +/// The counts are deterministic, so a scenario can bound them with `expect cost`. They are the +/// library's work and not a promise: a change that makes the library do less work lowers +/// them, so bound them with `<=` and never assert an exact number. +package struct StepCost: Codable, Equatable, Sendable { + /// The counters `expect cost` accepts, in the order the grammar lists them. + package static let names = ["bodies", "bytes", "measures", "placements", "renders"] + + /// View bodies evaluated while updating the graph. + package var bodies: Int + + /// Bytes the frame wrote to the terminal; 0 when nothing was drawn. + package var bytes: Int + + /// Measurements the layout pass took. + package var measures: Int + + /// Placements the layout pass made. + package var placements: Int + + /// Leaf nodes the render pass painted. + package var renders: Int + + /// `bodies=3 bytes=10 …`, every counter, for the message that shows what a bound met. + package var text: String { + Self.names.map { "\($0)=\(count(named: $0) ?? 0)" }.joined(separator: " ") + } + + /// The value of the counter `name`, or `nil` when no counter has that name. + package func count(named name: String) -> Int? { + switch name { + case "bodies": bodies + case "bytes": bytes + case "measures": measures + case "placements": placements + case "renders": renders + default: nil + } + } +} + +/// The event a step delivered, in the words the scenario grammar uses. +/// +/// A key, a paste, and a resize are the grammar's own words. A focus change of the terminal +/// window has no scenario statement, but a live session receives it, so it is recorded as +/// `focusGained` or `focusLost` and replays like the others. An event neither the grammar nor +/// replay can deliver (a mouse event, a terminal reply) is +/// recorded as `other`, so the trace stays complete and `replay` can refuse it by name. +package struct TraceEvent: Codable, Equatable, Sendable { + /// `key`, `paste`, `resize`, `focusGained`, `focusLost`, or `other`. + package var type: String + + /// For a key, its name as a `key` statement spells it. + package var key: String? + + /// For a key that was not a plain press, `repeat` or `release`. + package var kind: String? + + /// For a paste, the pasted text. + package var text: String? + + /// For a resize, the new size as `WxH`. + package var size: String? + + /// For `other`, what the event was. + package var detail: String? + + /// The event to deliver again, or `nil` for `other` and for a record that does not parse. + package var inputEvent: InputEvent? { + switch type { + case "key": + guard let key, let parsed = try? KeyName.parse(key) else { + return nil + } + var event = parsed + switch kind { + case "repeat": event.kind = .repeat + case "release": event.kind = .release + default: break + } + return .key(event) + case "paste": + return text.map(InputEvent.paste) + case "resize": + return size.flatMap { ScenarioParser.parseDimensions($0) }.map { + .resize(TerminalSize(columns: $0.0, rows: $0.1)) + } + case "focusGained": + return .focusGained + case "focusLost": + return .focusLost + default: + return nil + } + } + + /// The event as a statement reads, for a failure that names the step. + package var summary: String { + switch type { + case "key": "key \(key ?? "?")\(kind.map { " (\($0))" } ?? "")" + case "paste": "paste \(text.map { "\"\($0)\"" } ?? "")" + case "resize": "resize \(size ?? "?")" + case "focusGained": "focus gained" + case "focusLost": "focus lost" + default: "\(detail ?? "other") (not replayable)" + } + } + + package init(_ event: InputEvent) { + type = "other" + switch event { + case .key(let key): + if let name = KeyName.name(of: key) { + type = "key" + self.key = name + switch key.kind { + case .press: break + case .repeat: kind = "repeat" + case .release: kind = "release" + } + } else { + detail = "key \(KeyName.describe(key))" + } + case .paste(let text): + type = "paste" + self.text = text + case .resize(let size): + type = "resize" + self.size = "\(size.columns)x\(size.rows)" + case .focusGained: + type = "focusGained" + case .focusLost: + type = "focusLost" + case .mouse: + detail = "mouse" + case .kittyGraphicsResponse, .kittyKeyboardEnhancementFlags, .primaryDeviceAttributes, + .privateModeStatus: + detail = "terminalReply" + case .unknown: + detail = "unknown" + } + } +} + +/// One step of a run, as the trace records it: the event, why the frame was drawn, what it +/// changed, and what it cost. +/// +/// A record is one line of the trace file, a sorted-key JSON object. "Why did this redraw?" is +/// read from `reasons`: the library's invalidation reasons for the pass, which are value-free +/// and bounded. The record carries a `version` and is not part of the 1.0 promise. +package struct TraceRecord: Codable, Equatable, Sendable { + /// The nodes a step changed, by identity path. + package struct NodeChanges: Codable, Equatable, Sendable { + /// Nodes that did not exist before the step. + package var added: [String] + + /// Nodes that existed and whose type, geometry, or requirements differ. + package var changed: [String] + + /// Nodes the step removed. + package var removed: [String] + } + + /// The schema version the record carries. + package static let currentVersion = 1 + + package var version: Int + + /// The step's number: 0 is the first frame, and every event adds one. + package var seq: Int + + /// The event the step delivered. + package var event: TraceEvent + + /// Whether the step drew a frame; a step that leaves the graph clean draws none. + package var drew: Bool + + /// The invalidation reasons of the pass, in the library's fixed order. + package var reasons: [String] + + /// The nodes the step changed. + package var nodes: NodeChanges + + /// The rows of the screen whose text changed, counted from 0. + package var rows: [Int] + + /// The work the step took. + package var cost: StepCost + + /// The line of a trace file: compact sorted-key JSON with no trailing newline. + package var line: String { + JSONText.string(for: self) + } + + package init( + seq: Int, event: TraceEvent, drew: Bool, reasons: [String], nodes: NodeChanges, + rows: [Int], cost: StepCost + ) { + version = Self.currentVersion + self.seq = seq + self.event = event + self.drew = drew + self.reasons = reasons + self.nodes = nodes + self.rows = rows + self.cost = cost + } +} + +extension TraceRecord.NodeChanges { + /// The difference between two node lists, by identity: what appeared, what disappeared, and + /// what kept its identity and changed what it is or where it sits. + init(from before: [NodeDiagnostics], to after: [NodeDiagnostics]) { + let old = Dictionary( + before.map { ($0.identity.description, $0) } + ) { first, _ in first } + var seen: Set = [] + var added: [String] = [] + var changed: [String] = [] + for node in after { + let path = node.identity.description + seen.insert(path) + guard let previous = old[path] else { + added.append(path) + continue + } + if previous.viewType != node.viewType || previous.frame != node.frame + || previous.clip != node.clip || previous.measuredSize != node.measuredSize + || previous.proposal != node.proposal + || previous.environmentOverrides != node.environmentOverrides + || previous.handlerKinds != node.handlerKinds + || previous.requestedTerminalRequirements != node.requestedTerminalRequirements + { + changed.append(path) + } + } + self.init( + added: added, changed: changed, + removed: before.map(\.identity.description).filter { !seen.contains($0) }) + } +} + +extension TraceRecord { + /// The record for one step, from what the driver saw before and after it. + init( + seq: Int, + event: InputEvent, + drew: Bool, + statistics: GraphStatistics, + bytes: Int, + nodes: TraceRecord.NodeChanges, + rowsBefore: [String], + rowsAfter: [String] + ) { + let count = max(rowsBefore.count, rowsAfter.count) + // A row that does not exist reads as an empty one, so the rows a first frame leaves blank + // are not changes. + let changed = (0.. String { + records.map { $0.line + "\n" }.joined() + } + + /// The file `run --trace` writes a scenario's trace to: the scenario's path without its + /// extension, mirrored under `directory`, with `.trace.jsonl` added. Parent and current + /// directory components and a drive's colon are dropped, so the file always lands inside + /// `directory`. + static func path(forScenario path: String, in directory: String) -> String { + var name = path + if name.hasSuffix(ScenarioFiles.fileExtension) { + name.removeLast(ScenarioFiles.fileExtension.count) + } + let components = name.split { $0 == "/" || $0 == "\\" } + .map { $0.replacingOccurrences(of: ":", with: "_") } + .filter { $0 != "." && $0 != ".." && !$0.isEmpty } + return ([directory] + components).joined(separator: "/") + ".trace.jsonl" + } + + /// Writes `records` to the trace file of the scenario at `path`, creating the directories. + static func write( + _ records: [TraceRecord], forScenario path: String, into directory: String + ) throws { + let file = Self.path(forScenario: path, in: directory) + let url = URL(fileURLWithPath: file) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text(of: records).utf8).write(to: url, options: .atomic) + } + + /// The records in `text`, in order. A blank line is skipped; a line that is not a record, or + /// that has another schema version, throws. + static func records(in text: String) throws -> [TraceRecord] { + let decoder = JSONDecoder() + var records: [TraceRecord] = [] + for (offset, line) in ScenarioParser.sourceLines(of: text).enumerated() { + let number = offset + 1 + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + continue + } + // The version first: a later schema may rename or drop a key, and that line should say + // which version it is, not that a key is missing. + let version: Int + do { + version = try decoder.decode(VersionOnly.self, from: Data(trimmed.utf8)).version + } catch { + throw ReadError( + line: number, message: "this line is not a trace record (\(describe(error)))") + } + guard version == TraceRecord.currentVersion else { + throw ReadError( + line: number, + message: + "the record has version \(version); this harness reads version " + + "\(TraceRecord.currentVersion)") + } + let record: TraceRecord + do { + record = try decoder.decode(TraceRecord.self, from: Data(trimmed.utf8)) + } catch { + throw ReadError( + line: number, message: "this line is not a trace record (\(describe(error)))") + } + records.append(record) + } + return records + } + + private static func describe(_ error: any Error) -> String { + guard let error = error as? DecodingError else { + return "\(error)" + } + switch error { + case .dataCorrupted: return "not valid JSON" + case .keyNotFound(let key, _): return "no `\(key.stringValue)`" + case .typeMismatch(_, let context), .valueNotFound(_, let context): + let path = context.codingPath.map(\.stringValue).joined(separator: ".") + return "wrong value at `\(path)`" + @unknown default: return "\(error)" + } + } +} diff --git a/Sources/TesseraHarness/Trace/TraceWriter.swift b/Sources/TesseraHarness/Trace/TraceWriter.swift new file mode 100644 index 00000000..dcb0d722 --- /dev/null +++ b/Sources/TesseraHarness/Trace/TraceWriter.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Appends the records of a live session to a file as the steps happen, so a session that ends +/// badly still leaves the steps before it. +/// +/// `tessera launch` opens one when `TESSERA_TRACE` names a file. A write that fails stops the +/// writing and is kept in ``failure``, which the command reports once the session is over: the +/// terminal is in the app's hands until then, so a message could not be shown. +@MainActor +final class TraceWriter { + /// A trace file that cannot be created. + struct OpenError: CustomStringConvertible, Error { + var path: String + var reason: String + + var description: String { + "cannot create the trace file `\(path)`: \(reason)" + } + } + + /// Why the writing stopped, or `nil` while every record has been written. + private(set) var failure: String? + + private let path: String + private let handle: FileHandle + + /// Creates `path`, replacing a file that is there, and opens it for appending records. + init(path: String) throws(OpenError) { + guard FileManager.default.createFile(atPath: path, contents: Data()) else { + throw OpenError(path: path, reason: "the file could not be created") + } + do { + handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path)) + } catch { + throw OpenError(path: path, reason: "\(error)") + } + self.path = path + } + + /// Writes one record as a line. + func append(_ record: TraceRecord) { + guard failure == nil else { + return + } + do { + try handle.write(contentsOf: Data((record.line + "\n").utf8)) + } catch { + failure = "cannot write the trace file `\(path)`: \(error)" + } + } + + /// Closes the file; no record is written after this. + func close() { + do { + try handle.close() + } catch { + failure = failure ?? "cannot close the trace file `\(path)`: \(error)" + } + } +} diff --git a/Sources/TesseraTerminal/TerminalSession.swift b/Sources/TesseraTerminal/TerminalSession.swift index 8a7930cb..34be61ea 100644 --- a/Sources/TesseraTerminal/TerminalSession.swift +++ b/Sources/TesseraTerminal/TerminalSession.swift @@ -165,6 +165,13 @@ public actor TerminalSession { /// ``invalidateRenderer()`` does not clear it: the terminal still shows that frame. package private(set) var lastDrawn: DrawnFrame? + /// The total bytes of the frames the terminal has received; a test and harness seam. + /// + /// A draw adds its encoded byte count once its flush succeeds, as it sets ``lastDrawn``, so + /// the difference between two reads is what the draws between them wrote. It counts frames + /// only: mode changes, probes, and cursor-restore writes are not frames. + package private(set) var bytesDrawn = 0 + /// Receives the encoded bytes of each draw just before they are written, in write order; /// a test and harness seam. package var outputTap: (@Sendable ([UInt8]) -> Void)? @@ -806,6 +813,7 @@ public actor TerminalSession { do { try await io.flush() lastDrawnBuffer = buffer + bytesDrawn += bytes.count lastDrawn = DrawnFrame( buffer: buffer, cursor: cursor, diff --git a/Tests/TesseraCoreTests/DiagnosticsTests.swift b/Tests/TesseraCoreTests/DiagnosticsTests.swift index e2f7a08a..8be4c26b 100644 --- a/Tests/TesseraCoreTests/DiagnosticsTests.swift +++ b/Tests/TesseraCoreTests/DiagnosticsTests.swift @@ -152,3 +152,29 @@ func `diagnostics are immutable local snapshots with explicit requirements`() { #expect(!String(describing: initial).contains("one")) #expect(!String(describing: updated).contains("two")) } + +@Test +func `a resize before the update still names the viewport as a cause of the pass`() { + let size = TerminalSize(columns: 4, rows: 1) + let graph = ViewGraph(root: { Text("one") }, size: size) + _ = withTestFrame(size: size) { graph.render(into: $0) } + + graph.update() + #expect( + !graph.statistics.invalidationReasons.values.contains(.layoutViewportChanged), + "an update with no resize before it has no viewport reason") + + let resized = TerminalSize(columns: 6, rows: 1) + graph.resize(to: resized) + graph.update() + let reasons = graph.statistics.invalidationReasons.values + #expect(reasons.contains(.layoutViewportChanged)) + #expect(reasons.contains(.renderViewportChanged)) + #expect(reasons.contains(.updateRequested)) + + _ = withTestFrame(size: resized) { graph.render(into: $0) } + graph.update() + #expect( + !graph.statistics.invalidationReasons.values.contains(.layoutViewportChanged), + "the render pass that drew the new size ends the viewport's claim on later passes") +} diff --git a/Tests/TesseraHarnessTests/ChecksTests.swift b/Tests/TesseraHarnessTests/ChecksTests.swift new file mode 100644 index 00000000..f976bfeb --- /dev/null +++ b/Tests/TesseraHarnessTests/ChecksTests.swift @@ -0,0 +1,645 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import Testing + +@testable import TesseraHarness + +struct ChecksTests { + private typealias Rect4 = (column: Int, row: Int, columns: Int, rows: Int) + + private func box(_ rect: Rect4) -> Projection.Box { + Projection.Box( + column: rect.column, row: rect.row, columns: rect.columns, rows: rect.rows) + } + + private func node( + _ path: String, + parent: String? = nil, + children: [String] = [], + type: String = "Text", + layout: String? = nil, + list: Bool = false, + frame: Rect4, + clip: Rect4? = nil, + proposal: (width: Int?, height: Int?)? = nil, + measured: (columns: Int, rows: Int)? = nil, + dirty: (layout: Bool, render: Bool) = (false, false), + requirements: [String] = [], + text: String? = nil + ) -> Projection.Node { + Projection.Node( + path: path, parent: parent, children: children, depth: 0, paintOrder: 0, type: type, + layout: layout, isList: list, + proposal: proposal.map { Projection.Proposal(width: $0.width, height: $0.height) }, + measured: measured.map { Projection.Extent(columns: $0.columns, rows: $0.rows) }, + frame: box(frame), clip: box(clip ?? frame), needsLayout: dirty.layout, + needsRender: dirty.render, environmentOverrides: [], handlerKinds: [], + requirements: requirements, text: text) + } + + private func projection( + _ nodes: [Projection.Node], requested: [String] = [], pass: Int = 3 + ) -> Projection { + Projection( + pass: pass, nodes: nodes, + requirements: Projection.Requirements(effective: nil, requested: requested), + screen: Projection.Screen( + columns: 10, cursor: Projection.Screen.Cursor(column: 0, row: 0), rows: [], + styles: []), + statistics: Projection.Statistics( + bodyEvaluations: 0, equatableSkips: 0, focusChanges: 0, invalidationReasons: [], + leafUpdates: 0, measurements: 0, nodesCreated: 0, nodesDestroyed: 0, + nodesUpdated: 0, + placements: 0, renderedNodes: 0, terminalRequirementChanges: 0)) + } + + private func run(_ rule: CheckRule, _ projection: Projection) -> [Violation] { + Checker.run([rule], on: projection) + } + + // MARK: no-dirty-after-pass + + @Test + func + `a node still dirty after the pass is a violation that names the pass and the flags`() + { + let found = run( + .noDirtyAfterPass, + projection([ + node("root", children: ["root/a"], type: "VStack", frame: (0, 0, 4, 1)), + node( + "root/a", parent: "root", frame: (0, 0, 4, 1), + dirty: (layout: true, render: true)), + ])) + + expectNoDifference( + found, + [ + Violation( + rule: "no-dirty-after-pass", severity: "error", node: "root/a", + detail: "still dirty after pass 3: needsLayout, needsRender") + ] + ) + } + + @Test + func `a clean graph has no dirty node`() { + #expect( + run(.noDirtyAfterPass, projection([node("root", frame: (0, 0, 4, 1))])).isEmpty) + } + + // MARK: frame-inside-clip + + @Test + func `a leaf whose frame reaches past its clip is a warning that quotes what shows`() { + let found = run( + .frameInsideClip, + projection([ + node("root", frame: (0, 0, 10, 1), clip: (0, 0, 4, 1), text: "Hello, world") + ])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root", + detail: "frame 0,0,10x1 is clipped to 0,0,4x1 (visible text `Hello, world`)" + ) + ] + """ + } + } + + @Test + func `a clip that leaves nothing of the frame says so`() { + let found = run( + .frameInsideClip, + projection([node("root", frame: (5, 0, 3, 1), clip: (0, 0, 4, 1))])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root", + detail: "frame 5,0,3x1 is clipped to nothing" + ) + ] + """ + } + } + + @Test + func `containers, empty frames, and scrolled leaves are not clipped content`() { + let found = run( + .frameInsideClip, + projection([ + node( + "root", children: ["root/box", "root/scroll"], type: "VStack", + frame: (0, 0, 10, 4), clip: (0, 0, 2, 2)), + node("root/box", parent: "root", frame: (0, 0, 0, 0), clip: (0, 0, 0, 0)), + node( + "root/scroll", parent: "root", children: ["root/scroll/text"], + type: "ScrollView", + frame: (0, 0, 10, 2)), + node( + "root/scroll/text", parent: "root/scroll", frame: (0, 0, 10, 30), + clip: (0, 0, 10, 2)), + ])) + + #expect(found.isEmpty) + } + + // MARK: measured-within-proposal + + @Test + func `a node that measured more than it was offered names each axis it overflowed`() { + let found = run( + .measuredWithinProposal, + projection([ + node( + "root", frame: (0, 0, 12, 3), proposal: (width: 4, height: 1), + measured: (columns: 12, rows: 3)) + ])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "measured-within-proposal", + severity: "error", + node: "root", + detail: "measured 12 columns where 4 were offered and 3 rows where 1 were offered" + ) + ] + """ + } + } + + @Test + func `an axis nobody constrained cannot be overflowed`() { + let found = run( + .measuredWithinProposal, + projection([ + node( + "root", frame: (0, 0, 80, 40), proposal: (width: nil, height: nil), + measured: (columns: 80, rows: 40)), + node( + "root/b", frame: (0, 0, 80, 1), proposal: (width: 80, height: nil), + measured: (columns: 80, rows: 1)), + ])) + + #expect(found.isEmpty) + } + + @Test + func `a node that was never placed keeps a stale measurement and is skipped`() { + let found = run( + .measuredWithinProposal, + projection([ + node( + "root", frame: (0, 0, 0, 0), proposal: (width: 0, height: 0), + measured: (columns: 7, rows: 1)) + ])) + + #expect(found.isEmpty) + } + + // MARK: siblings-disjoint + + @Test + func `two children placed on one frame are a violation on the later one`() { + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/id(a)", "root/id(b)"], type: "VStack", + frame: (0, 0, 4, 2)), + node("root/id(a)", parent: "root", frame: (0, 0, 4, 1)), + node("root/id(b)", parent: "root", frame: (0, 0, 4, 1)), + ])) + + expectNoDifference( + found, + [ + Violation( + rule: "siblings-disjoint", severity: "error", node: "root/id(b)", + detail: + "shows 0,0,4x1, which overlaps sibling root/id(a) showing 0,0,4x1 under VStack" + ) + ] + ) + } + + @Test + func `a ZStack lays its children over one another by contract`() { + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/a", "root/b"], type: "ZStack", frame: (0, 0, 6, 1)), + node("root/a", parent: "root", frame: (0, 0, 6, 1)), + node("root/b", parent: "root", frame: (0, 0, 2, 1)), + ])) + + #expect(found.isEmpty) + } + + @Test + func `a ForEach's items are layers under a ZStack and overlap under a VStack`() { + func items(under container: String) -> [Violation] { + run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/index(0)"], type: container, frame: (0, 0, 4, 2)), + node( + "root/index(0)", parent: "root", + children: ["root/index(0)/id(a)", "root/index(0)/id(b)"], type: "ForEach", + list: true, frame: (0, 0, 4, 1)), + node("root/index(0)/id(a)", parent: "root/index(0)", frame: (0, 0, 4, 1)), + node("root/index(0)/id(b)", parent: "root/index(0)", frame: (0, 0, 2, 1)), + ])) + } + + #expect(items(under: "ZStack").isEmpty) + assertInlineSnapshot(of: items(under: "VStack"), as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(0)/id(b)", + detail: "shows 0,0,2x1, which overlaps sibling root/index(0)/id(a) showing 0,0,4x1 under ForEach" + ) + ] + """ + } + } + + @Test + func `the group of an if under a ZStack is looked through to the ZStack`() { + // `ZStack { Text("base"); if shown { Text("g"); Text("h") } }`: the two texts hang under + // a tuple under an optional, and layout places them as layers of the ZStack. + func tree(under container: String) -> Projection { + projection([ + node( + "root", children: ["root/index(0)", "root/index(1)"], type: container, + frame: (0, 0, 4, 2)), + node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), + node( + "root/index(1)", parent: "root", children: ["root/index(1)/branch(true)"], + type: "Optional", list: true, frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)", parent: "root/index(1)", + children: [ + "root/index(1)/branch(true)/index(0)", "root/index(1)/branch(true)/index(1)", + ], + type: "TupleView", list: true, frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)/index(0)", parent: "root/index(1)/branch(true)", + frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)/index(1)", parent: "root/index(1)/branch(true)", + frame: (0, 0, 1, 1)), + ]) + } + + #expect(run(.siblingsDisjoint, tree(under: "ZStack")).isEmpty) + // Under a stack the same two texts on one frame are the defect, and so is the text + // that the optional puts over `base`. + assertInlineSnapshot( + of: run(.siblingsDisjoint, tree(under: "VStack")), as: .customDump + ) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)", + detail: "shows 0,0,1x1, which overlaps sibling root/index(0) showing 0,0,4x1 under VStack" + ), + [1]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)/branch(true)/index(1)", + detail: "shows 0,0,1x1, which overlaps sibling root/index(1)/branch(true)/index(0) showing 0,0,1x1 under TupleView" + ) + ] + """ + } + } + + @Test + func + `a conditional branch and nested lists are looked through to the nearest container`() + { + func tree(under container: String, layout: String? = nil) -> Projection { + projection([ + node( + "root", children: ["root/index(0)"], type: container, layout: layout, + frame: (0, 0, 4, 2)), + node( + "root/index(0)", parent: "root", children: ["root/index(0)/branch(false)"], + type: "ConditionalView", list: true, frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)", parent: "root/index(0)", + children: [ + "root/index(0)/branch(false)/id(a)", "root/index(0)/branch(false)/id(b)", + ], + type: "TupleView", list: true, frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)/id(a)", parent: "root/index(0)/branch(false)", + frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)/id(b)", parent: "root/index(0)/branch(false)", + frame: (0, 0, 4, 1)), + ]) + } + + #expect(run(.siblingsDisjoint, tree(under: "ZStack")).isEmpty) + #expect(run(.siblingsDisjoint, tree(under: "HStack")).count == 1) + #expect( + run(.siblingsDisjoint, tree(under: "_LayoutContainer", layout: "Stagger")).count + == 1) + } + + @Test + func `a list that is not under any container is held to disjoint items`() { + // The root itself is a tuple: with nothing above it, its elements are its siblings. + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/index(0)", "root/index(1)"], type: "TupleView", + list: true, frame: (0, 0, 4, 1)), + node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), + node("root/index(1)", parent: "root", frame: (0, 0, 4, 1)), + ])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)", + detail: "shows 0,0,4x1, which overlaps sibling root/index(0) showing 0,0,4x1 under TupleView" + ) + ] + """ + } + } + + @Test + func `a view of an app's own that is named like a list is not looked through`() { + // The app's `ForEach` is a composite with one child, here the tuple of its body `Text; + // Text`. Layout does not look through a composite, so the two texts overlap by the + // library's own placement, and the rule must report them under `ZStack` as it would + // anywhere else. Only the tuple is the library's list. + func tree(userViewIsList: Bool) -> Projection { + projection([ + node("root", children: ["root/index(0)"], type: "ZStack", frame: (0, 0, 4, 1)), + node( + "root/index(0)", parent: "root", children: ["root/index(0)/index(0)"], + type: "ForEach", list: userViewIsList, frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)", parent: "root/index(0)", + children: [ + "root/index(0)/index(0)/index(0)", "root/index(0)/index(0)/index(1)", + ], + type: "TupleView", list: true, frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)/index(0)", parent: "root/index(0)/index(0)", + frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)/index(1)", parent: "root/index(0)/index(0)", + frame: (0, 0, 2, 1)), + ]) + } + + assertInlineSnapshot( + of: run(.siblingsDisjoint, tree(userViewIsList: false)), as: .customDump + ) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(0)/index(0)/index(1)", + detail: "shows 0,0,2x1, which overlaps sibling root/index(0)/index(0)/index(0) showing 0,0,4x1 under TupleView" + ) + ] + """ + } + #expect( + run(.siblingsDisjoint, tree(userViewIsList: true)).isEmpty, + "the library's own ForEach in that place is a list, and is looked through") + } + + @Test + func `a custom layout is held to disjoint children under its own name`() { + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/a", "root/b"], type: "_LayoutContainer", + layout: "Stagger", + frame: (0, 0, 6, 2)), + node("root/a", parent: "root", frame: (0, 0, 4, 1)), + node("root/b", parent: "root", frame: (2, 0, 4, 1)), + ])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 2,0,4x1, which overlaps sibling root/a showing 0,0,4x1 under Stagger" + ) + ] + """ + } + } + + @Test + func `children that overflow their segments but are clipped to them do not overlap`() { + let found = run( + .siblingsDisjoint, + projection([ + node("root", children: ["root/a", "root/b"], type: "Flex", frame: (0, 0, 10, 1)), + node("root/a", parent: "root", frame: (0, 0, 80, 1), clip: (0, 0, 4, 1)), + node("root/b", parent: "root", frame: (5, 0, 80, 1), clip: (5, 0, 5, 1)), + ])) + + #expect(found.isEmpty) + } + + @Test + func `a child that shows nothing overlaps nothing`() { + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/a", "root/b"], type: "HStack", frame: (0, 0, 4, 1)), + node("root/a", parent: "root", frame: (0, 0, 4, 1)), + node( + "root/b", parent: "root", type: "Spacer", frame: (2, 0, 2, 0), + clip: (2, 0, 0, 0)), + ])) + + #expect(found.isEmpty) + } + + // MARK: requirements-aggregate + + @Test + func `a requirement a node asks for and the graph does not request is a violation`() { + let found = run( + .requirementsAggregate, + projection( + [ + node("root", children: ["root/a"], type: "VStack", frame: (0, 0, 4, 1)), + node("root/a", parent: "root", frame: (0, 0, 4, 1), requirements: ["mouse"]), + ], requested: [])) + + expectNoDifference( + found, + [ + Violation( + rule: "requirements-aggregate", severity: "error", node: "root/a", + detail: "asks for `mouse`, which the graph does not request") + ] + ) + } + + @Test + func + `a requirement the graph requests and no node asks for is a violation at the root`() + { + let found = run( + .requirementsAggregate, + projection([node("root", frame: (0, 0, 4, 1))], requested: ["focus"])) + + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "requirements-aggregate", + severity: "error", + node: "root", + detail: "the graph requests `focus` and no node asks for it" + ) + ] + """ + } + } + + @Test + func `requirements that roll up are consistent`() { + let found = run( + .requirementsAggregate, + projection( + [ + node("root", children: ["root/a"], type: "VStack", frame: (0, 0, 4, 1)), + node( + "root/a", parent: "root", frame: (0, 0, 4, 1), + requirements: ["keyboard", "mouse"]), + ], requested: ["keyboard", "mouse"])) + + #expect(found.isEmpty) + } + + // MARK: The runner + + @Test + func `rules run in the order given and each violation carries its rule's severity`() { + let crafted = projection([ + node( + "root", children: ["root/a", "root/b"], type: "VStack", frame: (0, 0, 4, 2), + dirty: (layout: true, render: false)), + node("root/a", parent: "root", frame: (0, 0, 4, 1), clip: (0, 0, 2, 1)), + node("root/b", parent: "root", frame: (0, 0, 4, 1)), + ]) + + let forward = Checker.run( + [.noDirtyAfterPass, .frameInsideClip, .siblingsDisjoint], on: crafted) + let backward = Checker.run( + [.siblingsDisjoint, .frameInsideClip, .noDirtyAfterPass], on: crafted) + + assertInlineSnapshot(of: forward, as: .customDump) { + """ + [ + [0]: Violation( + rule: "no-dirty-after-pass", + severity: "error", + node: "root", + detail: "still dirty after pass 3: needsLayout" + ), + [1]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root/a", + detail: "frame 0,0,4x1 is clipped to 0,0,2x1" + ), + [2]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 0,0,4x1, which overlaps sibling root/a showing 0,0,2x1 under VStack" + ) + ] + """ + } + assertInlineSnapshot(of: backward, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 0,0,4x1, which overlaps sibling root/a showing 0,0,2x1 under VStack" + ), + [1]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root/a", + detail: "frame 0,0,4x1 is clipped to 0,0,2x1" + ), + [2]: Violation( + rule: "no-dirty-after-pass", + severity: "error", + node: "root", + detail: "still dirty after pass 3: needsLayout" + ) + ] + """ + } + #expect(Checker.run([], on: crafted).isEmpty) + } + + @Test + func `the rule ids are the five the catalog names, in the order bare check runs them`() + { + expectNoDifference( + CheckRule.allCases.map(\.rawValue), + [ + "no-dirty-after-pass", "frame-inside-clip", "measured-within-proposal", + "siblings-disjoint", "requirements-aggregate", + ] + ) + #expect( + CheckRule.accepted + == "no-dirty-after-pass, frame-inside-clip, measured-within-proposal, " + + "siblings-disjoint, requirements-aggregate") + expectNoDifference( + CheckRule.allCases.filter { $0.severity == .warning }, [.frameInsideClip]) + } +} diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index d21490cc..daa92436 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -14,6 +14,8 @@ private let failing = "app stack\nsize 40x10\nexpect text 0 \"Stack demos\"\n" @MainActor struct HarnessCLIScenarioTests { + private let traced = "app stack\nsize 40x10\ntype \"Rob\"\nresize 30x8\nkey Backspace\n" + private func run( _ arguments: String..., registry: ApplicationRegistry? = nil, @@ -156,7 +158,7 @@ struct HarnessCLIScenarioTests { CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":2,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink","verb":"parse"}],"observations":[],"passed":false,"scenario":"/bad.scn"}]} + {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":2,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost","statement":"blink","verb":"parse"}],"observations":[],"passed":false,"scenario":"/bad.scn"}]} """, stderr: "" @@ -238,7 +240,7 @@ struct HarnessCLIScenarioTests { CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ..."} + {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} """, stderr: "" @@ -256,9 +258,11 @@ struct HarnessCLIScenarioTests { let twice = await run("run", "x.scn", "--repeat", "2", "--repeat", "3", "--pretty") let sameTwice = await run( "run", "x.scn", "--repeat", "2", "--repeat", "2", "--pretty") + let traceTwice = await run( + "run", "x.scn", "--trace", "a", "--trace", "b", "--pretty") assertInlineSnapshot( - of: [unknown, noValue, zero, word, twice, sameTwice], as: .customDump + of: [unknown, noValue, zero, word, twice, sameTwice, traceTwice], as: .customDump ) { #""" [ @@ -266,7 +270,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -274,7 +278,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -282,7 +286,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -290,7 +294,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -298,7 +302,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -306,7 +310,15 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ) @@ -329,7 +341,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -337,7 +349,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -345,14 +357,14 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), [3]: CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"`--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ..."} + {"command":"run","exitCode":2,"message":"`--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} """, stderr: "" @@ -362,6 +374,38 @@ struct HarnessCLIScenarioTests { } } + @Test + func `--trace and --app given twice are errors, spelled either way`() async { + let trace = await run("run", "x.scn", "--trace=a", "--trace", "b", "--pretty") + let app = await run( + "replay", "t.jsonl", "--app=stack", "--app", "counter", "--pretty") + let afterSeparator = await run("run", "--pretty", "--", "--trace", "--trace") + + assertInlineSnapshot(of: [trace, app], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` is given twice; usage: tessera replay [--json] [--pretty] --app + + """ + ) + ] + """# + } + #expect(!(afterSeparator.stdout + afterSeparator.stderr).contains("given twice")) + } + @Test func `after the separator a repeated option name is a path, not an option`() async { let output = await run("run", "--pretty", "--", "--repeat", "--repeat") @@ -380,7 +424,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--nope'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Unknown option '--nope'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ) @@ -400,7 +444,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ) @@ -418,7 +462,7 @@ struct HarnessCLIScenarioTests { #expect(recorded.exitCode == 0) let report = try #require(try reports(recorded).first) - #expect((report["recorded"] as? [[String: Any]])?.count == 1) + #expect((report["recorded"] as? [[String: Any]])?.count == 2) #expect((report["seconds"] as? [Double])?.count == 2) #expect(checked.exitCode == 0) #expect(try reports(checked)[0]["seconds"] == nil) @@ -599,6 +643,627 @@ struct HarnessCLIScenarioTests { } } + // MARK: check + + private func projectionRun(_ arguments: String...) async -> CommandOutput { + await HarnessCLI.run( + arguments: arguments, registry: ProjectionFixtures.registry(), + inputIsTerminal: false, + outputIsTerminal: false, configuration: .default) + } + + @Test + func `check runs every rule over the app at a size and a clean app passes`() + async throws + { + let output = await projectionRun("check", "layers", "mobile") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"app":"layers","command":"check","exitCode":0,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"40x6","violations":[]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `check exits 1 when a rule that is an error is violated and lists it`() + async throws + { + let output = await projectionRun("check", "overlap") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overlap","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap","node":"root/index(0)/index(1)","rule":"siblings-disjoint","severity":"error"}]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `check lists a warning with its errors and a text form that says PASS or FAIL`() + async + { + let json = await projectionRun("check", "overflow") + let text = await projectionRun("check", "overflow", "--pretty") + let passing = await projectionRun("check", "layers", "--pretty") + + assertInlineSnapshot(of: [json, text, passing], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overflow","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)","node":"root/index(0)/index(0)/index(0)","rule":"frame-inside-clip","severity":"warning"},{"detail":"measured 12 columns where 4 were offered","node":"root/index(0)/index(0)/index(0)","rule":"measured-within-proposal","severity":"error"}]} + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 1, + stdout: """ + check overflow at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + error measured-within-proposal root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + FAIL overflow: 1 error, 1 warning + + """, + stderr: "" + ), + [2]: CommandOutput( + exitCode: 0, + stdout: """ + check layers at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + PASS layers + + """, + stderr: "" + ) + ] + """# + } + } + + @Test + func `check validates its arguments and names an unknown app`() async { + var duplicated = Fixtures.registry() + duplicated.register("stack", viewports: [.desktop]) { StackApp() } + + let noApp = await run("check", "--pretty") + let badSize = await run("check", "stack", "huge", "--pretty") + let unknown = await run("check", "ghost", "--pretty") + let duplicate = await run("check", "stack", "--pretty", registry: duplicated) + let greedy = await run("check", "greedy", "mobile", "--pretty") + + assertInlineSnapshot( + of: [noApp, badSize, unknown, duplicate, greedy], as: .customDump + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument ''; usage: tessera check [--json] [--pretty] [] + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen; usage: tessera check [--json] [--pretty] [] + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [3]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH + + """ + ) + ] + """# + } + } + + @Test + func `run --pretty shows a bare check's warnings under the scenario's line`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("w.scn", "app overflow\ncheck\n") + + let output = await projectionRun("run", scratch.path, "--pretty") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /w.scn + line 2: warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + line 2: check + measured-within-proposal at root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + screen: + 0| wide + 1| ok + 1 scenario, 0 passed, 1 failed + + """, + stderr: "" + ) + """# + } + } + + @Test + func `a scenario that passes with warnings prints them under its PASS line`() { + let report = ScenarioReport( + scenario: "flex.scn", failures: [], + observations: [ + ScenarioObservation( + line: 7, verb: "check", name: nil, + lines: [ + "ran no-dirty-after-pass, frame-inside-clip", + "warning frame-inside-clip root/a: shows 0,0,9x1", + ]), + ScenarioObservation( + line: 9, verb: "check", name: nil, lines: ["ran no-dirty-after-pass"]), + ScenarioObservation( + line: 3, verb: "shot", name: "a", lines: ["warning: not a check"]), + ]) + + assertInlineSnapshot(of: report.description, as: .lines) { + """ + PASS flex.scn + line 7: warning frame-inside-clip root/a: shows 0,0,9x1 + """ + } + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[],"observations":[{"line":7,"lines":["ran no-dirty-after-pass, frame-inside-clip","warning frame-inside-clip root/a: shows 0,0,9x1"],"verb":"check"},{"line":9,"lines":["ran no-dirty-after-pass"],"verb":"check"},{"line":3,"lines":["warning: not a check"],"name":"a","verb":"shot"}],"passed":true,"scenario":"flex.scn"} + """ + } + } + + @Test + func `an empty path, name, or flag value is a usage error and nothing runs`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + let out = scratch.appending("traces") + + let trace = await run("run", scratch.path, "--trace", "", "--pretty") + let path = await run("run", "", "--pretty") + let inside = await run("run", scratch.path, "", "--pretty") + let shot = await run("shot", "", "--pretty") + let tree = await run("tree", "", "--pretty") + let check = await run("check", "", "--pretty") + let file = await run("replay", "", "--app", "stack", "--pretty") + let app = await run("replay", "t.jsonl", "--app", "", "--pretty") + let json = await run("run", scratch.path, "--trace", "") + + assertInlineSnapshot( + of: scratch.dump([trace, path, inside, shot, tree, check, file, app, json]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera shot [--json] [--pretty] [] + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera tree [--json] [--pretty] [] + + """ + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera check [--json] [--pretty] [] + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [7]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` needs a value that is not empty; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [8]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"`--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} + + """, + stderr: "" + ) + ] + """# + } + #expect(!FileManager.default.fileExists(atPath: out)) + } + + @Test + func `the help for replay spells its required flag and the overview lists it`() async { + let overview = await run("help") + let help = await run("help", "replay") + + #expect(overview.exitCode == 0) + #expect(overview.stdout.contains(" replay ")) + #expect(overview.stdout.contains(" check ")) + #expect( + help.stdout.contains( + "USAGE: tessera replay [--json] [--pretty] --app ")) + } + + // MARK: trace and replay + + @Test + func `run --trace writes one trace file per scenario under the directory`() async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", traced) + try scratch.write("nested/b.scn", traced) + let out = scratch.appending("traces") + + let output = await run("run", scratch.path, "--trace", out, "--pretty") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + PASS /a.scn + PASS /nested/b.scn + 2 scenarios, 2 passed, 0 failed + + """, + stderr: "" + ) + """# + } + let files = [ + TraceFile.path(forScenario: scratch.appending("a.scn"), in: out), + TraceFile.path(forScenario: scratch.appending("nested/b.scn"), in: out), + ] + for file in files { + let text = try #require( + FileManager.default.contents(atPath: file).flatMap { + String(validating: $0, as: UTF8.self) + }, "no trace at \(file)") + let records = try TraceFile.records(in: text) + expectNoDifference(records.map(\.seq), [0, 1, 2, 3, 4, 5]) + expectNoDifference( + records[4].event, TraceEvent(.resize(TerminalSize(columns: 30, rows: 8)))) + } + } + + @Test + func `replay runs a recorded trace again and passes against the app it came from`() + async throws + { + let scratch = try ScratchDirectory() + let scenario = try scratch.write("a.scn", traced) + let out = scratch.appending("traces") + _ = await run("run", scenario, "--trace", out, "--pretty") + let file = TraceFile.path(forScenario: scenario, in: out) + + let output = await run("replay", file, "--app", "stack") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"command":"replay","exitCode":0,"scenarios":[{"failures":[],"observations":[{"line":0,"lines":["Stack demo","──────────────────────────────","Name: Ro","left right","──────────────────────────────","30 columns"],"verb":"replay"}],"passed":true,"scenario":"/traces/a.trace.jsonl"}]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `replay against a different app exits 1 and names the step that differs`() + async throws + { + let scratch = try ScratchDirectory() + let scenario = try scratch.write("a.scn", traced) + let out = scratch.appending("traces") + _ = await run("run", scenario, "--trace", out, "--pretty") + let file = TraceFile.path(forScenario: scenario, in: out) + var other = ApplicationRegistry() + other.register("stack", viewports: [.desktop]) { GreedyApp() } + + let output = await run("replay", file, "--app", "stack", "--pretty", registry: other) + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /traces/a.trace.jsonl + line 1: step 0: resize 40x10 + the changed nodes differ from the recording + expected: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(2)/index(0)","root/index(0)/index(2)/index(1)","root/index(0)/index(3)","root/index(0)/index(3)/index(0)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"changed":[],"removed":[]} + actual: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 2: step 1: key R + the changed rows differ from the recording + expected: [2] + actual: [] + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 3: step 2: key o + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 4: step 3: key b + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 5: step 4: resize 30x8 + the changed nodes differ from the recording + expected: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(3)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"removed":[]} + actual: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + line 6: step 5: key Backspace + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + + """, + stderr: "" + ) + """# + } + } + + @Test + func `replay validates its arguments, its file, and its app`() async throws { + let scratch = try ScratchDirectory() + let notTrace = try scratch.write("nope.jsonl", "not json\n") + let binary = scratch.appending("bin.jsonl") + try Data([0xff, 0xfe, 0x00]).write(to: URL(fileURLWithPath: binary)) + + let noFile = await run("replay", "--app", "stack", "--pretty") + let noName = await run("replay", "run.trace", "--pretty") + let missing = await run( + "replay", scratch.appending("none.jsonl"), "--app", "stack", "--pretty") + let notUTF8 = await run("replay", binary, "--app", "stack", "--pretty") + let notRecords = await run("replay", notTrace, "--app", "stack", "--pretty") + let unknown = await run("replay", "run.trace", "--app", "ghost", "--pretty") + let appTwice = await run( + "replay", "run.trace", "--app", "stack", "--app", "counter", "--pretty") + + assertInlineSnapshot( + of: scratch.dump([noFile, noName, missing, notUTF8, notRecords, unknown, appTwice]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument ''; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument '--app '; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: cannot read the trace `/none.jsonl` + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: the trace `/bin.jsonl` is not UTF-8 text + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + FAIL /nope.jsonl + /nope.jsonl:1: error: this line is not a trace record (not valid JSON) + + """, + stderr: "" + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` is given twice; usage: tessera replay [--json] [--pretty] --app + + """ + ) + ] + """# + } + } + + @Test + func `launch with a trace file it cannot create exits 3 before the terminal changes`() + async throws + { + let scratch = try ScratchDirectory() + + let output = await HarnessCLI.run( + arguments: ["launch", "stack", "--pretty"], registry: Fixtures.registry(), + inputIsTerminal: true, outputIsTerminal: true, configuration: .default, + environment: ["TESSERA_TRACE": scratch.path]) + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: cannot create the trace file ``: the file could not be created + + """ + ) + """# + } + } + // MARK: help @Test @@ -612,11 +1277,15 @@ struct HarnessCLIScenarioTests { #expect(help.stdout.count > 60, "\(command)") } let runHelp = await run("help", "run") - for flag in ["--record", "--repeat ", "--timing", "--json", "--pretty"] { + for flag in [ + "--record", "--repeat ", "--timing", "--trace ", "--json", "--pretty", + ] { #expect(runHelp.stdout.contains(flag), "\(flag)") } + // The help wraps at the terminal's width, so a phrase can break across lines. + let flat = runHelp.stdout.replacingOccurrences(of: "\n", with: " ") for field in [#""change": "created" or "updated""#, #""line" 0"#, #""--repeat N""#] { - #expect(runHelp.stdout.contains(field), "\(field)") + #expect(flat.contains(field), "\(field)") } } } diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index 79d96b27..b1c72f7b 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -17,6 +17,8 @@ private let completionRequests: [[String]] = [ ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""], ["shot", "--", "positional@0", "2", "0", "tessera", "shot", ""], ["tree", "--", "positional@0", "2", "0", "tessera", "tree", ""], + ["check", "--", "positional@0", "2", "0", "tessera", "check", ""], + ["replay", "--", "--app", "4", "0", "tessera", "replay", "t.jsonl", "--app", ""], ] @MainActor @@ -289,7 +291,7 @@ struct HarnessCLITests { CommandOutput( exitCode: 2, stdout: """ - {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, help"} + {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help"} """, stderr: "" @@ -308,7 +310,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: unknown command `jump`; accepted: apps, launch, run, shot, tree, help + error: unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help """ ) @@ -326,7 +328,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: a command is required; accepted: apps, launch, run, shot, tree, help + error: a command is required; accepted: apps, launch, run, shot, tree, check, replay, help """ ) @@ -346,7 +348,7 @@ struct HarnessCLITests { [0]: CommandOutput( exitCode: 0, stdout: """ - OVERVIEW: List, launch, run, and picture the registered applications. + OVERVIEW: List, launch, run, picture, and check the registered applications. Every command exits 0 on success, 1 when an expectation failed, 2 on a usage or parse error, and 3 on a harness error. When standard output is not a terminal @@ -371,6 +373,8 @@ struct HarnessCLITests { run Run scenario files, or directories of them. shot Print one application's screen as text. tree Print one application's node tree. + check Check one application against the invariant rules. + replay Run a recorded trace again against an application. See 'tessera help ' for detailed help. @@ -412,6 +416,11 @@ struct HarnessCLITests { Exits 2 for an unknown name, and 3 for a duplicate registration or when standard input or output is not a terminal. + When the environment variable TESSERA_TRACE names a file, the session writes + its trace there, one JSON record per step, so `replay` can run it again. A file + that cannot be created is exit 3, before the terminal changes mode. The trace + is not promised to stay as it is. + USAGE: tessera launch [--json] [--pretty] ARGUMENTS: @@ -503,7 +512,7 @@ struct HarnessCLITests { [0]: CommandOutput( exitCode: 2, stdout: """ - {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, run, shot, tree, help"} + {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, run, shot, tree, check, replay, help"} """, stderr: "" @@ -512,7 +521,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: unknown command `ghost`; accepted: apps, launch, run, shot, tree, help + error: unknown command `ghost`; accepted: apps, launch, run, shot, tree, check, replay, help """ ), @@ -520,7 +529,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `launch extra`; accepted: apps, launch, run, shot, tree, help + error: `help` takes one command, got `launch extra`; accepted: apps, launch, run, shot, tree, check, replay, help """ ), @@ -528,7 +537,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `ghost launch`; accepted: apps, launch, run, shot, tree, help + error: `help` takes one command, got `ghost launch`; accepted: apps, launch, run, shot, tree, check, replay, help """ ) diff --git a/Tests/TesseraHarnessTests/ProjectionFixtures.swift b/Tests/TesseraHarnessTests/ProjectionFixtures.swift new file mode 100644 index 00000000..a8b0c872 --- /dev/null +++ b/Tests/TesseraHarnessTests/ProjectionFixtures.swift @@ -0,0 +1,201 @@ +import TesseraCore +import TesseraLayout +import TesseraRuntime +import TesseraTerminal +import TesseraTerminalInput + +@testable import TesseraHarness + +/// A custom layout that puts every child at the origin, the defect of a `ForEach` whose rows +/// overlap, written by hand so a check has something to find. +private struct Overlap: Layout { + func sizeThatFits(_ proposal: ProposedSize, subviews: Subviews) -> TerminalSize { + let sizes = subviews.map { $0.sizeThatFits(proposal) } + return TerminalSize( + columns: sizes.map(\.columns).max() ?? 0, rows: sizes.map(\.rows).max() ?? 0) + } + + func placeSubviews(in bounds: Rect, proposal: ProposedSize, subviews: Subviews) { + for subview in subviews { + subview.place(at: bounds.origin, proposal: proposal) + } + } +} + +/// Two children on one frame, under a layout that is not a `ZStack`. +final class OverlapApp: Application { + var body: some View { + Overlap { + Text("first") + Text("second") + } + } + + func apply(_ event: InputEvent) {} +} + +/// A view wider than the fixed frame it is offered: it measures more than it was proposed and +/// is clipped by the frame. +final class OverflowApp: Application { + var body: some View { + VStack(spacing: 0) { + Text("wide content").frame(width: 4) + Text("ok") + } + } + + func apply(_ event: InputEvent) {} +} + +/// `OK` over `status`, and the frame and padding modifiers, as the catalog draws them. +final class LayersApp: Application { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack { + Text("status") + Text("OK").frame(width: 2) + } + Text("Go").padding(1) + ForEach(["a", "b"], id: \.self) { name in + Text(name) + } + } + } + + func apply(_ event: InputEvent) {} +} + +/// Text with a style of its own: bold red, then underlined on a blue background. +final class StyledApp: Application { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("bold", style: Style(foreground: .rgb(255, 0, 0), attributes: .bold)) + Text("plain") + Text("blue", style: Style(background: .indexed(4), underlineStyle: .single)) + } + } + + func apply(_ event: InputEvent) {} +} + +/// Wide content: `東京` is four cells. +final class WideApp: Application { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("東京 map") + Text("after") + } + } + + func apply(_ event: InputEvent) {} +} + +/// A counter that is clean after every step it ignores: it returns its root explicitly, so +/// reconciliation skips the whole tree when nothing changed. +private struct Stable: Equatable, View { + var count: Int + + var body: some View { + Text("count \(count)") + } +} + +/// An app whose steps sometimes change nothing: `+` counts and every other key is ignored. +/// +/// The body returns its root explicitly: a view builder would wrap it in a tuple view, which +/// is not equatable and so is dirty after every update. +final class SteadyApp: Application { + private var count = 0 + + var body: some View { + let root = Stable(count: count) + return root.equatable() + } + + func apply(_ event: InputEvent) { + if case .key(let key) = event, key.code == .character("+") { + count += 1 + } + } +} + +/// An app that grows and shrinks its node list, to show which nodes a step adds and removes. +final class GrowingApp: Application { + private var items = ["a"] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(items, id: \.self) { item in + Text(item) + } + } + } + + func apply(_ event: InputEvent) { + guard case .key(let key) = event else { + return + } + switch key.code { + case .character("+"): items.append(String(UnicodeScalar(UInt8(97 + items.count)))) + case .character("-"): _ = items.popLast() + default: break + } + } +} + +/// Layers that a list and an `if` build under a `ZStack`: layout looks through both, so the +/// items share one frame by contract. +final class ListLayersApp: Application { + private let shown = true + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack { + ForEach(["abcd", "ef"], id: \.self) { layer in + Text(layer) + } + } + ZStack { + Text("base") + if shown { + Text("g") + Text("h") + } + } + } + } + + func apply(_ event: InputEvent) {} +} + +/// The same list under a layout that is not a `ZStack`: its items on one frame are the defect. +final class ListOverlapApp: Application { + var body: some View { + Overlap { + ForEach(["a", "b"], id: \.self) { name in + Text(name) + } + } + } + + func apply(_ event: InputEvent) {} +} + +enum ProjectionFixtures { + /// The apps the projection, check, and trace tests run, each at one registered name. + @MainActor + static func registry() -> ApplicationRegistry { + var registry = ApplicationRegistry() + registry.register("overlap", viewports: [.desktop]) { OverlapApp() } + registry.register("overflow", viewports: [.desktop]) { OverflowApp() } + registry.register("layers", viewports: [.desktop]) { LayersApp() } + registry.register("styled", viewports: [.desktop]) { StyledApp() } + registry.register("wide", viewports: [.desktop]) { WideApp() } + registry.register("steady", viewports: [.desktop]) { SteadyApp() } + registry.register("growing", viewports: [.desktop]) { GrowingApp() } + registry.register("list-layers", viewports: [.desktop]) { ListLayersApp() } + registry.register("list-overlap", viewports: [.desktop]) { ListOverlapApp() } + registry.register("shadowed-list", viewports: [.desktop]) { ShadowedListApp() } + return registry + } +} diff --git a/Tests/TesseraHarnessTests/ProjectionTests.swift b/Tests/TesseraHarnessTests/ProjectionTests.swift new file mode 100644 index 00000000..2e3bae25 --- /dev/null +++ b/Tests/TesseraHarnessTests/ProjectionTests.swift @@ -0,0 +1,782 @@ +import CustomDump +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraCore +import TesseraTerminalCore +import TesseraTerminalInput +import Testing + +@testable import TesseraHarness + +@MainActor +struct ProjectionTests { + private let registry = ProjectionFixtures.registry() + + /// The projection of `name` at 40 columns by 12 rows, after the keys. + private func project( + of name: String, keys: [Character] = [], terminal: TerminalProfile = TerminalProfile() + ) async throws -> Projection { + final class Box { + var projection: Projection? + } + let box = Box() + try await registry.withHeadlessHost( + name, at: .custom(TerminalSize(columns: 40, rows: 12)), terminal: terminal + ) { host in + for key in keys { + try await host.send(.key(Key(code: .character(key)))) + } + box.projection = host.projection + } + return try #require(box.projection) + } + + private func node(_ projection: Projection, _ path: String) throws -> Projection.Node { + try #require(projection.node(at: path), "no node at \(path)") + } + + // MARK: Nodes + + @Test + func + `each node carries its short type and a container's layout, never a reflected name`() + async throws + { + let projection = try await project(of: "layers") + + assertInlineSnapshot( + of: projection.nodes.map { ($0.path, $0.type, $0.layout) }, as: .customDump + ) { + """ + [ + [0]: ( + "root", + "TupleView", + nil + ), + [1]: ( + "root/index(0)", + "VStack", + nil + ), + [2]: ( + "root/index(0)/index(0)", + "ZStack", + nil + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + "Text", + nil + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + "_LayoutContainer", + "_FrameLayout" + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "Text", + nil + ), + [6]: ( + "root/index(0)/index(1)", + "_LayoutContainer", + "_PaddingLayout" + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "Text", + nil + ), + [8]: ( + "root/index(0)/index(2)", + "ForEach", + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + "TupleView", + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "Text", + nil + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + "TupleView", + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "Text", + nil + ) + ] + """ + } + #expect( + projection.nodes.filter { $0.type != "_LayoutContainer" }.allSatisfy { + $0.layout == nil + }) + #expect( + projection.nodes.allSatisfy { !$0.type.contains(".") && !$0.type.contains("<") }) + } + + @Test + func + `depth is the nesting and paint order is the position among the parent's children`() + async throws + { + let projection = try await project(of: "layers") + + assertInlineSnapshot( + of: projection.nodes.map { + ($0.path, $0.parent, $0.depth, $0.paintOrder, $0.children) + }, + as: .customDump + ) { + """ + [ + [0]: ( + "root", + nil, + 0, + 0, + [ + [0]: "root/index(0)" + ] + ), + [1]: ( + "root/index(0)", + "root", + 1, + 0, + [ + [0]: "root/index(0)/index(0)", + [1]: "root/index(0)/index(1)", + [2]: "root/index(0)/index(2)" + ] + ), + [2]: ( + "root/index(0)/index(0)", + "root/index(0)", + 2, + 0, + [ + [0]: "root/index(0)/index(0)/index(0)", + [1]: "root/index(0)/index(0)/index(1)" + ] + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + "root/index(0)/index(0)", + 3, + 0, + [] + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + "root/index(0)/index(0)", + 3, + 1, + [ + [0]: "root/index(0)/index(0)/index(1)/index(0)" + ] + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "root/index(0)/index(0)/index(1)", + 4, + 0, + [] + ), + [6]: ( + "root/index(0)/index(1)", + "root/index(0)", + 2, + 1, + [ + [0]: "root/index(0)/index(1)/index(0)" + ] + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "root/index(0)/index(1)", + 3, + 0, + [] + ), + [8]: ( + "root/index(0)/index(2)", + "root/index(0)", + 2, + 2, + [ + [0]: "root/index(0)/index(2)/id(a)", + [1]: "root/index(0)/index(2)/id(b)" + ] + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + "root/index(0)/index(2)", + 3, + 0, + [ + [0]: "root/index(0)/index(2)/id(a)/index(0)" + ] + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "root/index(0)/index(2)/id(a)", + 4, + 0, + [] + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + "root/index(0)/index(2)", + 3, + 1, + [ + [0]: "root/index(0)/index(2)/id(b)/index(0)" + ] + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "root/index(0)/index(2)/id(b)", + 4, + 0, + [] + ) + ] + """ + } + } + + @Test + func `frames, clips, proposals, and measured sizes come from the graph`() async throws { + let projection = try await project(of: "layers") + + assertInlineSnapshot( + of: projection.nodes.map { ($0.path, $0.frame, $0.clip, $0.proposal, $0.measured) }, + as: .customDump + ) { + """ + [ + [0]: ( + "root", + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Proposal( + width: 40, + height: 12 + ), + Projection.Extent( + columns: 6, + rows: 6 + ) + ), + [1]: ( + "root/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Proposal( + width: 40, + height: 12 + ), + Projection.Extent( + columns: 6, + rows: 6 + ) + ), + [2]: ( + "root/index(0)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 6, + rows: 1 + ) + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Proposal( + width: 6, + height: 1 + ), + Projection.Extent( + columns: 6, + rows: 1 + ) + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 6, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 2, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [6]: ( + "root/index(0)/index(1)", + Projection.Box( + column: 0, + row: 1, + columns: 4, + rows: 3 + ), + Projection.Box( + column: 0, + row: 1, + columns: 4, + rows: 3 + ), + Projection.Proposal( + width: 40, + height: 3 + ), + Projection.Extent( + columns: 4, + rows: 3 + ) + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + Projection.Box( + column: 1, + row: 2, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 1, + row: 2, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 2, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [8]: ( + "root/index(0)/index(2)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 2 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 2 + ), + nil, + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + nil, + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 1, + rows: 1 + ) + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + nil, + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 1, + rows: 1 + ) + ) + ] + """ + } + #expect(projection.nodes.allSatisfy { !$0.needsLayout && !$0.needsRender }) + } + + // MARK: Text + + @Test + func `text is attributed to the topmost leaf, so covered text is not the lower node's`() + async throws + { + let projection = try await project(of: "layers") + + assertInlineSnapshot(of: projection.nodes.map { ($0.path, $0.text) }, as: .customDump) + { + """ + [ + [0]: ( + "root", + nil + ), + [1]: ( + "root/index(0)", + nil + ), + [2]: ( + "root/index(0)/index(0)", + nil + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + " atus" + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + nil + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "OK" + ), + [6]: ( + "root/index(0)/index(1)", + nil + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "Go" + ), + [8]: ( + "root/index(0)/index(2)", + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "a" + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "b" + ) + ] + """ + } + } + + @Test + func `a wide grapheme's text is read from its cells and its leaf keeps its width`() + async throws + { + let projection = try await project(of: "wide") + + let wide = try #require(projection.nodes.first { $0.text == "東京 map" }) + assertInlineSnapshot(of: wide.frame, as: .customDump) { + """ + Projection.Box( + column: 0, + row: 0, + columns: 8, + rows: 1 + ) + """ + } + #expect(projection.screen.rows.first == "東京 map") + } + + // MARK: Screen and statistics + + @Test + func `the screen holds its rows, cursor, and the runs of a style`() async throws { + let projection = try await project( + of: "styled", terminal: TerminalProfile(colors: .truecolor)) + + assertInlineSnapshot(of: projection.screen, as: .customDump) { + """ + Projection.Screen( + columns: 40, + cursor: Projection.Screen.Cursor( + column: 39, + row: 11 + ), + rows: [ + [0]: "bold", + [1]: "plain", + [2]: "blue", + [3]: "", + [4]: "", + [5]: "", + [6]: "", + [7]: "", + [8]: "", + [9]: "", + [10]: "", + [11]: "" + ], + styles: [ + [0]: Projection.Screen.StyleRun( + row: 0, + column: 0, + columns: 4, + style: "fg=#ff0000 bold" + ), + [1]: Projection.Screen.StyleRun( + row: 2, + column: 0, + columns: 4, + style: "bg=indexed(4) underline" + ) + ] + ) + """ + } + } + + @Test + func `the statistics carry the counters and the reasons, and no duration`() async throws + { + let projection = try await project(of: "layers") + + assertInlineSnapshot(of: projection.statistics, as: .customDump) { + """ + Projection.Statistics( + bodyEvaluations: 0, + equatableSkips: 0, + focusChanges: 0, + invalidationReasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + leafUpdates: 5, + measurements: 29, + nodesCreated: 0, + nodesDestroyed: 0, + nodesUpdated: 13, + placements: 13, + renderedNodes: 5, + terminalRequirementChanges: 0 + ) + """ + } + let json = try #require( + String(bytes: try JSONEncoder().encode(projection), encoding: .utf8)) + #expect(!json.lowercased().contains("duration")) + } + + @Test + func `the pass marker is the step that drew the projected frame`() async throws { + let first = try await project(of: "steady") + let third = try await project(of: "steady", keys: ["+", "+"]) + + #expect(first.pass == 0) + #expect(third.pass == 2) + } + + // MARK: JSON + + @Test + func `the JSON has sorted keys, a version, and the same bytes on every run`() + async throws + { + let first = try await project(of: "layers") + let second = try await project(of: "layers") + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + + let json = try #require(String(bytes: try encoder.encode(first), encoding: .utf8)) + + #expect(try encoder.encode(first) == encoder.encode(second)) + #expect(first.version == Projection.currentVersion) + #expect(json.contains(#""version":1"#)) + // Sorted at every level: the top-level keys follow the node list, in order. + #expect(json.hasPrefix(#"{"nodes":[{"#)) + let tail = try #require(json.range(of: #"],"pass":0,"requirements":{"#)) + let rest = json[tail.upperBound...] + let screen = try #require(rest.range(of: #""screen":{"#)) + let statistics = try #require(rest.range(of: #""statistics":{"#)) + #expect(screen.lowerBound < statistics.lowerBound) + #expect(json.hasSuffix(#""version":1}"#)) + #expect(!json.contains("TesseraCore")) + #expect(!json.contains("unknown context")) + } + + @Test + func `a node's JSON leaves out what it does not have`() async throws { + let projection = try await project(of: "layers") + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let root = try #require(projection.nodes.first) + + let json = try #require(String(bytes: try encoder.encode(root), encoding: .utf8)) + + assertInlineSnapshot(of: json, as: .lines) { + #""" + {"children":["root\/index(0)"],"clip":{"column":0,"columns":40,"row":0,"rows":12},"depth":0,"environmentOverrides":[],"frame":{"column":0,"columns":40,"row":0,"rows":12},"handlerKinds":[],"isList":true,"measured":{"columns":6,"rows":6},"needsLayout":false,"needsRender":false,"paintOrder":0,"path":"root","proposal":{"height":12,"width":40},"requirements":[],"type":"TupleView"} + """# + } + } +} diff --git a/Tests/TesseraHarnessTests/RegistryTests.swift b/Tests/TesseraHarnessTests/RegistryTests.swift index ab1dd578..c24da2a6 100644 --- a/Tests/TesseraHarnessTests/RegistryTests.swift +++ b/Tests/TesseraHarnessTests/RegistryTests.swift @@ -125,12 +125,15 @@ struct ApplicationRegistryTests { message: "applications registered more than once: `counter`, `stack`", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ) """ } diff --git a/Tests/TesseraHarnessTests/ReplayTests.swift b/Tests/TesseraHarnessTests/ReplayTests.swift new file mode 100644 index 00000000..67e6bb2f --- /dev/null +++ b/Tests/TesseraHarnessTests/ReplayTests.swift @@ -0,0 +1,436 @@ +import CustomDump +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraRuntime +import TesseraTerminalCore +import TesseraTerminalInput +import Testing + +@testable import TesseraHarness + +@MainActor +struct ReplayTests { + private let registry = ProjectionFixtures.registry() + + private func key(_ character: Character) -> InputEvent { + .key(Key(code: .character(character))) + } + + /// The records of `name` at 40 columns by 12 rows after `events`. + private func record(_ name: String, _ events: [InputEvent]) async throws + -> [TraceRecord] + { + final class Box { + var records: [TraceRecord] = [] + } + let box = Box() + try await registry.withHeadlessHost( + name, at: .custom(TerminalSize(columns: 40, rows: 12)) + ) { host in + for event in events { + try await host.send(event) + } + box.records = host.records + } + return box.records + } + + private func replay( + _ records: [TraceRecord], app: String, in registry: ApplicationRegistry? = nil + ) async -> ScenarioReport { + await Replayer.replay( + text: TraceFile.text(of: records), file: "run.trace.jsonl", app: app, + registry: registry ?? self.registry) + } + + /// A registry whose only app is `make`'s, registered under `name`. + private func registryWith( + _ name: String, _ make: @escaping @MainActor () -> App + ) -> ApplicationRegistry { + var registry = ApplicationRegistry() + registry.register(name, viewports: [.desktop], make: make) + return registry + } + + // MARK: What a replay compares + + @Test + func + `a trace replays against the app it came from and the final screen is the report's`() + async throws + { + let records = try await record("growing", [key("+"), key("+"), key("-")]) + + let report = await replay(records, app: "growing") + + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 0, + verb: "replay", + name: nil, + lines: [ + [0]: "a", + [1]: "b" + ], + projection: nil + ) + ] + """ + } + expectNoDifference(report.trace.map(\.seq), [0, 1, 2, 3]) + } + + @Test + func + `a replay against a different app reports the first field that differs, step by step`() + async throws + { + let records = try await record("growing", [key("+")]) + let other = registryWith("growing") { SteadyApp() } + + let report = await replay(records, app: "growing", in: other) + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 1, + statement: "step 0: resize 40x12", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutEnvironmentChanged, layoutViewChanged, renderEnvironmentChanged, renderRequested, renderViewChanged, updateRequested", + actual: "renderRequested, updateRequested", + screen: [ + [0]: "count 0" + ], + rule: nil, + node: nil + ), + [1]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutEnvironmentChanged, layoutViewChanged, renderEnvironmentChanged, renderRequested, renderViewChanged, updateRequested", + actual: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + """ + } + } + + @Test + func `the bytes a frame wrote are not compared and the other costs are`() async throws { + var records = try await record("steady", [key("+")]) + for index in records.indices { + records[index].cost.bytes += 99 + } + + let bytesOnly = await replay(records, app: "steady") + records[1].cost.measures += 1 + let measured = await replay(records, app: "steady") + + expectNoDifference(bytesOnly.failures, []) + assertInlineSnapshot(of: measured.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the costs differ from the recording", + expected: "bodies=1 bytes=116 measures=5 placements=4 renders=1", + actual: "bodies=1 bytes=17 measures=4 placements=4 renders=1", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + """ + } + } + + @Test + func `a changed reason, row list, or draw decision is a difference`() async throws { + let recorded = try await record("steady", [key("+"), key("x")]) + + var reasons = recorded + reasons[1].reasons.append("layoutViewportChanged") + var rows = recorded + rows[1].rows = [3] + var drawing = recorded + drawing[2].drew = true + + let reasonReport = await replay(reasons, app: "steady") + let rowReport = await replay(rows, app: "steady") + let drawReport = await replay(drawing, app: "steady") + + assertInlineSnapshot( + of: [reasonReport.failures, rowReport.failures, drawReport.failures], + as: .customDump + ) { + """ + [ + [0]: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested, layoutViewportChanged", + actual: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ], + [1]: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the changed rows differ from the recording", + expected: "[3]", + actual: "[0]", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ], + [2]: [ + [0]: ScenarioFailure( + line: 3, + statement: "step 2: key x", + verb: "replay", + message: "the recording drew a frame and the replay drew none", + expected: "drew true", + actual: "drew false", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + ] + """ + } + } + + @Test + func `an app that finishes before the trace does is a failure that says so`() + async throws + { + let records = try await record("growing", [key("+"), key("+")]) + let finished = registryWith("growing") { FinishedApp() } + + let report = await replay(records, app: "growing", in: finished) + + assertInlineSnapshot(of: report.failures.last, as: .customDump) { + """ + ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the application finished before step 1, where the recording kept going", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + """ + } + } + + // MARK: What a replay refuses + + @Test + func `a trace with an event replay cannot deliver is refused before anything runs`() + async throws + { + var records = try await record("steady", [key("+"), key("+")]) + records[1] = TraceRecord( + seq: 1, event: TraceEvent(.unknown([0x1b, 0x5b])), drew: false, reasons: [], + nodes: .init(added: [], changed: [], removed: []), rows: [], + cost: StepCost(bodies: 0, bytes: 0, measures: 0, placements: 0, renders: 0)) + + let report = await replay(records, app: "steady") + + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "run.trace.jsonl", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1", + verb: "usage", + message: "step 1 holds an event replay cannot deliver: unknown (not replayable); replay delivers keys, pastes, resizes, and focus changes", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ) + """ + } + } + + @Test + func `a trace with the terminal's focus changes replays like any other`() async throws { + // A live session records a focus change as it receives it, and that must not make the + // whole trace unreplayable. + let records = try await record( + "growing", [.focusGained, key("+"), .focusLost, key("+"), .focusGained]) + + expectNoDifference( + records.map(\.event.type), + ["resize", "focusGained", "key", "focusLost", "key", "focusGained"] + ) + let report = await replay(records, app: "growing") + + expectNoDifference(report.failures, []) + expectNoDifference(report.trace.map(\.event), records.map(\.event)) + expectNoDifference(report.observations.first?.lines, ["a", "b", "c"]) + } + + @Test + func `a trace must start with step 0, the resize that gives the starting size`() + async throws + { + let records = try await record("steady", [key("+")]) + + let withoutStart = await replay(Array(records.dropFirst()), app: "steady") + var skipped = records + skipped[1].seq = 5 + let skippedStep = await replay(skipped, app: "steady") + let empty = await Replayer.replay( + text: "\n", file: "empty.jsonl", app: "steady", registry: registry) + + assertInlineSnapshot( + of: [withoutStart.failures, skippedStep.failures, empty.failures], as: .customDump + ) { + """ + [ + [0]: [ + [0]: ScenarioFailure( + line: 1, + statement: "", + verb: "usage", + message: "the first record must be step 0, the resize that tells the application its starting size; it is step 1, key +", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + [1]: [ + [0]: ScenarioFailure( + line: 2, + statement: "", + verb: "usage", + message: "the steps are numbered 0, 1, 2, …; this one is 5", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + [2]: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "the trace holds no steps", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + ] + """ + } + } + + @Test + func `text that is not a trace is a usage failure at the line that is wrong`() async { + let report = await Replayer.replay( + text: "{\"seq\": 0}\n", file: "bad.jsonl", app: "steady", registry: registry) + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 1, + statement: "", + verb: "usage", + message: "this line is not a trace record (no `version`)", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + assertInlineSnapshot(of: report.description, as: .lines) { + """ + FAIL bad.jsonl + bad.jsonl:1: error: this line is not a trace record (no `version`) + """ + } + } + + @Test + func `an unknown app is a harness failure naming the registered ones`() async throws { + let records = try await record("steady", []) + + let report = await replay(records, app: "ghost") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "harness", + message: "unknown app `ghost`; accepted: growing, layers, list-layers, list-overlap, overflow, overlap, shadowed-list, steady, styled, wide", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + } +} diff --git a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift index 7f4dd6e9..d08f1157 100644 --- a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift @@ -117,12 +117,15 @@ struct ScenarioRunnerFileTests { message: "no such file or directory", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ), [1]: ScenarioReport( scenario: "/plain.txt", @@ -134,12 +137,15 @@ struct ScenarioRunnerFileTests { message: "scenario files end in `.scn`", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ), [2]: ScenarioReport( scenario: "/empty", @@ -151,19 +157,79 @@ struct ScenarioRunnerFileTests { message: "no scenario files (*.scn) under this directory", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ), [3]: ScenarioReport( scenario: "/ok.scn", failures: [], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -203,7 +269,9 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ), [1]: ScenarioFailure( line: 7, @@ -219,7 +287,9 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ), [2]: ScenarioFailure( line: 8, @@ -235,7 +305,9 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ), [3]: ScenarioFailure( line: 10, @@ -251,7 +323,9 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ) ] """ @@ -280,22 +354,82 @@ struct ScenarioRunnerFileTests { line: 3, statement: "blink 3", verb: "parse", - message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node", + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ), [1]: ScenarioReport( scenario: "/b-good.scn", failures: [], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -303,12 +437,12 @@ struct ScenarioRunnerFileTests { assertInlineSnapshot(of: scratch.redacted(reports[0].description), as: .lines) { """ FAIL /a-bad.scn - /a-bad.scn:3: error: unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + /a-bad.scn:3: error: unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost """ } assertInlineSnapshot(of: scratch.redacted(reports[0].json), as: .lines) { """ - {"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"/a-bad.scn"} + {"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"/a-bad.scn"} """ } } @@ -349,12 +483,15 @@ struct ScenarioRunnerFileTests { message: "applications registered more than once: `stack`", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ) ] """ @@ -385,7 +522,9 @@ struct ScenarioRunnerFileTests { message: "shot `after-typing` has no golden at `/stack/after-typing.txt`; run with --record to create it, and review the file", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [ @@ -400,11 +539,183 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -439,16 +750,192 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [ [0]: GoldenChange( path: "/stack/after-typing.txt", change: .created + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created ) ], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -471,11 +958,183 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -489,6 +1148,21 @@ struct ScenarioRunnerFileTests { ──────────────────────────────────────── 40 columns + """ + } + assertInlineSnapshot(of: scratch.read("stack/after-typing.grid.txt"), as: .lines) { + """ + ── chars ── + Stack demo + ──────────────────────────────────────── + Name: Rob + left right + ──────────────────────────────────────── + 40 columns + ── style ── + ── legend ── + . default + """ } } @@ -527,7 +1201,9 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ) ], observations: [ @@ -542,11 +1218,183 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """# @@ -580,16 +1428,192 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [ [0]: GoldenChange( path: "/stack/after-typing.txt", change: .updated + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created ) ], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -598,11 +1622,518 @@ struct ScenarioRunnerFileTests { """ PASS /stack.scn recorded /stack/after-typing.txt (updated) + recorded /stack/after-typing.grid.txt (created) """ } #expect(scratch.read("stack/after-typing.txt")?.hasPrefix("Stack demo\n") == true) } + @Test + func + `a drifted styled grid fails with the grid golden's path while the text still matches`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + try scratch.write( + "stack/after-typing.grid.txt", + (scratch.read("stack/after-typing.grid.txt") ?? "") + .replacingOccurrences(of: "── style ──\n", with: "── style ──\n.AA\n") + .replacingOccurrences(of: ". default\n", with: ". default\nA bold\n")) + + let reports = await runner().run(scratch.path) + + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + #""" + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` differs from `/stack/after-typing.grid.txt` at row 8: the golden has 12 rows and the screen has 10. If the new rendering is intended, rerun with --record and review the golden\'s diff", + expected: ".AA", + actual: "── legend ──", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """# + } + } + + @Test + func `a missing grid golden fails the shot even when the text golden is there`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + try FileManager.default.removeItem( + atPath: scratch.appending("stack/after-typing.grid.txt")) + + let reports = await runner().run(scratch.path) + + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` has no golden at `/stack/after-typing.grid.txt`; run with --record to create it, and review the file", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } + } + + @Test + func `a styled screen records its styles in the grid`() async throws { + let scratch = try ScratchDirectory() + try scratch.write( + "styled.scn", + """ + app styled + size 40x6 + term colors=true + shot colours + """) + + let reports = await ScenarioRunner( + registry: ProjectionFixtures.registry(), record: true + ) + .run(scratch.path) + + #expect(reports.map(\.passed) == [true]) + assertInlineSnapshot(of: scratch.read("styled/colours.txt"), as: .lines) { + """ + bold + plain + blue + + """ + } + assertInlineSnapshot(of: scratch.read("styled/colours.grid.txt"), as: .lines) { + """ + ── chars ── + bold + plain + blue + ── style ── + AAAA + + BBBB + ── legend ── + . default + A fg=#ff0000 bold + B bg=indexed(4) underline + + """ + } + } + @Test func `record leaves an unchanged golden alone and lists nothing`() async throws { let scratch = try ScratchDirectory() @@ -642,7 +2173,9 @@ struct ScenarioRunnerFileTests { message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [ @@ -657,11 +2190,183 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -697,7 +2402,9 @@ struct ScenarioRunnerFileTests { message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [ @@ -712,11 +2419,183 @@ struct ScenarioRunnerFileTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) ] """ @@ -793,7 +2672,9 @@ struct ScenarioRunnerFileTests { message: "run 2 of 3 differs from run 1: it has 1 failures where run 1 has 0", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ), [1]: ScenarioFailure( line: 0, @@ -802,7 +2683,9 @@ struct ScenarioRunnerFileTests { message: "run 3 of 3 differs from run 1: it has 1 failures where run 1 has 0", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ] """ @@ -819,7 +2702,20 @@ struct ScenarioRunnerFileTests { let reports = await repeating.run(scratch.path) #expect(reports.map(\.passed) == [true]) - #expect(reports[0].recorded.count == 1) + assertInlineSnapshot(of: scratch.dump(reports[0].recorded), as: .lines) { + """ + [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .created + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created + ) + ] + """ + } } @Test @@ -930,7 +2826,9 @@ struct ScenarioReportShapeTests { message: "unknown", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ] """ diff --git a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift new file mode 100644 index 00000000..9d64d7a1 --- /dev/null +++ b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift @@ -0,0 +1,778 @@ +import CustomDump +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import Testing + +@testable import TesseraHarness + +private func parse(_ source: String) throws -> Scenario { + try ScenarioParser.parse( + source, file: "t.scn", + knownApps: ProjectionFixtures.names + Fixtures.names) +} + +extension ProjectionFixtures { + static let names = [ + "overlap", "overflow", "layers", "styled", "wide", "steady", "growing", "list-layers", + "list-overlap", "shadowed-list", + ] +} + +// MARK: Parsing + +struct NewStatementParserTests { + private func statement(_ line: String) throws -> Statement { + try #require(try parse("app layers\n" + line).lines.first?.statement) + } + + private func message(_ line: String) -> String? { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app layers\n" + line) + } + return error?.message + } + + @Test + func `check with no word runs every rule and with words runs those`() throws { + expectNoDifference( + try [statement("check"), statement("check siblings-disjoint no-dirty-after-pass")], + [.check(rules: []), .check(rules: [.siblingsDisjoint, .noDirtyAfterPass])] + ) + } + + @Test + func `an unknown or repeated check rule is a parse error that names the rules`() { + assertInlineSnapshot( + of: [ + message("check no-such-rule"), + message("check siblings-disjoint siblings-disjoint"), + ], + as: .customDump + ) { + """ + [ + [0]: "unknown check rule `no-such-rule`; accepted: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate", + [1]: "check rule `siblings-disjoint` is named twice" + ] + """ + } + } + + @Test + func `dump takes no argument`() throws { + #expect(try statement("dump") == .dump) + assertInlineSnapshot(of: message("dump everything"), as: .customDump) { + """ + "usage: dump (the whole projection; it takes no argument)" + """ + } + } + + @Test + func `expect cost takes a bound for each counter it names`() throws { + expectNoDifference( + try [ + statement("expect cost bodies<=5 bytes<=40"), statement("expect cost renders<=0"), + ], + [ + .expectCost([ + CostBound(counter: "bodies", limit: 5), CostBound(counter: "bytes", limit: 40), + ]), + .expectCost([CostBound(counter: "renders", limit: 0)]), + ] + ) + } + + @Test + func + `expect cost refuses everything but a counter, then less than or equal, then a number`() + throws + { + let lines = [ + "expect cost", "expect cost bodies<5", "expect cost bodies <= 5", + "expect cost frames<=5", "expect cost bodies<=many", "expect cost bodies<=-1", + "expect cost bodies<=1 bodies<=2", + ] + + assertInlineSnapshot(of: lines.map(message), as: .customDump) { + #""" + [ + [0]: "usage: expect cost NAME<=N… (counters: bodies, bytes, measures, placements, renders)", + [1]: "cost bound `bodies<5` is not NAME<=N, written without spaces; only `<=` is accepted, because the counts are the library\'s work and change with it (counters: bodies, bytes, measures, placements, renders)", + [2]: "cost bound `bodies` is not NAME<=N, written without spaces; only `<=` is accepted, because the counts are the library\'s work and change with it (counters: bodies, bytes, measures, placements, renders)", + [3]: "unknown cost counter `frames`; accepted: bodies, bytes, measures, placements, renders", + [4]: "cost bound `bodies<=many` needs a non-negative whole number after `<=`", + [5]: "cost bound `bodies<=-1` needs a non-negative whole number after `<=`", + [6]: "cost counter `bodies` is bounded twice" + ] + """# + } + } + + @Test + func `the unknown statement error lists the new statements`() { + assertInlineSnapshot(of: message("blink"), as: .customDump) { + """ + "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost" + """ + } + } + + @Test + func `a locator takes a layout, and the accepted keys name it`() throws { + expectNoDifference( + try statement("expect node layout=_FrameLayout nth=1 frame 0,0,2x1"), + .expectNode( + Locator(layout: "_FrameLayout", nth: 1), + FrameExpectation(column: 0, row: 0, columns: 2, rows: 1)) + ) + assertInlineSnapshot( + of: message("expect node colour=red frame 0,0,1x1"), as: .customDump + ) { + """ + "unknown locator key `colour`; accepted: type, layout, id, text, nth" + """ + } + } + + @Test + func + `a locator key with an empty value is a parse error and not a query for everything`() + { + let lines = + ["type", "layout", "id", "text"].map { "expect node \($0)= frame 0,0,1x1" } + + [#"expect node text="" frame 0,0,1x1"#] + + assertInlineSnapshot(of: lines.map(message), as: .customDump) { + """ + [ + [0]: "locator key `type` needs a value that is not empty", + [1]: "locator key `layout` needs a value that is not empty", + [2]: "locator key `id` needs a value that is not empty", + [3]: "locator key `text` needs a value that is not empty", + [4]: "locator key `text` needs a value that is not empty" + ] + """ + } + } +} + +// MARK: Playing + +@MainActor +struct NewStatementPlayerTests { + private let registry = ProjectionFixtures.registry() + + private func play(_ source: String) async throws -> ScenarioReport { + let scenario = try parse(source) + return await ScenarioPlayer.run(scenario, name: "t.scn", registry: registry) + } + + // MARK: check + + @Test + func `check passes on a clean app and records the rules it ran`() async throws { + let report = try await play("app layers\ncheck") + + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate" + ], + projection: nil + ) + ] + """ + } + } + + @Test + func `layers a ForEach and an if build under a ZStack pass siblings-disjoint`() + async throws + { + // The items really do share a frame, which is what makes this the case worth checking. + let report = try await play( + """ + app list-layers + expect node id=abcd frame 0,0,4x1 + expect node id=ef frame 0,0,2x1 + expect node type=Text text="h" frame 0,1,1x1 + check siblings-disjoint + check + """) + + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 5, + verb: "check", + name: nil, + lines: [ + [0]: "ran siblings-disjoint" + ], + projection: nil + ), + [1]: ScenarioObservation( + line: 6, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate" + ], + projection: nil + ) + ] + """ + } + } + + @Test + func `a list on a layout that is not a ZStack still fails siblings-disjoint`() + async throws + { + let report = try await play("app list-overlap\ncheck siblings-disjoint") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(0)/id(b): shows 0,0,1x1, which overlaps sibling root/index(0)/index(0)/id(a) showing 0,0,1x1 under ForEach", + expected: nil, + actual: nil, + screen: [ + [0]: "b" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(0)/id(b)" + ) + ] + """ + } + } + + @Test + func + `an app's own view named ForEach is not a list, so its overlapping body is reported`() + async throws + { + let report = try await play( + """ + app shadowed-list + expect node type=Text text="second" frame 0,0,6x1 + check siblings-disjoint + """) + + // The app's `ForEach` is not looked through, so the `ZStack` does not excuse the two texts + // that its container-less body puts on one frame. + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(0)/body/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0)/body/index(0) showing 0,0,5x1 under TupleView", + expected: nil, + actual: nil, + screen: [ + [0]: "second" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(0)/body/index(1)" + ) + ] + """ + } + } + + @Test + func `a violated rule fails the statement with its rule and node`() async throws { + let report = try await play("app overlap\ncheck siblings-disjoint") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap", + expected: nil, + actual: nil, + screen: [ + [0]: "second" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(1)" + ) + ] + """ + } + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[{"line":2,"message":"siblings-disjoint at root/index(0)/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap","node":"root/index(0)/index(1)","rule":"siblings-disjoint","screen":["second"],"statement":"check siblings-disjoint","verb":"check"}],"observations":[{"line":2,"lines":["ran siblings-disjoint"],"verb":"check"}],"passed":false,"scenario":"t.scn"} + """ + } + } + + @Test + func `a bare check reports a warning without failing and a named rule fails on it`() + async throws + { + let bare = try await play("app overflow\ncheck") + let named = try await play("app overflow\ncheck frame-inside-clip") + let others = try await play( + "app overflow\ncheck no-dirty-after-pass siblings-disjoint") + + assertInlineSnapshot(of: bare, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "check", + verb: "check", + message: "measured-within-proposal at root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered", + expected: nil, + actual: nil, + screen: [ + [0]: "wide", + [1]: "ok" + ], + rule: "measured-within-proposal", + node: "root/index(0)/index(0)/index(0)" + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate", + [1]: "warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/index(0)", + [4]: "root/index(0)/index(1)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 11, + placements: 5, + renders: 2 + ) + ) + ] + ) + """ + } + assertInlineSnapshot(of: named, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "check frame-inside-clip", + verb: "check", + message: "frame-inside-clip at root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)", + expected: nil, + actual: nil, + screen: [ + [0]: "wide", + [1]: "ok" + ], + rule: "frame-inside-clip", + node: "root/index(0)/index(0)/index(0)" + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran frame-inside-clip" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/index(0)", + [4]: "root/index(0)/index(1)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 11, + placements: 5, + renders: 2 + ) + ) + ] + ) + """ + } + expectNoDifference(others.failures, []) + } + + // MARK: dump + + @Test + func `dump observes the whole projection as a JSON object in the report`() async throws + { + let report = try await play("app layers\ndump") + + let observation = try #require(report.observations.first) + let projection = try #require(observation.projection) + assertInlineSnapshot( + of: (observation.verb, observation.lines, projection.pass, projection.nodes.count), + as: .customDump + ) { + """ + ( + "dump", + [], + 0, + 13 + ) + """ + } + let json = try #require( + JSONSerialization.jsonObject(with: Data(report.json.utf8)) as? [String: Any]) + let observations = try #require(json["observations"] as? [[String: Any]]) + let dumped = try #require(observations.first?["projection"] as? [String: Any]) + #expect(dumped["version"] as? Int == 1) + #expect((dumped["nodes"] as? [Any])?.count == projection.nodes.count) + expectNoDifference( + dumped.keys.sorted(), + ["nodes", "pass", "requirements", "screen", "statistics", "version"]) + } + + @Test + func `two dumps of one scenario are the same bytes`() async throws { + let first = try await play("app layers\nkey x\ndump") + let second = try await play("app layers\nkey x\ndump") + + #expect(first.json == second.json) + #expect(first.observations.first?.projection?.pass == 1) + } + + // MARK: expect cost + + @Test + func `expect cost holds while the last step took no more than each bound`() async throws + { + let report = try await play( + """ + app steady + key x + expect cost bodies<=0 bytes<=0 measures<=0 placements<=0 renders<=0 + key + + expect cost bytes<=100 renders<=1 + """) + + expectNoDifference(report.failures, []) + } + + @Test + func + `expect cost names the step, the counter, and every count when a bound is exceeded`() + async throws + { + let report = try await play("app steady\nkey +\nexpect cost bytes<=0 measures<=5") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "expect cost bytes<=0 measures<=5", + verb: "expect cost", + message: "step 1 (key +) took 17 bytes, above the bound 0", + expected: "bytes<=0 measures<=5", + actual: "bodies=1 bytes=17 measures=4 placements=4 renders=1", + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + } + + @Test + func `expect cost before any event bounds the first frame`() async throws { + let report = try await play("app layers\nexpect cost bytes<=1") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect cost bytes<=1", + verb: "expect cost", + message: "step 0 (resize 80x24) took 2097 bytes, above the bound 1", + expected: "bytes<=1", + actual: "bodies=0 bytes=2097 measures=29 placements=13 renders=5", + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + } + + @Test + func `the last event of a type statement is the step the cost belongs to`() async throws + { + let report = try await play( + "app steady\ntype \"+x\"\nexpect cost bytes<=0 renders<=0") + + expectNoDifference(report.failures, []) + } + + // MARK: Locators + + @Test + func `a locator finds a container by its layout and a ForEach item by its key`() + async throws + { + let report = try await play( + """ + app layers + expect node layout=_FrameLayout frame 0,0,2x1 + expect node layout=_PaddingLayout frame 0,1,4x3 + expect node id=a frame 0,4,1x1 + expect node id=b frame 0,5,1x1 + expect node type=ForEach frame 0,4,1x2 + """) + + expectNoDifference(report.failures, []) + } + + @Test + func `text is the topmost leaf's, so covered text does not match`() async throws { + let covered = try await play("app layers\nexpect node text=status frame 0,0,6x1") + let shown = try await play("app layers\nexpect node text=atus frame 0,0,6x1") + let top = try await play("app layers\nexpect node text=OK frame 0,0,2x1") + + assertInlineSnapshot(of: covered.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node text=status frame 0,0,6x1", + verb: "expect node", + message: #"locator `text="status"` matches no node"#, + expected: "0,0,6x1", + actual: "(no node; types: TupleView, VStack, ZStack, Text, _LayoutContainer, ForEach; layouts: _FrameLayout, _PaddingLayout)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } + expectNoDifference(shown.failures, []) + expectNoDifference(top.failures, []) + } + + @Test + func `a locator that matches nothing names the types and layouts present`() async throws + { + let report = try await play( + "app layers\nexpect node layout=_NoSuchLayout frame 0,0,1x1") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node layout=_NoSuchLayout frame 0,0,1x1", + verb: "expect node", + message: "locator `layout=_NoSuchLayout` matches no node", + expected: "0,0,1x1", + actual: "(no node; types: TupleView, VStack, ZStack, Text, _LayoutContainer, ForEach; layouts: _FrameLayout, _PaddingLayout)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } + } + + @Test + func `a locator that matches several without nth says so and lists them`() async throws + { + let report = try await play("app layers\nexpect node type=Text frame 0,0,1x1") + + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node type=Text frame 0,0,1x1", + verb: "expect node", + message: "locator `type=Text` matches 5 nodes; add nth=", + expected: "0,0,1x1", + actual: "root/index(0)/index(0)/index(0), root/index(0)/index(0)/index(1)/index(0), root/index(0)/index(1)/index(0), root/index(0)/index(2)/id(a)/index(0), root/index(0)/index(2)/id(b)/index(0)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } + } + + // MARK: Trace and shots + + @Test + func `a played scenario carries one trace record per step`() async throws { + let report = try await play("app growing\nkey +\ntype \"+-\"\nresize 30x6") + + expectNoDifference(report.trace.map(\.seq), [0, 1, 2, 3, 4]) + expectNoDifference( + report.trace.map(\.event.type), ["resize", "key", "key", "key", "resize"]) + } + + @Test + func `a scenario that stops at a quit still keeps the trace it made`() async throws { + let registry = Fixtures.registry() + let scenario = try ScenarioParser.parse( + "app counter\nkey q\nexpect text 0 \"x\"", file: "t.scn", + knownApps: try registry.names()) + + let report = await ScenarioPlayer.run(scenario, name: "t.scn", registry: registry) + + #expect(report.trace.count == 2) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: #"expect text 0 "x""#, + verb: "expect text", + message: "the application finished at line 2 (`key q`), and in a terminal nothing after it runs", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + } +} diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 0e8f3040..0116e90c 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -153,7 +153,7 @@ struct ScenarioParserTests { func `unknown statements list the accepted vocabulary`() { assertInlineSnapshot(of: parseFailure(of: "app stack\njump 3"), as: .lines) { """ - t.scn:2: error: unknown statement `jump`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: unknown statement `jump`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost """ } } @@ -163,9 +163,10 @@ struct ScenarioParserTests { assertInlineSnapshot(of: parseFailure(of: "app stack\nexpect colour red"), as: .lines) { """ - t.scn:2: error: unknown expectation `colour`; accepted: text, screen, node + t.scn:2: error: unknown expectation `colour`; accepted: text, screen, node, cost """ } + } @Test @@ -176,10 +177,7 @@ struct ScenarioParserTests { "focus": "focus role=button", "mouse": "mouse click 3,4", "hit": "hit 1,2", - "dump": "dump", - "check": "check", "expect cell": "expect cell 1,2 bold", - "expect cost": "expect cost bodies<=1", "expect focused": "expect focused role=button", "expect wireframe": "expect wireframe docs/design/a.md 1", "expect node SEL visible": "expect node type=Text visible", @@ -199,25 +197,22 @@ struct ScenarioParserTests { assertInlineSnapshot(of: failures, as: .lines) { """ - t.scn:2: error: `check` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `dump` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect cell` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect cost` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect focused` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect node SEL clipped` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect node SEL count N` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect node SEL visible` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `expect wireframe` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `focus` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `hit` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `mouse` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `settle` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `shot NAME SEL` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `term glyphs=` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `term keyboard=` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `term theme=probe` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `tree SEL` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node - t.scn:2: error: `wait` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect cell` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect focused` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL clipped` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL count N` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL visible` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect wireframe` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `focus` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `hit` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `mouse` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `settle` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `shot NAME SEL` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term glyphs=` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term keyboard=` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term theme=probe` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `tree SEL` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `wait` is not a statement a scenario can use; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost """ } } @@ -238,12 +233,17 @@ struct ScenarioParserTests { let start = try #require(lines.firstIndex { $0.hasPrefix("## Not yet expressible") }) let section = lines[(start + 1)...].prefix { !$0.hasPrefix("## ") } - let forms = section.filter { $0.hasPrefix("| `") }.map { line in - String(line.dropFirst(3).prefix { $0 != "`" }) + // Each row is `| `FORM` | NEEDS |`: the form must match. + func deferred(_ line: String) -> ScenarioGrammar.Deferred { + let cells = line.split(separator: "|", omittingEmptySubsequences: true) + let form = cells[0].trimmingCharacters(in: .whitespaces).dropFirst().dropLast() + return ScenarioGrammar.Deferred(form: String(form)) } + let rows = section.filter { $0.hasPrefix("| `") }.map(deferred) expectNoDifference( - forms.sorted(), ScenarioGrammar.notYetExpressible.map(\.form).sorted()) + rows.sorted { $0.form < $1.form }, + ScenarioGrammar.notYetExpressible.sorted { $0.form < $1.form }) } @Test @@ -358,7 +358,7 @@ struct ScenarioParserTests { file: "t.scn", line: 3, source: "blink", - message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node" + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost" ) """ } @@ -607,12 +607,71 @@ struct ScenarioPlayerTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) """ } @@ -742,12 +801,15 @@ struct ScenarioPlayerTests { message: "`greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ) """ } @@ -771,12 +833,15 @@ struct ScenarioPlayerTests { message: "`workspace` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ) """ } @@ -910,11 +975,69 @@ struct ScenarioPlayerTests { [3]: "left right", [4]: "────────────────────────────────────────", [5]: "40 columns" - ] + ], + projection: nil ) ], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] ) """ } @@ -939,12 +1062,15 @@ struct ScenarioPlayerTests { message: "applications registered more than once: `stack`", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [] ) """ } @@ -975,12 +1101,85 @@ struct ScenarioPlayerTests { message: "the application finished at line 2 (`key q`), and in a terminal nothing after it runs", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 2, + placements: 2, + renders: 1 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "q", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [], + cost: StepCost( + bodies: 0, + bytes: 10, + measures: 2, + placements: 2, + renders: 1 + ) + ) + ] ) """ } @@ -1021,12 +1220,149 @@ struct ScenarioPlayerTests { message: #"the application finished at line 2 (`type "abqcd"`), and in a terminal nothing after it runs"#, expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 2, + placements: 2, + renders: 1 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "a", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [], + cost: StepCost( + bodies: 0, + bytes: 10, + measures: 2, + placements: 2, + renders: 1 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [], + cost: StepCost( + bodies: 0, + bytes: 10, + measures: 2, + placements: 2, + renders: 1 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "q", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [], + cost: StepCost( + bodies: 0, + bytes: 10, + measures: 2, + placements: 2, + renders: 1 + ) + ) + ] ) """ } @@ -1055,12 +1391,53 @@ struct ScenarioPlayerTests { message: "the application had already finished when the scenario started, and in a terminal nothing after it runs", expected: nil, actual: nil, - screen: nil + screen: nil, + rule: nil, + node: nil ) ], observations: [], recorded: [], - seconds: [] + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 2, + placements: 2, + renders: 1 + ) + ) + ] ) """ } diff --git a/Tests/TesseraHarnessTests/ScratchDirectory.swift b/Tests/TesseraHarnessTests/ScratchDirectory.swift index beaafe37..0ac88ef2 100644 --- a/Tests/TesseraHarnessTests/ScratchDirectory.swift +++ b/Tests/TesseraHarnessTests/ScratchDirectory.swift @@ -52,8 +52,14 @@ final class ScratchDirectory: Sendable { /// `text` with this directory's path shown as ``, so text that names a file in it is /// the same in every run. + /// + /// A trace file's name mirrors the path of its scenario under the trace directory, with a + /// drive letter's colon replaced, so that spelling of the path is redacted too. func redacted(_ text: String) -> String { - text.replacingOccurrences(of: path, with: "") + let mirrored = TraceFile.path(forScenario: path, in: "").dropLast( + ".trace.jsonl".count) + return text.replacingOccurrences(of: mirrored, with: "") + .replacingOccurrences(of: path, with: "") } /// `value` as a custom dump with the directory's path redacted. diff --git a/Tests/TesseraHarnessTests/ScreenGridTests.swift b/Tests/TesseraHarnessTests/ScreenGridTests.swift new file mode 100644 index 00000000..3a7ea5a9 --- /dev/null +++ b/Tests/TesseraHarnessTests/ScreenGridTests.swift @@ -0,0 +1,289 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraTerminalANSI +import TesseraTerminalCore +import TesseraTerminalSnapshotSupport +import Testing + +@testable import TesseraHarness + +struct ScreenGridTests { + private func cell( + _ character: Character, foreground: RenderedColor = .default, + background: RenderedColor = .default, bold: Bool = false, italic: Bool = false, + underline: UnderlineStyle = .none + ) -> RenderedCell { + RenderedCell( + character: character, foreground: foreground, background: background, bold: bold, + dim: false, italic: italic, reverse: false, strikethrough: false, + underlineStyle: underline) + } + + private func grid(_ rows: [[RenderedCell]]) -> ScreenGrid { + ScreenGrid(ScreenSnapshot(cells: rows, cursor: TerminalPosition(column: 0, row: 0))) + } + + private func plain(_ text: String, width: Int? = nil) -> [RenderedCell] { + var cells = text.map { cell($0) } + while cells.count < (width ?? 0) { + cells.append(.blank) + } + return cells + } + + // MARK: Text + + @Test + func `a blank after a wide grapheme is its continuation and leaves no character`() { + let screen = grid([[cell("東"), .blank, cell("京"), .blank, cell("a"), .blank]]) + + expectNoDifference(screen.textRows, ["東京a"]) + expectNoDifference( + screen.rows[0].map(\.isContinuation), [false, true, false, true, false, false]) + } + + @Test + func `content right after a wide grapheme is never taken for its continuation`() { + let screen = grid([[cell("東"), cell("x"), .blank]]) + + expectNoDifference(screen.textRows, ["東x"]) + expectNoDifference(screen.rows[0].map(\.isContinuation), [false, false, false]) + } + + @Test + func `a row's text loses its trailing spaces and a blank row is empty`() { + let screen = grid([plain("ab", width: 5), plain("", width: 5)]) + + expectNoDifference(screen.textRows, ["ab", ""]) + #expect(screen.columns == 5) + } + + // MARK: Styled grid + + @Test + func `a screen with no styled cell has an empty style section and a default legend`() + throws + { + let screen = grid([plain("Hi", width: 4), plain("", width: 4)]) + + assertInlineSnapshot(of: try screen.styledGridText(), as: .lines) { + """ + ── chars ── + Hi + ── style ── + ── legend ── + . default + + """ + } + } + + @Test + func + `style letters are handed out in reading order and rows lose trailing default cells`() + throws + { + let bold = cell("A", bold: true) + let accent = cell("B", foreground: .rgb(0, 128, 255), bold: true) + let screen = grid([ + [cell("x"), bold, bold, cell("y"), .blank], + [accent, cell("z"), accent, .blank, .blank], + plain("", width: 5), + ]) + + assertInlineSnapshot(of: try screen.styledGridText(), as: .lines) { + """ + ── chars ── + xAAy + BzB + ── style ── + .AA + B.B + ── legend ── + . default + A bold + B fg=#0080ff bold + + """ + } + } + + @Test + func `a continuation cell is styled like the wide grapheme it continues`() throws { + let wide = cell("東", background: .indexed(4), italic: true) + let screen = grid([[wide, cell(" "), cell("a")]]) + + assertInlineSnapshot(of: try screen.styledGridText(), as: .lines) { + """ + ── chars ── + 東a + ── style ── + AA + ── legend ── + . default + A bg=indexed(4) italic + + """ + } + expectNoDifference( + screen.styleRuns, + [ScreenGrid.StyleRun(row: 0, column: 0, columns: 2, style: "bg=indexed(4) italic")] + ) + } + + @Test + func `a style spells colours first and attributes in the catalog's order`() { + let cell = cell( + "x", foreground: .indexed(1), background: .rgb(1, 2, 255), bold: true, italic: true, + underline: .curly) + + #expect( + ScreenGrid.style(of: cell) == "fg=indexed(1) bg=#0102ff bold italic underline=curly" + ) + #expect(ScreenGrid.style(of: .blank).isEmpty) + } + + @Test + func `style runs merge adjacent cells of one style within a row`() { + let bold = cell("a", bold: true) + let screen = grid([[bold, bold, cell("b"), bold], [bold, .blank]]) + + expectNoDifference( + screen.styleRuns, + [ + ScreenGrid.StyleRun(row: 0, column: 0, columns: 2, style: "bold"), + ScreenGrid.StyleRun(row: 0, column: 3, columns: 1, style: "bold"), + ScreenGrid.StyleRun(row: 1, column: 0, columns: 1, style: "bold"), + ] + ) + } + + // MARK: Grid rows + + /// The chars rows and the style rows of a grid's text. + private func sections(of text: String) -> ( + chars: [String], style: [String], legend: [String] + ) { + var chars: [String] = [] + var style: [String] = [] + var legend: [String] = [] + var section = "" + for line in text.split(separator: "\n", omittingEmptySubsequences: false).dropLast() { + if line.hasPrefix("── ") { + section = String(line) + continue + } + switch section { + case "── chars ──": chars.append(String(line)) + case "── style ──": style.append(String(line)) + default: legend.append(String(line)) + } + } + return (chars, style, legend) + } + + @Test + func `a styled blank row below the text keeps its place in the chars section`() throws { + let fill = cell(" ", background: .indexed(4)) + let screen = grid([ + plain("Hi", width: 4), [fill, fill, fill, fill], [fill, fill, fill, fill], + ]) + + expectNoDifference(screen.textRows, ["Hi", "", ""]) + assertInlineSnapshot(of: try screen.styledGridText(), as: .lines) { + """ + ── chars ── + Hi + + + ── style ── + + AAAA + AAAA + ── legend ── + . default + A bg=indexed(4) + + """ + } + } + + @Test + func `the text golden still ends at the last row with text`() throws { + let fill = cell(" ", background: .indexed(4)) + let screen = grid([plain("Hi", width: 4), [fill, fill, fill, fill]]) + + #expect(GoldenStore.text(of: screen.textRows) == "Hi\n") + expectNoDifference(try sections(of: screen.styledGridText()).chars, ["Hi", ""]) + } + + @Test + func `a grid never has more style rows than chars rows`() throws { + let bold = cell("a", bold: true) + let fill = cell(" ", background: .indexed(1)) + let screens: [ScreenGrid] = [ + grid([]), + grid([plain("", width: 3)]), + grid([plain("text", width: 4), plain("", width: 4)]), + grid([[bold], plain("", width: 1), plain("", width: 1)]), + grid([plain("", width: 2), [fill, fill], plain("x", width: 2), [fill, fill]]), + grid([[fill, fill], [fill, fill], plain("", width: 2), plain("", width: 2)]), + ] + for screen in screens { + let found = try sections(of: screen.styledGridText()) + #expect(found.style.count <= found.chars.count, "\(screen.textRows)") + #expect(found.style.last?.isEmpty != true, "\(screen.textRows)") + #expect(found.chars.last?.isEmpty != true || found.style.count == found.chars.count) + } + } + + @Test + func `a grid names up to 62 styles, one letter each`() throws { + let cells = (0..<62).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let found = try sections(of: grid([cells]).styledGridText()) + + #expect(found.legend.count == 63) + #expect(Set(found.legend.compactMap(\.first)).count == 63) + #expect(found.legend.last == "9 fg=#3d0000") + #expect(found.style == [String(found.legend.dropFirst().compactMap(\.first))]) + } + + @Test + func `a screen with 63 styles is refused, not written with a repeated letter`() { + let cells = (0..<63).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let screen = grid([cells]) + + #expect(throws: ScreenGrid.GridError(styles: 63)) { + try screen.styledGridText() + } + #expect(ScreenGrid.GridError(styles: 63).description.contains("63 distinct styles")) + } + + @Test + func `a shot of a screen with too many styles fails as a harness error in both modes`() + { + let cells = (0..<63).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let screen = grid([cells]) + + var messages: [String] = [] + for record in [false, true] { + let store = GoldenStore(scenarioPath: "/nonexistent/many.scn", record: record) + guard case .failed(let message) = store.check(shot: "wide", screen: screen) else { + Issue.record("record \(record): the shot did not fail as a harness error") + continue + } + messages.append(message) + } + + assertInlineSnapshot(of: messages, as: .customDump) { + """ + [ + [0]: "shot `wide` cannot be compared with a grid golden: the screen has 63 distinct styles, and a grid golden names at most 62 with one letter each; take the shot where fewer styles show, or compare the screen with `expect text` instead", + [1]: "shot `wide` cannot be compared with a grid golden: the screen has 63 distinct styles, and a grid golden names at most 62 with one letter each; take the shot where fewer styles show, or compare the screen with `expect text` instead" + ] + """ + } + } +} diff --git a/Tests/TesseraHarnessTests/ShadowedListFixtures.swift b/Tests/TesseraHarnessTests/ShadowedListFixtures.swift new file mode 100644 index 00000000..996d18c5 --- /dev/null +++ b/Tests/TesseraHarnessTests/ShadowedListFixtures.swift @@ -0,0 +1,28 @@ +import TesseraCore +import TesseraLayout +import TesseraRuntime +import TesseraTerminalInput + +/// A view of the app's own that is named like a library list. It is a composite whose body is +/// two views and no container, so layout has no container to place them in and puts them on +/// one frame; nothing about the name makes it a `ForEach`. +/// +/// This file uses no `ForEach` of the library: the name here is the app's. +private struct ForEach: View { + @ViewBuilder var body: some View { + Text("first") + Text("second") + } +} + +/// `ZStack { ForEach() }` with the app's own `ForEach`. A `ZStack` overlaps its layers by +/// contract, but the app's view is one layer, and the two texts in its body are not. +final class ShadowedListApp: Application { + var body: some View { + ZStack { + ForEach() + } + } + + func apply(_ event: InputEvent) {} +} diff --git a/Tests/TesseraHarnessTests/TraceTests.swift b/Tests/TesseraHarnessTests/TraceTests.swift new file mode 100644 index 00000000..f28ce400 --- /dev/null +++ b/Tests/TesseraHarnessTests/TraceTests.swift @@ -0,0 +1,622 @@ +import CustomDump +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraTerminalCore +import TesseraTerminalInput +import Testing + +@testable import TesseraHarness + +@MainActor +struct TraceTests { + private let registry = ProjectionFixtures.registry() + + /// The records of `name` at 40 columns by 12 rows after delivering `events`. + private func records( + of name: String, _ events: [InputEvent] = [] + ) async throws -> [TraceRecord] { + final class Box { + var records: [TraceRecord] = [] + } + let box = Box() + try await registry.withHeadlessHost( + name, at: .custom(TerminalSize(columns: 40, rows: 12)) + ) { host in + for event in events { + try await host.send(event) + } + box.records = host.records + } + return box.records + } + + private func key(_ character: Character) -> InputEvent { + .key(Key(code: .character(character))) + } + + // MARK: Records + + @Test + func `the first record is step 0 and its event is the starting size`() async throws { + let records = try await records(of: "steady") + + #expect(records.first?.version == TraceRecord.currentVersion) + assertInlineSnapshot(of: records, as: .customDump) { + """ + [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x12", + detail: nil + ), + drew: true, + reasons: [ + [0]: "renderRequested", + [1]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/body", + [3]: "root/index(0)/body/index(0)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0 + ], + cost: StepCost( + bodies: 0, + bytes: 573, + measures: 4, + placements: 4, + renders: 1 + ) + ) + ] + """ + } + } + + @Test + func `every step numbers itself and names the reasons its frame was drawn`() + async throws + { + let records = try await records(of: "layers", [key("x"), key("y")]) + + assertInlineSnapshot( + of: records.map { ($0.seq, $0.drew, $0.reasons) }, as: .customDump + ) { + """ + [ + [0]: ( + 0, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [1]: ( + 1, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [2]: ( + 2, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ) + ] + """ + } + for record in records { + #expect(record.reasons == record.reasons.sorted(), "the library's fixed order") + } + } + + @Test + func `a resize names the viewport as a cause of the redraw`() async throws { + let records = try await records( + of: "layers", [.resize(TerminalSize(columns: 30, rows: 10)), key("x")]) + + assertInlineSnapshot(of: records.map { ($0.event, $0.reasons) }, as: .customDump) { + """ + [ + [0]: ( + TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x12", + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [1]: ( + TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "30x10", + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "layoutViewportChanged", + [3]: "renderEnvironmentChanged", + [4]: "renderRequested", + [5]: "renderViewChanged", + [6]: "renderViewportChanged", + [7]: "updateRequested" + ] + ), + [2]: ( + TraceEvent( + type: "key", + key: "x", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ) + ] + """ + } + } + + @Test + func `a step that leaves the graph clean draws nothing and writes no bytes`() + async throws + { + let records = try await records(of: "steady", [key("x"), key("+"), key("x")]) + + assertInlineSnapshot( + of: records.map { ($0.drew, $0.cost, $0.rows, $0.nodes, $0.reasons) }, + as: .customDump + ) { + """ + [ + [0]: ( + true, + StepCost( + bodies: 0, + bytes: 573, + measures: 4, + placements: 4, + renders: 1 + ), + [ + [0]: 0 + ], + TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/body", + [3]: "root/index(0)/body/index(0)" + ], + changed: [], + removed: [] + ), + [ + [0]: "renderRequested", + [1]: "updateRequested" + ] + ), + [1]: ( + false, + StepCost( + bodies: 0, + bytes: 0, + measures: 0, + placements: 0, + renders: 0 + ), + [], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "updateRequested" + ] + ), + [2]: ( + true, + StepCost( + bodies: 1, + bytes: 17, + measures: 4, + placements: 4, + renders: 1 + ), + [ + [0]: 0 + ], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ] + ), + [3]: ( + false, + StepCost( + bodies: 0, + bytes: 0, + measures: 0, + placements: 0, + renders: 0 + ), + [], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "updateRequested" + ] + ) + ] + """ + } + } + + @Test + func `a step lists the nodes it added and removed by identity`() async throws { + let records = try await records(of: "growing", [key("+"), key("+"), key("-")]) + + assertInlineSnapshot(of: records.map(\.nodes), as: .customDump) { + """ + [ + [0]: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/id(a)", + [4]: "root/index(0)/index(0)/id(a)/index(0)" + ], + changed: [], + removed: [] + ), + [1]: TraceRecord.NodeChanges( + added: [ + [0]: "root/index(0)/index(0)/id(b)", + [1]: "root/index(0)/index(0)/id(b)/index(0)" + ], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [] + ), + [2]: TraceRecord.NodeChanges( + added: [ + [0]: "root/index(0)/index(0)/id(c)", + [1]: "root/index(0)/index(0)/id(c)/index(0)" + ], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [] + ), + [3]: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [ + [0]: "root/index(0)/index(0)/id(c)", + [1]: "root/index(0)/index(0)/id(c)/index(0)" + ] + ) + ] + """ + } + } + + @Test + func `a node whose frame moved is changed and a node that did not is not`() async throws + { + let records = try await records( + of: "layers", [.resize(TerminalSize(columns: 20, rows: 12))]) + + assertInlineSnapshot(of: records[1].nodes, as: .customDump) { + """ + TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)/id(a)/index(0)", + [5]: "root/index(0)/index(2)/id(b)/index(0)" + ], + removed: [] + ) + """ + } + } + + // MARK: Events + + @Test + func `an event is spelled the way a scenario statement spells it`() { + let keys = [ + Key(code: .down), Key(code: .tab, modifiers: .shift), + Key(code: .character("c"), modifiers: .control), Key(code: .character(" ")), + ] + let kinds = [KeyEventKind.release, .repeat, .press].map { + TraceEvent(.key(Key(code: .character("a"), kind: $0))).kind + } + + expectNoDifference( + keys.map { TraceEvent(.key($0)).key }, + ["ArrowDown", "Shift+Tab", "Ctrl+c", "Space"]) + expectNoDifference(kinds, ["release", "repeat", nil]) + #expect(TraceEvent(.paste("a b")).text == "a b") + #expect(TraceEvent(.resize(TerminalSize(columns: 9, rows: 4))).size == "9x4") + } + + @Test + func `an event neither the grammar nor replay can say is recorded as other`() { + let recorded = [ + InputEvent.unknown([0x1b, 0x5b]), .primaryDeviceAttributes([1]), + .key(Key(code: .character("a"), modifiers: .capsLock)), + ].map(TraceEvent.init) + + assertInlineSnapshot( + of: recorded.map { ($0.type, $0.inputEvent == nil, $0.summary, $0.detail) }, + as: .customDump + ) { + """ + [ + [0]: ( + "other", + true, + "unknown (not replayable)", + "unknown" + ), + [1]: ( + "other", + true, + "terminalReply (not replayable)", + "terminalReply" + ), + [2]: ( + "other", + true, + "key a (not replayable)", + "key a" + ) + ] + """ + } + } + + @Test + func `a focus change of the terminal is recorded by name and delivers again`() throws { + let gained = TraceEvent(.focusGained) + let lost = TraceEvent(.focusLost) + + assertInlineSnapshot( + of: [gained, lost].map { ($0.type, $0.inputEvent, $0.summary) }, as: .customDump + ) { + """ + [ + [0]: ( + "focusGained", + .focusGained, + "focus gained" + ), + [1]: ( + "focusLost", + .focusLost, + "focus lost" + ) + ] + """ + } + // Nothing else rides along, so a record is just the type. + #expect(JSONText.string(for: gained) == #"{"type":"focusGained"}"#) + let decoded = try JSONDecoder().decode( + TraceEvent.self, from: Data(JSONText.string(for: lost).utf8)) + #expect(decoded == lost) + } + + @Test + func `a recorded event delivers again as the event it came from`() { + let events: [InputEvent] = [ + .key(Key(code: .left)), + .key(Key(code: .character("c"), modifiers: [.control, .alt])), + .key(Key(code: .function(5))), .key(Key(code: .character("a"), kind: .release)), + .paste("x y"), .resize(TerminalSize(columns: 7, rows: 3)), .focusGained, .focusLost, + ] + + for event in events { + #expect(TraceEvent(event).inputEvent == event, "\(event)") + } + } + + // MARK: Lines and files + + @Test + func `a record is one line of sorted-key JSON with its version`() async throws { + let records = try await records(of: "steady") + + let line = try #require(records.first?.line) + #expect(!line.contains("\n")) + assertInlineSnapshot(of: line, as: .lines) { + """ + {"cost":{"bodies":0,"bytes":573,"measures":4,"placements":4,"renders":1},"drew":true,"event":{"size":"40x12","type":"resize"},"nodes":{"added":["root","root/index(0)","root/index(0)/body","root/index(0)/body/index(0)"],"changed":[],"removed":[]},"reasons":["renderRequested","updateRequested"],"rows":[0],"seq":0,"version":1} + """ + } + } + + @Test + func `a trace file reads back the records it was written from`() async throws { + let records = try await records(of: "growing", [key("+"), key("-")]) + + let text = TraceFile.text(of: records) + + #expect(text.hasSuffix("}\n")) + #expect(text.split(separator: "\n").count == records.count) + expectNoDifference(try TraceFile.records(in: text), records) + expectNoDifference(try TraceFile.records(in: "\n" + text + "\n\n"), records) + } + + @Test + func `a line that is not a record fails with its line number and what is wrong`() throws + { + let valid = TraceRecord( + seq: 0, event: TraceEvent(.resize(TerminalSize(columns: 4, rows: 1))), drew: true, + reasons: [], nodes: .init(added: [], changed: [], removed: []), rows: [], + cost: StepCost(bodies: 0, bytes: 0, measures: 0, placements: 0, renders: 0)) + + let notJSON = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: valid.line + "\nnot json\n") + } + let missing = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: valid.line + "\n" + #"{"version":1}"# + "\n") + } + let later = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records( + in: valid.line.replacingOccurrences(of: #""version":1"#, with: #""version":2"#)) + } + + assertInlineSnapshot(of: [notJSON, missing, later], as: .customDump) { + """ + [ + [0]: TraceFile.ReadError( + line: 2, + message: "this line is not a trace record (not valid JSON)" + ), + [1]: TraceFile.ReadError( + line: 2, + message: "this line is not a trace record (no `seq`)" + ), + [2]: TraceFile.ReadError( + line: 1, + message: "the record has version 2; this harness reads version 1" + ) + ] + """ + } + } + + @Test + func `a record of another version is named by its version even when its keys are gone`() + throws + { + // A later schema may rename or drop any key but `version`, so the version is read first. + let renamed = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"version":2,"steps":[],"verdict":"new"}"# + "\n") + } + let older = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"version":0}"# + "\n") + } + let noVersion = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"seq":0}"# + "\n") + } + + assertInlineSnapshot(of: [renamed, older, noVersion], as: .customDump) { + """ + [ + [0]: TraceFile.ReadError( + line: 1, + message: "the record has version 2; this harness reads version 1" + ), + [1]: TraceFile.ReadError( + line: 1, + message: "the record has version 0; this harness reads version 1" + ), + [2]: TraceFile.ReadError( + line: 1, + message: "this line is not a trace record (no `version`)" + ) + ] + """ + } + } + + @Test + func + `a scenario's trace file mirrors its path under the directory and stays inside it`() + { + let scenarios = [ + "Examples/Scenarios/flex-desktop.scn", "/tmp/../etc/x.scn", "./a/./b.scn", + #"C:\work\s.scn"#, + ] + + expectNoDifference( + scenarios.map { TraceFile.path(forScenario: $0, in: "out") }, + [ + "out/Examples/Scenarios/flex-desktop.trace.jsonl", "out/tmp/etc/x.trace.jsonl", + "out/a/b.trace.jsonl", "out/C_/work/s.trace.jsonl", + ] + ) + } +} diff --git a/Tests/TesseraHarnessTests/TypeNameTests.swift b/Tests/TesseraHarnessTests/TypeNameTests.swift new file mode 100644 index 00000000..d8520eb5 --- /dev/null +++ b/Tests/TesseraHarnessTests/TypeNameTests.swift @@ -0,0 +1,84 @@ +import CustomDump +import Testing + +@testable import TesseraHarness + +struct TypeNameTests { + @Test + func `a short name drops the module and every generic argument`() { + let reflected = [ + "TesseraCore.Text", + "TesseraLayout.VStack>", + "MyApp.Outer.Inner", "Text", + ] + + expectNoDifference( + reflected.map(TypeName.short), ["Text", "VStack", "Inner", "Text"]) + } + + @Test + func `a private type in another module keeps its name and loses its build address`() { + let reflected = "TesseraLayout.(unknown context at $104f0c1a8)._FrameLayout" + + #expect(TypeName.short(reflected) == "_FrameLayout") + #expect(!TypeName.short(reflected).contains("$")) + } + + @Test + func `a layout container reports the short name of its algorithm`() { + let frame = + "TesseraCore._LayoutContainer" + let padding = + "TesseraCore._LayoutContainer>" + let custom = "TesseraCore._LayoutContainer, TesseraCore.Text>" + + #expect(TypeName.short(frame) == "_LayoutContainer") + expectNoDifference( + [frame, padding, custom].map(TypeName.layout(of:)), + ["_FrameLayout", "_PaddingLayout", "Stagger"]) + } + + @Test + func `only a layout container has a layout`() { + let reflected = [ + "TesseraLayout.HStack", "TesseraCore.Text", + "TesseraCore._LayoutContainer", + ] + + expectNoDifference(reflected.map(TypeName.layout(of:)), [nil, nil, nil]) + } + + @Test + func `the library's view lists are lists whatever they hold`() { + let lists = [ + "TesseraCore.ForEach, TesseraCore.Text>", + "TesseraCore.TupleView<(TesseraCore.Text, TesseraCore.Text)>", + "TesseraCore.ConditionalView", + "TesseraCore.EmptyView", "Swift.Optional", + ] + + expectNoDifference(lists.map(TypeName.isViewList), Array(repeating: true, count: 5)) + } + + @Test + func `a view of an app's own with a list's name is not a list`() { + let views = [ + "MyApp.ForEach", "MyApp.TupleView", "MyApp.Optional", + "MyApp.Outer.EmptyView", "ForEach", + ] + + expectNoDifference(views.map(TypeName.isViewList), Array(repeating: false, count: 5)) + } + + @Test + func `a view that only has a list inside its arguments is not a list`() { + let views = [ + "TesseraLayout.VStack>", "TesseraCore.Text", + "Swift.Array>", + ] + + expectNoDifference(views.map(TypeName.isViewList), Array(repeating: false, count: 3)) + } +} diff --git a/Tests/TesseraTerminalTests/TerminalSessionTests.swift b/Tests/TesseraTerminalTests/TerminalSessionTests.swift index 4eb4773b..74923e5a 100644 --- a/Tests/TesseraTerminalTests/TerminalSessionTests.swift +++ b/Tests/TesseraTerminalTests/TerminalSessionTests.swift @@ -2631,6 +2631,47 @@ func `a failed flush leaves the last drawn frame unchanged`() async throws { ) } +@Test +func `bytes drawn adds the byte count of each flushed frame`() async throws { + let size = TerminalSize(columns: 3, rows: 1) + let device = InMemoryTerminalDevice(size: size) + let session = await makeSession(device, synchronizedOutput: .disabled) + #expect(await session.bytesDrawn == 0) + + try await session.draw { frame in + frame.write("abc", at: TerminalPosition(column: 0, row: 0)) + } + let afterFirst = await session.bytesDrawn + #expect(afterFirst > 0) + #expect(afterFirst == (await device.bytes).count) + + try await session.draw { frame in + frame.write("axc", at: TerminalPosition(column: 0, row: 0)) + } + #expect(await session.bytesDrawn > afterFirst) + #expect(await session.bytesDrawn == (await device.bytes).count) +} + +@Test +func `a failed flush does not count its bytes as drawn`() async throws { + let size = TerminalSize(columns: 1, rows: 1) + let device = FailOnceTerminalDevice(size: size) + let session = TerminalSession( + io: PlatformIO(terminalDevice: await device.terminalDevice)) + + await #expect(throws: PlatformIOError.writeFailed(errno: .ioError)) { + try await session.draw { frame in + frame.write("x", at: TerminalPosition(column: 0, row: 0)) + } + } + #expect(await session.bytesDrawn == 0) + + try await session.draw { frame in + frame.write("y", at: TerminalPosition(column: 0, row: 0)) + } + #expect(await session.bytesDrawn > 0) +} + @Test func `output tap receives each draw's bytes until it is cleared`() async throws { let session = InMemoryTerminalSession(size: TerminalSize(columns: 3, rows: 1)) diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 85705e4b..b497f491 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -50,15 +50,15 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | Tool | Who uses it | Where it lives | Status | | --- | --- | --- | --- | -| Demos and the registry | both | demos in `Examples/Sources/DemoSupport`, `ApplicationRegistry` in `TesseraHarness` | built; one demo so far, the counter | +| Demos and the registry | both | demos in `Examples/Sources/DemoSupport`, `ApplicationRegistry` in `TesseraHarness` | built; the counter, the workspace, and the demos of text, stacks, frame and padding, `ZStack`, `Flex`, and `ForEach` | | The app contract (`Application`) | both | `TesseraRuntime`, re-exported by `Tessera` | built | | Headless driver (`Driver`) | both | `TesseraHarness` | built: one `Driver` under a headless host, a live host, and a per-test actor host, all internal to `TesseraHarness` (tests reach them with `@testable import`) | | Scenario language and runner | both | grammar in `Examples/Scenarios/README.md`, parser, runner, and report in `TesseraHarness` | built for the statements the harness can honour today; the rest are refused by name until their prerequisite exists | -| `tessera` CLI and `mise run dev:*` | agents first | `Examples/Sources/TesseraDev`, `HarnessCLI` in `TesseraHarness`, `mise-tasks/dev/` | `apps`, `launch`, `run`, `shot`, `tree`, and `help` built; `check` and `replay` are named and refuse until the projection and the trace exist | -| Projection and locators | agents first | `TesseraHarness` | designed | -| Invariant checks (`check`) | both | `TesseraHarness` | designed | -| Shots (text, grid, SVG) | both | goldens beside the scenario; pictures in `.build/tessera/` | text shots and goldens built (`NAME.txt`); the styled grid and the pictures designed | -| Trace and cost | both | `TesseraHarness` | designed | +| `tessera` CLI and `mise run dev:*` | agents first | `Examples/Sources/TesseraDev`, `HarnessCLI` in `TesseraHarness`, `mise-tasks/dev/` | `apps`, `launch`, `run`, `shot`, `tree`, `check`, `replay`, and `help` built | +| Projection and locators | agents first | `TesseraHarness` | built: the projection (`dump`, `tree`) and the locators `type=`, `layout=`, `id=`, `text=`, `nth=`; `focused`, `focusable`, `role=`, and `label=` designed | +| Invariant checks (`check`) | both | `TesseraHarness` | built: five rules (`no-dirty-after-pass`, `frame-inside-clip`, `measured-within-proposal`, `siblings-disjoint`, `requirements-aggregate`), as a statement and as `tessera check`; the focus, scroll, and collection rules designed | +| Shots (text, grid, SVG) | both | goldens beside the scenario; pictures in `.build/tessera/` | text and styled-grid shots and goldens built (`NAME.txt`, `NAME.grid.txt`); the pictures designed | +| Trace and cost | both | `TesseraHarness` | built: one record per step with `reasons`, changed nodes and rows, and `cost`, written by `run --trace` and by `launch` under `TESSERA_TRACE`; `expect cost` with `<=` bounds; the `dispatch` and `focus` fields wait for input routing and focus | | Catalog conformance and the gallery | both | `tessera conform`, `DevTools/gallery/` | designed | | Keyboard coverage walk | agents | `tessera coverage` | designed | | Pointer verbs and hit query | both | scenario `mouse`, `hit` | designed | @@ -80,7 +80,7 @@ A demo has a registry name (kebab-case), a minimum size from its catalog doc, an The registry (`ApplicationRegistry`) is the one place apps, Tessera's demos among them, are selected; it fails closed on a duplicate name, and its factories are main-actor closures. `tessera launch NAME` runs an app live in your terminal; the same demo renders identically headless, so its renders are snapshot fixtures, and its snapshot test is a few lines. An intro app composes the per-component demos with navigation only; it adds no component behaviour. "Demo" is Tessera's word for its own per-component example apps; the CLI and the scenario header say `app`. -Built today: `Demo` in `Examples/Sources/DemoSupport` is an `Application` that also declares its registry name, minimum size, viewports, and fixtures (further registrations of the same demo), none of them defaulted, and `Demos.registry()` registers every demo and fixture into one `ApplicationRegistry`. A demo quits on `q`, matched by `isQuitKey`, which compares the key's code, modifiers, and kind and never `Key` equality, so an enriched Kitty key still quits and a release or a repeat does not. The first demo is the counter. Registering a name twice is not an error where it is written; it makes every read of the registry fail (exit 3 from a command, a failing report from a scenario), so a later registration never replaces an earlier one. A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`: it opens the demo headless at a viewport, sends keys, and asserts rows of the screen. +Built today: `Demo` in `Examples/Sources/DemoSupport` is an `Application` that also declares its registry name, minimum size, viewports, and fixtures (further registrations of the same demo), none of them defaulted, and `Demos.registry()` registers every demo and fixture into one `ApplicationRegistry`. A demo quits on `q`, matched by `isQuitKey`, which compares the key's code, modifiers, and kind and never `Key` equality, so an enriched Kitty key still quits and a release or a repeat does not. The demos are the counter, the workspace (a split view over a scrolling catalog, the one that fills any height and so runs at `mobileScreen`), and one for each layout primitive that exists so far: `text` (every kind of content it measures, with the archived Unicode demo's transcript), `stacks` (alignments, spacing, and the `Spacer` family), `frame-padding`, `zstack`, `flex`, and `foreach` (a keyed list beside static rows). Each primitive demo runs at `desktop` and `mobile`, takes keys that change what it shows, and has a scenario at each size that pins its frames to the numbers in its catalog doc and takes a shot. Registering a name twice is not an error where it is written; it makes every read of the registry fail (exit 3 from a command, a failing report from a scenario), so a later registration never replaces an earlier one. A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`: it opens the demo headless at a viewport, sends keys, and asserts rows of the screen. ### The headless driver @@ -112,10 +112,10 @@ shot after-steps | --- | --- | --- | | Header | `app`, `size`, `term colors= theme=light\|dark` | `term glyphs=`, `term keyboard=`, `term theme=probe` | | Input | `resize`, `key`, `type`, `paste`, `bytes` | `focus SEL`, `mouse`, `wait`, `settle` | -| Observe | `shot NAME`, `tree` | `shot NAME SEL`, `tree SEL`, `dump`, `hit R,C` | -| Assert | `expect text`, `expect screen`, `expect node SEL frame` | `expect node SEL visible\|clipped\|count N`, `expect focused SEL`, `expect cell`, `expect cost`, `expect wireframe`, `check` | +| Observe | `shot NAME`, `tree`, `dump` | `shot NAME SEL`, `tree SEL`, `hit R,C` | +| Assert | `expect text`, `expect screen`, `expect node SEL frame`, `expect cost`, `check` | `expect node SEL visible\|clipped\|count N`, `expect focused SEL`, `expect cell`, `expect wireframe` | -`Examples/Scenarios/README.md` is the grammar's single source: the exact syntax of every statement, the quoting rules, the key names, the report, and the "not yet expressible" table; this page says what each part is for and what is built. A form the library cannot honour yet (`wait` before the schedulable clock, `mouse` before hit testing, `check` before the projection) is a parse error that says what it waits for, never a statement that is skipped or a check that passes without checking. +`Examples/Scenarios/README.md` is the grammar's single source: the exact syntax of every statement, the quoting rules, the key names, the report, and the "not yet expressible" table; this page says what each part is for and what is built. A form the library cannot honour yet (`wait` before the schedulable clock, `mouse` before hit testing, `focus` before focus tracking) is a parse error that says what it waits for, never a statement that is skipped or a check that passes without checking. Input is typed `InputEvent` verbs with `bytes` as the escape hatch, which runs its text through the same input parser a live session uses; key names follow Playwright and are normalised (`Ctrl+C`). The parser binds the header first and fails fast with `FILE:LINE: error:`, reads lines at `\n` and treats a `\r` as whitespace so a CRLF checkout parses like an LF one, and treats an unknown statement as an error so a scenario written for a newer harness fails loudly on an older one. A failed `expect` is recorded with the screen and the run continues, so one run reports every failure. @@ -127,9 +127,9 @@ The `term` header names the terminal the app runs on, not the machine running th A selector (`SEL`) is a set of `key=value` pairs that addresses nodes by meaning and re-resolves on every statement: -- `type=Text` (short type name), `layout=…`, `id=…` (a `.id` or `ForEach` key, or a full identity path), `text="Submit"` (screen text attributed to the topmost leaf, so obscured text does not match), `nth=2`. -- `focused`, `focusable`. -- `role=button`, `label="Submit"`. Library controls set role and label through an internal hook; there is no public view API for it. A node without them falls back to a role derived from its type and a label from its screen text. The fields are plain values, so a later accessibility or Tern Surface Protocol export can read the same description. +- `type=Text` (short type name), `layout=_FrameLayout` (the algorithm of a `_LayoutContainer`: `frame`, `padding`, or an app's own `Layout`), `id=X` (the id the app gave a node, a `.id(X)` or a `ForEach` key, or else a full identity path), `text="Submit"` (a substring of the screen text attributed to the topmost leaf, so obscured text does not match; an empty value is a parse error), `nth=2`. These are built. +- `focused`, `focusable`. Designed. +- `role=button`, `label="Submit"`. Designed: library controls set role and label through an internal hook; there is no public view API for it. A node without them falls back to a role derived from its type and a label from its screen text. The fields are plain values, so a later accessibility or Tern Surface Protocol export can read the same description. Scenarios for controls use `role=` and `label=`, never coordinates. @@ -137,6 +137,8 @@ Scenarios for controls use `role=` and `label=`, never coordinates. The projection is sorted-key JSON of the graph diagnostics (short `type` and `layout`, never reflected type names; counters and invalidation reasons; a pass marker; no durations) plus the screen (rows, style runs, and the cursor under its own key). Expectations, locators, checks, and `tree` all read it. A diff of two projections lists nodes added, removed, and changed by field, and the screen rows that changed. +Built today: `Projection` in `TesseraHarness` is built from `GraphDiagnostics` and the screen after every step, and `dump` puts it in the report as a JSON object (a `version`, then `nodes`, `pass`, `requirements`, `screen`, and `statistics`). A node has its identity `path`, `parent`, `children`, `depth`, `paintOrder` (its position among its parent's children, which the spec's `dump()` schema names), `type`, `layout`, `proposal`, `measured`, `frame`, `clip`, the two dirty flags, `environmentOverrides`, `handlerKinds`, `requirements`, and the `text` a leaf shows. Nothing in it comes from `TesseraCore` that was not already public: `type` and `layout` are the short names of the reflected type (a `_LayoutContainer` reports its `Algorithm`, so `frame`, `padding`, and an app's own `Layout` are told apart), and a leaf's `text` is read from the screen by paint order, since diagnostics hold no view values. `pass` is the step that drew the projected frame, because a projection is only ever taken between steps, after the pass has laid out and drawn. Reading a wide grapheme from the virtual terminal derives its continuation cell from the width table the library draws with, because the terminal reports no wide flag yet. The diff of two projections is designed, and not built as a command; the trace carries the diff of consecutive steps (nodes and rows). + ### Checks `check` runs named invariants over the projection and screen. Each rule is a pure function returning violations with a stable kebab-case id, the node, and a detail; `check` with no arguments runs them all. @@ -146,20 +148,22 @@ The projection is sorted-key JSON of the graph diagnostics (short `type` and `la | `no-dirty-after-pass` | a node still dirty after a completed pass | | `frame-inside-clip` (warning) | content placed outside its clip | | `measured-within-proposal` | a view claiming more than it was offered | -| `siblings-disjoint` | stack children on overlapping frames (the `ForEach` defect); `ZStack` and overlays are exempt | +| `siblings-disjoint` | stack children that show a cell in common (the `ForEach` defect: every item on one frame); `ZStack` and overlays are exempt, also through the `ForEach`, `if`, or group of views that builds their layers | | `requirements-aggregate` | terminal requirements that do not roll up to the root | | `exactly-one-focus`, `focus-resolves` | zero or several focused nodes; focus on a node that no longer exists | | `focused-visible`, `focus-indicated` | focus on a node that is clipped away; focus with no visible difference, including under `colors=none` | | `tab-cycle-terminates`, `legend-keys-handled` | Tab cycles that never return; KeyLegend advertising a key nothing handles | | `scroll-offset-within-content`, `selection-visible`, `unique-collection-ids` | scrolling past content; a selection scrolled out of view; duplicate collection IDs | +Built today: `Checker.run(rules, on: projection)` runs the five v0 rules, each a pure function of a `Projection` value that returns violations in tree order, so the same projection gives the same list and a crafted projection tests a rule without an app. `check` as a statement runs the rules named, or all of them in the table's order, after the statement before it; `tessera check NAME [SIZE]` runs all of them over an app with no input. An error rule that does not hold fails the statement (exit 1) and names the rule and the node in the failure's `rule` and `node` fields. A warning rule (`frame-inside-clip`) does not fail a bare `check`, which lists it in its observation; naming it asks for it to hold, so `check frame-inside-clip` fails on it. Every `check` observes the rules it ran, so a pass says what it checked. Three of the rules read cells a child actually shows, and so do not mistake a clip by contract for a fault: `siblings-disjoint` compares each child's frame clipped to its clip (children that overflow their segments and are clipped to them, as `Flex` children are, are disjoint, and every `ForEach` item on one frame is not), `frame-inside-clip` skips leaves under a `ScrollView`, and `measured-within-proposal` skips a node that was never placed. A rule can still fire by contract: a fixed `frame(width:)` wider than the width its parent offered is, by the catalog, wider than it was offered, and `Text` does not wrap yet, so it measures wider than the width it is offered; a scenario that shows that on purpose names the rules that must hold, and a bare `check` covers an app that clips nothing. `requirements-aggregate` passes vacuously today, since no view requests a terminal feature before the input slices, and its tests run on crafted projections. + Single ownership of each `NodeState` row cannot be seen in the projection, so it stays a review rule backed by a lint. ### Shots and goldens `shot NAME [SEL]` records the screen, or one node's frame region, as two text files beside the scenario: `NAME.txt` (the trimmed text) and `NAME.grid.txt` (the styled cell grid). They are the only committed artefacts. Drift from a committed golden fails the statement; `run --record` rewrites goldens and prints what changed. Never use `--record` to make a failing run pass. -Built today: `shot NAME` compares the trimmed screen text, every row without its trailing spaces and trailing empty rows dropped, with `NAME.txt` in a directory named like the scenario file without its extension, beside it (`counter-basics.scn` keeps its goldens in `counter-basics/`), so shot names only need to be unique within a scenario. A missing golden or a drifted one fails the `shot` statement with the first differing row and the screen, and `tessera run --record` writes the files and lists what changed; a golden checked out with CRLF fails with a message that says so, and `.gitattributes` keeps this repository's goldens LF. The styled grid (`NAME.grid.txt`), the SVG, and the PNG are designed and not built, so the `shot` statement takes no selector yet. +Built today: `shot NAME` compares two files with the screen, each in a directory named like the scenario file without its extension, beside it (`counter-basics.scn` keeps its goldens in `counter-basics/`), so shot names only need to be unique within a scenario. `NAME.txt` is the trimmed screen text, every row without its trailing spaces and trailing empty rows dropped, with a wide grapheme as one character. `NAME.grid.txt` is the catalog's styled grid of the same screen: `── chars ──` (the rows of `NAME.txt`, and an empty row below them for each blank row that has a style, down to the last styled row, so a background below the text is kept and the grid never has more style rows than chars rows), `── style ──` (one letter under each cell, `.` for the default style, trailing default cells left off, so an unstyled screen has no style rows), and `── legend ──` (each letter named in reading order, a colour as `#rrggbb` or `indexed(N)`, the spelling this page fixes because the catalog's dialect knows only `fg=ROLE`; a colour that a semantic role owns is spelled as the role once `theme=probe` exists). A grid has 62 letters (`A-Z`, `a-z`, `0-9`); a screen with more distinct styles fails its `shot` as a harness error, since a letter that names two styles would make the golden ambiguous. A missing golden or a drifted one fails the `shot` statement with the first differing row of the first golden that differs, the text before the grid, and the screen, and `tessera run --record` writes the files and lists what changed; a golden checked out with CRLF fails with a message that says so, and `.gitattributes` keeps this repository's goldens LF. The SVG and the PNG are designed and not built, and `shot` takes no selector yet. Shot names are unique within a scenario ignoring case, since goldens are files and the macOS and Windows file systems fold case: `shot a` and `shot A` is a parse error. A golden that holds a carriage return fails as a CRLF golden before any row is compared, and a missing or CRLF golden reports no expected or actual row, because there is nothing to compare. @@ -169,6 +173,8 @@ Each shot also writes an SVG to `.build/tessera/` (one text element per row, one The driver writes one JSONL record per step: sequence number, event, the pass's invalidation reasons, the nodes and rows that changed, cost (bodies evaluated, measures, placements, rendered nodes, bytes written), `dispatch{disposition, handledBy, path}`, and `focus{from, to, cause}`. "Why did this redraw?", "which node swallowed this key?", and "why did focus move?" are all read from the trace. Cost is deterministic, so `expect cost` can assert it; wall-clock durations appear only under `run --timing` and are never golden. `run --repeat N` reruns a scenario and reports any divergence. A live session records its events to `TESSERA_TRACE=` for `tessera replay`. +Built today: every step of every host, headless and live, is one record (`seq`, `event`, `drew`, `reasons`, `nodes`, `rows`, `cost`, `version`), written by the one `Driver`. `seq` 0 is the first frame, whose event is the resize that tells the app its starting size. `event` is spelled as the scenario statement spells it (`{"type": "key", "key": "Shift+Tab"}`, a `paste`, a `resize` as `WxH`); the terminal's focus changes are `focusGained` and `focusLost`, which a live session receives and no statement says yet but which replay delivers; and any other event (a mouse event, a terminal reply) is `other`, which `replay` refuses by name. `drew` says whether the step drew a frame, because a step that leaves the graph clean draws none and costs nothing. `reasons` is the library's invalidation reasons for the pass, in their fixed order, so a resize shows `layoutViewportChanged` and `renderViewportChanged` and a key that changed a view shows `layoutViewChanged` and `renderViewChanged`; a step in which the library redraws although nothing visible changed shows that too. `nodes` lists the identity paths added, changed (type, geometry, or requirements), and removed, and `rows` the screen rows whose text changed. `cost` is the counters `expect cost` bounds: `bodies`, `bytes` (what `TerminalSession.bytesDrawn` counted for the frame), `measures`, `placements`, and `renders`. `dispatch` and `focus` arrive with the input routing and focus slices that produce them. `run --trace DIR` writes a file per scenario, `launch` under `TESSERA_TRACE` writes the live session's, and `replay` runs either again, comparing everything but the bytes. The trace is not promised to stay as it is; each record carries `version`. + ### `tessera` `tessera` is a thin CLI over the harness; it adds no behaviour the tests do not share. Tessera's own executable registers Tessera's demos, and an app author's executable is `HarnessCLI.main(registry:)` over the app's own registry. @@ -176,7 +182,7 @@ The driver writes one JSONL record per step: sequence number, event, the pass's | Command | Does | | --- | --- | | `apps`, `launch NAME` | list registered apps; run one live in this terminal | -| `run PATH...` | run scenario files or directories (`--record`, `--repeat N`, `--timing`, `--json`, `--pretty`) | +| `run PATH...` | run scenario files or directories (`--record`, `--repeat N`, `--timing`, `--trace DIR`, `--json`, `--pretty`) | | `shot`, `tree`, `check` | one-off picture, tree, or check of a demo at a size | | `replay FILE --app NAME` | rerun a recorded trace as a scenario | | `coverage --app NAME` | walk the keyboard from initial focus with Tab and Shift+Tab, and the arrow keys inside lists and pickers: per focusable, its level (`shortcut` for a KeyLegend binding, `tab`, or `unreachable`) and its shortest route as a starting point plus key-and-count steps (`[["Tab", 2], ["Down", 3]]`, which replay directly as `key` statements), and why the walk stopped | @@ -185,24 +191,24 @@ The driver writes one JSONL record per step: sequence number, event, the pass's Exit codes: 0 pass, 1 an expectation or check failed, 2 a usage or parse error, 3 a harness error. When stdout is not a terminal, each command writes one JSON object to stdout; `help COMMAND` prints real text. `mise run dev:run`, `dev:shot`, and the rest mirror the commands. CI runs `swift test --package-path Examples` and `tessera run Examples/Scenarios` on every leg of the test matrix, macOS, Linux, and Windows among them, and all are required checks; goldens are checked out with LF line endings everywhere, so a Windows checkout cannot drift them. -Built today: `apps`, `launch NAME`, `run PATH...`, `shot NAME [SIZE]`, `tree NAME [SIZE]`, and `help [COMMAND]`, with `--json` and `--pretty` forcing either form of any of them. `apps` prints a name, minimum, and viewports table on a terminal, and otherwise one sorted-key JSON object, `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, where `minimum` is `null` for an app that declares none. `launch` refuses to start unless standard input and output are both terminals (exit 3, before the terminal changes mode), because it opens a live session; an unknown name is a usage error (exit 2) that lists the registered names. `run` takes `.scn` files and directories (every `.scn` under a directory, sorted, hidden entries skipped), runs each scenario against a fresh app, and prints one report per scenario: the human form on a terminal, and otherwise `{"command": "run", "exitCode", "scenarios": [REPORT...]}`, where a report is `{"failures", "observations", "passed", "scenario"}` with `recorded` when `--record` wrote goldens and `seconds` under `--timing`. A failure is `{"line", "statement", "verb", "message"}` with `expected`, `actual`, and `screen` when the statement compares values, and its `verb` decides the exit code: a statement's own verb, `size`, or `repeat` is an expectation that failed (1), `parse` and `usage` are mistakes in the file or the command (2), and `harness` is a fault in the run (3); the highest of a run wins. A scenario that does not parse is not run and reports one `parse` failure. `--record` writes goldens, `--repeat N` runs each scenario N times and reports a `repeat` failure for any run whose report differs from the first, and `--timing` adds the seconds of each run, which are the only numbers that differ between two runs. `shot` prints the screen of one app at a size (`desktop`, `mobile`, `mobileScreen`, or `WxH`) as a golden would hold it, and `tree` prints its node tree, one line per node; both take the app as a positional name, answer a size the app cannot take with a usage error, and have JSON forms `{"app", "command", "exitCode", "rows" or "lines", "size"}`. `check` and `replay` are designed and are not commands: a command the harness does not have is a usage error (exit 2) that lists the commands, so nothing passes without checking. `HarnessCLI.main(registry:)` is the entry point an app author's executable calls; `Examples/Sources/TesseraDev` is Tessera's, over `Demos.registry()`, and its product is named `tessera` while its module is `TesseraDev`, so the executable and the `Tessera` library share no artifact name on a case-insensitive file system. `ScenarioRunner(registry:record:)` is the Swift entry point to `run`, one report per scenario, so an app's tests and its command line run the same scenarios the same way. +Built today: `apps`, `launch NAME`, `run PATH...`, `shot NAME [SIZE]`, `tree NAME [SIZE]`, `check NAME [SIZE]`, `replay FILE --app NAME`, and `help [COMMAND]`, with `--json` and `--pretty` forcing either form of any of them. `apps` prints a name, minimum, and viewports table on a terminal, and otherwise one sorted-key JSON object, `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, where `minimum` is `null` for an app that declares none. `launch` refuses to start unless standard input and output are both terminals (exit 3, before the terminal changes mode), because it opens a live session; an unknown name is a usage error (exit 2) that lists the registered names. When `TESSERA_TRACE` names a file, `launch` creates it before the terminal changes mode (exit 3 if it cannot) and writes the session's trace into it as the steps happen. `run` takes `.scn` files and directories (every `.scn` under a directory, sorted, hidden entries skipped), runs each scenario against a fresh app, and prints one report per scenario: the human form on a terminal, and otherwise `{"command": "run", "exitCode", "scenarios": [REPORT...]}`, where a report is `{"failures", "observations", "passed", "scenario"}` with `recorded` when `--record` wrote goldens and `seconds` under `--timing`. A failure is `{"line", "statement", "verb", "message"}` with `expected`, `actual`, and `screen` when the statement compares values, and `rule` and `node` when a `check` rule did not hold, and its `verb` decides the exit code: a statement's own verb, `size`, or `repeat` is an expectation that failed (1), `parse` and `usage` are mistakes in the file or the command (2), and `harness` is a fault in the run (3); the highest of a run wins. A scenario that does not parse is not run and reports one `parse` failure. `--record` writes goldens, `--repeat N` runs each scenario N times and reports a `repeat` failure for any run whose report or trace differs from the first, `--timing` adds the seconds of each run, which are the only numbers that differ between two runs, and `--trace DIR` writes each scenario's trace under `DIR`, mirroring the scenario's path with `.trace.jsonl` added. `shot` prints the screen of one app at a size (`desktop`, `mobile`, `mobileScreen`, or `WxH`) as a golden would hold it, and `tree` prints its node tree, one line per node, with a container's layout algorithm in parentheses; both take the app as a positional name, answer a size the app cannot take with a usage error, and have JSON forms `{"app", "command", "exitCode", "rows" or "lines", "size"}`. `check` runs every rule over one app at a size with no input and prints `{"app", "command": "check", "exitCode", "rules", "size", "violations": [{"detail", "node", "rule", "severity"}]}`; an error is exit 1, a warning is listed and is not. `replay` runs a trace's events again against the named app and prints a run report for it, `{"command": "replay", "exitCode", "scenarios": [REPORT]}`: a step that differs from the recording is a `replay` failure (exit 1), and a trace that is not a trace, or that holds an event the grammar cannot say, is a usage error (exit 2). `HarnessCLI.main(registry:)` is the entry point an app author's executable calls; `Examples/Sources/TesseraDev` is Tessera's, over `Demos.registry()`, and its product is named `tessera` while its module is `TesseraDev`, so the executable and the `Tessera` library share no artifact name on a case-insensitive file system. `ScenarioRunner(registry:record:)` is the Swift entry point to `run`, one report per scenario, so an app's tests and its command line run the same scenarios the same way. -The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and `launch`, `shot`, and `tree` complete the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. +The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and the commands that take an app name (`launch`, `shot`, `tree`, `check`, and `replay --app`) complete the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. -Each `dev:*` task that takes an app name takes it as an optional argument. Run without one, it prints the command's help and the registered apps, where mise would report a missing argument; `dev:run` without a path prints `run`'s help. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran, so the task exits 2, as `tessera` does, and an unset variable cannot pass for success. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, and `dev:shot` and `dev:tree` the sizes after them, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. +Each `dev:*` task that takes an app name takes it as an optional argument (`dev:replay`, its trace file). Run without one, it prints the command's help and the registered apps, where mise would report a missing argument; `dev:run` without a path prints `run`'s help. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran, so the task exits 2, as `tessera` does, and an unset variable cannot pass for success. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, and `dev:shot`, `dev:tree`, and `dev:check` the sizes after them (`dev:replay` completes the names after `--app`), from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. ### Stability Tessera 1.0 promises a small surface to app authors and their CI, and leaves everything else free to change. The public Swift names are `Application` (in `TesseraRuntime`, re-exported by `Tessera`) and, in `TesseraHarness`, `ApplicationRegistry`, `Viewport`, `HarnessCLI` (`main(registry:)`), `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure`. Everything else in the harness is `package` or internal, and there is no public stepping API: app authors drive an app through scenarios, run from `swift test` by `ScenarioRunner` or from their dev executable by `HarnessCLI`. Within a major version these stay as they are: -- **The scenario grammar.** Every statement, header key, `term` option, locator key, role name, and check rule id documented in `Examples/Scenarios/README.md` keeps its syntax and meaning. Additions are additive, and an unknown statement stays a parse error (exit 2), so a scenario that uses a newer statement fails loudly on an older harness. `type=` values are public view type names; framework-private names are not covered. +- **The scenario grammar.** Every statement, header key, `term` option, locator key, role name, and check rule id documented in `Examples/Scenarios/README.md` keeps its syntax and meaning. Additions are additive, and an unknown statement stays a parse error (exit 2), so a scenario that uses a newer statement fails loudly on an older harness. `type=` values are public view type names and `layout=` values are the names of an app's own `Layout` types; framework-private names (`_LayoutContainer`, `_FrameLayout`, `_PaddingLayout`) are not covered. - **The exit codes**: 0 pass, 1 an expectation or check failed, 2 a usage or parse error, 3 a harness error. - **The JSON report's fields.** Fields are added, never removed, renamed, or retyped, and consumers ignore fields they do not know. - **The CLI's commands and flags**: `apps`, `launch`, `run`, `shot`, `tree`, `check`, and `help`, with `--record`, `--repeat N`, `--timing`, `--json`, and `--pretty`. A prebuilt front end or a SwiftPM plugin only forwards to this command line, which keeps the question of how an app gets `tessera` open. - **The golden file formats.** `NAME.txt` and `NAME.grid.txt` keep their format, so a harness upgrade alone never drifts an app's goldens. A library rendering change (a widget drawing differently) is a library change and drifts goldens like any other; the release notes say so. - **The check rule ids.** A rule added in a minor version runs when named and joins bare `check` only at the next major, so a minor release cannot fail an app's CI with a rule the app never asked for. -Free to change in any release, each carrying a version field where it has a schema: the tree and projection JSON that `tree` and `dump` print; the trace and `replay`; the numbers `expect cost` compares (its syntax is covered, the counts are the library's work, so assert them with `<=` bounds); SVG and PNG shots; `coverage` output; `conform`, which reads Tessera's own catalog; and the live-inspection socket protocol and `attach`. +Free to change in any release, each carrying a version field where it has a schema: the tree and projection JSON that `tree` and `dump` print; the trace, `run --trace`, `TESSERA_TRACE`, and `replay`; the numbers `expect cost` compares (its syntax is covered, the counts are the library's work, so assert them with `<=` bounds); SVG and PNG shots; `coverage` output; `conform`, which reads Tessera's own catalog; and the live-inspection socket protocol and `attach`. ### Catalog conformance and the gallery diff --git a/docs/design/primitives/text.md b/docs/design/primitives/text.md index 87a699ad..017c8fa8 100644 --- a/docs/design/primitives/text.md +++ b/docs/design/primitives/text.md @@ -419,4 +419,4 @@ Each is deferred to the plan step that lands it: ## Inspiration -The Text demo renders ordinary, wide, combining, multiline, wrapped, budget-truncated, and mode-truncated Text fixtures before it introduces focusable controls. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. +The Text demo renders ordinary, wide, emoji, combining, half-width, and multiline Text fixtures at both viewports before it introduces focusable controls. Wrapped, budget-truncated, and mode-truncated fixtures join it when wrapping and truncation land with the styling slice (Slice 9 of [plan 036](../../plans/phase-4-view-layer-and-developer-tooling.md)); `Text` neither wraps nor truncates before then. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. diff --git a/docs/plans/phase-4-view-layer-and-developer-tooling.md b/docs/plans/phase-4-view-layer-and-developer-tooling.md index 6a82b340..61ada15c 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -19,23 +19,24 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [x] 3 Harness target, `TesseraRuntime`, and Showcase retirement: runtime and harness `38cc7fd`, Showcase retirement `53b7a25` on `phase-4/03-harness-runtime` (PR #64, stacked on #59 and merged with its head `08174d2`); full `mise run core:test` runs 700 tests green on Xcode 26.6 (676 on the base; 6 `TesseraRuntimeTests` adoption-shape tests and the spike's 18 tests, now `TesseraHarnessTests`, added), and `mise run quality:format`, `quality:lint` (its `quality:architecture` runs the boundary checker's 10 unit tests, one added, over the new `TesseraRuntime` and `TesseraHarness` rows), and `docs:lint` are green; `swift build` and `swift test --package-path Examples` are green on Xcode 26.6 (13 tests, `Examples/Package.resolved` unchanged), and the full suite (700 tests) and the Examples build are green on Xcode 27.1; no `public` declaration remains in `Sources/TesseraHarness`, nothing under `Sources`, `Tests`, `Examples`, `mise-tasks`, `scripts`, or `.github` mentions the Showcase, and `git diff --name-only origin/phase-4/02-seams...HEAD` lists no `Package.resolved`; `spikes/README.md` says nothing new goes into `spikes/`; CI run 40059692549 (`53b7a25`) and run 40060342201 (the merged head `e05d6e9`) are green on every leg, Windows and the static Linux SDK included; the critic-review fixes (the isolation wording in `Application`'s docs and the spec, `Examples/Sources/ApplicationConsumer`, and the removal of the harness's unused `CLI`) follow on the same branch, and the PR carries the head's run. - [x] 4 Registry, viewports, the main-actor driver, and `tessera launch`: `338bd6f` on `phase-4/04-driver` (PR #65, stacked on #64); full `mise run core:test` runs 750 tests green on Xcode 26.6 and on Xcode 27.1 (700 on the base; the registry, driver, host, CLI, and viewport tests in `TesseraHarnessTests`, five step tests in `TesseraRuntimeTests`, and three `measuredRootSize` tests in `TesseraCoreTests` added), `swift test --package-path Examples` runs 23 tests on both toolchains (13 on the base; the counter demo's snapshot tests and the quit-key tests added), and `mise run quality:format`, `quality:lint` (its `quality:architecture` runs the boundary checker's 10 unit tests over the widened `TesseraHarness` row and both isolation probes, each with its control; a wrong expectation fails the script), and `docs:lint` are green; neither `Package.resolved` changed. `tessera launch counter` and `mise run dev:launch counter` ran live under a pty (`expect`, 80x24): the alternate screen opened, `+ + + -` drew counts 1, 2, 3, 2, `q` drew a last frame, every mode was restored, and the exit status was 0. `DriverTests` renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and pins the bytes, which restores the retired Showcase's `ShowcaseTerminalSessionTests` coverage. The `tessera` executable and the `Tessera` library coexist on case-insensitive APFS: the build trees hold no two names that differ only by case. CI on `e707aa0` (run 40067583854) passed all 14 jobs, including `test (windows-latest)`, which compiles the WinSDK branch of the stream shim and runs the harness tests, and `test (xcode-27)`; the six nits of the critic review of PR #65 are addressed on top of it, and CI now also runs the Examples tests. - [x] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus: `3faa2f4` and `ef4592b` on `phase-4/05-scenarios` (PR #66, stacked on #65); full `mise run core:test` runs 838 tests green on Xcode 26.6 and on Xcode 27.1 (750 on the base; 88 added across the parser, player, runner, golden, report, answerer, and command tests in `TesseraHarnessTests`), `swift test --package-path Examples` runs 25 tests on both toolchains (23 on the base; `ScenarioCorpusTests` added), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed. Two runs of `tessera run Examples/Scenarios` give the same bytes (`shasum` `55fa9588…` twice, 6,996 bytes of JSON); a parse error under `--json` is a report with `"verb":"parse"` and exit 2; a drifted golden fails without `--record` (exit 1, the first differing row named) and `--record` rewrites it (`(updated)`); and the local CI probe, with `counter-basics.scn`'s `expect text 2` changed to a wrong count, exits 1 with its failure at line 19 and exits 0 once reverted (reports below). CI on `ef4592b` (run 40074331235) and on the plan record `5b4dabf` (run 40075060791) passed all 14 jobs, and every `test` leg ran `tessera run --pretty Examples/Scenarios` and printed `5 scenarios, 5 passed, 0 failed`, `test (windows-latest)` included, with its goldens checked out by Git for Windows' defaults and LF by `.gitattributes`; the first run (`3faa2f4`, run 40073722211) failed only that leg, in the README table test, because a Windows checkout of `README.md` has CRLF endings and a `"\r\n"` is one `Character` (fixed in `ef4592b`). The seven findings of the critic review of PR #66 (the app finishing, case-folded shot names, a CRLF golden that had also drifted, `run --repeat PATH`, the unnamed JSON fields, the heredoc closer, and `check`'s app name) are addressed on top of it, and the PR carries the head's run. -- [ ] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos -- [ ] 7 Flow layout and `ViewThatFits` -- [ ] 8 Styling and decoration, with pictures, roles, and catalog review -- [ ] 9 Focus, key routing, and KeyLegend, with focus tooling -- [ ] 10 Mouse, hit testing, and hover, with pointer tooling -- [ ] 11 Interactive ScrollView, with scroll checks -- [ ] 12 Roles and labels in the projection -- [ ] 13 Controls -- [ ] 14 Collections -- [ ] 15 Interactive SplitView -- [ ] 16 The animation path and the schedulable clock -- [ ] 17 Remaining 1.0 views -- [ ] 18 Live inspection -- [ ] 19 Docs, skill, and the conformance gate -- [ ] 20 Intro app and Phase 4 closure - -Next: Slice 1 in its own pull request (PR #60 is open). Slices 2, 3, 4, and 5 are done (PR #59: the harness seams and the caller-isolated `TerminalSession.draw`; PR #64, stacked on #59: `TesseraRuntime`, `TesseraHarness`, and the Showcase retirement; PR #65, stacked on #64: the registry, viewports, the main-actor driver, and `tessera launch`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus). Slice 6 follows Slice 5 and can branch from `phase-4/05-scenarios` until #59, #64, #65, and #66 merge; it replaces Slice 5's refusals of `dump`, `check`, `expect cost`, `tessera check`, and `tessera replay` as it builds their engines, and the owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24, and the Slice 4 and 5 decisions marked "(owner to weigh)" are reversible and do not block it. Decision 9 (i) is recorded below, so nothing in Slices 6-7 waits on the owner. +- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), `b2c3161` (an `expect cost` bound in the `ForEach` scenario), `a7260a7` (the critic review's fixes: list-looking `siblings-disjoint`, the grid row rule, 62 styles, `run --pretty` warnings, empty arguments, the trace version and focus events, and the Flex, Stacks, and ZStack demo gaps), and `a7d0b5c` (the re-check's fix: a view list is recognised by its module-qualified type, so an app's own `ForEach` is not one) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 959 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 118 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 39 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green (the pre-commit hook's lint of `Examples/Tests` too); neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `968b46d6…` twice, 34,563 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`), and on `zstack` with layers from a `ForEach` and an `if` (it fails there with the list-looking removed), while an app's own view named `ForEach` whose body is `Text; Text` under a `ZStack` is reported (a live test that fails under short-name matching). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340), on the plan record `5b8b4c1` (run 40083937741), and on the head after the critic fixes `6ea1bd5` (run 40087437615) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF; the PR carries the head's run after the re-check's fix. +- [ ] 7 Library fixes from the first demos +- [ ] 8 Flow layout and `ViewThatFits` +- [ ] 9 Styling and decoration, with pictures, roles, and catalog review +- [ ] 10 Focus, key routing, and KeyLegend, with focus tooling +- [ ] 11 Mouse, hit testing, and hover, with pointer tooling +- [ ] 12 Interactive ScrollView, with scroll checks +- [ ] 13 Roles and labels in the projection +- [ ] 14 Controls +- [ ] 15 Collections +- [ ] 16 Interactive SplitView +- [ ] 17 The animation path and the schedulable clock +- [ ] 18 Remaining 1.0 views +- [ ] 19 Live inspection +- [ ] 20 Docs, skill, and the conformance gate +- [ ] 21 Intro app and Phase 4 closure + +Next: Slice 1 in its own pull request (PR #60 is open). Slices 2 through 5 are merged (PRs #59, #64, #65, #66); Slice 6 is PR #67 (the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 (the library fixes from the first demos) follows #67 and can branch from `phase-4/06-projection` until #67 merges; Slice 8 (Flow and `ViewThatFits`) follows it, and the design catalog's PR #62 it needs is merged. The owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24; the Slice 4-6 decisions marked "(owner to weigh)" are reversible and do not block them. Decision 9 (i) is recorded below and the owner decided Q14-Q16 on 2026-10-25, so nothing in Slices 7 and 8 waits on the owner. ## Approach @@ -57,7 +58,7 @@ Open questions: Decision 9 (iii) below (how an app gets `tessera`); (i) and (ii) ### 1 Terminal colour reports -The terminal half of [terminal color reports](../spec/phase-3/slice-06-terminal-capability-detection.md#terminal-color-reports) (spec Phase 3 Slice 6, the 2026-10-19 amendment) and the [probe contract](../design/tokens.md#probe) in the tokens doc: the parser gains an OSC reply path (`rgb:` and `rgba:` bodies, 1 to 4 hex digits per channel scaled to 8 bits, BEL or ST, OSC 4 slots in any order, consumed as probe evidence and never delivered as keys); the active generation sends OSC 10, OSC 11, and OSC 4 for slots 0-15 as one write ended by the round's single DA1; the round's timeout default rises to 1 s; `NO_COLOR` asks only OSC 11; `.passive` and `.disabled` detection send no colour queries; the first frame waits for the round; late replies update the report; mode 2031 is enabled at startup with `CSI ? 2031 l` registered for cleanup, and each `CSI ? 997 ; 1 n` or `; 2 n` notification and each resume re-runs the round; replies fill `TerminalCapabilities.colorReport`. Resolving colour roles from the report (the token tiers) is Slice 8's, not this slice's. This slice depends on no other slice and uses only the existing terminal test support (`InMemoryTerminalDevice` with scripted replies), so it runs in parallel with Slices 2-7. +The terminal half of [terminal color reports](../spec/phase-3/slice-06-terminal-capability-detection.md#terminal-color-reports) (spec Phase 3 Slice 6, the 2026-10-19 amendment) and the [probe contract](../design/tokens.md#probe) in the tokens doc: the parser gains an OSC reply path (`rgb:` and `rgba:` bodies, 1 to 4 hex digits per channel scaled to 8 bits, BEL or ST, OSC 4 slots in any order, consumed as probe evidence and never delivered as keys); the active generation sends OSC 10, OSC 11, and OSC 4 for slots 0-15 as one write ended by the round's single DA1; the round's timeout default rises to 1 s; `NO_COLOR` asks only OSC 11; `.passive` and `.disabled` detection send no colour queries; the first frame waits for the round; late replies update the report; mode 2031 is enabled at startup with `CSI ? 2031 l` registered for cleanup, and each `CSI ? 997 ; 1 n` or `; 2 n` notification and each resume re-runs the round; replies fill `TerminalCapabilities.colorReport`. Resolving colour roles from the report (the token tiers) is Slice 9's, not this slice's. This slice depends on no other slice and uses only the existing terminal test support (`InMemoryTerminalDevice` with scripted replies), so it runs in parallel with Slices 2-8. Delivers: a session against a terminal that answers fills `colorReport` before the first frame, and one that does not leaves every input unknown without delaying past the timeout. Verify: the spec's test list for the round (one write, both reply forms and terminators, DA1 or timeout ending, late replies as updates, OSC 11 only under `NO_COLOR`, notification and resume re-runs, cleanup bytes); the Windows input path parses the same replies (`mise run vm:windows-test`); the spec section's "not yet implemented" note is removed in the same change. @@ -94,7 +95,7 @@ Plan 032 Step 8.2 and plan 033 Step B.1, under Decision 9 (i) and the spec's [Ru `TesseraHarness` gains two public names. `ApplicationRegistry` has `init()` and `mutating func register(_ name: String, minimum: TerminalSize? = nil, viewports: [Viewport], make: @escaping @MainActor () -> App)`. It is the one selection point; the harness opens factories on the main actor, once per run, so each run starts fresh and a fixture is one more registration (`register("browser-empty") { Browser(fs: .empty) }`). A duplicate name makes every entry point that reads the registry fail (exit 3, or a failing report), never last-wins; registration itself does not throw (the spike's did; do not copy it). A `@Sendable` factory for a main-actor app is a compile error at the registration site. `Viewport` is a `Hashable, Sendable` enum: `desktop` (80x24), `mobile`, `mobileScreen` (40x16), `custom(TerminalSize)`. -Everything else is `package` or internal (the step and the registry's reads are `package`; the driver and its hosts are internal, because nothing outside the harness needs them and tests reach them with `@testable import`). `TesseraRuntime` gains the canonical step, a nonisolated `package` function that takes the host's isolation as a parameter, written as the spec's [canonical step](../spec/phase-5-runtime-and-polish.md#the-canonical-step) has it (`Step.run(_ app:graph:event:isolation: isolated any Actor)`, returning the `EventDisposition`): resize the graph if the event is a `.resize`, `graph.dispatch`, `app.apply(event)` when the disposition is `.ignored` or the event is a resize (a resize is never routed, so Slice 9's routing cannot take it from apps), then `graph.update()`. There is no `#isolation` default, so a caller must name an isolation: a synchronous nonisolated caller cannot compile the call, and an async caller that passes another actor hops to it. `TesseraHarness` gains the internal `@MainActor` `Driver`, a shell over that step: it owns the app (built by the registry factory), the `ViewGraph` and its base environment, and a `TerminalSession`; `step(_:)` calls `Step.run(…, isolation: MainActor.shared)` and then `session.draw` when `graph.needsRender`, and the host stops after the step that sets `isFinished`. The step order and update policy are ported from the review loop's `ApplicationDriver`, without its `isolated TerminalSession` hosting and its hard-coded `q`. `HeadlessHost` (a session over `InMemoryTerminalDevice` into one persistent Ghostty virtual terminal) and `LiveHost` (a session over the real device inside `withApplicationTerminal`, handing its scope a main-actor function value rather than the generic driver, as the spec's runtime section explains) share the `Driver` and the `draw` path. The harness has no quit key and no Tab policy. Tessera's own nonisolated demos may additionally be stepped on per-test actors, one actor per test (an internal shell over the same `Step.run` with `isolation: self`), which keeps parallel suites parallel; that shell is not public. +Everything else is `package` or internal (the step and the registry's reads are `package`; the driver and its hosts are internal, because nothing outside the harness needs them and tests reach them with `@testable import`). `TesseraRuntime` gains the canonical step, a nonisolated `package` function that takes the host's isolation as a parameter, written as the spec's [canonical step](../spec/phase-5-runtime-and-polish.md#the-canonical-step) has it (`Step.run(_ app:graph:event:isolation: isolated any Actor)`, returning the `EventDisposition`): resize the graph if the event is a `.resize`, `graph.dispatch`, `app.apply(event)` when the disposition is `.ignored` or the event is a resize (a resize is never routed, so Slice 10's routing cannot take it from apps), then `graph.update()`. There is no `#isolation` default, so a caller must name an isolation: a synchronous nonisolated caller cannot compile the call, and an async caller that passes another actor hops to it. `TesseraHarness` gains the internal `@MainActor` `Driver`, a shell over that step: it owns the app (built by the registry factory), the `ViewGraph` and its base environment, and a `TerminalSession`; `step(_:)` calls `Step.run(…, isolation: MainActor.shared)` and then `session.draw` when `graph.needsRender`, and the host stops after the step that sets `isFinished`. The step order and update policy are ported from the review loop's `ApplicationDriver`, without its `isolated TerminalSession` hosting and its hard-coded `q`. `HeadlessHost` (a session over `InMemoryTerminalDevice` into one persistent Ghostty virtual terminal) and `LiveHost` (a session over the real device inside `withApplicationTerminal`, handing its scope a main-actor function value rather than the generic driver, as the spec's runtime section explains) share the `Driver` and the `draw` path. The harness has no quit key and no Tab policy. Tessera's own nonisolated demos may additionally be stepped on per-test actors, one actor per test (an internal shell over the same `Step.run` with `isolation: self`), which keeps parallel suites parallel; that shell is not public. `Examples/Sources/DemoSupport`: `Demo` is an `Application` that declares its registry name, minimum size, `viewports`, and `fixtures` (further registrations of the same demo), no defaults; `DemoSupport` registers every demo into one `ApplicationRegistry`. A demo handles its own quit key in `apply` (whole-key matching through a shared `isQuitKey` helper, never `Key` equality on enriched keys) and reports through `isFinished`. The first demo, a counter built from landed primitives, proves the path; Slice 6's Text demo may absorb it. The `mobile` rule is decided (Decision 1, (b)): `mobile` is 40 columns at natural height, derived through a package-level nil-height proposal on `ViewGraph` plus two-height detection, so a height-greedy demo fails `size mobile` with an error naming `mobileScreen` (40x16). Demos follow the rules in [Developer tooling](../architecture/developer-tooling.md#demos): interactive, every variable style shown, extra controls allowed for complex components, no workarounds for library bugs. @@ -104,7 +105,7 @@ Isolation is pinned by two compile-fail fixtures beside `Tests/CompileFailFixtur Create the two skills: `.agents/skills/tessera/SKILL.md`, the published skill for app authors, with what this slice makes usable (the `Application`, `ApplicationRegistry`, and `HarnessCLI.main(registry:)` wiring, `tessera apps`, `tessera launch NAME`), and `.agents/skills/tessera-contributing/SKILL.md`, the in-repo skill for contributors, with the `mise run dev:*` tasks, the demos, and a demo's headless snapshot test; and an `AGENTS.md` pointer to both. Later slices extend the `tessera` skill first and the contributor skill after it. -Delivers: one demo steps through one driver, headless and live: `tessera launch NAME` runs it in a terminal and a snapshot test renders it identically through the in-memory device. Verify: registry tests cover lookup and duplicates (every reading entry point fails); a greedy demo fails `size mobile`; one demo's snapshot test is a few lines, and it is the test that renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and asserts the bytes the device received, which restores the coverage the retired Showcase's `ShowcaseTerminalSessionTests` gave; a nonisolated demo steps on a per-test actor and on the main actor through the same `Step.run`; both compile-fail fixtures fail with their expected diagnostics (the second one only in plain Swift 6 mode: Slice 3 checked it in a scratch two-module package on Swift 6.3.3 and 6.4, where the line is an error without `InferIsolatedConformances`, builds with default main-actor isolation in the extension's module, and builds with `InferIsolatedConformances` on 6.4 but still errors on 6.3.3 for a type isolated only through its module's default isolation), their controls compile, and `mise run quality:architecture` runs them; the `.resize` rule is in the step's doc comment and Slice 9's routing tests pin it once `dispatch` can consume events. `DemoSupport` re-adds the `Tessera` product to the Examples manifest and so replaces `Examples/Sources/ApplicationConsumer`, the one-file guard Slice 3 added for `import Tessera` from outside the root package; delete it here. +Delivers: one demo steps through one driver, headless and live: `tessera launch NAME` runs it in a terminal and a snapshot test renders it identically through the in-memory device. Verify: registry tests cover lookup and duplicates (every reading entry point fails); a greedy demo fails `size mobile`; one demo's snapshot test is a few lines, and it is the test that renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and asserts the bytes the device received, which restores the coverage the retired Showcase's `ShowcaseTerminalSessionTests` gave; a nonisolated demo steps on a per-test actor and on the main actor through the same `Step.run`; both compile-fail fixtures fail with their expected diagnostics (the second one only in plain Swift 6 mode: Slice 3 checked it in a scratch two-module package on Swift 6.3.3 and 6.4, where the line is an error without `InferIsolatedConformances`, builds with default main-actor isolation in the extension's module, and builds with `InferIsolatedConformances` on 6.4 but still errors on 6.3.3 for a type isolated only through its module's default isolation), their controls compile, and `mise run quality:architecture` runs them; the `.resize` rule is in the step's doc comment and Slice 10's routing tests pin it once `dispatch` can consume events. `DemoSupport` re-adds the `Tessera` product to the Examples manifest and so replaces `Examples/Sources/ApplicationConsumer`, the one-file guard Slice 3 added for `import Tessera` from outside the root package; delete it here. ### 5 Scenario language, runner, the `tessera` CLI, and the CI corpus @@ -118,85 +119,93 @@ Plan 033 Step B.4 and plan 032 Step 8.3. `Projection` (sorted-key JSON of `Graph Delivers: each landed primitive has snapshot coverage at both sizes and reads well at 40 columns; a demo's trace names the invalidation reason for each redraw. Verify: `mise run dev:run Examples/Scenarios` green; `siblings-disjoint` passes on a `ForEach` demo (the defect plan 034 fixed stays fixed). -### 7 Flow layout and `ViewThatFits` +### 7 Library fixes from the first demos -Plan 032 Step 14.1, moved before styling (Decision 2) so every later demo can wrap and switch arrangements at the 40-column `mobile` viewport; Picker's radio group wraps with Flow and `ViewThatFits` chooses its title and menu layouts, and Form, LabeledContent, ProgressView, and TabView need both. A wrapping row layout and `ViewThatFits` with layout priority, from the `ready` docs `docs/design/primitives/flow.md` and `view-that-fits.md`. Their plain requirements land here; the requirements under each doc's `### Focus` and `### Pointer` subsections (unchosen candidates leave focus and hit testing, item focus survives a reflow) are not testable yet, and Slices 9 and 10 finish them. The catalog's capability subsections (2026-10-23 decision) exist before this slice starts; if they do not, this slice adds them for every catalog doc first. +Owner decisions Q14-Q16 (2026-10-25). The Slice 6 demos exposed library defects, recorded in Discoveries: a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, every step of `stacks`, `flex`, and `foreach` reports `layoutEnvironmentChanged`, a `Spacer` with `layoutPriority(1)` takes the whole remainder, `Flex` fill weights invert when its children are larger than the space, and `Flex` takes no alignment. The demos work around or pin them today, so they are fixed here, right after Slice 6 and before Flow and `ViewThatFits` build on the same layout code; the harness still never compensates for a library bug. Six fixes in one reviewable step, the sixth in the harness's own rules. (i) A graph rooted at an `@ViewBuilder` body is clean after an `update()` that changes nothing: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked every time, and the slice makes reconciliation skip an unchanged root. (ii) Reconciliation stops reporting `layoutEnvironmentChanged` and `renderEnvironmentChanged` for an unchanged environment: stacks rebuild their children's `EnvironmentValues` on each update and reconciliation compares storage by identity (`===`); the slice chooses between comparing by value and keeping one storage across updates, and records the choice under Decisions. (iii) Q15: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), not the whole remainder, so `HStack { L; Spacer().layoutPriority(1); M; Spacer(); R }` keeps `M` and `R` on screen. The slice amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority-tier distribution tests in the same change; the amended wording says how rigid children, the reserved minimums, and the integer remainder rule interact, and the slice notes in Discoveries whether Flex's own tier rule needs the same reservation. (iv) `Flex` fill weights stop inverting when the children are larger than the space: the slice settles the rule against the negative-remainder text of the Flex spec (`docs/spec/phase-4/slice-02-5-flex-and-splitview-negotiation.md` and `docs/design/primitives/flex.md`: `fill` allocations are removed by weight toward zero, equal-tier remainder cells from later children first) and amends whichever of the code and the text is wrong. (v) Q16: `Flex` gains `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, the cross-axis placement of a child smaller than the cross extent, relative to the axis, like CSS `align-items`; there is no `stretch` and no main-axis justification for now. The default keeps today's frames (`.start` is the leading edge: `VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one), and `docs/design/primitives/flex.md` (its cross-axis placement sentence and public initializer) and the Flex spec are updated in the same change. `Flex`'s `min(n)` floor on a non-wrapping `Text` child stays with Text wrapping (Slice 9). -Delivers: Flow and ViewThatFits demos with scenarios at both sizes. Verify: `check` passes with `siblings-disjoint` on a ViewThatFits demo that swaps arrangements on resize; Slice 9 adds `focus-resolves` to that scenario. +(vi) Owner decision 2026-10-25: projection nodes gain a `module` field, and `siblings-disjoint` and `frame-inside-clip` match their overlapping and scrolling containers (`ZStack`, `ScrollView` in `Checks.swift`) by module and type, as view lists already are through `isList`, so an app's own `ZStack` or `ScrollView` is held to the rules instead of exempted. -### 8 Styling and decoration, with pictures, roles, and catalog review +Delivers: a graph is clean after an idle step, an unchanged environment is not reported as changed, a high-priority `Spacer` no longer pushes lower tiers past the edge, oversized `Flex` fill weights keep their order, `Flex` aligns children on its cross axis, and the rules' container exemptions apply to Tessera's types only. Verify: an idle step of the `stacks`, `flex`, and `foreach` demos reports no environment reasons and draws nothing (`drew: false`, no bytes); the `stacks` demo drops the neighbour priorities it uses as a workaround; the `flex` demo shows `.center` and `.end`, and its scenario pins the frames; distribution-table tests cover the reserved minimums and oversized fill weights; a `ChecksTests` fixture with an app type named `ZStack` whose children overlap fails `siblings-disjoint`; goldens change only where these fixes move frames. + +### 8 Flow layout and `ViewThatFits` + +Plan 032 Step 14.1, moved before styling (Decision 2) so every later demo can wrap and switch arrangements at the 40-column `mobile` viewport; Picker's radio group wraps with Flow and `ViewThatFits` chooses its title and menu layouts, and Form, LabeledContent, ProgressView, and TabView need both. A wrapping row layout and `ViewThatFits` with layout priority, from the `ready` docs `docs/design/primitives/flow.md` and `view-that-fits.md`. Their plain requirements land here; the requirements under each doc's `### Focus` and `### Pointer` subsections (unchosen candidates leave focus and hit testing, item focus survives a reflow) are not testable yet, and Slices 10 and 11 finish them. The catalog's capability subsections (2026-10-23 decision) exist before this slice starts; if they do not, this slice adds them for every catalog doc first. + +Delivers: Flow and ViewThatFits demos with scenarios at both sizes. Verify: `check` passes with `siblings-disjoint` on a ViewThatFits demo that swaps arrangements on resize; Slice 10 adds `focus-resolves` to that scenario. + +### 9 Styling and decoration, with pictures, roles, and catalog review Plan 032 Phase 9 (Steps 9.1-9.4) and plan 033 Phase C. Style environment, tones, and `Text + Text` styled runs measured once (9.1), resolving each role in the `derived`, `slots`, or `monochrome` tier of `docs/design/tokens.md` from Slice 1's `colorReport`; word wrapping that breaks inside a word only when the word is wider than the line, `.lineLimit`, `.truncationMode` (9.2); border glyph families with ASCII fallbacks, `Box` with a View title, `background`, `overlay`, the focus border style (9.3); the shared output-only `ScrollIndicator` with thumb and track styles (9.4). Tooling: `RenderedCell.width` from Ghostty's wide flag, SVG shots as a review aid, the `theme=probe` role palette with `fg=ROLE`/`bg=ROLE` cell assertions (C.1); `expect wireframe PATH N [at SEL]`, `shot NAME SEL`, `conform`, and the gallery's Current and Previous slots fed by role-styled grids in the catalog's dialect (C.2). Delivers: demos for text styling, truncation, borders, and indicators; styled-grid snapshots cover every glyph family and its ASCII fallback; `conform docs/design/primitives` reports pinned and unpinned fences in JSON. Verify: a focus-border demo asserts `fg=focus` under `theme=probe` and reads identically in SVG and `.txt`; SVG determinism by SHA-256 across macOS and Lima Linux. -### 9 Focus, key routing, and KeyLegend, with focus tooling +### 10 Focus, key routing, and KeyLegend, with focus tooling First, every component built before this slice gets the requirements under its catalog doc's `### Focus` subsection (find them with `iwe find --blocks '{ $section: Focus }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips focus; a component built later handles its own `### Focus` subsection in its own slice. Plan 032 Phase 10 (Steps 10.1-10.3) and plan 033 Phase D. Document-order focus with wrap, removal handling, and leaf-to-ancestor bubbling (ported from `phase4` `Focus.swift`, `Responder.swift`, `FocusRoutingTests`); Step 10.1 also wires the dead `focusChanges` counter (`GraphStatistics.swift`), `handlerKinds`, and `terminalRequirementChanges`. Once `dispatch` can consume events, a routing test pins Slice 4's rule that the app still sees every resize and only the other events the graph left `.ignored`. Focus appearance hosts and focus scopes (10.2); `KeyLegend` from responder key bindings, `plain` by default, `keycaps` optional, dropping whole entries by priority when narrow (10.3). Tooling: the spec amendment that adds `isFocusable` and `isFocused` to the projection, `focused`/`focusable` locators, `focus SEL`, `expect focused SEL` (D.1); the rules `exactly-one-focus`, `focus-resolves`, `focused-visible`, `focus-indicated`, `tab-cycle-terminates`, `legend-keys-handled`, and `tessera coverage --app NAME` (D.2; Decision 8: per focusable, the level `shortcut`, `tab`, or `unreachable` and the shortest route as `from` plus key-and-count `steps`, arrow steps counted in the route but not as presses, plus why the walk stopped); trace `dispatch{disposition, handledBy, path}` and `focus{from, to, cause}` (D.3). Delivers: a focus demo and a KeyLegend demo with scenarios asserting `focused`; routing tables cover focused, bubbled, ignored, and removed responders. Verify: coverage reports no `unreachable` focusable in any demo; a swallowed key's trace names the node that consumed it; an invisible-focus fixture fails `focused-visible`; a `colors=none` run fails `focus-indicated` when focus has no glyph. -### 10 Mouse, hit testing, and hover, with pointer tooling +### 11 Mouse, hit testing, and hover, with pointer tooling First, every component built before this slice gets the requirements under its catalog doc's `### Pointer` subsection (find them with `iwe find --blocks '{ $section: Pointer }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips pointer input; a component built later handles its own `### Pointer` subsection in its own slice. Plan 032 Phase 11 (Steps 11.1-11.3) and plan 033 Phase E. Clipped, deepest-first hit testing in paint order, click-to-focus, pointer capture (ported from review-loop `ab272c2`); hover state with any-event tracking requested only while a handler needs it, cleared on focus loss; application-owned text selection and copy per `docs/design/primitives/text-selection.md`, or an explicit deferral recorded in the spec. Tooling: `mouse click|move|scroll|down|up R,C`, `hit R,C` returning the deepest node path and whether it captured; traces record capture changes. Delivers: a pointer demo; the overlap, clipping, capture, and de-escalation tests exist as scenarios. Verify: `mise run dev:run` over the pointer scenarios; the requirement de-escalation shows in the trace. -### 11 Interactive ScrollView, with scroll checks +### 12 Interactive ScrollView, with scroll checks First, every component built before this slice gets the requirements under its catalog doc's `### Scroll` subsection (find them with `iwe find --blocks '{ $section: Scroll }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips interactive scrolling; a component built later handles its own `### Scroll` subsection in its own slice. Plan 032 Phase 12 (Steps 12.1-12.2) and plan 033 Step G.2. Keyboard and wheel scrolling, boundary bubbling to an enclosing viewport, focus only while scrollable; indicators stay output-only (a click or drag on one returns `.ignored`); reveal on focus change (`docs/research/focus-appearance-host-inside-scroll-content.md`). Rules `scroll-offset-within-content`, `selection-visible`, `unique-collection-ids`; `expect node SEL visible` understands scroll clips. Delivers: a ScrollView demo with nested viewports at both sizes, ending with `check`. Verify: moving focus into hidden content scrolls it into view in a scenario. -### 12 Roles and labels in the projection +### 13 Roles and labels in the projection Plan 033 Step G.1 (before the first control). `role` and `label` as value-typed, optional projection fields set by library controls through an internal hook (no public view API), added by spec amendment in this slice; `role=`/`label=` locators; nodes without a role fall back to one derived from `type`, and without a label to their screen text (Decision 4). No app-facing label modifier in 1.0; the fields are shaped so a later accessibility or Tern Surface Protocol export can read them. -Delivers: locators address controls by meaning. Verify: a scenario finds a Button by `role=button label="Submit"` before Slice 13 lands. +Delivers: locators address controls by meaning. Verify: a scenario finds a Button by `role=button label="Submit"` before Slice 14 lands. -### 13 Controls +### 14 Controls Plan 032 Phase 13 (Steps 13.1-13.4). Each control lands with its demo, keyboard and pointer paths, and disabled state, driven by `role=`/`label=` locators, never coordinates. Button with the three emphases and the shape set with ASCII ends, the pressed state reaching rendering; Toggle (`.checkbox`, `.circle`) with the Form row focus rule; Picker as radio group (chips wrap with Flow), menu (with the "Other" row), and segmented (icons-only and tail truncation when narrow), and MultiPicker; Stepper (arrows visible unfocused, the limit arrow disabled, clamped stepping and overflow tests); TextField on a shared grapheme-safe text editing core with the character counter (ported from review-loop `TextFieldEditor.swift`). Delivers: four control demos with scenarios. Verify: every control scenario uses semantic locators; `check` passes; the pressed Button state is visible in a shot. -### 14 Collections +### 15 Collections Plan 032 Steps 14.2-14.4. List and Section (one focus stop, controlled selection, the shared indicator, badges on rows); Outline with path mode (expanders, guides, a root folder icon, badges before icons, path folding); Grid on the shared Flex solver and Table with `.plain`, `.bordered`, and `.grid` styles plus alternating row backgrounds, sorted by column identity. Delivers: List, Outline, Grid, and Table demos ending with `check`. Verify: `unique-collection-ids` fails closed on a duplicate-ID fixture; `selection-visible` holds while scrolling a List. -### 15 Interactive SplitView +### 16 Interactive SplitView Plan 032 Step 15.1. Divider keyboard and mouse resizing (arrow and Shift+arrow steps, a wider grab target, double-click or `=` to reset), the focus style, clipping without automatic collapse, the ASCII junctions, nesting, and content-minimum sizing; ported from review-loop `SplitView.swift` after fixing its duplicate-ID trap and its binding writes during update. Also settle the pane-content rule plan 034 discovered (a pane whose content is a nested multi-view list places only the first view): one view per pane is an error or a documented rule. Delivers: a SplitView demo covering the five edge cases. Verify: scenarios drive the divider by keyboard and pointer and assert pane frames. -### 16 The animation path and the schedulable clock +### 17 The animation path and the schedulable clock First, every component built before this slice gets the requirements under its catalog doc's `### Clock` subsection (find them with `iwe find --blocks '{ $section: Clock }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips the clock; a component built later handles its own `### Clock` subsection in its own slice. Plan 032 Step 16.1 and plan 033 Decision 7. A clock in the environment that the harness can advance, and a declared tick requirement aggregated like `TerminalRequirements`; with it, the scenario verbs `wait` and `settle` and the motion views become expressible (they are listed as not expressible until here). The seams-spike clock feeds pass durations only, so this is a design step, not a port. Delivers: a spinner demo whose scenario advances the clock deterministically. Verify: two runs give identical bytes; no scenario sleeps. -### 17 Remaining 1.0 views +### 18 Remaining 1.0 views Plan 032 Steps 16.2-16.3. `ProgressView` (determinate and indeterminate spinner sets), `Callout`, `LabeledContent`, `Label` with the icon catalog, `Badge`, `ContentUnavailableView`; then TabView, Form, Markdown, the code view, and `Image`, per `docs/spec/phase-4/` "The 1.0 surface". Delivers: a demo per view with scenarios at both sizes. Verify: `conform` pins every `implemented` fence these views graduate. -### 18 Live inspection +### 19 Live inspection Plan 033 Phase F, under Decision 3 (b). First amend the spec's diagnostics paragraph: `TesseraCore` still serializes nothing; a dev runner outside the library may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path. Then the Unix-domain NDJSON endpoint behind `TESSERA_DEBUG_SOCKET` (`text`, `grid`, `tree`, `stats`, `shot`, `key`, `type`, `mouse`, `resize`, `quit`), `tessera attach`, `quit` unlinking before replying, `ctl` exit 3 for missing, stale, silent, and unsupported; excluded on Windows for now (Windows 10 and later have `AF_UNIX`, so the spike's exclusion can be revisited). The protocol carries a version and is outside the 1.0 promise. Nothing else depends on it. Delivers: a demo in a real terminal answers `text`, `tree focused`, and `key Tab` from a second shell. Verify: the transport-parity test runs every scenario in-process and over the socket with identical reports. -### 19 Docs, skill, and the conformance gate +### 20 Docs, skill, and the conformance gate -Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. +Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 13 and 19 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. Delivers: a fresh agent given only the `tessera` skill drives an app, reads a failure, and produces a shot without reading source, with the `tessera-contributing` skill checked separately by a fresh agent that adds a demo and its snapshot test; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: both agent runs, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). -### 20 Intro app and Phase 4 closure +### 21 Intro app and Phase 4 closure Plan 032 Steps 17.1-17.2. A Textual-style intro app that composes the per-component demos (navigation only, no component behaviour), with a scenario per screen; move each implemented doc's durable content into DocC, mark it `implemented`, run the performance and platform checks, and the full quality gate. @@ -225,7 +234,7 @@ Rules for an agent running slices of this plan without the owner. - [ ] `mise run dev:run Examples/Scenarios` passes on macOS, Linux, and Windows in CI; committed goldens are text only and drift fails without `--record`; two runs give identical bytes. - [ ] An app outside this repository's package graph depends on Tessera, declares `Application`, adopts the harness, and runs a scenario with `check` in its own CI. - [ ] The stable surface the 2026-10-23 decision lists is public and documented in [Developer tooling](../architecture/developer-tooling.md#stability), and every other declaration in `TesseraHarness` is `package` or internal. -- [ ] By Slice 9, focus is addressable, traced with cause, invariant-checked, and coverage-walked; by Slice 13, control demos use semantic locators; by Slice 20, `conform` gates `implemented` docs. +- [ ] By Slice 10, focus is addressable, traced with cause, invariant-checked, and coverage-walked; by Slice 14, control demos use semantic locators; by Slice 21, `conform` gates `implemented` docs. - [ ] The spec's diagnostics paragraph carries every amendment this plan makes, and no test affordance is public API. - [ ] [Developer tooling](../architecture/developer-tooling.md) describes every tool as built, and no tool that has landed is still described only as designed. @@ -240,13 +249,13 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-21 Plan 032 Step 10.1 wires the dead `focusChanges` counter; the role and label hook lands before the first control; the environment clock is a `Clock` the harness can advance. (owner, plan 035 Step 6.1) - 2026-10-22 Flow and `ViewThatFits` (plan 032 Step 14.1) move before the controls, after focus and hit testing. (this plan, from the 2026-10-21 owner review note under plan 032 Step 13.2) Superseded 2026-10-23 by Decision 2 below. - 2026-10-22 Decision 1, the `mobile` viewport: (b), 40 columns at the component's natural height, with `mobileScreen` (40x16) declared per demo for screen-filling compositions; the harness derives natural height with the package nil-height probe and two-height detection, and a height-greedy demo fails `size mobile` naming `mobileScreen`. This confirms the 2026-10-10 catalog decision; `docs/design/templates/widget.md`, `docs/design/widgets/stepper.md`, and Slice 4 were brought into line, and plan 032 Step 8.2 stays as history in the cancelled plan. (owner, plan 035 Step 6.2) -- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) -- 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 18 is no longer optional. (owner) -- 2026-10-22 Decision 4, `role` and `label`: (b) with (a) as the fallback. Library controls set optional, value-typed fields through an internal hook (spec amendment in Slice 12); nodes without them derive role from `type` and label from screen text. No public label modifier in 1.0; the fields are shaped for a later accessibility or Tern Surface Protocol export, which the owner wants eventually so a Tessera app can render natively in Tern or a similar host. (owner) +- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 20 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) +- 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 19 is no longer optional. (owner) +- 2026-10-22 Decision 4, `role` and `label`: (b) with (a) as the fallback. Library controls set optional, value-typed fields through an internal hook (spec amendment in Slice 13); nodes without them derive role from `type` and label from screen text. No public label modifier in 1.0; the fields are shaped for a later accessibility or Tern Surface Protocol export, which the owner wants eventually so a Tessera app can render natively in Tern or a similar host. (owner) - 2026-10-22 Decision 7, Windows: the scenario corpus is required on Windows from Slice 5, with LF goldens through `.gitattributes`. If a Windows toolchain bug ever blocks it, that leg drops to non-blocking for that case and the reason is recorded here. (owner) - 2026-10-22 App authors get scenarios and checks: `TesseraHarness` becomes a library app authors import to drive and check their own apps, because agent-driven development of Tessera apps depends on it. This reverses plan 033's "not offered to app authors". (owner) -- 2026-10-23 Decision 2, Flow and `ViewThatFits`: they get their own slice (Slice 7) between the projection slice and styling, before focus exists, so every later demo can lay out well at 40 columns; neither needs styling. Their focus and pointer requirements are finished by Slices 9 and 10. (owner) -- 2026-10-23 Capability requirements: each catalog doc's `## Requirements` gains `### Focus` (all keyboard handling and focused appearance included), `### Pointer`, `### Scroll` (interactive scrolling only), and `### Clock` subsections, present only where the component needs that capability, enforced by the IWE design schema. A requirement needing several capabilities goes under the one that lands last; negative assertions ("installs no responder") need the machinery, so they go in the subsection too. Slices 9, 10, 11, and 16 start by making every already-built component's subsection pass. (owner) +- 2026-10-23 Decision 2, Flow and `ViewThatFits`: they get their own slice (Slice 8) between the projection slice and styling, before focus exists, so every later demo can lay out well at 40 columns; neither needs styling. Their focus and pointer requirements are finished by Slices 10 and 11. (owner) +- 2026-10-23 Capability requirements: each catalog doc's `## Requirements` gains `### Focus` (all keyboard handling and focused appearance included), `### Pointer`, `### Scroll` (interactive scrolling only), and `### Clock` subsections, present only where the component needs that capability, enforced by the IWE design schema. A requirement needing several capabilities goes under the one that lands last; negative assertions ("installs no responder") need the machinery, so they go in the subsection too. Slices 10, 11, 12, and 17 start by making every already-built component's subsection pass. (owner) - 2026-10-23 Decision 5, the terminal colour-report probe: a slice of this plan (Slice 1) that depends on no other slice and runs in parallel, so styling is built and reviewed in the `derived` tier; the harness answers colour queries from the scenario's `term` header so goldens never depend on Ghostty's defaults. (owner) - 2026-10-23 Decision 8, the coverage walk: the levels `shortcut`, `tab`, and `unreachable`, the shortest route as key-and-count steps, and the stop reason, without Ternbook's app-only levels and landmarks. The rule behind it covers every tool: an output field earns its place when it lets an agent find and fix a problem in fewer turns with less context. (owner) - 2026-10-23 Slices renumbered: the colour-report slice is Slice 1, and Flow and `ViewThatFits` move from Slice 10 to Slice 7, so former Slices 1-5 are now 2-6, 6-9 are 8-11, 10 is 7, and 11-19 are 12-20; entries above use the new numbers. (this plan) @@ -271,29 +280,42 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 `HarnessCLI` lives in `TesseraHarness`, and `TesseraDev` is a one-line `main.swift` over `Demos.registry()`. The slice text says `TesseraDev` "is created here as `HarnessCLI.main(registry:)`" and the Slice 3 note says Slice 4 writes `HarnessCLI` in `TesseraDev`, but the stable surface lists `HarnessCLI` among the `TesseraHarness` names and an app author's executable calls it, so it has to be in the library. `main` is `@MainActor`, because the registry's factories run there. `apps` prints a table on a terminal and one sorted-key JSON object otherwise (`--json` and `--pretty` force either); `launch` refuses unless standard input and output are both terminals, checked before the terminal changes mode, so a test or a pipe never opens a live session and a closed stdin does not leave a half-entered lifecycle to tear down; a failure is exit 2 for a usage error and 3 for a harness error, as JSON `{command, exitCode, message}` when JSON is on. `minimum` in the `apps` listing and `command` in a failure are `null`, never absent, so every entry has the same keys. Only the commands and flags are promised, so those shapes are free to change. (this plan, Slice 4 run) - 2026-10-24 `mobile` height is the root's measured rows when no height is proposed, and an app has none if its root measures differently at 500 and at 1000 rows (owner to weigh the probe heights). `ViewGraph.measuredRootSize(proposing:)` is the `package` nil-height seam: it leaves the statistics alone and invalidates layout, so the same graph can lay out afterwards. Two tall probes replace the spike's `rows == probe` test, which could not tell a greedy root from one exactly as tall as the probe. The harness reads the app's `body` for the probe and nothing else, so one factory call serves the probe and the run. (this plan, Slice 4 run) - 2026-10-24 Registry reads fail closed on an exact duplicate name only. A duplicate fails `checkedEntries`, `names`, and `entry(named:)` (also for a unique name), and so `apps`, `launch`, and a scenario run; `register` never throws, and nothing validates an empty `viewports` list or a name's spelling. `minimum` and `viewports` are stored and listed, not enforced: a scenario may size an app below its floor to check how it degrades, and the catalog says each component declares that degradation. (this plan, Slice 4 run) -- 2026-10-24 The driver does not reconcile terminal requirements after a draw (owner to weigh). The review loop's `ApplicationDriver` applied `graph.terminalRequirements` (mouse tracking, the keyboard protocol, focus and paste reporting) to the session after each frame; nothing at HEAD produces a requirement, and `dispatch` routes nothing, so the port stops at the step order and the update policy. Slices 9 and 10 add the reconciliation when focus and pointer input exist, in the one `Driver` both hosts share. (this plan, Slice 4 run) +- 2026-10-24 The driver does not reconcile terminal requirements after a draw (owner to weigh). The review loop's `ApplicationDriver` applied `graph.terminalRequirements` (mouse tracking, the keyboard protocol, focus and paste reporting) to the session after each frame; nothing at HEAD produces a requirement, and `dispatch` routes nothing, so the port stops at the step order and the update policy. Slices 10 and 11 add the reconciliation when focus and pointer input exist, in the one `Driver` both hosts share. (this plan, Slice 4 run) - 2026-10-24 The registry and demo shapes. The two spike fixtures (`StackApp`, `WorkspaceApp`) moved out of the library into `Tests/TesseraHarnessTests/Fixtures.swift` as `Application` types, with a greedy fixture, counters of each adoption shape, and a still app, so `TesseraHarness` no longer imports `TesseraLayout` or `TesseraWidgets` and its allowlist row dropped them. `Demo` requires `name`, `minimum`, `viewports`, `fixtures`, and `init()` with no defaults; a `DemoFixture` is a name and a main-actor factory that inherits the demo's minimum and viewports; `Demos.registry()` is the one list. The counter (32x5 minimum, `desktop` and `mobile`) quits on `q` through `isQuitKey`, which compares code, modifiers, and kind, and has one fixture (`counter-high`). `ActorHost` is the per-test actor shell; its initializer is `async`, because a synchronous actor initializer is nonisolated and cannot pass `self` as the step's isolation. The driver, the three hosts, the natural-height probe, the headless device, and the stream shim are internal rather than `package` (critic review of PR #65, F2): nothing outside `TesseraHarness` needs them, since the Examples tests go through `withHeadlessHost` and the `@testable` import reaches internal declarations anyway; the step, the registry's reads, `ScenarioHost`, and `withHeadlessHost` are `package`. If a later slice needs a host from another target, widening it is one keyword. (this plan, Slice 4 run) - 2026-10-24 The Examples tests run in CI from this slice (critic review of PR #65, F4). `ci.yml` gained a `Test Examples` step after `Test` (`swift test --package-path Examples`, on every leg), and `ci:build-test` the same line, so the counter's snapshot tests and the quit-key tests cannot regress unseen; Slice 5 adds the scenario corpus run to it. (this plan, Slice 4 run) -- 2026-10-24 Slice 5 refuses what it cannot honour, by name (owner to weigh). The slice's grammar sentence lists `dump`, `expect cell`, `expect cost`, and `check`, and its CLI sentence lists `check` and `replay`, while Slice 6 builds their engines (the projection, the cost counters, the invariant rules, the trace) and Slice 8 the roles. Executing them here would be a stub, and a bare `check` over zero rules would pass vacuously, which a scenario author reads as assurance. So the README's "Not yet expressible" table holds `wait`, `settle`, `focus`, `mouse`, `hit`, `dump`, `check`, `expect cell|cost|focused|wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, `term glyphs=|keyboard=|theme=probe`, each a parse error that says what it waits for, and `ScenarioGrammar.notYetExpressible` is the same list, held to the table by a test. `tessera check` and `tessera replay` are real commands with real help and real usage errors that exit 2 with "not yet available"; Slice 6 replaces the refusals. Superseded 2026-10-24 for the commands and the refusal text by the help-text entry below. (this plan, Slice 5 run) +- 2026-10-24 Slice 5 refuses what it cannot honour, by name (owner to weigh). The slice's grammar sentence lists `dump`, `expect cell`, `expect cost`, and `check`, and its CLI sentence lists `check` and `replay`, while Slice 6 builds their engines (the projection, the cost counters, the invariant rules, the trace) and Slice 9 the roles. Executing them here would be a stub, and a bare `check` over zero rules would pass vacuously, which a scenario author reads as assurance. So the README's "Not yet expressible" table holds `wait`, `settle`, `focus`, `mouse`, `hit`, `dump`, `check`, `expect cell|cost|focused|wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, `term glyphs=|keyboard=|theme=probe`, each a parse error that says what it waits for, and `ScenarioGrammar.notYetExpressible` is the same list, held to the table by a test. `tessera check` and `tessera replay` are real commands with real help and real usage errors that exit 2 with "not yet available"; Slice 6 replaces the refusals. Superseded 2026-10-24 for the commands and the refusal text by the help-text entry below. (this plan, Slice 5 run) - 2026-10-24 Scenario files end in `.scn`, and a scenario's goldens are `NAME.txt` in a directory named like the file without its extension (owner to weigh). The architecture page said `.scenario` while `.gitattributes` and the spike said `.scn`, and "beside the scenario" put two scenarios' `shot desktop` on one file. `counter-basics.scn` keeps `counter-basics/reset.txt`, so a shot name is unique only within its scenario, `run` needs no rule to tell a golden from a scenario, and the one extension is the one `.gitattributes` already sets to LF (`Examples/Scenarios/**/*.txt` joins it). The flat alternative is `STEM.NAME.txt`; it costs one function. A golden is the screen with each row's trailing spaces removed, trailing empty rows dropped, rows joined by `\n`, and one final `\n`; comparison is byte for byte, a missing golden fails like drift (`--record` creates it), and a CRLF golden fails with a message that says to check it out with LF. (this plan, Slice 5 run) - 2026-10-24 Exit codes come from failure verbs, and the JSON shapes are the promise (owner to weigh). A failure's `verb` is the statement's own (`expect text`, `expect screen`, `expect node`, `shot`, and the input verbs), `size` (the app cannot take the size), `repeat` (a run differed), `parse`, `usage`, or `harness`; exit 1 is any of the first group, 2 is `parse` or `usage`, 3 is `harness` (a duplicate registration, an event the host could not deliver, a golden that could not be written), and the highest of a run wins, so an agent can derive the code from the JSON it already holds. `run` writes `{"command", "exitCode", "scenarios": [REPORT...]}` and a failure of the command itself `{"command", "exitCode", "message"}`, as `apps` and `launch` do; a REPORT is `{"failures", "observations", "passed", "scenario"}` with `recorded` only when `--record` wrote goldens and `seconds` only under `--timing`; a failure is `{"line", "statement", "verb", "message"}` with `expected`, `actual`, and `screen` when it compares values (a missing or CRLF golden leaves out `screen`). A `parse` failure's `message` is the bare message, with the offending line in `statement`, and the human form prints `FILE:LINE: error: message`. `ScenarioReport` and `ScenarioFailure` are not `Encodable` publicly, so the promised surface is the fields and not a conformance. For `shot` and `tree`, a size the app cannot take is exit 2 (a mistake in the command) where a scenario's `size` line is exit 1 (an expectation that failed). (this plan, Slice 5 run) - 2026-10-24 The `app` header stays required (settles the Slice 4 question): a registry is a value, a one-app registry may grow, and the line costs nothing. The runner reads a leading byte order mark and a CRLF file, and splits lines on the `\n` scalar so the `"\r\n"` character cannot hide a line end. -- 2026-10-24 The `term` header builds `colors=none|16|256|true` and `theme=light|dark` and refuses `glyphs=`, `keyboard=`, and `theme=probe` (owner to weigh). `colors` becomes the session's forced colour capability; the library has no ASCII glyph fallback, no keyboard-protocol-dependent routing, and no roles yet, so those three would be accepted and ignored, which the grammar never does. No colour exists in the library to observe `colors` either (styling is Slice 8), so it is pinned by its mapping tests. The harness answers a session's colour queries from the header (Decision 5): the headless device parses what the session writes, answers OSC 10, 11, and 4 from fixed values (`dark` `#d4d4d4` on `#1e1e1e`, `light` `#1e1e1e` on `#ffffff`, xterm's slots 0-15 for both) echoing the query's terminator, answers DA1 always so a probe round ends at once, and answers nothing else; without a `theme` it answers DA1 only, which is "colours unknown". The headless session still detects capabilities passively, and the colour round is Slice 1's (PR #60, not in this branch), so nothing asks yet: the answering is pinned at the device with the exact bytes of Slice 1's `colorQueryBytes`, and switching the headless host to active detection is one line once #60 merges, when Slice 8 first reads colours. (this plan, Slice 5 run) +- 2026-10-24 The `term` header builds `colors=none|16|256|true` and `theme=light|dark` and refuses `glyphs=`, `keyboard=`, and `theme=probe` (owner to weigh). `colors` becomes the session's forced colour capability; the library has no ASCII glyph fallback, no keyboard-protocol-dependent routing, and no roles yet, so those three would be accepted and ignored, which the grammar never does. No colour exists in the library to observe `colors` either (styling is Slice 9), so it is pinned by its mapping tests. The harness answers a session's colour queries from the header (Decision 5): the headless device parses what the session writes, answers OSC 10, 11, and 4 from fixed values (`dark` `#d4d4d4` on `#1e1e1e`, `light` `#1e1e1e` on `#ffffff`, xterm's slots 0-15 for both) echoing the query's terminator, answers DA1 always so a probe round ends at once, and answers nothing else; without a `theme` it answers DA1 only, which is "colours unknown". The headless session still detects capabilities passively, and the colour round is Slice 1's (PR #60, not in this branch), so nothing asks yet: the answering is pinned at the device with the exact bytes of Slice 1's `colorQueryBytes`, and switching the headless host to active detection is one line once #60 merges, when Slice 9 first reads colours. (this plan, Slice 5 run) - 2026-10-24 The ported corpus runs against Tessera's own registry, so the spike's two apps became demos (owner to weigh): `Examples/Sources/DemoSupport/Stack.swift` (`stack`, 20x6 minimum, `desktop` and `mobile`) and `Workspace.swift` (`workspace`, 40x10 minimum, `desktop` and `mobileScreen`), with the spike's key-name helper written out in `Workspace`, since the harness's is internal. The four passing scenarios moved with `git mv` and their text unchanged; `counter-basics.scn` is new and uses `term`, `expect screen`, `bytes`, and `paste`. `Tests/TesseraHarnessTests` keeps its own `StackApp` and `WorkspaceApp` as fixtures and `failing/mismatch.scn`, which must not sit under `Examples/Scenarios` because CI runs that directory green; Slice 6's per-primitive demos may absorb the two demos. The counter's tests stay on `withHeadlessHost`, which reads `isFinished` and `size` that no statement can yet; `ScenarioCorpusTests` runs the corpus through the public `ScenarioRunner`, the route an app author uses. (this plan, Slice 5 run) - 2026-10-24 `--repeat N` records on its first run only and compares every later run's report (with `recorded` and `seconds` removed) with the first, so a divergence is a `repeat` failure, exit 1, and a nondeterministic app shows up at the golden as well. `--timing` adds `seconds`, one rounded number per run, the only field that differs between two runs. `ShotArtifact` and the spike's `--out` are gone: goldens are the artefact, and `.build/tessera/` stays unused until pictures exist. A `\xHH` escape in a double-quoted word is ASCII only (00-7f), so a scenario never builds invalid UTF-8; a zero-scenario directory or a path that is not a `.scn` file is a usage error, so a wrong path in CI cannot pass for a green run. (this plan, Slice 5 run) - 2026-10-24 CI runs `tessera run --pretty Examples/Scenarios` as a step of the `test` job, so every leg of its matrix runs it, the three required names (`macos-26`, `ubuntu-latest`, `windows-latest`) and the Swift 6.4 lanes included, and no new required check name is needed (Decision 7); `--pretty` keeps the log readable. `ci:build-test` runs the same line. (this plan, Slice 5 run) - 2026-10-24 Critic review of PR #66, decisions the findings settled (owner to weigh). A scenario ends when its app finishes (F1): `LiveHost` returns the moment `isFinished` is true, so the player does too. The statement that finishes the app is the last that runs and the rest of its input is dropped (`type "q+"` stops after `q`); any statement after that fails with its own verb (exit 1), with a message naming the line that finished the app and no `screen`, and the run stops there, so no `expect`, `shot`, or `tree` after a quit is evaluated; an app already finished at the start fails its first statement. The README says "any statement" and notes that statements asserting the finish itself may be added later, so a future `expect finished` is additive and not a contradiction. The final frame after a quit therefore cannot be asserted, only what the app showed before the statement that quit. Shot names are unique ignoring case (F2), a parse error naming both lines, because goldens are files and APFS and NTFS fold case. A golden containing a carriage return is a CRLF failure before any row is compared (F3). The `expect screen` closing line may carry trailing spaces or tabs, and nothing else (F6). `check` and `replay` validate their app name before they refuse (F7), so only the refusal changes when the rules exist. `--record` records every `shot` the run reaches, including after a failed expectation, and the run still fails; the README says so. The README and the `run` help now name every promised JSON field (F5): the `recorded` entry shape, `line` 0 with an empty `statement` for file- and command-level failures, `--repeat N` as a `repeat` failure's statement, and that a missing or CRLF golden carries no `expected`, `actual`, or `screen`. (this plan, Slice 5 run) +- 2026-10-24 Slice 6 builds the engines Slice 5 refused and removes the refusals (owner to weigh; this supersedes the Slice 5 entry on refusals). `dump`, `check`, `expect cost`, `tessera check`, and `tessera replay` run, and the README's not-yet-expressible table and its parity test now list only the forms that still wait: `wait`, `settle`, `focus`, `mouse`, `hit`, `expect cell`, `expect focused`, `expect wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, and the `term` keys `glyphs=`, `keyboard=`, and `theme=probe`, each a parse error that names what it waits for. `tessera check` keeps Slice 5's argument validation and exit codes. (this plan, Slice 6 run) +- 2026-10-24 Four of the five rules are errors and `frame-inside-clip` is a warning (owner to weigh). A bare `check` runs all five in a fixed order; an error fails the step (exit 1, the failure's `rule` and `node` fields name it), a warning is listed in the step's observation and does not fail it, and naming the rule (`check frame-inside-clip`) makes it strict. The library lets content leave its clip by contract (a fixed frame wider than its parent, a Flex child that overflows, `Text`, which does not wrap at HEAD), so the rule shows the overflow without failing every demo that has one. `siblings-disjoint` compares the cells each sibling shows (its frame intersected with its clip), not the frames: a Flex child clipped to its segment is not an overlap, and two children placed on one frame (the `ForEach` defect) are. `ZStack` is exempt from it as a container that overlaps by contract, and `frame-inside-clip` skips descendants of a `ScrollView`; a new overlay or scrolling container must join those two lists. A rule added after 1.0 runs when named and joins bare `check` at the next major version, so a release cannot fail an app's CI with a rule it never asked for. (this plan, Slice 6 run) +- 2026-10-24 `text=` belongs to a leaf by the cells it shows, not by frame containment, and `id=` takes the app's own id or else a full path (owner to weigh). `text="S"` matches the leaf whose own cells show S: a container has no text, and text that a later layer paints over matches nothing, because a cell belongs to the last leaf in paint order that covers it. `id=X` is the `X` of `.id(X)` or of a `ForEach` key, or else the node's full identity path; `layout=NAME` is the short algorithm name of a `_LayoutContainer` (`_FrameLayout`, `_PaddingLayout`, or an app's own `Layout` type). A selector that matches nothing, or several without `nth=`, fails and names what is present. (this plan, Slice 6 run) +- 2026-10-24 The trace is one sorted-key JSON object per step, and its switch is a flag (owner to weigh; the slice names the record and not the switch). `tessera run --trace DIR` writes `DIR/PATH.trace.jsonl`, mirroring the scenario's path with parent components dropped so a file stays inside DIR, and a live session writes the file `TESSERA_TRACE` names (exit 3 before the terminal changes mode when it cannot). A record has `version`, `seq` (0 is the first frame), the `event` (a key, paste, or resize in the scenario grammar's words, or `other`), `drew`, the invalidation `reasons`, the `nodes` added, changed, and removed, the screen `rows` that changed, and the `cost`. `tessera replay FILE --app NAME` runs the events again at the recorded size and compares reasons, nodes, rows, and cost but not bytes, which belong to the terminal; a trace holding an `other` event (a mouse event, a terminal reply) is a usage error (exit 2), since the grammar cannot say it yet. Neither the flag nor the record is promised: the record carries a `version` and may change in any release. (this plan, Slice 6 run) +- 2026-10-24 `expect cost NAME<=N…` bounds the last step the scenario took, and only with `<=` (owner to weigh). The counters are `bodies`, `bytes`, `measures`, `placements`, and `renders`; the step is the last event delivered, so the statement can follow a `shot` or an `expect`; a failure prints the whole record (`bodies=0 bytes=430 measures=143 placements=34 renders=18`). `bytes` is the size of the step's frame, read from a new `package` property, `TerminalSession.bytesDrawn`, which the session sets when a draw commits, so the harness needs no lock and no output tap. The counts are the library's work and not a promise, so the corpus bounds them loosely (`foreach-desktop.scn` allows 1,000 bytes for a step that takes 430). (this plan, Slice 6 run) +- 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 9's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) +- 2026-10-24 Per-primitive demos absorb Slice 5's `stack` demo and are written to be read at 40 columns (owner to weigh). `stacks` replaces `stack` (and `stack-mobile.scn` with its goldens) and shows stacks, `Spacer`, and `layoutPriority`; the other five are `text` (the archived Unicode samples, kept short), `frame-padding`, `zstack`, `flex`, and `foreach`. Each takes a desktop and a mobile scenario that shots both, asserts one frame, and mostly runs `check`; a demo that overflows on purpose (`frame-padding`, `flex`) names the rules it passes, and a demo shows every variable style it has (alignments, padding, priorities, weights). (this plan, Slice 6 run) +- 2026-10-24 Critic review of PR #67, decisions the findings settled (owner to weigh). `siblings-disjoint` looks through view lists (F1): the container that decides whether items overlap is the nearest ancestor that is not a `ForEach`, an `if` (`Optional` or `ConditionalView`), a tuple, or an empty view, so the layers a list or an `if` builds under a `ZStack` are layers and the same items under a stack are not. A list is recognised by its module-qualified type (`TesseraCore.ForEach`, `Swift.Optional`, and so on, decided in `TypeName` before the name is shortened and carried as the projection node's `isList`), so an app's own view named `ForEach` is an ordinary composite and is not looked through, and a new list type joins that set; the overlay and scrolling containers (`ZStack`, `ScrollView`) are still matched by their short name, so a view of an app's own named `ZStack` would be excused the same way, which only matters for a container-less body under it (owner to weigh). A body of several views with no container around them overlaps by the library's own placement, so `siblings-disjoint` reports it as a true positive, and the README says so. The grid golden has one row rule (F2), pinned before any golden has a style: the chars section ends at the last row with text or a style, with an empty line for a styled blank row, the style section ends at the last row with a style, and `NAME.txt` still ends at the last row with text, so the catalog dialect's "no more style rows than chars rows" holds by construction. A grid has 62 letters, and a screen with more distinct styles fails its `shot` as a harness error (exit 3, in `--record` too), because one letter for two styles would make the golden ambiguous (F3); a longer alphabet can be added later without drifting any golden that fits in 62. `run --pretty` prints a bare `check`'s warnings as `line N: warning RULE NODE: DETAIL` under the scenario's line and before its failures (F4). An empty path, name, or flag value (`--trace ""`, `--app ""`, `run ""`) is a usage error, exit 2 (F5), and so is an empty locator value, since `text=""` would match every leaf and the other keys nothing; `text=` is a substring match, and the README, the Developer tooling page, and the `tessera` skill say so (F11). A trace record's `version` is read before the rest of the line (F9), so a later schema is named by its version. A live trace records the terminal's focus changes as `focusGained` and `focusLost`, and replay delivers them (the review's open question): the library enables focus events by default and some terminals send a focus-in as soon as the mode is on, which would make every live trace unreplayable. The scenario grammar still has no statement for them, and mouse events and terminal replies stay `other` and are refused by name. The `flex` demo gained a second page (a `layoutPriority` tier, a child with no `.flex` value, cross-axis placement), the `stacks` demo a `Spacer(minLength:)` row and a `Spacer` in a `VStack`, and the `zstack` demo layers from a `ForEach` and an `if` (F6). (this plan, Slice 6 run) - 2026-10-24 The `tessera-devtools` skill is split in two (owner, Q13 option (a)). `.agents/skills/tessera/` is the published skill for app authors: the `tessera` CLI and `TesseraHarness` as an app author sees them, with one `| tessera | Does |` table, no repository paths, `mise`, demos, `DemoSupport`, or `@testable` API, and the rule that a Tessera bug is reported and not worked around. `.agents/skills/tessera-contributing/` is the in-repo contributor skill: it opens by saying everything in `tessera` applies, then adds a `| mise | tessera | Does |` table (`mise run dev:*` builds and runs the `tessera` executable), the demos and `DemoSupport`, adding a demo with its `withHeadlessHost` snapshot test, and the rule that the library is fixed with a regression test and a demo is never bent. Both skills describe only what exists, written as the finished current skill, with no future work in them. The published skill sits beside the repository's other skills so agents working here find it, and it links to nothing outside its own directory, so publishing it later is a copy of that directory; there is no symlink, because Windows checkouts handle them badly. `tessera-contributing` is named for who it serves (the repository has a `CONTRIBUTING.md`), where `tessera-devtools` named the tools and would now sit confusingly beside `tessera`. How the `tessera` skill is distributed is settled with Decision 9 (iii). This supersedes the skill sentences of the 2026-10-22 entry on the tools' design. (owner) - 2026-10-24 Help and error text is self-contained (owner). `tessera help`, every usage and parse error, and the public doc comments of `TesseraHarness` hold no repository path, plan language, or promise of later work, because the CLI is part of the 1.0 surface that app authors use and they do not have this repository. `tessera check` and `tessera replay` are not commands in Slice 5, so they are an unknown command (exit 2, naming the commands that exist), and their `mise` tasks are gone; Slice 6 adds them. A reserved scenario form reads "`wait` is not a statement a scenario can use; accepted: ..." and no longer names a README or what the form waits for, and `help run` lists the statements, so no pointer is needed. The README's "Not yet expressible" table stays as the design's list, held to `ScenarioGrammar.notYetExpressible` by the parity test. This supersedes the Slice 5 refusal entry for the commands and the refusal text. (this plan, PR #66 follow-up) - 2026-10-24 The `tessera` CLI adopts swift-argument-parser, and the `dev:*` tasks complete and explain themselves (owner, review of PR #65). `HarnessCLI` declares its commands with `apple/swift-argument-parser` (`from: "1.8.2"`, the newest release, which fixes fish completion scripts) now, before the CLI surface freezes at 1.0. Only `TesseraHarness` depends on it, and `scripts/check-package-boundaries.py` allowlists that one product; `Tessera`, core, layout, widgets, and the terminal targets do not. The stable surface is unchanged: exit codes 0 to 3 (`HarnessCLI` maps ArgumentParser's usage exit 64 to 2), one JSON object when stdout is not a terminal or under `--json` and the text form under `--pretty`, `{"command","exitCode","message"}` for a failure, the command and flag names, and `help [COMMAND]`; help text and parse-error wording become ArgumentParser's. The executable completes app names itself through ArgumentParser's custom completion, with `--generate-completion-script SHELL` for fish, zsh, and bash, so `tessera launch ` works under each. A `dev:*` task run with no app name prints the command's help and the registered apps instead of mise's "Missing required arg" error, and exits 0 at a terminal and 2 in a pipe or a script, as `tessera` does, and `mise run dev:launch ` completes the names, through the usage spec's `arg "[name]"` and `complete "name" run=...`, from the already-built `Examples/.build/debug/tessera`, printing nothing rather than building when there is none. (owner) -- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-18 build that and nothing forecloses the others; settle before Slice 19. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 19. +- 2026-10-25 Q14, the first demos' library problems get their own slice: the Slice 6 demos exposed them (the dirty `@ViewBuilder` root, `layoutEnvironmentChanged` on every step, the `Spacer` with `layoutPriority(1)`, `Flex`'s inverted fill weights and missing alignment; see Discoveries), so a new Slice 7, Library fixes from the first demos, follows Slice 6 and precedes Flow and `ViewThatFits`. Slices 7-20 become 8-21: Flow and `ViewThatFits` 7 to 8, styling 8 to 9, focus 9 to 10, mouse 10 to 11, ScrollView 11 to 12, roles and labels 12 to 13, controls 13 to 14, collections 14 to 15, SplitView 15 to 16, the animation path 16 to 17, remaining views 17 to 18, live inspection 18 to 19, docs and the conformance gate 19 to 20, the intro app 20 to 21. Entries above use the new numbers, as at the 2026-10-23 renumber. (owner) +- 2026-10-25 Q15, layout priority reserves the lower tiers' minimums: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), instead of the whole remainder, so a high-priority `Spacer` cannot push lower tiers past the edge. This amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority tests; Slice 7 makes the amendment. (owner) +- 2026-10-25 Q16, `Flex` gains cross-axis alignment: `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, placing a child smaller than the cross extent relative to the axis, like CSS `align-items`. There is no `stretch` and no main-axis justification for now. The default keeps today's frames. `docs/design/primitives/flex.md` and the Flex spec are updated in Slice 7. (owner) +- 2026-10-25 The rules' container exemptions match by module: projection nodes gain a `module` field, and `siblings-disjoint` and `frame-inside-clip` exempt only Tessera's own `ZStack` and `ScrollView`, as view lists are already matched by module-qualified name, so an app type with the same short name is held to the rules. Closed in Slice 7 rather than left as a known gap. (owner) +- 2026-10-25 Q18, the grid golden's style keys are not capped at 62: after `0-9`, `A-Z`, and `a-z`, keys continue through a short published list of Unicode ranges of single-width letters that do not look like ASCII (Latin Extended-A, U+0100-U+017F, then further ranges as needed), assigned in order of first appearance; the dialect states the ranges, not a character list, and still puts one key under each cell. Slice 9 makes the change and amends the dialect in `docs/design.md` and the harness's grid documentation, since styling is where screens first need more than 62 styles. (owner) +- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-19 build that and nothing forecloses the others; settle before Slice 20. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 20. ## Discoveries - `ForEach` and other view lists inside stacks laid every item at the same frame (spikes 1, 2, and 4; plan 033) — fixed by plan 034 Step 10.1 (`f4172e8`), with `siblings-disjoint` as the harness rule that would have caught it. -- A `SplitView` pane whose content is a nested multi-view list now places only its first view (plan 034 Step 10.1 discovery) — Slice 15 settles the rule. -- `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 8. -- `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 9. +- A `SplitView` pane whose content is a nested multi-view list now places only its first view (plan 034 Step 10.1 discovery) — Slice 16 settles the rule. +- `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 9. +- `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 10. - The seams spike's `sizeChanges` fan-out was superseded by plan 034 Step 6.6, which fans one subscription out newest-only — Slice 2 adapts the patch rather than applying it. - A `@MainActor` host could not present through `TerminalSession.draw`: its closure and result were `sending`, so a main-actor caller could not send its graph in (decision 9 (i) record, result 4). The runtime isolation decision relaxes `draw` to `nonisolated(nonsending)` — Slice 2. The Phase 5 sketch of `TestRuntime(scene:)` did not compile either (`borrowing var body`, an undefined `EventRouting`) and is gone from the spec. - A device whose input has finished (the default `InMemoryTerminalDevice`) never waits on a probe timeout: `finishInput()` marks the coordinator closed and every round resolves at once, so an injected `probeSleep` is observable only on an open device. The seams README's "waits out `activeProbeTimeout`" holds for an input stream that never finishes. Slice 2's probe-sleep test uses `keepsInputOpen` for this reason. @@ -312,7 +334,7 @@ Rules for an agent running slices of this plan without the owner. - SwiftPM names a module's package after the root package's identity, which is its checkout directory (`phase_4_04_driver` in this worktree, `tessera` on CI), and `package` declarations are visible only to a client with the same `-package-name`. `scripts/check-isolation-fixtures.sh` reads the identity from `swift package show-dependencies --format json`, so the fixture that calls the `package` step compiles in any checkout. - After a new file is added to a path dependency, `swift build --package-path Examples` can keep compiling the old file list (`error: cannot find type …` from a file that exists); deleting `Examples/.build/debug.yaml` regenerates it. CI builds from clean trees and is not affected. - A graph rooted at an `@ViewBuilder` body is dirty after every `update()`: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked, while an equatable subtree below it is skipped. The driver therefore draws after every step, and an unchanged frame costs a cursor hide and show (10 bytes) and no cell. This is the library's invalidation granularity, so the harness does not compensate; the cost counters of Slice 6 will show it, and a fixture whose body returns its root explicitly (`StillApp`) proves the driver does skip a clean graph. -- `extension MyApp: Application {}` for a type declared in another module (a test or development target, the shape the docs recommend) draws Swift's retroactive-conformance warning, because both the type and the protocol are imported. `@retroactive` silences it, and `@retroactive @MainActor Application` and `@MainActor @retroactive Application` both compile. The `Application` docs and the spec's adoption section do not say so; Slice 19's app-author path should. +- `extension MyApp: Application {}` for a type declared in another module (a test or development target, the shape the docs recommend) draws Swift's retroactive-conformance warning, because both the type and the protocol are imported. `@retroactive` silences it, and `@retroactive @MainActor Application` and `@MainActor @retroactive Application` both compile. The `Application` docs and the spec's adoption section do not say so; Slice 20's app-author path should. - Registration-site errors, checked against the real signature: registering a main-actor app from a nonisolated context fails with "main actor-isolated conformance of … to 'Application' cannot be used in nonisolated context"; a nonisolated factory function passed as `make` fails with "converting non-Sendable function value to '@MainActor @Sendable () -> …' may introduce data races"; a closure literal marked `@Sendable` at the call site is inferred main-actor from the parameter and compiles, since it is no more than a main-actor closure that is also `Sendable`. - The isolated-conformance diagnostics read the same on Swift 6.3.3 and 6.4: an actor host stepping a main-actor app says "main actor-isolated conformance of 'Browser' to 'Application' cannot be used in actor-isolated context" on both, and the nonisolated wording appears only for a nonisolated caller. The fixture script matches the prefix they share. Swift Build (the default from 6.4) writes modules to `/out/Products/Debug`, which the script's candidate list already handles. - `quality:format` runs SwiftLint's `--fix`, whose `duplicate_imports` correction deletes one of `import X` and `@testable import X` from a file that has both, and it deleted the `@testable` one in three new tests; a test file imports `@testable import TesseraHarness` alone. `quality:format` does not cover `Examples/Tests`, so `Examples/Tests/Phase3ProtocolsDemoSupportTests` keeps two long lines that the commit hook warns about. @@ -327,6 +349,18 @@ Rules for an agent running slices of this plan without the owner. - The reports an agent reads, as `tessera run … > report.json` wrote them (rows elided). A pass, `Examples/Scenarios/counter-basics.scn`: `{"command":"run","exitCode":0,"scenarios":[{"failures":[],"observations":[{"line":26,"lines":["Counter","────…","Count … 0","────…","+ up - down 0 reset q quit"],"name":"reset","verb":"shot"}],"passed":true,"scenario":"Examples/Scenarios/counter-basics.scn"}]}`. The CI probe, the same file with `expect text 2` asking for a count of 4 where the screen shows 3, exit 1: `{"command":"run","exitCode":1,"scenarios":[{"failures":[{"actual":"Count … 3","expected":"Count … 4","line":19,"message":"row 2 text differs","screen":["Counter","…","Count … 3","…","+ up - down 0 reset q quit"],"statement":"expect text 2 \"Count … 4\"","verb":"expect text"}],"observations":[…],"passed":false,"scenario":"Examples/Scenarios/counter-basics.scn"}]}`. A parse error in `bad.scn`, exit 2: ``{"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"bad.scn"}]}``. - Anything a test reads from a repository file and searches with Foundation sees the platform's line endings on Windows: `components(separatedBy: "\n## ")` found no section in a CRLF checkout of `README.md`, because "\r\n" is one `Character` and a search for "\n" does not match inside it, so the README table test (and any future test that reads a Markdown file) splits at the `\n` scalar, as the parser does. Only scenario files and goldens have a `.gitattributes` rule; the rest of the repository keeps Git for Windows' default. - Once the headless host switches to active detection, a probe round the answerer does not answer waits out `activeProbeTimeout` for every scenario. The answerer replies to colour queries and to DA1, so the colour round and every round that ends on a DA1 end at once; the DECRQM round ends on its DA1 only with the sentinel fix of PR #63, and without it the round would also need `CSI ? Ps ; 0 $ y` replies. Check this against the real session from Slice 1 before flipping the switch. +- A resize's viewport reasons were dropped by the next `update()`: `resize` recorded `layoutViewportChanged` and `renderViewportChanged`, and `update()` reset them before the pass that draws, so a resize redraw named `updateRequested` only. Fixed in `6781c57` (the graph keeps `viewportChangedSinceRender` until the next render), with a test in `DiagnosticsTests` and a line in the Slice 1 and Phase 5 specs. +- Every step of a demo built on stacks reports `layoutEnvironmentChanged` and `renderEnvironmentChanged`: stacks rebuild their children's `EnvironmentValues` on each update, and reconciliation compares environment storage by identity (`===`), so an environment that did not change reads as changed. The trace names it on every redraw of `stacks`, `flex`, and `foreach`, so no scenario can bound the reasons yet; the library should compare values or keep one storage across updates (owner to weigh). +- The headless virtual terminal lacks DEC mode 2027, so a ZWJ emoji, a regional-indicator pair, and a variation-selector-16 sequence advance a different number of cells than Tessera's width table says (`👩🏽‍💻` takes 6 cells in the terminal and measures 2), and Tessera neither enables mode 2027 nor repositions the cursor after a wide grapheme. The `text` demo keeps its Unicode lines short and puts the samples where a drift cannot shift a neighbour, and its goldens show the terminal's cells (owner to weigh). +- A `Spacer` with `layoutPriority(1)` in `HStack { L; Spacer.layoutPriority(1); M; Spacer; R }` takes all the remaining width and places `M` and `R` past the edge, because a higher tier allocates first (as the spec says) and a `Spacer`'s ideal is unbounded; the `stacks` demo also gives priority to its neighbours so every row fits (owner to weigh). +- `Flex`'s `min(n)` floor cannot compress a `Text` child, since `Text`'s minimum equals its ideal while it does not wrap (Slice 9), and when the children are larger than the space the fill weights invert (owner to weigh); the `flex` demo asserts today's frames. +- `measured-within-proposal` and `frame-inside-clip` fire by contract on a fixed frame wider than its parent, on a Flex child that overflows, and on `Text` that does not wrap, which is why `frame-padding` and `flex` name the rules they pass. The `workspace` demo at `mobileScreen` (40x16) fails `measured-within-proposal` on a collapsed pane's `Text`: `tessera check workspace mobileScreen` exits 1 with `measured 7 columns where 0 were offered` at `root/index(2)/id(catalog)/index(0)/index(0)` until Slice 9 wraps `Text`. +- As Slice 4 found, a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, so every step of every demo draws (`drew: true`); the cost counters show it, and a step that changes nothing still writes bytes. `no-dirty-after-pass` passes on the corpus because a completed pass clears every flag, and `ChecksTests` crafts a projection that still has a dirty node to prove it fails. The fixtures in `ProjectionFixtures` with an explicit root skip draws (`drew: false`), which is how a clean step's trace record and cost are tested. +- No view in the corpus sets a terminal requirement, so `requirements-aggregate` passes vacuously there; its tests use crafted projections, and Slice 11's pointer demo (any-event tracking requested only while a handler needs it) is its first live subject. +- Tooling: `mise run quality:format` and `quality:lint` skip `Examples/Tests`, but the pre-commit hook lints every staged Swift file, so format that directory by hand (`scripts/swift-format.sh format --configuration .swift-format -i` and `swiftlint lint --fix`) before committing. A `--scratch-path .build-27` directory is untracked and not ignored, so `git add -A` stages SwiftPM's checkouts as embedded repositories and the hook's stash of untracked files timed out on it; delete the directory before staging. `swift run --package-path Examples` without `--only-use-versions-from-resolved-file` rewrites `Examples/Package.resolved` (the mise tasks pass the flag). +- The layers of a `ForEach` or an `if` under a `ZStack` hang off a list node in the tree, so a rule that reads a node's parent sees `ForEach` where layout sees the `ZStack`: the first `siblings-disjoint` tested only the immediate parent and failed on `ZStack { ForEach … }` and on `ZStack { if … { A; B } }` (critic review of PR #67, F1). A rule that reasons about layout must look through the list nodes (`ForEach`, `Optional`, `ConditionalView`, `TupleView`, `EmptyView`) the way `makeLayoutSubviews` does; `frame-inside-clip` and `measured-within-proposal` read one node and are not affected. +- `Flex` takes no alignment: a child smaller than the cross extent always sits at the leading edge (`VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one, in `Flex.swift`), where `flex.md` says placement uses "the selected" alignment and the public initializer has no such parameter. The `flex` demo's second page shows what exists, and a scenario pins it (owner to weigh). +- A desktop viewport is 24 rows, so a demo that needs more pages: the `flex` demo's constraints and its priority, default, and cross-axis samples are two pages (`n`), and the `stacks` demo needs 22 rows, 24 with `v` pressed. The `text` demo's wrapped, truncated, and mode-truncated fixtures are not in it because `Text` does not wrap or truncate yet (Slice 9), and `text.md` now says so. ## Outcome diff --git a/docs/spec/module-and-file-layout.md b/docs/spec/module-and-file-layout.md index cbffb144..2311b2e3 100644 --- a/docs/spec/module-and-file-layout.md +++ b/docs/spec/module-and-file-layout.md @@ -151,7 +151,7 @@ Sources/ Viewport.swift // Viewport (public) HarnessCLI.swift // HarnessCLI.main(registry:) (public): parsing, dispatch, exit codes HarnessCLI+Commands.swift // apps, launch, and the ArgumentParser declarations they share (internal) - HarnessCLI+Scenarios.swift // run, shot, and tree (internal) + HarnessCLI+Scenarios.swift // run, shot, tree, check, and replay (internal) Driver.swift // main-actor shell over the step (internal) HeadlessHost.swift // a session over a headless device into one virtual terminal (internal) HeadlessTerminalDevice.swift // the device under a headless session; answers its terminal queries (internal) diff --git a/docs/spec/phase-4/slice-01-tesseracore.md b/docs/spec/phase-4/slice-01-tesseracore.md index 813ee5bc..8a263369 100644 --- a/docs/spec/phase-4/slice-01-tesseracore.md +++ b/docs/spec/phase-4/slice-01-tesseracore.md @@ -136,7 +136,7 @@ public final class ViewGraph { } ``` -`GraphStatistics` includes counts for nodes created/destroyed/updated, body evaluations, equatable skips, leaf updates, `sizeThatFits` calls, placements, rendered nodes, focus changes, terminal requirement changes, debug-only invalidation reasons, and pass durations. Anything developers need print statements to answer belongs here or in `dump()`. +`GraphStatistics` includes counts for nodes created/destroyed/updated, body evaluations, equatable skips, leaf updates, `sizeThatFits` calls, placements, rendered nodes, focus changes, terminal requirement changes, debug-only invalidation reasons, and pass durations. Anything developers need print statements to answer belongs here or in `dump()`. Each `update()` starts a fresh set of statistics, but a `resize(to:)` that changed the size since the last render pass keeps `layoutViewportChanged` and `renderViewportChanged` for the update that follows it, because the canonical loop resizes before it updates and the viewport is a cause of that pass. `ViewGraph` is non-`Sendable` and unisolated; whoever creates it owns it. The canonical immediate-mode loop (the Phase 5 runtime automates exactly this, nothing more): diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index 701c6feb..e5713f53 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -162,7 +162,7 @@ nonisolated(nonsending) public func draw(_ body: (borrowing Frame) throws -> R) async throws -> R ``` -Its transaction, in order: acquire the draw gate (on the session); begin the frame on the session (`checkOpen`, read the size, fix the rendering policy for this frame: color capability, hyperlinks, synchronized output, underline); allocate the frame storage and run `body` synchronously in the caller's isolation; commit on the session (`checkOpen` again, since the session's scope can have closed while the body ran; then encode the diff against the last drawn buffer, write, flush, record `lastDrawn`); release the gate. The policy commit point keeps its meaning: it follows the size suspension and precedes the body, so no setter can affect a frame once its bytes begin to be constructed. The `Buffer` and cursor that cross into the commit are values. A caller inside the session's isolation (`isolated TerminalSession`, today's examples) runs the body on the session and hops nowhere; a main-actor caller renders on the main actor and hops once to commit. +Its transaction, in order: acquire the draw gate (on the session); begin the frame on the session (`checkOpen`, read the size, fix the rendering policy for this frame: color capability, hyperlinks, synchronized output, underline); allocate the frame storage and run `body` synchronously in the caller's isolation; commit on the session (`checkOpen` again, since the session's scope can have closed while the body ran; then encode the diff against the last drawn buffer, write, flush, record `lastDrawn` and add the frame's byte count to `bytesDrawn`, the package seam that tells the harness what a step wrote); release the gate. The policy commit point keeps its meaning: it follows the size suspension and precedes the body, so no setter can affect a frame once its bytes begin to be constructed. The `Buffer` and cursor that cross into the commit are values. A caller inside the session's isolation (`isolated TerminalSession`, today's examples) runs the body on the session and hops nowhere; a main-actor caller renders on the main actor and hops once to commit. Everything else in the session is unchanged: `events` is already a `nonisolated let` stream a main-actor loop consumes directly; `withApplicationTerminal` still scopes the session's isolation around its body. A caller-isolated `withApplicationTerminal` overload is a convenience this phase may add; the runtime does not need it. diff --git a/mise-tasks/dev/check b/mise-tasks/dev/check new file mode 100755 index 00000000..157fe471 --- /dev/null +++ b/mise-tasks/dev/check @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +#MISE description="Check one app against the invariant rules (tessera check NAME [SIZE])" +#USAGE arg "[name]" help="App name, as listed by `mise run dev:apps`; without one, shows the help and the apps" +#USAGE arg "[size]" help="desktop, mobile, mobileScreen, or WxH; desktop when omitted" +#USAGE complete "name" run=#""$(git rev-parse --show-toplevel)/scripts/dev-app-names.sh""# +#USAGE complete "size" run=#"printf '%s\n' desktop mobile mobileScreen"# +#USAGE flag "--json" help="Print one JSON object even on a terminal" +#USAGE flag "--pretty" help="Print the text form even when output is not a terminal" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +source scripts/dev-tessera.sh +if [[ -z "${usage_name:-}" ]]; then + tessera_exit_with_help_and_apps check +fi +tessera check "$@" diff --git a/mise-tasks/dev/replay b/mise-tasks/dev/replay new file mode 100755 index 00000000..f2dd06ca --- /dev/null +++ b/mise-tasks/dev/replay @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +#MISE description="Run a recorded trace again against an app (tessera replay FILE --app NAME)" +#USAGE arg "[file]" help="A trace written by `launch` under TESSERA_TRACE or by `run --trace`; without one, shows the help and the apps" +#USAGE flag "--app " help="The app to replay it against" +#USAGE complete "name" run=#""$(git rev-parse --show-toplevel)/scripts/dev-app-names.sh""# +#USAGE flag "--json" help="Print one JSON object even on a terminal" +#USAGE flag "--pretty" help="Print the text form even when output is not a terminal" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +source scripts/dev-tessera.sh +if [[ -z "${usage_file:-}" ]]; then + tessera_exit_with_help_and_apps replay +fi +tessera replay "$@" diff --git a/mise-tasks/dev/run b/mise-tasks/dev/run index cbe21440..c798dc56 100755 --- a/mise-tasks/dev/run +++ b/mise-tasks/dev/run @@ -5,6 +5,7 @@ #USAGE flag "--record" help="Write the goldens that shot statements compare against, and list what changed" #USAGE flag "--repeat " help="Run each scenario N times and fail a run that differs from the first" #USAGE flag "--timing" help="Add the wall-clock seconds of each run to the report" +#USAGE flag "--trace " help="Write each scenario's trace, one JSON line per step, under this directory" #USAGE flag "--json" help="Print one JSON object even on a terminal" #USAGE flag "--pretty" help="Print the text form even when output is not a terminal" set -euo pipefail