787e354b9b
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
150 lines
49 KiB
HTML
150 lines
49 KiB
HTML
<!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<String></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'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'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("/")]</code> in the client's <code>render_model</code>, or <code>rows.item(ri).join("/")</code> in the widget'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'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'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() > 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'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 = ["_Measure", "Month"] 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("Q1"), 2), (None, 1), (Some("Q1"), 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("▼ Q1")</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's group line is compared cell for cell against spans derived from the browser'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>["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""]</code> against <code>["▼ Q1","","","▼ Q1","",""]</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'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'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 &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>'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>'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>"Régions"</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'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'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's hint line to the panel width">improvise-q0vb</a>. The wizard's bottom row has two writers — the step'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'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 "does the hint survive" 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'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'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'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(&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>'s careful effect ordering.</p>
|
||
<p>Of the ticket'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's hint line to the panel width">improvise-q0vb</a>'s cost), and "always leave the mode before reporting" 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'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<TestBackend></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's actual defect. Fault injected by restoring <code>format!("{}{}", 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 "fix" 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!(" {msg} ")</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 "above", and a frame cannot tell the two apart from <code>idx</code> alone, because the browser'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>'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>'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'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>'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's painted group line against the browser'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("Q1"), 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>
|