Files
improvise/site/docs/test-evidence/tui.html
T
Edward Langley 787e354b9b doc-site: name context/code/pr-review.md as an omission
The `build` job fails on main and on every branch merged from it:

  doc-site: context/code/pr-review.md is neither published nor a named omission

NOT a merge loss -- scripts/build-doc-site.mjs in this branch is
byte-identical to main's, and neither lists the file. PR #96 added
context/code/pr-review.md without a manifest line, which is exactly the
gate improvise-res9 added: a doc added without a line publishes nothing
and says nothing, so the generator refuses.

Omitted rather than published, and the line is cloud-agents.md's rather
than tooling.md's. tooling.md explains checkers a reader of the code will
meet; pr-review.md is about THIS REPOSITORY'S PROCESS -- where a Codex
review appears, how to tell it from our own agents' comments, why absence
is not approval. Nobody visiting a website is about to read a PR thread.

Both homes updated, because an omission is restated beside the manifest
AND in doc-site.md section 2, and the prose there counted them ("Neither
is referenced...") so it went stale at three.

This strays into scripts/, which is Wave N's ground. Flagged in the
hand-back; it is a one-line data entry either way if the orchestrator
would rather publish it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 11:12:00 -07:00

150 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The TUI — improvise docs</title>
<link rel="stylesheet" href="../style.css" />
</head>
<body>
<div class="shell">
<nav class="side"><a class="brand" href="../index.html">improvise docs</a><input type="checkbox" id="navtoggle" class="navtoggle" /><label class="navsummary" for="navtoggle">Pages</label><div class="navlinks"><ul><li><a href="../../index.html">← Project home</a></li><li><a href="../../app/">Browser demo</a></li><li><a href="https://github.com/fiddlerwoaroof/improvise">Source on GitHub</a></li></ul><h3>Start here</h3><ul><li><a href="../design-notes.html">Design notes</a></li><li><a href="../design-principles.html">Design principles</a></li><li><a href="../repo-map.html">Repository map</a></li></ul><h3>Decision records</h3><ul><li><a href="../adr/001-concurrent-edits.html">ADR 001 — Concurrent edits</a></li><li><a href="../adr/002-naming-a-cell.html">ADR 002 — Naming a cell</a></li><li><a href="../adr/003-live-backed-models.html">ADR 003 — Live-backed models</a></li></ul><h3>The arguments, by area</h3><ul><li><a href="../code/core.html">improvise-core</a></li><li><a href="../code/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../code/protocol.html">improvise-protocol</a></li><li><a href="../code/command.html">improvise-command</a></li><li><a href="../code/clients.html">The clients</a></li><li><a href="../code/tui.html">The TUI</a></li><li><a href="../code/gp.html">gp-tree and improvise-gp</a></li><li><a href="../code/kb-tool.html">The kb tool</a></li><li><a href="../code/tooling.html">The repository's own checkers</a></li></ul><h3>Test evidence</h3><ul><li><a href="../test-evidence/core.html">improvise-core</a></li><li><a href="../test-evidence/formula-and-io.html">improvise-formula and improvise-io</a></li><li><a href="../test-evidence/protocol.html">improvise-protocol</a></li><li><a href="../test-evidence/command.html">improvise-command</a></li><li><a href="../test-evidence/clients.html">The clients</a></li><li><a href="../test-evidence/tui.html" class="here">The TUI</a></li><li><a href="../test-evidence/gp.html">gp-tree and improvise-gp</a></li><li><a href="../test-evidence/tooling.html">The repository's own gates</a></li></ul><h3>About this site</h3><ul><li><a href="../doc-site.html">About this site</a></li></ul><h3>Reference</h3><ul><li><a href="../tickets.html">Ticket index</a></li></ul></div></nav>
<main>
<p class="src">Source: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/context/test-evidence/tui.md">context/test-evidence/tui.md</a></p><details class="toc" open><summary>On this page</summary><ul><li class="l1"><a href="#test-evidence-the-terminal-front-end-src-tests-examples">Test evidence — the terminal front end (src/, tests/, examples/)</a></li><li class="l2"><a href="#tests-grid-frame-agreement-rs-the-two-front-ends-draw-the-same-frame">tests/grid_frame_agreement.rs — the two front ends draw the same frame</a></li><li class="l3"><a href="#the-two-front-ends-draw-the-same-row-header">the_two_front_ends_draw_the_same_row_header</a></li><li class="l3"><a href="#the-two-front-ends-draw-the-same-column-header">the_two_front_ends_draw_the_same_column_header</a></li><li class="l3"><a href="#neither-front-end-produces-a-slash-joined-label">neither_front_end_produces_a_slash_joined_label</a></li><li class="l3"><a href="#the-collapse-state-crosses-as-a-fact-not-a-glyph">the_collapse_state_crosses_as_a_fact_not_a_glyph</a></li><li class="l2"><a href="#src-ui-grid-rs">src/ui/grid.rs</a></li><li class="l3"><a href="#the-whole-frame-is-unchanged">the_whole_frame_is_unchanged</a></li><li class="l3"><a href="#two-row-categories-produce-cross-product-labels">two_row_categories_produce_cross_product_labels</a></li><li class="l2"><a href="#src-ui-import-wizard-ui-rs">src/ui/import_wizard_ui.rs</a></li><li class="l3"><a href="#a-cut-inside-a-multibyte-character-does-not-panic">a_cut_inside_a_multibyte_character_does_not_panic</a></li><li class="l3"><a href="#no-cut-point-of-a-multibyte-string-panics">no_cut_point_of_a_multibyte_string_panics</a></li><li class="l3"><a href="#ascii-truncation-is-unchanged">ascii_truncation_is_unchanged</a></li><li class="l3"><a href="#every-step-draws-what-it-drew-before">every_step_draws_what_it_drew_before</a></li><li class="l3"><a href="#the-formula-step-draws-its-transient-state">the_formula_step_draws_its_transient_state</a></li><li class="l3"><a href="#a-message-shorter-than-the-hint-leaves-none-of-it">a_message_shorter_than_the_hint_leaves_none_of_it</a></li><li class="l3"><a href="#set-bottom-line-clears-every-cell-it-covers-and-no-more">set_bottom_line_clears_every_cell_it_covers_and_no_more</a></li><li class="l3"><a href="#the-array-path-step-draws-its-candidates">the_array_path_step_draws_its_candidates</a></li><li class="l3"><a href="#the-preview-step-draws-an-empty-document">the_preview_step_draws_an_empty_document</a></li><li class="l2"><a href="#tests-shipped-documents-load-rs">tests/shipped_documents_load.rs</a></li><li class="l3"><a href="#every-shipped-document-loads">every_shipped_document_loads</a></li><li class="l2"><a href="#tests-web-demo-model-rs">tests/web_demo_model.rs</a></li><li class="l3"><a href="#every-web-demo-cell-is-settable-except-the-column-a-formula-owns">every_web_demo_cell_is_settable_except_the_column_a_formula_owns</a></li><li class="l3"><a href="#the-web-demo-shows-a-formula-beside-its-inputs">the_web_demo_shows_a_formula_beside_its_inputs</a></li><li class="l2"><a href="#src-draw-rs">src/draw.rs</a></li><li class="l3"><a href="#release-kind-converts-like-press">release_kind_converts_like_press</a></li><li class="l2"><a href="#the-panel-migration-equivalence-tests-improvise-35e">The panel-migration equivalence tests (improvise-35e)</a></li><li class="l3"><a href="#ui-tile-bar-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model">ui::tile_bar::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model</a></li><li class="l3"><a href="#ui-formula-panel-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model">ui::formula_panel::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model</a></li><li class="l3"><a href="#the-cache-reproduces-the-summary-the-widget-used-to-build">the_cache_reproduces_the_summary_the_widget_used_to_build</a></li><li class="l3"><a href="#the-active-view-summary-is-not-empty">the_active_view_summary_is_not_empty</a></li><li class="l2"><a href="#examples-gen-grammar-rs">examples/gen-grammar.rs</a></li><li class="l3"><a href="#a-status-message-reaches-the-bottom-row-while-a-minibuffer-is-open">a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open</a></li><li class="l3"><a href="#the-minibuffer-prompt-is-not-displaced-by-the-message">the_minibuffer_prompt_is_not_displaced_by_the_message</a></li><li class="l3"><a href="#an-empty-message-never-pushes-a-row-that-fits-past-its-width">an_empty_message_never_pushes_a_row_that_fits_past_its_width</a></li><li class="l3"><a href="#a-group-header-first-on-its-axis-reaches-both-front-ends">a_group_header_first_on_its_axis_reaches_both_front_ends</a></li><li class="l3"><a href="#two-abutting-runs-of-one-group-name-are-two-spans-at-both-ends">two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends</a></li></ul></details><h1 id="test-evidence-the-terminal-front-end-src-tests-examples">Test evidence — the terminal front end (<a href="https://github.com/fiddlerwoaroof/improvise/tree/main/src"><code>src/</code></a>, <a href="https://github.com/fiddlerwoaroof/improvise/tree/main/tests"><code>tests/</code></a>, <a href="https://github.com/fiddlerwoaroof/improvise/tree/main/examples"><code>examples/</code></a>)</h1>
<p>Keyed by exact test name, per design-principles §7. A renamed test orphans its entry; <code>python3 scripts/check-comment-runs.py --check-evidence</code> catches a heading that names no test.</p>
<p>A <code>###</code> heading is a <strong>test</strong>, and <code>--check-evidence</code> only counts <code>#[test]</code> functions, so it cannot be satisfied by a helper of the same name. Fixtures and helpers that carry an argument get a subordinate <code>####</code> heading under the tests they serve — unchecked, and deliberately so: there is nothing to orphan, because a renamed helper is found by the compiler.</p>
<hr />
<h2 id="tests-grid-frame-agreement-rs-the-two-front-ends-draw-the-same-frame"><a class="anchor" href="#tests-grid-frame-agreement-rs-the-two-front-ends-draw-the-same-frame">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/grid_frame_agreement.rs"><code>tests/grid_frame_agreement.rs</code></a> — the two front ends draw the same frame</h2>
<p><a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>/s1xw. <strong>The divergence this file exists to rule out was real and shipped</strong>: <code>GridCache::row_labels</code> was a <code>Vec&lt;String&gt;</code> built by <code>GridLayout::row_label</code>, which slash-joins a multi-level tuple — the exact rendering <code>src/ui/grid.rs::two_row_categories_produce_cross_product_labels</code> asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and the browser drew <code>Food/Alice</code> where the TUI drew two header columns, with no groups and no collapse indicators.</p>
<p><strong>The shape of the check is the point.</strong> Each front end is driven the way it is really driven — the widget into a ratatui buffer, the client through a <code>Snapshot</code> of the same cache&#39;s <code>seed()</code> — and the two are compared on what they <em>draw</em>, not on what they were handed. Comparing the caches would be vacuous: it is one cache.</p>
<h4 id="two-level-rows-helper"><a class="anchor" href="#two-level-rows-helper">#</a>two_level_rows (helper)</h4>
<p>The fixture. Both axes multi-level and grouped — the cross product the slash-join used to flatten, on rows <em>and</em> columns, so the frame carries header levels, repeat suppression, group spans and a collapse state in one fixture.</p>
<p>Two deliberate asymmetries:</p>
<ul>
<li><code>Carol</code> sits outside the <code>Family</code> group and <em>before</em> it, so collapsing <code>Family</code> still leaves a row. An all-collapsed axis draws no group headers at all, in both front ends and before this change, and a fixture that hit that would be testing that bug rather than the frame.</li>
<li><code>Month</code> has one grouped quarter and one ungrouped month, so the column runs are not uniform — a <code>col_groups</code> that emitted one span per column, or one span for the whole axis, would pass a uniform fixture.</li>
</ul>
<h4 id="tui-row-headers-helper"><a class="anchor" href="#tui-row-headers-helper">#</a>tui_row_headers (helper)</h4>
<p>Slices the painted row header by <code>AxisFrame::header_widths</code>, because that is the same rule the widget lays the sub-columns out with (<a class="tid" href="../tickets.html#improvise-1125" title="Row-header width is computed per frame in the widget">improvise-1125</a>). Reading the widths a second way here would be the very duplication under test. The body ends at the blank space below the grid, or at the rule above the totals strip — which is not a row of the frame.</p>
<h4 id="tui-header-cells-helper"><a class="anchor" href="#tui-header-cells-helper">#</a>tui_header_cells (helper)</h4>
<p>Cuts one painted header line by the cache&#39;s own <code>col_widths</code> past the row header, for the same reason. Labels are right-aligned into <code>width - 1</code>, so a trim recovers them.</p>
<h3 id="the-two-front-ends-draw-the-same-row-header"><a class="anchor" href="#the-two-front-ends-draw-the-same-row-header">#</a>the_two_front_ends_draw_the_same_row_header</h3>
<p><strong>Injected fault, watched fail before keeping</strong>: re-introducing the slash-join on either side — <code>labels: vec![rows.item(r).join(&quot;/&quot;)]</code> in the client&#39;s <code>render_model</code>, or <code>rows.item(ri).join(&quot;/&quot;)</code> in the widget&#39;s row header — makes this test fail, naming the row and the two spellings. Both were watched fail.</p>
<p>A group header is one label across the whole row header; a data row is one label per level, and the TUI&#39;s slicing puts a group label in the first sub-column, so the comparison joins the non-empty text. The group arm compares the <strong>full painted string, glyph and name</strong>: a branch that checked only the glyph would pass on any group name whatsoever. <code>indicator</code> is the TUI&#39;s glyph spelled out locally so the comparison is against what is really painted rather than against a prefix test.</p>
<p>The <code>tui.len() &gt; 4</code> precondition exists because a fixture with no group headers and few data rows compares almost nothing.</p>
<h3 id="the-two-front-ends-draw-the-same-column-header"><a class="anchor" href="#the-two-front-ends-draw-the-same-column-header">#</a>the_two_front_ends_draw_the_same_column_header</h3>
<p>A separate test because the row-header one drives <strong>one arm</strong>: a <code>col_levels</code> that stopped suppressing repeats, or a <code>col_groups</code> that emitted one span per column, would sit green behind it (<code>doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)</code>).</p>
<p>Each header line is cut into columns and compared <em>exactly</em>. A <code>contains</code> would be one-directional: it cannot see a label the browser wrongly blanked (the filter would skip it) and it cannot see one painted under the wrong column.</p>
<p><strong><a class="tid" href="../tickets.html#improvise-gd83" title="AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group">improvise-gd83</a> is fixed and <code>THE_MEMBERSHIP_CORRECT_SPANS</code> is gone</strong>, along with the <code>assert_ne!</code> tripwire that named it. The value it predicted — <code>[(Q1,2), (None,1), (Q1,2), (None,1)]</code> — is now what the test asserts, element for element. <code>Sep</code> is ungrouped, so the run ends before it and resumes under the second measure&#39;s own <code>Q1</code> header; a precondition asserts the fixture still contains an ungrouped column, because without one the test cannot see a leaked group at all.</p>
<p>The superseded expectation — Q1 covering all six columns <em>including</em> the two under the ungrouped <code>Sep</code>, because <code>expand_category</code> emitted a header on entering a group and no boundary on leaving one, so the nearest-preceding walk kept answering Q1 — is recorded here because it is what <code>AxisFrame::group_of</code> answered before the fix, and the frame it was derived from still explains the current value. The frame is <strong>not</strong> one Q1 run — <code>_Measure</code> is the outer level, so the column axis is:</p>
<p>col_cats = [&quot;_Measure&quot;, &quot;Month&quot;] Group(Q1) [Revenue,Jan] [Revenue,Feb] [Revenue,Sep] Group(Q1) [Cost,Jan] [Cost,Feb] [Cost,Sep]</p>
<p>Two Q1 headers, one per measure, and the ungrouped <code>Sep</code> sits <em>inside</em> each run rather than after both. So membership-correct spans are <strong>four</strong> runs, not two — and they do not coalesce across the second header, because <code>Sep</code> breaks the run between them. Hence <code>[(Some(&quot;Q1&quot;), 2), (None, 1), (Some(&quot;Q1&quot;), 2), (None, 1)]</code>.</p>
<p><a class="tid" href="../tickets.html#improvise-8qlr" title="Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm">improvise-8qlr</a>: the TUI arm was <code>group_line.contains(&quot;▼ Q1&quot;)</code>, which is one-directional and passes while the TUI labels <strong>every</strong> column. The run rule now lives once, as <code>AxisFrame::group_runs</code>/<code>group_run_heads</code>, and both ends call it; the TUI&#39;s group line is compared cell for cell against spans derived from the browser&#39;s own runs.</p>
<p>Fault injected exactly as that ticket specifies — forcing the TUI to emit the group label on every column (<code>head = col_groups.get(ci)</code>, dropping the run-head lookup). Before: <code>cargo test -p improvise</code> stayed green. After: this test fails with <code>[&quot;▼ Q1&quot;,&quot;▼ Q1&quot;,&quot;&quot;,&quot;▼ Q1&quot;,&quot;▼ Q1&quot;,&quot;&quot;]</code> against <code>[&quot;▼ Q1&quot;,&quot;&quot;,&quot;&quot;,&quot;▼ Q1&quot;,&quot;&quot;,&quot;&quot;]</code>.</p>
<p>One thing the shared rule does <strong>not</strong> flatten: the TUI labels its first <em>visible</em> column even mid-run, because a scrolled grid would otherwise draw an unlabelled group line. That is a scrolling concession, kept explicitly, and it is why the comparison is run at <code>col_offset == 0</code>.</p>
<h3 id="neither-front-end-produces-a-slash-joined-label"><a class="anchor" href="#neither-front-end-produces-a-slash-joined-label">#</a>neither_front_end_produces_a_slash_joined_label</h3>
<p>A separate assertion because the agreement tests above would also hold if <em>both</em> ends started slash-joining. This one is the bug&#39;s own words.</p>
<h3 id="the-collapse-state-crosses-as-a-fact-not-a-glyph"><a class="anchor" href="#the-collapse-state-crosses-as-a-fact-not-a-glyph">#</a>the_collapse_state_crosses_as_a_fact_not_a_glyph</h3>
<p>The group&#39;s collapse state reaches the browser as a <em>fact</em>, not as a glyph. The TUI spells it ▼/▶ (presentation, gotcha 26); the client gets the boolean and spells it however the DOM wants. <strong>Both states are checked</strong>, because a <code>collapsed</code> hard-wired to <code>false</code> would pass the first half. The non-empty precondition states the rule it depends on: a collapsed group still owes the user a header to expand again.</p>
<hr />
<h2 id="src-ui-grid-rs"><a class="anchor" href="#src-ui-grid-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/grid.rs"><code>src/ui/grid.rs</code></a></h2>
<h4 id="render-helper"><a class="anchor" href="#render-helper">#</a>render (helper)</h4>
<p>The widget takes a <code>GridCache</code>, so the fixture builds one the way the TUI does: through an <code>App</code>, over the whole grid. Going through <code>App</code> rather than hand-rolling a cache is deliberate — these are characterization tests, and a handcrafted cache would test the widget against a fixture rather than against what the projection actually produces.</p>
<h4 id="grouped-model-helper"><a class="anchor" href="#grouped-model-helper">#</a>grouped_model (helper)</h4>
<p>A grid with a row group, a column group, a page axis and a total row, written against the pre-migration path (<a class="tid" href="../tickets.html#improvise-n10" title="Migrate GridWidget to consume RenderCache + ViewState (not &amp;App)">improvise-n10</a>) so the move to <code>GridCache</code> is provably behaviour-preserving (<code>doctrine(characterization_survives_the_move)</code>). The fixture and <code>GROUPED_FRAME</code> are byte-identical to the pre-<a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a> versions, which is what makes the comparison mean anything.</p>
<h3 id="the-whole-frame-is-unchanged"><a class="anchor" href="#the-whole-frame-is-unchanged">#</a>the_whole_frame_is_unchanged</h3>
<p>Asserts the exact buffer text, not a <code>contains</code>, because the facts it <em>does</em> cover — group indicator placement, column alignment, adaptive row-header width, the page bar, the totals strip — are all <em>positional</em> and a <code>contains</code> cannot see any of them.</p>
<p><strong>What it does not cover, stated because an overclaim here is worse than a gap</strong>: <code>Region</code> is alone on Row and <code>Month</code> alone on Column, so <strong>both axes are single-level</strong> and this exercises no multi-level header and no repeat suppression — which is the whole subject of <a class="tid" href="../tickets.html#improvise-anr9" title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a>. Widening the fixture would destroy the only byte-exact before/after evidence in that change, so it stays as it is; the multi-level agreement is <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/grid_frame_agreement.rs"><code>tests/grid_frame_agreement.rs</code></a>, which drives both front ends over a two-level, grouped fixture.</p>
<p>Re-pinned for <a class="tid" href="../tickets.html#improvise-0o6x" title="A group header first on its axis is never drawn, so a leading collapsed group has no way back">improvise-0o6x</a>. <code>GROUPED_FRAME</code> moved by exactly one line: the leading <code>▼ Co…</code> header now draws above <code>East</code>, and everything below shifts down one. Nothing else in the buffer changed. That the group line, the column headers, the separator, the data rows and the totals strip are byte-identical is the evidence the change is the boundary condition it claims to be and not a reshuffle of the draw loop. It also fails alongside <code>a_group_header_first_on_its_axis_reaches_both_front_ends</code> when <code>body_from</code>&#39;s match is forced to <code>false</code>.</p>
<h3 id="two-row-categories-produce-cross-product-labels"><a class="anchor" href="#two-row-categories-produce-cross-product-labels">#</a>two_row_categories_produce_cross_product_labels</h3>
<p>Names the rendering the protocol must not carry: multi-level row headers show category values separately, never joined with <code>/</code>. It is the assertion <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/grid_frame_agreement.rs"><code>tests/grid_frame_agreement.rs</code></a>&#39;s module argument cites as the thing <code>GridLayout::row_label</code> violated.</p>
<hr />
<h2 id="src-ui-import-wizard-ui-rs"><a class="anchor" href="#src-ui-import-wizard-ui-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/import_wizard_ui.rs"><code>src/ui/import_wizard_ui.rs</code></a></h2>
<h3 id="a-cut-inside-a-multibyte-character-does-not-panic"><a class="anchor" href="#a-cut-inside-a-multibyte-character-does-not-panic">#</a>a_cut_inside_a_multibyte_character_does_not_panic</h3>
<p>The bug, found by the PR #12 Copilot review and pre-existing on <code>main</code>: <code>truncate</code> sliced by byte index, so a cut landing inside a multi-byte character panicked. Every string it cuts can carry user input — a CSV column name, a formula being typed, an error message naming a field — so a document with an accented header crashed the TUI rather than eliding a name.</p>
<p><code>&quot;Régions&quot;</code> is 8 bytes for 7 characters: the é occupies bytes 1..3, so <code>max = 3</code> puts <code>max - 1 = 2</code> inside it.</p>
<h3 id="no-cut-point-of-a-multibyte-string-panics"><a class="anchor" href="#no-cut-point-of-a-multibyte-string-panics">#</a>no_cut_point_of_a_multibyte_string_panics</h3>
<p>Not one cut point, every cut point. The interesting indices are the ones inside a character, and which those are depends on the string — so the test walks them rather than picking one and hoping it was the bad one. Each result must also be a <em>prefix</em> of the input, which is what says the boundary search walked down rather than dropping the character it landed in.</p>
<h3 id="ascii-truncation-is-unchanged"><a class="anchor" href="#ascii-truncation-is-unchanged">#</a>ascii_truncation_is_unchanged</h3>
<p>ASCII is unaffected, which is what lets the characterization fixtures in this file keep asserting exact frames written before the boundary fix.</p>
<h4 id="render-helper-2"><a class="anchor" href="#render-helper-2">#</a>render (helper)</h4>
<p>Drives a real <code>ImportWizard</code> and draws it through the projection, which is the whole path the TUI takes. Building the cache from the live wizard rather than by hand is deliberate: a handcrafted fixture would test the widget against the fixture instead of against what <code>wizard_of</code> actually produces, and the mapping is the half of <a class="tid" href="../tickets.html#improvise-764" title="Migrate import wizard widget and state to consume RenderCache">improvise-764</a> most able to be silently wrong.</p>
<h3 id="every-step-draws-what-it-drew-before"><a class="anchor" href="#every-step-draws-what-it-drew-before">#</a>every_step_draws_what_it_drew_before</h3>
<p>Every step the wizard can draw, byte for byte. Written against the pre-migration path (<a class="tid" href="../tickets.html#improvise-764" title="Migrate import wizard widget and state to consume RenderCache">improvise-764</a>), where the widget reached into <code>ImportWizard</code>, <code>ImportPipeline</code>, <code>FieldKind</code> and <code>Workbook</code> directly, so the move onto a projected cache is provably behaviour-preserving (<code>doctrine(characterization_survives_the_move)</code>).</p>
<p>Exact text rather than <code>contains</code>: the interesting facts are positional — the column stops in the proposals table, where the hint line sits relative to the panel bottom, how far the date components are indented — and a <code>contains</code> sees none of them.</p>
<p>The walk is bounded and stops when <code>advance</code> no longer moves: the wizard&#39;s transitions are conditional (<a class="tid" href="../tickets.html#improvise-907" title="Import wizard step machine: validate early, allow going back, make the terminal step real">improvise-907</a>) and a step that declines to advance must end the walk rather than spin it. <code>NameModel</code> is terminal for <code>advance</code> (gotcha 22) — the Enter there builds, and building is what parks the workbook on <code>Done</code>.</p>
<h3 id="the-formula-step-draws-its-transient-state"><a class="anchor" href="#the-formula-step-draws-its-transient-state">#</a>the_formula_step_draws_its_transient_state</h3>
<p>The transient bits of <code>DefineFormulas</code>: a formula in the list, one being typed, and a message overriding the hint line. Its <code>FORMULA_STEP</code> fixture is the one of the eight pinned frames that <a class="tid" href="../tickets.html#improvise-q0vb" title="Pad the import wizard&#39;s hint line to the panel width">improvise-q0vb</a> moved: it used to read <code>something went wrongcel</code>, the <code>cel</code> being the tail of <code>Esc: cancel</code> left behind because the message was written over a longer hint without clearing the rest of the row. The frame changing there was the point of that commit, not a side effect.</p>
<h3 id="a-message-shorter-than-the-hint-leaves-none-of-it"><a class="anchor" href="#a-message-shorter-than-the-hint-leaves-none-of-it">#</a>a_message_shorter_than_the_hint_leaves_none_of_it</h3>
<p><a class="tid" href="../tickets.html#improvise-q0vb" title="Pad the import wizard&#39;s hint line to the panel width">improvise-q0vb</a>. The wizard&#39;s bottom row has two writers — the step&#39;s key hint and the message that overrides it — and <code>set_string</code> writes only as many cells as its string has. A message shorter than the hint therefore left the hint&#39;s tail beside it, and the artifact was pinned as correct output in <code>FORMULA_STEP</code>.</p>
<p>Asserted on <code>ReviewProposals</code>, whose hint is the longest in the wizard, so the tail it would leave is unmistakable. The precondition asserts the hint really is longer than the message — without it the test would pass on a step where there was nothing to leave behind.</p>
<h3 id="set-bottom-line-clears-every-cell-it-covers-and-no-more"><a class="anchor" href="#set-bottom-line-clears-every-cell-it-covers-and-no-more">#</a>set_bottom_line_clears_every_cell_it_covers_and_no_more</h3>
<p>The padding is in terminal cells, not characters (Copilot, PR #22). <code>Buffer</code> lays a string out by display width, so a <code>chars().count()</code> pad is wrong in both directions: a wide character counts one and occupies two, so the helper runs past the panel and over its right border; a combining mark counts one and occupies none, so the helper stops short and leaves cells it was supposed to clear.</p>
<p><strong>Asserted against a sentinel</strong>, which is the correction Copilot made to a first version of this test. Drawing through the widget cannot see the under-padding case at all: the hint that ran first had <em>already</em> filled the row with its own padding, so the cells the combining message failed to reach held spaces rather than anything visible, and a &quot;does the hint survive&quot; assertion passed either way. The first version also looked like it had been proved, because the loop panicked on the wide-character case before ever reaching the combining one — <code>doctrine(an_injected_fault_must_be_the_fault_you_named)</code>, from the other end.</p>
<p>So the test fills the row with <code>X</code> first and asks the helper directly: every interior cell must be the text or a space, and the two cells standing in for the panel border must still be <code>X</code>. Both faults are then visible as a surviving <code>X</code> — one inside, one outside.</p>
<h3 id="the-array-path-step-draws-its-candidates"><a class="anchor" href="#the-array-path-step-draws-its-candidates">#</a>the_array_path_step_draws_its_candidates</h3>
<p>One of the two steps the flat-array fixture skips. <code>SelectArrayPath</code> is reached by a document with more than one candidate array, which is also the only step that draws <code>pipeline.array_paths</code>.</p>
<h3 id="the-preview-step-draws-an-empty-document"><a class="anchor" href="#the-preview-step-draws-an-empty-document">#</a>the_preview_step_draws_an_empty_document</h3>
<p><code>WizardStep::Preview</code> is the one step <code>every_step_draws_what_it_drew_before</code> never reaches (<a class="tid" href="../tickets.html#improvise-g6s1" title="WizardStep::Preview has no characterization fixture">improvise-g6s1</a>). Both fixtures there start at <code>ReviewProposals</code> or <code>SelectArrayPath</code>, and nothing advances <em>into</em> Preview — it is only ever an entry step, chosen by <code>ImportWizard::new</code> when the document needs no path selection and parses to zero records.</p>
<p>That left it the single arm where a change to <code>wizard_cache</code> could alter what is drawn with nothing failing, which is the same hole the other steps were pinned to close. An empty array is the smallest document that lands there.</p>
<p>It also puts on the record a wart the step has today: the summary ends <code>Sample keys:</code> with nothing after the colon, because there are no records to take keys from. Pinned as it is rather than quietly fixed — the point of a characterization fixture is that the next change to it is deliberate.</p>
<hr />
<h2 id="tests-shipped-documents-load-rs"><a class="anchor" href="#tests-shipped-documents-load-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/shipped_documents_load.rs"><code>tests/shipped_documents_load.rs</code></a></h2>
<h3 id="every-shipped-document-loads"><a class="anchor" href="#every-shipped-document-loads">#</a>every_shipped_document_loads</h3>
<p><a class="tid" href="../tickets.html#improvise-odbc" title="[bug] bank-info.improv in the repo root does not parse">improvise-odbc</a>. The finding behind it: <code>bank-info.improv</code> sat in the repo root for five months unable to parse — hand-edited through the v2025-04-09 format change rather than round-tripped, so its item lines carried bare names the grammar cannot spell (<code>- JORDAN MARTINEZ</code>) and its data lines named a category <code>Measure</code> the file declares as <code>_Measure</code>. Nothing loaded it, so nothing said so.</p>
<p>The grammar itself is covered far better than a fixture can manage, by the generator-driven proptests in <code>improvise-io</code>. What <em>those</em> cannot see is a checked-in document drifting out of the format, because they generate their inputs. This test is aimed at exactly that gap.</p>
<p><strong>It is deliberately a directory walk rather than a list of names</strong>: a list is a second place to remember, and the file that gets forgotten is the one nobody added to it. The scope is the two directories <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/.gitignore"><code>.gitignore</code></a> un-ignores — <a href="https://github.com/fiddlerwoaroof/improvise/tree/main/examples"><code>examples/</code></a> and <a href="https://github.com/fiddlerwoaroof/improvise/tree/main/web"><code>web/</code></a>; everything else matching <code>*.improv</code> is somebody&#39;s working file.</p>
<p><strong>It matches on the full name, not on <code>Path::extension()</code></strong>: <code>.improv.gz</code> is the same document gzipped and <code>extension()</code> calls it <code>gz</code>, so matching on the extension alone would skip it and put back the very hole this test exists to close.</p>
<hr />
<h2 id="tests-web-demo-model-rs"><a class="anchor" href="#tests-web-demo-model-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/tests/web_demo_model.rs"><code>tests/web_demo_model.rs</code></a></h2>
<h3 id="every-web-demo-cell-is-settable-except-the-column-a-formula-owns"><a class="anchor" href="#every-web-demo-cell-is-settable-except-the-column-a-formula-owns">#</a>every_web_demo_cell_is_settable_except_the_column_a_formula_owns</h3>
<p><a class="tid" href="../tickets.html#improvise-bck" title="Standalone web MVP: end-to-end offline demo">improvise-bck</a>. The demo document the standalone page opens must be one a visitor can <em>type into</em>, and the bug is not hypothetical: <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/examples/demo.improv"><code>examples/demo.improv</code></a> leaves <code>Customer</code> and <code>Date</code> on the None axis, so every cell in it is an aggregate over records the grid does not name, and an aggregate is not settable — correct behaviour, and a demo where the first thing a visitor clicks does nothing.</p>
<p><strong>The question is asked exactly the way the browser asks it.</strong> ADR 002 says a cell is settable iff its <code>CellId</code> is <code>Record</code>, <code>project::cell_id</code> is the one place that decides, and <code>render.rs::editable</code> in the wasm client is that variant and nothing else (gotcha 42). So the test projects the demo&#39;s whole grid through <code>grid_of</code> — the same function the server calls per frame — and asserts on the surrogate, not on the axes. Asserting the axes instead would re-derive the rule and could agree with itself while disagreeing with the client.</p>
<p>It was proved discriminating by pointing it at <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/examples/demo.improv"><code>examples/demo.improv</code></a>, where it names the aggregate at (0,0).</p>
<p><strong><a class="tid" href="../tickets.html#improvise-ir7l" title="[bug] A formula cell is offered as editable and silently reverts">improvise-ir7l</a> renamed it, because the old name was false.</strong> The demo&#39;s third column is <code>Profit = Revenue - Cost</code>, and a formula target is a stored coordinate, so it projected <code>Record</code> and the page offered an editor over it — the test passed while its name was wrong for a third of the landing-page grid, which is exactly the shape this repo keeps finding. The assertion is now two-sided: the two raw columns must be <code>Record</code> and the <code>Profit</code> column must be <code>CellId::Derived</code>, so it fails if either half moves. Proved discriminating in both directions by deleting the <code>derived</code> branch of <code>project::cell_id</code> (Profit reads back as a <code>Record</code>) and, separately, by pointing it at <a href="https://github.com/fiddlerwoaroof/improvise/blob/main/examples/demo.improv"><code>examples/demo.improv</code></a> again (the aggregate at (0,0) still fails it).</p>
<h3 id="the-web-demo-shows-a-formula-beside-its-inputs"><a class="anchor" href="#the-web-demo-shows-a-formula-beside-its-inputs">#</a>the_web_demo_shows_a_formula_beside_its_inputs</h3>
<p>The payoff a visitor should see without pressing a key: a formula column beside the two columns that feed it, so an edit visibly moves it. <code>Profit</code> at (0,2) is <code>18,400 - 6,200</code>, recomputed on load.</p>
<hr />
<h2 id="src-draw-rs"><a class="anchor" href="#src-draw-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/draw.rs"><code>src/draw.rs</code></a></h2>
<h3 id="release-kind-converts-like-press"><a class="anchor" href="#release-kind-converts-like-press">#</a>release_kind_converts_like_press</h3>
<p>The pre-conversion event loop never inspected <code>KeyEventKind</code>, so all kinds (Press/Repeat/Release) were dispatched. The conversion keeps that behaviour: a Release event converts like any other.</p>
<hr />
<h2 id="the-panel-migration-equivalence-tests-improvise-35e"><a class="anchor" href="#the-panel-migration-equivalence-tests-improvise-35e">#</a>The panel-migration equivalence tests (<a class="tid" href="../tickets.html#improvise-35e" title="Temporary adapter: RenderCache::from_app bridge for incremental widget migration">improvise-35e</a>)</h2>
<p>Each panel widget carries a pair: one test saying the <em>inputs</em> are unchanged by the move onto a projected cache, one pinning the <em>drawing</em> of them. The pair is the shape — a cache equivalence alone would not notice a changed renderer, and a rendered fixture alone would not notice the cache carrying different data.</p>
<h3 id="ui-tile-bar-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model"><a class="anchor" href="#ui-tile-bar-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model">#</a>ui::tile_bar::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model</h3>
<h3 id="ui-formula-panel-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model"><a class="anchor" href="#ui-formula-panel-tests-the-cache-carries-what-the-widget-used-to-read-off-the-model">#</a>ui::formula_panel::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model</h3>
<p>Two tests share this leaf name, which is why both headings are qualified: one entry describes the pair, and an unqualified heading would have been satisfied by whichever of them the checker happened to see. The tile bar used to read <code>view.tileable_cats(&amp;model.category_names())</code> and <code>view.axis_of(cat)</code> off the model directly; the formula panel used to read <code>model.formulas()[i].raw</code>. Each now reads its cache, and a divergence is a rendering change nobody asked for.</p>
<h3 id="the-cache-reproduces-the-summary-the-widget-used-to-build"><a class="anchor" href="#the-cache-reproduces-the-summary-the-widget-used-to-build">#</a>the_cache_reproduces_the_summary_the_widget_used_to_build</h3>
<p><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/src/ui/view_panel.rs"><code>src/ui/view_panel.rs</code></a>. The axis summary used to be built from <code>workbook.views</code> plus <code>View::categories_on</code>; it is now built from <code>ViewSummary::axes</code>. Same names, same order, same virtual-category filtering.</p>
<h3 id="the-active-view-summary-is-not-empty"><a class="anchor" href="#the-active-view-summary-is-not-empty">#</a>the_active_view_summary_is_not_empty</h3>
<p>The fixture must actually exercise the summary, or the equivalence above is two empty strings agreeing.</p>
<hr />
<h2 id="examples-gen-grammar-rs"><a class="anchor" href="#examples-gen-grammar-rs">#</a><a href="https://github.com/fiddlerwoaroof/improvise/blob/main/examples/gen-grammar.rs"><code>examples/gen-grammar.rs</code></a></h2>
<p>Not a test, but the generator the <code>improvise-io</code> proptests drive. It adds constraints beyond what the grammar requires, to produce realistic, round-trippable output rather than degenerate minima: bare names are drawn from a word pool instead of random letters, <code>pipe_inner</code> is never empty, <code>rest_of_line</code> always produces at least one character, and repetitions (<code>*</code>) produce 1–4 items rather than 0. Each invocation is seeded from the current time plus the PID.</p>
<h3 id="a-status-message-reaches-the-bottom-row-while-a-minibuffer-is-open"><a class="anchor" href="#a-status-message-reaches-the-bottom-row-while-a-minibuffer-is-open">#</a>a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open</h3>
<h3 id="the-minibuffer-prompt-is-not-displaced-by-the-message"><a class="anchor" href="#the-minibuffer-prompt-is-not-displaced-by-the-message">#</a>the_minibuffer_prompt_is_not_displaced_by_the_message</h3>
<h3 id="an-empty-message-never-pushes-a-row-that-fits-past-its-width"><a class="anchor" href="#an-empty-message-never-pushes-a-row-that-fits-past-its-width">#</a>an_empty_message_never_pushes_a_row_that_fits_past_its_width</h3>
<p><a class="tid" href="../tickets.html#improvise-mai9" title="A status message written while a minibuffer is open is never drawn">improvise-mai9</a>. <code>draw_bottom_bar</code> rendered the minibuffer <strong>instead of</strong> the status line, so a message written while a text-entry mode was open was never drawn — not truncated, not overwritten. A test asserting <code>app.status_msg</code> was set passed in every one of those cases, which is how this survived <a class="tid" href="../tickets.html#improvise-6t3" title="Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)">improvise-6t3</a>&#39;s careful effect ordering.</p>
<p>Of the ticket&#39;s two candidate fixes, the row is <strong>shared</strong> rather than the callers being made structurally unable to report: giving the status a second row changes frame geometry (<a class="tid" href="../tickets.html#improvise-q0vb" title="Pad the import wizard&#39;s hint line to the panel width">improvise-q0vb</a>&#39;s cost), and &quot;always leave the mode before reporting&quot; is what <code>CommitSave</code> does by hand and would have to be repeated at every text-entry commit — the thing the ticket calls a workaround.</p>
<p><strong>The first version of these tests could not fail for the bug, and that is the entry worth reading.</strong> They called the composing helper <code>minibuffer_line</code> directly. The defect was in the <em>wiring</em> — <code>draw_bottom_bar</code> building its own <code>format!</code> and never consulting <code>status_msg</code> — so reverting that one line restored the bug in full with all 1411 tests green. The helper did not even go dead: the test module&#39;s <code>use</code> kept it alive while it asserted nothing about the product. Found by review (PR #77), reproduced here before rewriting.</p>
<p>So the guard is now <code>Buffer</code>-backed: a real <code>Terminal&lt;TestBackend&gt;</code> renders <code>draw_bottom_bar</code> and the assertion reads the row the terminal received. That is the only assertion that can fail for mai9&#39;s actual defect. Fault injected by restoring <code>format!(&quot;{}{}&quot;, mb.prompt, buf)</code> plus the cursor glyph — both wiring tests fail, the first naming the missing message and printing the row it did compose.</p>
<p>The second test exists because a &quot;fix&quot; that dropped to <code>draw_status</code> would satisfy the first on the message alone; it pins that the prompt still leads.</p>
<p><strong>The third test is the negative arm, and its first version was unfalsifiable for arithmetic reasons.</strong> <code>fill_line</code> computes <code>pad = width - left.len() - right.len()</code>, so with an empty status an unconditional <code>format!(&quot; {msg} &quot;)</code> produces a byte-identical row at any width where the line fits: <code>left</code> plus <code>width - left.len()</code> spaces, either way. The <code>match status_msg.is_empty()</code> is observable <strong>only</strong> in the band where the prompt fits but <code>left.len() + 4</code> does not — widths 16..=19 for this fixture, where the unguarded arm returns a row wider than the terminal.</p>
<p>That test then failed a second time in a more interesting way: it derived its lower bound by <em>calling <code>minibuffer_line</code> itself</em>, so the injected fault moved the bound with it and the loop skipped the failing band entirely. The bound is now spelled locally. <strong>A bound read off the function under test is not a bound</strong> — it is the fault grading its own exam.</p>
<p>Known and not fixed here: <code>fill_line</code> pads on <code>left.len()</code>, which is bytes, so the right-hand message sits two cells further right than intended whenever the left contains the multi-byte cursor glyph. <code>draw_status</code> has always done this with its own multi-byte content; it is pre-existing and out of scope. It is also what makes the band above four columns wide rather than two.</p>
<h3 id="a-group-header-first-on-its-axis-reaches-both-front-ends"><a class="anchor" href="#a-group-header-first-on-its-axis-reaches-both-front-ends">#</a>a_group_header_first_on_its_axis_reaches_both_front_ends</h3>
<p><a class="tid" href="../tickets.html#improvise-0o6x" title="A group header first on its axis is never drawn, so a leading collapsed group has no way back">improvise-0o6x</a>. <code>AxisFrame::body_from(idx)</code> started at the first <strong>item</strong>, so at the top of the axis a leading <code>FrameEntry::Group</code> was skipped on the way to the screen — in both front ends. A collapsed group first on its axis therefore had its rows hidden and its header nowhere, so there was no affordance to get them back. The frame carried the header correctly; <code>body_from</code> dropped it.</p>
<p>It is a boundary condition, as the ticket suspected, but the boundary is not <code>idx == 0</code>. The windowing rule carries the nearest header ABOVE a scrolled window for <code>group_of</code> and <code>body_from</code> discards it — correctly. At the top of the axis there is no &quot;above&quot;, and a frame cannot tell the two apart from <code>idx</code> alone, because the browser&#39;s frame is windowed and always passes <code>0</code>. So <code>AxisFrame</code> gained <code>first</code>, the absolute data index of its first item, and the rule is <code>first + idx == 0</code>. Without it the browser would have drawn an extra header row at every scrolled position that the TUI does not.</p>
<p>This test is separate from <code>the_two_front_ends_draw_the_same_row_header</code> because that fixture orders <code>Carol</code> before <code>Family</code> to dodge this case <strong>on purpose</strong>, and says so. Both collapse states are driven: the uncollapsed arm alone passes against a frame that leads with a header only when it has items under it, which is the shape the windowing bug below produced.</p>
<p>The windowing half was found by this test and not predicted by the ticket. A collapsed leading group has NO item to carry its header into the window, so it reaches the frame only through <code>axis_frame</code>&#39;s <code>pending</code> queue — and a first version of the fix had <code>GroupEnd</code> <strong>clear</strong> <code>pending</code> above the window, which deleted exactly that header. <code>GroupEnd</code> is now queued like a header and clears nothing.</p>
<p>Fault injected by forcing <code>body_from</code>&#39;s match to <code>false</code>: this test fails and <code>the_whole_frame_is_unchanged</code> fails with it.</p>
<h3 id="two-abutting-runs-of-one-group-name-are-two-spans-at-both-ends"><a class="anchor" href="#two-abutting-runs-of-one-group-name-are-two-spans-at-both-ends">#</a>two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends</h3>
<p><a class="tid" href="../tickets.html#improvise-h0iv" title="group_runs coalesces by name alone, so two runs under different headers merge">improvise-h0iv</a>, end to end. The unit demonstration is in <a href="../test-evidence/protocol.html"><code>context/test-evidence/protocol.md</code></a>; this is the same rule driven through a real <code>GridLayout</code>, a real <code>project::grid_of</code> and both renderers, because the reachability of the shape was itself in question.</p>
<p><code>two_level_rows</code> has exactly h0iv&#39;s shape — <code>Q1</code> grouping two of three months, cross-producted under two measures — and is <strong>saved by the ungrouped <code>Sep</code> between the runs</strong>, which is also what makes <a class="tid" href="../tickets.html#improvise-gd83" title="AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group">improvise-gd83</a>&#39;s spans come out as <code>[(Q1,2), (None,1), (Q1,2), (None,1)]</code>. <code>abutting_column_groups</code> removes <code>Sep</code> and nothing else, so the two <code>Q1</code> runs abut. The test asserts that no run is unlabelled before comparing anything: a fixture that grew an ungrouped column back would fail there rather than passing on the easy case.</p>
<p>It then compares the TUI&#39;s painted group line against the browser&#39;s runs exactly, which is the part that makes it an agreement test rather than a second assertion about <code>group_runs</code>.</p>
<p>Fault injected 2026-09-21: restoring the name-only comparison in <code>AxisFrame::group_runs</code> fails this with <code>left: [(Some(&quot;Q1&quot;), 4)]</code> while the five existing agreement tests all pass — the ungrouped <code>Sep</code> in their fixture is precisely why they cannot see it.</p>
</main>
</div>
</body>
</html>