1480 lines
78 KiB
Markdown
1480 lines
78 KiB
Markdown
# Test evidence — the terminal front end (`src/`, `tests/`, `examples/`)
|
||
|
||
Keyed by exact test name, per design-principles §7. A renamed test orphans its
|
||
entry; `python3 scripts/check-comment-runs.py --check-evidence` catches a
|
||
heading that names no test.
|
||
|
||
A `###` heading is a **test**, and `--check-evidence` only counts `#[test]`
|
||
functions, so it cannot be satisfied by a helper of the same name. Fixtures and
|
||
helpers that carry an argument get a subordinate `####` heading under the tests
|
||
they serve — unchecked, and deliberately so: there is nothing to orphan, because
|
||
a renamed helper is found by the compiler.
|
||
|
||
---
|
||
|
||
## `tests/grid_frame_agreement.rs` — the two front ends draw the same frame
|
||
|
||
improvise-anr9/s1xw. **The divergence this file exists to rule out was real and
|
||
shipped**: `GridCache::row_labels` was a `Vec<String>` built by
|
||
`GridLayout::row_label`, which slash-joins a multi-level tuple — the exact
|
||
rendering `src/ui/grid.rs::two_row_categories_produce_cross_product_labels`
|
||
asserts must not appear. So the protocol carried a form of the labels the TUI
|
||
itself considered wrong, and the browser drew `Food/Alice` where the TUI drew
|
||
two header columns, with no groups and no collapse indicators.
|
||
|
||
**The shape of the check is the point.** Each front end is driven the way it is
|
||
really driven — the widget into a ratatui buffer, the client through a
|
||
`Snapshot` of the same cache's `seed()` — and the two are compared on what they
|
||
*draw*, not on what they were handed. Comparing the caches would be vacuous: it
|
||
is one cache.
|
||
|
||
#### two_level_rows (helper)
|
||
|
||
The fixture. Both axes multi-level and grouped — the cross product the
|
||
slash-join used to flatten, on rows *and* columns, so the frame carries header
|
||
levels, repeat suppression, group spans and a collapse state in one fixture.
|
||
|
||
Two deliberate asymmetries:
|
||
|
||
- `Carol` sits outside the `Family` group and *before* it, so collapsing
|
||
`Family` 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.
|
||
- `Month` has one grouped quarter and one ungrouped month, so the column runs
|
||
are not uniform — a `col_groups` that emitted one span per column, or one
|
||
span for the whole axis, would pass a uniform fixture.
|
||
|
||
#### tui_row_headers (helper)
|
||
|
||
Slices the painted row header by `AxisFrame::header_widths`, because that is
|
||
the same rule the widget lays the sub-columns out with (improvise-1125).
|
||
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.
|
||
|
||
#### tui_header_cells (helper)
|
||
|
||
Cuts one painted header line by the cache's own `col_widths` past the row
|
||
header, for the same reason. Labels are right-aligned into `width - 1`, so a
|
||
trim recovers them.
|
||
|
||
### the_two_front_ends_draw_the_same_row_header
|
||
|
||
**Injected fault, watched fail before keeping**: re-introducing the slash-join
|
||
on either side — `labels: vec![rows.item(r).join("/")]` in the client's
|
||
`render_model`, or `rows.item(ri).join("/")` in the widget's row header — makes
|
||
this test fail, naming the row and the two spellings. Both were watched fail.
|
||
|
||
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 **full painted string, glyph and name**: a branch that checked only the
|
||
glyph would pass on any group name whatsoever. `indicator` is the TUI's glyph
|
||
spelled out locally so the comparison is against what is really painted rather
|
||
than against a prefix test.
|
||
|
||
The `tui.len() > 4` precondition exists because a fixture with no group headers
|
||
and few data rows compares almost nothing.
|
||
|
||
### the_two_front_ends_draw_the_same_column_header
|
||
|
||
A separate test because the row-header one drives **one arm**: a `col_levels`
|
||
that stopped suppressing repeats, or a `col_groups` that emitted one span per
|
||
column, would sit green behind it
|
||
(`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`).
|
||
|
||
Each header line is cut into columns and compared *exactly*. A `contains` 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.
|
||
|
||
**improvise-gd83 is fixed and `THE_MEMBERSHIP_CORRECT_SPANS` is gone**, along
|
||
with the `assert_ne!` tripwire that named it. The value it predicted —
|
||
`[(Q1,2), (None,1), (Q1,2), (None,1)]` — is now what the test asserts, element
|
||
for element. `Sep` is ungrouped, so the run ends before it and resumes under
|
||
the second measure's own `Q1` header; a precondition asserts the fixture still
|
||
contains an ungrouped column, because without one the test cannot see a leaked
|
||
group at all.
|
||
|
||
The superseded expectation — Q1 covering all six columns *including* the two
|
||
under the ungrouped `Sep`, because `expand_category` 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 `AxisFrame::group_of`
|
||
answered before the fix, and the frame it was derived from still explains the
|
||
current value. The frame is **not** one Q1 run — `_Measure` is the outer level,
|
||
so the column axis is:
|
||
|
||
col_cats = ["_Measure", "Month"]
|
||
Group(Q1) [Revenue,Jan] [Revenue,Feb] [Revenue,Sep]
|
||
Group(Q1) [Cost,Jan] [Cost,Feb] [Cost,Sep]
|
||
|
||
Two Q1 headers, one per measure, and the ungrouped `Sep` sits *inside* each run
|
||
rather than after both. So membership-correct spans are **four** runs, not two
|
||
— and they do not coalesce across the second header, because `Sep` breaks the
|
||
run between them. Hence
|
||
`[(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)]`.
|
||
|
||
improvise-8qlr: the TUI arm was `group_line.contains("▼ Q1")`, which is
|
||
one-directional and passes while the TUI labels **every** column. The run rule
|
||
now lives once, as `AxisFrame::group_runs`/`group_run_heads`, and both ends
|
||
call it; the TUI's group line is compared cell for cell against spans derived
|
||
from the browser's own runs.
|
||
|
||
Fault injected exactly as that ticket specifies — forcing the TUI to emit the
|
||
group label on every column (`head = col_groups.get(ci)`, dropping the run-head
|
||
lookup). Before: `cargo test -p improvise` stayed green. After: this test fails
|
||
with `["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""]` against
|
||
`["▼ Q1","","","▼ Q1","",""]`.
|
||
|
||
One thing the shared rule does **not** flatten: the TUI labels its first
|
||
*visible* 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 `col_offset == 0`.
|
||
|
||
### neither_front_end_produces_a_slash_joined_label
|
||
|
||
A separate assertion because the agreement tests above would also hold if
|
||
*both* ends started slash-joining. This one is the bug's own words.
|
||
|
||
### the_collapse_state_crosses_as_a_fact_not_a_glyph
|
||
|
||
The group's collapse state reaches the browser as a *fact*, not as a glyph. The
|
||
TUI spells it ▼/▶ (presentation, gotcha 26); the client gets the boolean and
|
||
spells it however the DOM wants. **Both states are checked**, because a
|
||
`collapsed` hard-wired to `false` 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.
|
||
|
||
---
|
||
|
||
## `src/ui/grid.rs`
|
||
|
||
#### render (helper)
|
||
|
||
The widget takes a `GridCache`, so the fixture builds one the way the TUI does:
|
||
through an `App`, over the whole grid. Going through `App` 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.
|
||
|
||
#### grouped_model (helper)
|
||
|
||
A grid with a row group, a column group, a page axis and a total row, written
|
||
against the pre-migration path (improvise-n10) so the move to `GridCache` is
|
||
provably behaviour-preserving
|
||
(`doctrine(characterization_survives_the_move)`). The fixture and
|
||
`GROUPED_FRAME` are byte-identical to the pre-improvise-anr9 versions, which is
|
||
what makes the comparison mean anything.
|
||
|
||
### the_whole_frame_is_unchanged
|
||
|
||
Asserts the exact buffer text, not a `contains`, because the facts it *does*
|
||
cover — group indicator placement, column alignment, adaptive row-header width,
|
||
the page bar, the totals strip — are all *positional* and a `contains` cannot
|
||
see any of them.
|
||
|
||
**What it does not cover, stated because an overclaim here is worse than a
|
||
gap**: `Region` is alone on Row and `Month` alone on Column, so **both axes are
|
||
single-level** and this exercises no multi-level header and no repeat
|
||
suppression — which is the whole subject of improvise-anr9. 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
|
||
`tests/grid_frame_agreement.rs`, which drives both front ends over a two-level,
|
||
grouped fixture.
|
||
|
||
Re-pinned for improvise-0o6x. `GROUPED_FRAME` moved by exactly one line: the
|
||
leading `▼ Co…` header now draws above `East`, 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
|
||
`a_group_header_first_on_its_axis_reaches_both_front_ends` when `body_from`'s
|
||
match is forced to `false`.
|
||
|
||
### two_row_categories_produce_cross_product_labels
|
||
|
||
Names the rendering the protocol must not carry: multi-level row headers show
|
||
category values separately, never joined with `/`. It is the assertion
|
||
`tests/grid_frame_agreement.rs`'s module argument cites as the thing
|
||
`GridLayout::row_label` violated.
|
||
|
||
---
|
||
|
||
## `src/ui/text.rs` — the one display-width-safe truncator (improvise-8l0c)
|
||
|
||
`src/ui/formula_panel.rs` cut a formula by **bytes** and subtracted 1 from an
|
||
unchecked `inner.width`, so rendering `|é| = 1` into a panel of outer width 7
|
||
panicked on a char boundary and an inner width of 0 panicked on the subtraction.
|
||
Formula identifiers are pipe-quoted and may carry any character, so an ordinary
|
||
window resize over an ordinary document crashed the whole TUI.
|
||
|
||
The repair is not a third truncator. `src/ui/grid.rs` already had a
|
||
display-width one and `src/ui/import_wizard_ui.rs` has a deliberately
|
||
byte-measured one (`context/code/tui.md` argues why that one stays bytes: it
|
||
travels with the hint-line padding, and changing its measure moves where every
|
||
wizard line wraps). The grid's version moved to `src/ui/text.rs` unchanged in
|
||
behaviour for ASCII, and the formula panel now calls it. Two things about the
|
||
move: `prefix_by_width` replaced the `chars().take(max_width)` tail, which
|
||
over-ran by a cell for a wide character at `max_width == 1`; and the width
|
||
invariant is now asserted rather than assumed.
|
||
|
||
Widths 0, 1 and 2 are in the loop because the comment on improvise-8l0c
|
||
reported the same line panicking on pure ASCII at inner width 0.
|
||
|
||
### ui::text::tests::no_cut_point_of_a_multibyte_string_panics
|
||
|
||
Every cut point of `Ünïcödé = 1`, and each result asserted to be a **prefix** of
|
||
the input — which is what says the search walked *down* to a boundary rather
|
||
than up past one.
|
||
|
||
### ui::text::tests::a_result_never_exceeds_the_width_it_was_given
|
||
|
||
The invariant the panel's arithmetic depends on, over a wide-character string as
|
||
well as ASCII.
|
||
|
||
Rival fix injected 2026-09-21: the obvious "unicode-safe" repair — measure and
|
||
cut by `chars().count()`/`char_indices().nth()` — passes both panic tests and
|
||
fails this one on `日本語 = 1`, because a char count is not a cell count. That is
|
||
the whole reason the shared truncator is the width-measured one and not a
|
||
character-measured one.
|
||
|
||
### ui::text::tests::ascii_truncation_is_unchanged
|
||
|
||
The grid's eight exact-frame characterization fixtures are ASCII, so this is
|
||
what says the move cost no evidence.
|
||
|
||
### ui::formula_panel::tests::a_cut_inside_a_multibyte_formula_does_not_panic
|
||
|
||
The ticket's repro, driven through the real `Panel` widget rather than a copied
|
||
substring expression.
|
||
|
||
### ui::formula_panel::tests::no_panel_width_panics_the_formula_panel
|
||
|
||
Widths 0 through 12 over an ASCII formula and a multi-byte one.
|
||
|
||
Rival fix injected 2026-09-21: repairing only the subtraction
|
||
(`(inner.width as usize).saturating_sub(1)`, byte slice kept) fails both of
|
||
these — the first on the char boundary, the second at width 1, where a
|
||
`saturating_sub` yields a slice bound of 0 for a string that needs one. A test
|
||
written only at width 0 would have accepted it.
|
||
|
||
#### render_formulas / drawn (helpers)
|
||
|
||
`render_formulas` returns the `Buffer` rather than a joined string because
|
||
`chunks(0)` panics, and a test that cannot be *run* at width 0 cannot see the
|
||
width-0 bug.
|
||
|
||
### ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name
|
||
### ui::view_panel::tests::the_axis_summary_sits_beside_a_multibyte_view_name
|
||
|
||
The same measure confusion two files over, found by searching for the *class*
|
||
rather than the line: both panels placed a second string at
|
||
`inner.x + name.len()`, a **byte** count used as a column. Neither panics —
|
||
`Buffer::set_string` clips — so the symptom is a gap that widens with every
|
||
non-ASCII character in the name, and the axis tag drifting toward and eventually
|
||
past the panel's border.
|
||
|
||
Both tests draw the row twice, once with `Cafe` and once with `Café`, and
|
||
require the two to differ *only* by that character. Comparing against a
|
||
hard-coded frame would have pinned the current spacing; comparing the two
|
||
renderings names the question — does a character that is one cell wide and two
|
||
bytes long move anything. Before the fix the `é` row put the tag one cell
|
||
further right; both were re-checked by reverting each one-line change
|
||
independently.
|
||
|
||
Not fixed here, and filed as improvise-n8vh: neither panel truncates at all, so a name
|
||
longer than the panel overwrites its right border. That is a different bug from
|
||
the one 8l0c names — no truncation, rather than wrong truncation — and
|
||
`src/ui/text.rs::truncate` is now sitting there for whoever takes it.
|
||
|
||
## `src/ui/import_wizard_ui.rs`
|
||
|
||
### a_cut_inside_a_multibyte_character_does_not_panic
|
||
|
||
The bug, found by the PR #12 Copilot review and pre-existing on `main`:
|
||
`truncate` 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.
|
||
|
||
`"Régions"` is 8 bytes for 7 characters: the é occupies bytes 1..3, so `max = 3`
|
||
puts `max - 1 = 2` inside it.
|
||
|
||
### ui::import_wizard_ui::truncate_tests::no_cut_point_of_a_multibyte_string_panics
|
||
|
||
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 *prefix* of the input, which is what says the boundary search walked down
|
||
rather than dropping the character it landed in.
|
||
|
||
### ui::import_wizard_ui::truncate_tests::ascii_truncation_is_unchanged
|
||
|
||
ASCII is unaffected, which is what lets the characterization fixtures in this
|
||
file keep asserting exact frames written before the boundary fix.
|
||
|
||
#### render (helper)
|
||
|
||
Drives a real `ImportWizard` 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 `wizard_of` actually produces, and the mapping
|
||
is the half of improvise-764 most able to be silently wrong.
|
||
|
||
### every_step_draws_what_it_drew_before
|
||
|
||
Every step the wizard can draw, byte for byte. Written against the
|
||
pre-migration path (improvise-764), where the widget reached into
|
||
`ImportWizard`, `ImportPipeline`, `FieldKind` and `Workbook` directly, so the
|
||
move onto a projected cache is provably behaviour-preserving
|
||
(`doctrine(characterization_survives_the_move)`).
|
||
|
||
Exact text rather than `contains`: 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 `contains` sees
|
||
none of them.
|
||
|
||
The walk is bounded and stops when `advance` no longer moves: the wizard's
|
||
transitions are conditional (improvise-907) and a step that declines to advance
|
||
must end the walk rather than spin it. `NameModel` is terminal for `advance`
|
||
(gotcha 22) — the Enter there builds, and building is what parks the workbook
|
||
on `Done`.
|
||
|
||
### the_formula_step_draws_its_transient_state
|
||
|
||
The transient bits of `DefineFormulas`: a formula in the list, one being typed,
|
||
and a message overriding the hint line. Its `FORMULA_STEP` fixture is the one
|
||
of the eight pinned frames that improvise-q0vb moved: it used to read
|
||
`something went wrongcel`, the `cel` being the tail of `Esc: cancel` 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.
|
||
|
||
### a_message_shorter_than_the_hint_leaves_none_of_it
|
||
|
||
improvise-q0vb. The wizard's bottom row has two writers — the step's key hint
|
||
and the message that overrides it — and `set_string` 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
|
||
`FORMULA_STEP`.
|
||
|
||
Asserted on `ReviewProposals`, 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.
|
||
|
||
### set_bottom_line_clears_every_cell_it_covers_and_no_more
|
||
|
||
The padding is in terminal cells, not characters (Copilot, PR #22). `Buffer`
|
||
lays a string out by display width, so a `chars().count()` 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.
|
||
|
||
**Asserted against a sentinel**, 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 *already* 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 —
|
||
`doctrine(an_injected_fault_must_be_the_fault_you_named)`, from the other end.
|
||
|
||
So the test fills the row with `X` 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 `X`. Both faults are then visible as a surviving `X`
|
||
— one inside, one outside.
|
||
|
||
### the_array_path_step_draws_its_candidates
|
||
|
||
One of the two steps the flat-array fixture skips. `SelectArrayPath` is reached
|
||
by a document with more than one candidate array, which is also the only step
|
||
that draws `pipeline.array_paths`.
|
||
|
||
### the_preview_step_draws_an_empty_document
|
||
|
||
`WizardStep::Preview` is the one step `every_step_draws_what_it_drew_before`
|
||
never reaches (improvise-g6s1). Both fixtures there start at `ReviewProposals`
|
||
or `SelectArrayPath`, and nothing advances *into* Preview — it is only ever an
|
||
entry step, chosen by `ImportWizard::new` when the document needs no path
|
||
selection and parses to zero records.
|
||
|
||
That left it the single arm where a change to `wizard_cache` 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.
|
||
|
||
It also puts on the record a wart the step has today: the summary ends
|
||
`Sample keys:` 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.
|
||
|
||
---
|
||
|
||
## `tests/shipped_documents_load.rs`
|
||
|
||
### every_shipped_document_loads
|
||
|
||
improvise-odbc. The finding behind it: `bank-info.improv` 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 (`- JORDAN MARTINEZ`) and its data lines named a category
|
||
`Measure` the file declares as `_Measure`. Nothing loaded it, so nothing said
|
||
so.
|
||
|
||
The grammar itself is covered far better than a fixture can manage, by the
|
||
generator-driven proptests in `improvise-io`. What *those* 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.
|
||
|
||
**It walks the files the repository tracks, not a list of names**: a list is a
|
||
second place to remember, and the file that gets forgotten is the one nobody
|
||
added to it. "Shipped" means "tracked", and `shipped_documents` reads it off
|
||
the index: every `*.improv` and `*.improv.gz` path that `ls-files` lists. It does
|
||
not include untracked files, so a working `.improv.gz` in someone's tree cannot
|
||
fail their run. A document not yet added is walked once it is, which is the
|
||
state CI tests.
|
||
|
||
**How the walk got here: two steps, each closing the previous one's holes.**
|
||
|
||
- **improvise-qlrh.** The test hard-coded `["examples", "web"]`. When
|
||
improvise-plcx un-ignored `harness/fixtures/*.improv`, nothing added that
|
||
directory. Its two fixtures were then loaded only by the Playwright job,
|
||
where a missing seed degrades to an empty model and fails as a missing label,
|
||
not a load error. The fix read `!<dir>/*.improv` lines from the root
|
||
`.gitignore`, with a per-directory precondition. Red at `705c92d`:
|
||
`harness/fixtures/tall.improv` replaced with `{"legacy": true}` passed the old
|
||
test.
|
||
- **improvise-c0aj.** That `.gitignore` reading was a parser for one shape of
|
||
one file, so four other ways of shipping a document were missed silently:
|
||
- a single-file un-ignore (`!web/demo.improv`);
|
||
- a `.improv.gz` anywhere, since `*.improv` never ignores it and so no
|
||
un-ignore line names it;
|
||
- a `!` line in a nested `.gitignore`;
|
||
- a force-added (`add -f`) document.
|
||
|
||
Each now has its own test, which builds the shape in a scratch repository
|
||
and asserts the walk lists the document. **All four were red** against the
|
||
`.gitignore` walk, each failing at its `walked` assertion with its setup
|
||
commands succeeding. Walking the index closes all four by construction and
|
||
deletes the parser, because git has already answered what the parser
|
||
approximated.
|
||
|
||
**The one new failure mode, answered: no index.** `ls-files` needs a checkout.
|
||
In CI (`actions/checkout`) and every worktree it has one. No nix build or
|
||
packaging step runs this test; the flake is a dev shell only. Where there is no
|
||
index, the walk must not answer "nothing", because an empty walk passes every
|
||
per-document assertion vacuously. So it asserts `ls-files` succeeded, and
|
||
`a_directory_that_is_not_a_checkout_fails_loudly` pins that. The whole-walk
|
||
precondition "at least one document" stays as the second line of defence.
|
||
|
||
**Rivals, each run against the new walk:**
|
||
|
||
- **The `.improv.gz` pathspec removed.** Fails `a_gzipped_document_is_walked`,
|
||
and nothing else.
|
||
- **A failed `ls-files` answered with an empty list instead of a panic.** Fails
|
||
`a_directory_that_is_not_a_checkout_fails_loudly`.
|
||
- **`harness/fixtures/tall.improv` replaced with `{"legacy": true}`.** Fails
|
||
`every_shipped_document_loads`, naming the file.
|
||
|
||
**It matches on the full name, not on `Path::extension()`.** `.improv.gz` is the
|
||
same document gzipped, and `extension()` calls it `gz`. The pathspec lists both
|
||
suffixes for the same reason.
|
||
|
||
### a_single_file_un_ignore_is_walked
|
||
|
||
improvise-c0aj. This test is the first of four shape tests. Its siblings are
|
||
`a_gzipped_document_is_walked`,
|
||
`a_document_un_ignored_by_a_nested_gitignore_is_walked` and
|
||
`a_force_added_document_is_walked`. Each builds one way of shipping a document
|
||
in a scratch repository and asserts the walk lists it. All four were red
|
||
against the `.gitignore` walk. The account, including the rivals, is under
|
||
`every_shipped_document_loads` above.
|
||
|
||
### a_hostile_git_environment_changes_nothing
|
||
|
||
improvise-eahe. Each section below it once had a test of its own, and each of those tests re-ran this binary in child processes, one child per setting (a0i3 alone ran ten). By #276 that was 18 children, and each one spawns several `git` processes, so this binary took 2.1–3.3s against the 2s-per-binary tripwire (`context/design-principles.md` §7).
|
||
|
||
**What changed.** The six tests are now one test, and its two children carry every hostile setting at once:
|
||
- **Children:** one re-runs `a_gzipped_document_is_walked` and one re-runs `every_shipped_document_loads`.
|
||
- **Settings:** each child gets the template dir, the global and system config, `XDG_CONFIG_HOME`, `GIT_DIR`/`GIT_INDEX_FILE`, the five pathspec and attribute settings, the six trace variables, and the config's trace2 targets.
|
||
- **Outer index:** the test then asserts the outer repository's index is still empty. That is the check that makes the inherited `GIT_DIR` visible, as in its section below.
|
||
|
||
**Why one child still separates the settings.** The sections below show that each setting breaks the walk on its own. So a rival that disables the defence against one setting leaves that setting live in the child, and the child fails. Every rival was re-run against the combined test. The ones that were red against the per-setting tests are still red:
|
||
- dropping `--template=`;
|
||
- the `--local-env-vars` loop;
|
||
- the `core.excludesFile` or `core.attributesFile` null;
|
||
- each of `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS` and `GIT_ATTR_SOURCE`;
|
||
- the trace prefix: no strip, the exact name only, the exact three names, and the prefix minus `GIT_TRACE`, `_SETUP` or `_PERFORMANCE`;
|
||
- the trace2 `0` override.
|
||
|
||
**The ones green before are green now.**
|
||
- `GIT_ICASE_PATHSPECS` is green, as its section explains.
|
||
- The prefix minus `GIT_TRACE2_EVENT` is green, because the `0` override covers it. It was red against the per-setting test at ddcdf93a, before #276 added the override.
|
||
- **Dropping `GIT_CONFIG_GLOBAL=/dev/null` or `GIT_CONFIG_NOSYSTEM=1` was green, both at #276's head and in eahe's first version.** Their witness was an excludes line, and vwjj's `-c core.excludesFile=/dev/null` outranks every config file, so the witness could not see them. Consolidation did not cause this. improvise-3j70 has since given them a witness that no `-c` reaches (see “The user's global and system excludes” below), and both rivals are red.
|
||
|
||
**Measured** on an Apple M4 Max (16 cores), three `cargo test --workspace` runs each, before and after. The median counts only binaries that ran a test:
|
||
|
||
| | this binary | load average | run median | ratio | next largest |
|
||
|---|---|---|---|---|---|
|
||
| before (#276, 18 children) | 2.10, 3.04, 3.31s | 37–39 (1 min) | 0.00–0.01s | ≈300× | 0.90s, one 2.66s spike |
|
||
| after (2 children) | 0.57, 0.59, 0.91s | 30–33 (1 min) | 0.00–0.02s | ≈30–90× | 1.02s, one 2.49s spike |
|
||
|
||
The median is at the 0.01s resolution cargo prints, so the ratio is coarse. Before, this binary was the outlier by more than two orders of magnitude and over the tripwire in every run. After, it is below the next-largest binary in every run and well under 2s. The occasional 2.5s spike in "next largest" is some other binary, in both runs; that is load, not this file.
|
||
|
||
### An inherited `GIT_DIR` (improvise-53zz)
|
||
|
||
improvise-53zz. The scratch repositories inherited the caller's git
|
||
environment, and two parts of it change what `git -C <scratch>` does.
|
||
|
||
**The repo-local variables beat `-C`.** A hook running in a linked worktree
|
||
exports `GIT_DIR` (and, during a commit, `GIT_INDEX_FILE`). Under those,
|
||
`git -C <scratch> init` re-initialised the *outer* repository and `git add`
|
||
staged the scratch files into the *outer* index, so the test suite wrote into
|
||
the checkout that ran it. Three of the four shape tests still passed that way,
|
||
because `ls-files` read the same wrong index that `add` had just written. So a
|
||
green run was no evidence the walk had looked at the scratch repository.
|
||
|
||
The fix is one helper, `git(root)`, which removes every name that
|
||
`git rev-parse --local-env-vars` prints before adding `-C`. It asks git rather
|
||
than hard-coding a list, so the list follows the installed git. Both
|
||
`shipped_documents` and the scratch helpers go through it, so the production
|
||
walk ignores an inherited `GIT_DIR` too.
|
||
|
||
The test must set the variable, and a test thread cannot set it without
|
||
setting it for every other thread. So the test re-runs this same binary as a
|
||
child, restricted to `a_single_file_un_ignore_is_walked`, with `GIT_DIR` and
|
||
`GIT_INDEX_FILE` naming a throwaway outer repository. It asserts two things:
|
||
the child passed, and the outer index is still empty. Before the fix the second
|
||
assertion failed with `the outer index gained: .gitignore`.
|
||
|
||
Rivals, each run against the fixed file:
|
||
|
||
- Remove only `GIT_DIR`: red, because `GIT_INDEX_FILE` still sends the add to
|
||
the outer index.
|
||
- Remove only `GIT_INDEX_FILE`: red, because `GIT_DIR` does.
|
||
- Rename the child test so the child runs nothing: red on
|
||
`… did not run`. `rerun_with_env` asserts `running 1 test`, because
|
||
libtest exits 0 when a filter matches nothing, and both of these tests would
|
||
then pass without running anything.
|
||
|
||
### The user's global and system excludes (improvise-53zz)
|
||
|
||
improvise-53zz. The scratch repositories also read the user's own git config. A
|
||
`*.gz` line in a global `core.excludesFile` made `git add` refuse
|
||
`docs/model.improv.gz`, so `a_gzipped_document_is_walked` failed on that
|
||
machine and passed on others. `run_git` now sets `GIT_CONFIG_GLOBAL=/dev/null`
|
||
and `GIT_CONFIG_NOSYSTEM=1`. `shipped_documents` does not need them: `ls-files`
|
||
reads the index, and excludes never apply to a tracked file.
|
||
|
||
The test writes a config whose `excludesFile` holds `*.gz`, and re-runs the
|
||
gzip shape test in a child once under `GIT_CONFIG_GLOBAL` and once under
|
||
`GIT_CONFIG_SYSTEM`. Before the fix the global run failed at `git add`.
|
||
|
||
Rivals:
|
||
|
||
- Drop `GIT_CONFIG_GLOBAL=/dev/null`: red, in the global run.
|
||
- Drop `GIT_CONFIG_NOSYSTEM=1`: red, in the system run. The system scope is
|
||
there so that this half of the fix has a witness at all.
|
||
|
||
**Both rivals later went green, and nothing noticed (improvise-3j70).**
|
||
improvise-vwjj added `-c core.excludesFile=/dev/null` to `run_git`, and a `-c`
|
||
argument outranks every config file. So a global or system `excludesFile` could
|
||
no longer reach `git add` whether or not the config was nulled, and the two
|
||
rivals above passed from vwjj onwards. The config lines were still worth having,
|
||
since they null every *other* key, but nothing pinned them.
|
||
|
||
The witness is now a second key in the same config, `core.sharedRepository =
|
||
nonsense`. Measured on git 2.55:
|
||
- `git init` and `git add` die on it (`fatal: bad boolean config value
|
||
'nonsense' for 'core.sharedrepository'`);
|
||
- `git ls-files` does not read it, so the walk itself, which does not null the
|
||
user's config, is unaffected;
|
||
- `run_git` passes no `-c` for it.
|
||
|
||
Two keys were tried first and rejected: `init.defaultObjectFormat` and
|
||
`init.defaultRefFormat` only **warn** on an unknown value, so `init` carries on.
|
||
The key sits in `a_hostile_git_environment_changes_nothing`'s config, which the
|
||
child reads as both `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM`, so there is
|
||
still one child. Dropping either env line is red. Every other rival in the
|
||
combined test is unchanged.
|
||
|
||
### The user's default ignore and attributes files (improvise-vwjj)
|
||
|
||
improvise-vwjj, found reviewing PR #248 at `f5cfc7ed`. The test above sets
|
||
`core.excludesFile` *inside* a config file, so emptying the config files looks
|
||
like a complete fix. It is not. When `core.excludesFile` is unset, git falls
|
||
back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when
|
||
`XDG_CONFIG_HOME` is unset. No config file is involved, so
|
||
`GIT_CONFIG_GLOBAL=/dev/null` leaves the fallback in force. It is also the
|
||
commonest place to keep a global ignore, because it needs no config line.
|
||
`core.attributesFile` has the same kind of default, `git/attributes`.
|
||
|
||
The test points `XDG_CONFIG_HOME` at a temp directory and re-runs the gzip
|
||
shape test in a child twice:
|
||
|
||
- once with `git/ignore` holding `*.gz`;
|
||
- once with `git/attributes` holding `*.gz working-tree-encoding=UTF-16`.
|
||
|
||
The attributes line is chosen because it breaks `git add`: the one-byte file
|
||
cannot be decoded as UTF-16. An attribute that only changes line endings would
|
||
leave the add succeeding, and the case would witness nothing.
|
||
|
||
Before the fix (at `f5cfc7ed`) the ignore run failed at `git add`. With only
|
||
`-c core.excludesFile=/dev/null` added, the attributes run failed. So each half
|
||
of the fix is witnessed separately.
|
||
|
||
The fix is two `-c` arguments in `run_git`: `core.excludesFile=/dev/null` and
|
||
`core.attributesFile=/dev/null`. A `-c` argument outranks every config file and
|
||
every default path, so the `~/.config` fallback is closed by the same two lines.
|
||
This test sets only `XDG_CONFIG_HOME`, so the `~/.config` path is covered by
|
||
that argument, not exercised.
|
||
|
||
Rivals, each run against the fixed file:
|
||
|
||
- Drop `core.excludesFile=/dev/null`: red, `fails under a default git/ignore`.
|
||
- Drop `core.attributesFile=/dev/null`: red, `fails under a default
|
||
git/attributes`.
|
||
|
||
### The user's template directory (improvise-pr3i)
|
||
|
||
improvise-pr3i, found reviewing PR #255 at `56ac72a2`. This is a third path to the
|
||
53zz symptom. `git init` copies a template directory into the new `.git`, and
|
||
`GIT_TEMPLATE_DIR` names one. The variable is not in
|
||
`git rev-parse --local-env-vars`, so `git(root)` does not remove it. It also
|
||
bypasses config, so `GIT_CONFIG_GLOBAL=/dev/null` (which nulls
|
||
`init.templateDir`) does not reach it either. A template whose `info/exclude`
|
||
holds `*.gz` therefore became the scratch repository's own exclude file, and
|
||
`git add` refused the gzipped document.
|
||
|
||
The test points `GIT_TEMPLATE_DIR` at such a template and re-runs
|
||
`a_gzipped_document_is_walked` in a child. Red at `56ac72a2`, failing at `git add`
|
||
with `Use -f if you really want to add them`. That line appears in the failure
|
||
message only because this PR also makes `rerun_with_env`'s callers print the
|
||
child's **stderr** (`told`). The child's panic goes to stderr, and before this
|
||
change the message printed an empty stdout.
|
||
|
||
The fix is `git init -q --template=`. An empty template directory copies nothing,
|
||
whatever the environment, config or compiled-in default says. The obvious
|
||
alternative, `env_remove("GIT_TEMPLATE_DIR")`, passes this test equally well, so
|
||
the choice between them is made by argument, not by the test. Removing the
|
||
variable still lets git copy its compiled-in default template, which a
|
||
distribution may have edited. `--template=` closes that too, for the same reason
|
||
the `-c` arguments in `run_git` beat relying on the absence of a config file.
|
||
|
||
Rival: drop `--template=`. Red, as above.
|
||
|
||
### Pathspec and attribute settings (improvise-a0i3)
|
||
|
||
improvise-a0i3. `git(root)` strips what `git rev-parse --local-env-vars` names,
|
||
and that list holds only the variables that point at a repository (`GIT_DIR`,
|
||
`GIT_INDEX_FILE` and so on). Pathspec magic and the attribute source are
|
||
process-wide settings, so the list names none of them. They still change what
|
||
`git ls-files -- '*.improv' '*.improv.gz'` answers:
|
||
|
||
- `GIT_LITERAL_PATHSPECS=1` reads `*.improv` as a file literally named
|
||
`*.improv`, so the walk finds nothing.
|
||
- `GIT_GLOB_PATHSPECS=1` stops `*` at `/`, so only a document at the root
|
||
matches. `GIT_NOGLOB_PATHSPECS=1` reads `*` literally, like the literal
|
||
setting. Every document these tests walk sits below the root, so both
|
||
settings made both tests fail.
|
||
- `GIT_ATTR_SOURCE=HEAD` makes git read attributes from `HEAD`. The scratch
|
||
repository in the gzip shape test has no commit, so `git add` fails with
|
||
`bad --attr-source`.
|
||
|
||
The test reruns `a_gzipped_document_is_walked` and `every_shipped_document_loads`
|
||
in a child under each of five settings, which is ten runs, and collects every
|
||
failure before it asserts. Red at #260's head `322de370`: 7 of 10 runs failed.
|
||
`LITERAL`, `GLOB` and `NOGLOB` each broke both tests. `ATTR_SOURCE` broke only
|
||
the gzip shape test, because the real checkout has a `HEAD`. The ticket reported
|
||
`ATTR_SOURCE` breaking both tests. Here it broke one, so the difference is in
|
||
the checkout the ticket measured, not in the fix.
|
||
|
||
The fix is to remove the five variables in `git()`, next to the
|
||
`--local-env-vars` loop. A `--literal-pathspecs`-style command-line flag would
|
||
have to be chosen per variable, and `GIT_ATTR_SOURCE` has no negating flag.
|
||
|
||
Rivals, each dropping one name from `PATHSPEC_AND_ATTRIBUTE_SETTINGS`:
|
||
|
||
- Without `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS` or
|
||
`GIT_NOGLOB_PATHSPECS`: red, both tests under that setting.
|
||
- Without `GIT_ATTR_SOURCE`: red, the gzip shape test.
|
||
- Without `GIT_ICASE_PATHSPECS`: **green.** A case-insensitive match of
|
||
`*.improv` still finds every document, so this row of the test cannot fail.
|
||
The name stays in the list because it is the fourth of git's pathspec
|
||
variables, and a future spelling of the walk that depends on case would be
|
||
exposed to it. The test covers it, but that coverage is vacuous today.
|
||
|
||
### A git trace sent to stdout (improvise-2iws, improvise-vav0)
|
||
|
||
improvise-2iws, with improvise-vav0 folded in. The `GIT_TRACE*` family is
|
||
process-wide, like improvise-a0i3's settings, so `--local-env-vars` lists none
|
||
of them either. Pointed at `/dev/stdout`, git writes trace lines into the
|
||
`ls-files -z` output the walk parses, and they are read as paths.
|
||
`every_shipped_document_loads` panicked on "Cannot open <root>/17:39:40… git.c:502
|
||
trace: built-in: git ls-files …".
|
||
|
||
**The fix has two parts.**
|
||
- `untraced_git()` removes every variable whose name starts with `GIT_TRACE`. A
|
||
prefix match, because the family is open-ended. `git()` builds on it, and so
|
||
does the `rev-parse --local-env-vars` call, whose stdout is parsed as well.
|
||
- It then sets `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0`.
|
||
A user config's `trace2.normalTarget`, `eventTarget` or `perfTarget` is a
|
||
**config** setting, which no environment removal reaches, and the walk's git
|
||
does not null the user's config (only `run_git` does). An environment value of
|
||
`0` outranks it (improvise-vav0, verified by the #276 reviewer on git 2.55).
|
||
|
||
**The test** re-runs `a_gzipped_document_is_walked` and
|
||
`every_shipped_document_loads` in child processes. Each child gets all six
|
||
family variables that write to their target on git 2.55, set to `/dev/stdout`:
|
||
`GIT_TRACE`, `GIT_TRACE_SETUP`, `GIT_TRACE_PERFORMANCE`, `GIT_TRACE2`,
|
||
`GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF`. It also gets a `GIT_CONFIG_GLOBAL`
|
||
whose `[trace2]` section sends all three targets to `/dev/stdout`. There is one
|
||
child per test, not one per setting, to keep this binary's child count down
|
||
(improvise-eahe). The test failed at #269's head 4cef40c9 for both tests.
|
||
|
||
The first version set only three variables, so an exact list of those three
|
||
names passed it: the review of #276 found that. With all six set, an exact
|
||
three-name list leaves `GIT_TRACE_SETUP` behind and fails.
|
||
|
||
Rivals, each injected into `untraced_git` and restored. Every one fails:
|
||
- **no strip**;
|
||
- **the exact names `GIT_TRACE`, `GIT_TRACE2_EVENT`, `GIT_TRACE_PERFORMANCE`**:
|
||
this is the rival the review found passing;
|
||
- **the prefix minus `GIT_TRACE`**, **minus `GIT_TRACE_SETUP`**, and **minus
|
||
`GIT_TRACE_PERFORMANCE`**: each of the three variables that the `0` override
|
||
does not cover breaks the walk on its own;
|
||
- **no `0` override** (removal only): the config's targets break the walk.
|
||
|
||
The `GIT_TRACE2*` variables have no removal rival of their own. The `0` override
|
||
covers them whether or not they are removed.
|
||
|
||
---
|
||
|
||
## `tests/web_demo_model.rs`
|
||
|
||
### every_web_demo_cell_is_settable_except_the_column_a_formula_owns
|
||
|
||
improvise-bck. The demo document the standalone page opens must be one a visitor
|
||
can *type into*, and the bug is not hypothetical: `examples/demo.improv` leaves
|
||
`Customer` and `Date` 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.
|
||
|
||
**The question is asked exactly the way the browser asks it.** ADR 002 says a
|
||
cell is settable iff its `CellId` is `Record`, `project::cell_id` is the one
|
||
place that decides, and `render.rs::editable` in the wasm client is that variant
|
||
and nothing else (gotcha 42). So the test projects the demo's whole grid through
|
||
`grid_of` — 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.
|
||
|
||
It was proved discriminating by pointing it at `examples/demo.improv`, where it
|
||
names the aggregate at (0,0).
|
||
|
||
**improvise-ir7l renamed it, because the old name was false.** The demo's
|
||
third column is `Profit = Revenue - Cost`, and a formula target is a stored
|
||
coordinate, so it projected `Record` 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 `Record` and the `Profit` column must
|
||
be `CellId::Derived`, so it fails if either half moves. Proved discriminating
|
||
in both directions by deleting the `derived` branch of `project::cell_id`
|
||
(Profit reads back as a `Record`) and, separately, by pointing it at
|
||
`examples/demo.improv` again (the aggregate at (0,0) still fails it).
|
||
|
||
### the_web_demo_shows_a_formula_beside_its_inputs
|
||
|
||
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. `Profit` at (0,2) is
|
||
`18,400 - 6,200`, recomputed on load.
|
||
|
||
---
|
||
|
||
## `src/draw.rs`
|
||
|
||
### release_kind_converts_like_press
|
||
|
||
The pre-conversion event loop never inspected `KeyEventKind`, so all kinds
|
||
(Press/Repeat/Release) were dispatched. The conversion keeps that behaviour: a
|
||
Release event converts like any other.
|
||
|
||
---
|
||
|
||
## The panel-migration equivalence tests (improvise-35e)
|
||
|
||
Each panel widget carries a pair: one test saying the *inputs* are unchanged by
|
||
the move onto a projected cache, one pinning the *drawing* 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.
|
||
|
||
### ui::tile_bar::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
|
||
|
||
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
|
||
`view.tileable_cats(&model.category_names())` and `view.axis_of(cat)` off the
|
||
model directly; the formula panel used to read `model.formulas()[i].raw`. Each
|
||
now reads its cache, and a divergence is a rendering change nobody asked for.
|
||
|
||
### the_cache_reproduces_the_summary_the_widget_used_to_build
|
||
|
||
`src/ui/view_panel.rs`. The axis summary used to be built from `workbook.views`
|
||
plus `View::categories_on`; it is now built from `ViewSummary::axes`. Same
|
||
names, same order, same virtual-category filtering.
|
||
|
||
### the_active_view_summary_is_not_empty
|
||
|
||
The fixture must actually exercise the summary, or the equivalence above is two
|
||
empty strings agreeing.
|
||
|
||
---
|
||
|
||
## `examples/gen-grammar.rs`
|
||
|
||
Not a test, but the generator the `improvise-io` 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, `pipe_inner` is never empty,
|
||
`rest_of_line` always produces at least one character, and repetitions (`*`)
|
||
produce 1–4 items rather than 0. Each invocation is seeded from the current time
|
||
plus the PID.
|
||
|
||
|
||
### a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open
|
||
|
||
### the_minibuffer_prompt_is_not_displaced_by_the_message
|
||
|
||
### an_empty_message_never_pushes_a_row_that_fits_past_its_width
|
||
|
||
improvise-mai9. `draw_bottom_bar` rendered the minibuffer **instead of** the
|
||
status line, so a message written while a text-entry mode was open was never
|
||
drawn — not truncated, not overwritten. A test asserting `app.status_msg` was
|
||
set passed in every one of those cases, which is how this survived
|
||
improvise-6t3's careful effect ordering.
|
||
|
||
Of the ticket's two candidate fixes, the row is **shared** rather than the
|
||
callers being made structurally unable to report: giving the status a second
|
||
row changes frame geometry (improvise-q0vb's cost), and "always leave the mode
|
||
before reporting" is what `CommitSave` does by hand and would have to be
|
||
repeated at every text-entry commit — the thing the ticket calls a workaround.
|
||
|
||
**The first version of these tests could not fail for the bug, and that is the
|
||
entry worth reading.** They called the composing helper `minibuffer_line`
|
||
directly. The defect was in the *wiring* — `draw_bottom_bar` building its own
|
||
`format!` and never consulting `status_msg` — 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 `use` kept it alive while it asserted nothing about the
|
||
product. Found by review (PR #77), reproduced here before rewriting.
|
||
|
||
So the guard is now `Buffer`-backed: a real `Terminal<TestBackend>` renders
|
||
`draw_bottom_bar` 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 `format!("{}{}", mb.prompt, buf)` plus the cursor glyph — both wiring
|
||
tests fail, the first naming the missing message and printing the row it did
|
||
compose.
|
||
|
||
The second test exists because a "fix" that dropped to `draw_status` would
|
||
satisfy the first on the message alone; it pins that the prompt still leads.
|
||
|
||
**The third test is the negative arm, and its first version was unfalsifiable
|
||
for arithmetic reasons.** `fill_line` computes `pad = width - left.len() -
|
||
right.len()`, so with an empty status an unconditional `format!(" {msg} ")`
|
||
produces a byte-identical row at any width where the line fits: `left` plus
|
||
`width - left.len()` spaces, either way. The `match status_msg.is_empty()` is
|
||
observable **only** in the band where the prompt fits but `left.len() + 4`
|
||
does not — widths 16..=19 for this fixture, where the unguarded arm returns a
|
||
row wider than the terminal.
|
||
|
||
That test then failed a second time in a more interesting way: it derived its
|
||
lower bound by *calling `minibuffer_line` itself*, so the injected fault moved
|
||
the bound with it and the loop skipped the failing band entirely. The bound is
|
||
now spelled locally. **A bound read off the function under test is not a
|
||
bound** — it is the fault grading its own exam.
|
||
|
||
Known and not fixed here: `fill_line` pads on `left.len()`, which is bytes, so
|
||
the right-hand message sits two cells further right than intended whenever the
|
||
left contains the multi-byte cursor glyph. `draw_status` 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.
|
||
|
||
### a_group_header_first_on_its_axis_reaches_both_front_ends
|
||
|
||
improvise-0o6x. `AxisFrame::body_from(idx)` started at the first **item**, so at
|
||
the top of the axis a leading `FrameEntry::Group` 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; `body_from` dropped it.
|
||
|
||
It is a boundary condition, as the ticket suspected, but the boundary is not
|
||
`idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled
|
||
window for `group_of` and `body_from` discards it — correctly. At the top of
|
||
the axis there is no "above", and a frame cannot tell the two apart from `idx`
|
||
alone, because the browser's frame is windowed and always passes `0`. So
|
||
`AxisFrame` gained `first`, the absolute data index of its first item, and the
|
||
rule is `first + idx == 0`. Without it the browser would have drawn an extra
|
||
header row at every scrolled position that the TUI does not.
|
||
|
||
This test is separate from `the_two_front_ends_draw_the_same_row_header`
|
||
because that fixture orders `Carol` before `Family` to dodge this case **on
|
||
purpose**, 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.
|
||
|
||
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 `axis_frame`'s `pending` queue — and a first
|
||
version of the fix had `GroupEnd` **clear** `pending` above the window, which
|
||
deleted exactly that header. `GroupEnd` is now queued like a header and clears
|
||
nothing.
|
||
|
||
Fault injected by forcing `body_from`'s match to `false`: this test fails and
|
||
`the_whole_frame_is_unchanged` fails with it.
|
||
|
||
### two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends
|
||
|
||
improvise-h0iv, end to end. The unit demonstration is in
|
||
`context/test-evidence/protocol.md`; this is the same rule driven through a real
|
||
`GridLayout`, a real `project::grid_of` and both renderers, because the
|
||
reachability of the shape was itself in question.
|
||
|
||
`two_level_rows` has exactly h0iv's shape — `Q1` grouping two of three months,
|
||
cross-producted under two measures — and is **saved by the ungrouped `Sep`
|
||
between the runs**, which is also what makes improvise-gd83's spans come out as
|
||
`[(Q1,2), (None,1), (Q1,2), (None,1)]`. `abutting_column_groups` removes `Sep`
|
||
and nothing else, so the two `Q1` 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.
|
||
|
||
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 `group_runs`.
|
||
|
||
Fault injected 2026-09-21: restoring the name-only comparison in
|
||
`AxisFrame::group_runs` fails this with `left: [(Some("Q1"), 4)]` while the five
|
||
existing agreement tests all pass — the ungrouped `Sep` in their fixture is
|
||
precisely why they cannot see it.
|
||
|
||
---
|
||
|
||
## `src/draw.rs::title_bar_tests` — the title bar's escape hatch
|
||
|
||
improvise-t14d. `fill_line` composed a left and a right segment into one row and
|
||
did **neither** of the two things that composition needs. It padded with
|
||
`left.len() + right.len()` — **bytes** — and it never truncated, so on overflow
|
||
it simply concatenated and left ratatui to clip. ratatui clips the *right*, and
|
||
the right segment of the title bar is `" ?:help :q quit "`: the only thing on
|
||
screen telling a stuck user how to get help or get out.
|
||
|
||
**The priority was backwards.** The left segment is decoration — the product
|
||
name, the document name, the filename, a dirty marker — and its length is
|
||
unbounded because a document name is. The right segment is a fixed 17 cells and
|
||
is the escape hatch. Giving the unbounded decoration unconditional priority over
|
||
the fixed affordance means the affordance is the first thing sacrificed, and it
|
||
is sacrificed at ordinary widths: measured through a real `TestBackend`, a
|
||
document named `Quarterly Regional Sales Analysis` stored as
|
||
`quarterly-regional-sales-2026.improv` loses `:q quit` **at 100 columns**.
|
||
|
||
The byte fault is separate and universal. The title's own format string spells
|
||
`·`, two bytes and one cell, so `left.len()` over-counts by one for *every*
|
||
document — the padding is one column short and the right segment sits one cell
|
||
in from the edge it is anchored to, before any non-ASCII document name makes it
|
||
worse.
|
||
|
||
`Keeps` is the fix's shape: which segment survives is a per-call-site decision,
|
||
not a global rule. The title bar keeps the **right** (the hatch outranks the
|
||
name). The two bottom-bar call sites keep the **left**, which is what they did
|
||
before — there the left is the user's own typing (`minibuffer_line`) or the
|
||
status message (`draw_status`), and the right is a view badge, so right-priority
|
||
would have let a long message eat the text the user is editing along with the
|
||
cursor. Widths are `ui::text::truncate`'s display cells throughout, so the
|
||
overflow is a graceful `…` rather than a mid-token clip of `?:`.
|
||
|
||
### the_escape_hatch_outlives_a_long_document_name
|
||
|
||
The defect itself, at the five widths the ticket measured (36, 44, 52, 60, 100).
|
||
Rendered through a `TestBackend` and read off the terminal buffer, not off
|
||
`fill_line`'s return value: the loss happens in ratatui's clip, so a helper's
|
||
return value cannot see it. **A test at 80 columns with a short fixture name
|
||
passes against the broken code** — title length is the variable, not width.
|
||
|
||
Fault injected: `Keeps::Right` → `Keeps::Left` at the `draw_title` call site.
|
||
Fails at width 36 with `" improvise · Quarterly Regional S"`; the other four
|
||
tests in the module pass, so this one and only this one names the priority.
|
||
|
||
### the_document_name_still_leads_the_row
|
||
|
||
The other direction, which the escape-hatch assertion alone cannot supply: a fix
|
||
that dropped the left segment entirely, or that put the hatch first, satisfies
|
||
it. At 80 columns with a short name nothing is truncated, so this pins that the
|
||
name leads, the hatch follows, and the filename survives intact.
|
||
|
||
### a_multibyte_title_does_not_shift_the_right_segment
|
||
|
||
The byte-versus-cell fault. Asserts the row *ends with* the right segment — the
|
||
edge it is anchored to — for an ASCII name, an accented one and a CJK one. The
|
||
ASCII case is the discriminating one and the surprise: it fails against the
|
||
broken code too, because the `·` is in the format string rather than in the
|
||
document's name.
|
||
|
||
Fault injected: `width - left.width() - right.width()` →
|
||
`width.saturating_sub(left.len() + right.len())`, keeping the truncation. Fails
|
||
on `"Sales"` with the right segment one cell in from the edge, and takes
|
||
`a_composed_line_is_exactly_its_width` with it. The escape-hatch test does not
|
||
see it, which is why the two faults need two tests.
|
||
|
||
### a_composed_line_is_exactly_its_width
|
||
|
||
The property both directions owe, over 0..40 columns, three segment pairs and
|
||
both `Keeps` arms: a wide-character left segment, an empty left, and an empty
|
||
right. Width 0 and the band where the two segments cannot both fit are the cases
|
||
`fill_line`'s arithmetic can get wrong — it subtracts rather than saturating, so
|
||
an off-by-one here is a panic and not a cosmetic slip.
|
||
|
||
### the_kept_side_is_the_side_that_survives
|
||
|
||
`Keeps::Left` has no other witness in this module: every rendering test above
|
||
drives the title bar, which keeps the right. Without this, both arms could be
|
||
the same code and the suite would stay green — which is what the enum exists to
|
||
prevent.
|
||
|
||
Fault injected: the `Keeps::Left` arm rewritten to the body of the `Keeps::Right`
|
||
arm. This test fails; the other four pass.
|
||
|
||
---
|
||
|
||
## `src/ui/tile_bar.rs` + `src/draw.rs` — one hint table on screen, and it is the derived one
|
||
|
||
improvise-rr8n. Two hand-written key tables were drawn **beside** derived ones,
|
||
in a different notation, and one of them was outright false.
|
||
|
||
**The tile bar's non-TileSelect hint named keys that do something else.** It read
|
||
`" Ctrl+↑↓←→ to move tiles"`, while `Ctrl-Left/Right/Up/Down` are bound in Normal
|
||
to `enter-tile-select` — pressing one *enters* tile mode and moves nothing. It
|
||
was also the only persistent on-screen documentation of those keys, and it spelt
|
||
`Ctrl+` where `keymap::key_label`, and therefore every derived hint and help row,
|
||
spells `Ctrl-`.
|
||
|
||
**Nothing could have caught it.** `every_hint_key_resolves_in_its_mode` checks
|
||
`AppMode::hint_text()` against the keymaps; a string literal in a widget is
|
||
invisible to it. And the shipped fixture `renders_each_tile_with_its_axis_label`
|
||
renders at **80 columns**, where the hint does not fit (`x + hint.width() <=
|
||
area.x + area.width` is false) and is never drawn — so the one test that pins
|
||
this widget's output could not see either hint at all. That is why the new tests
|
||
render at 140.
|
||
|
||
**The fix deletes rather than rewrites, and that departs from the ticket's AC.**
|
||
improvise-rr8n proposed rewriting the string to `" Ctrl-↑↓←→ or T to pick a
|
||
tile"` and suppressing the *status bar's* hint in TileSelect. Measured against
|
||
the ticket's own second finding, that is backwards:
|
||
|
||
```
|
||
tile bar (hand-written): [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel
|
||
status bar (derived): hl←→:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back
|
||
```
|
||
|
||
The derived line is a strict **superset** — it carries `Space`, `h`/`l` and
|
||
`Tab`, which the tile bar's omits — and it cannot go stale, being read off the
|
||
keymap. Suppressing it would have deleted information and kept the table that
|
||
had just been shown to rot. So the tile bar now advertises nothing, and in
|
||
Normal the derived line already says `T:tiles`; only the `Ctrl-`arrow spelling
|
||
is lost, and it was never documented correctly.
|
||
|
||
**The wizard goes the other way, and there the ticket is right.** Its overlay
|
||
draws correct per-step hints, and the status bar drew one static
|
||
`Space:toggle c:cycle Enter:next Esc:cancel` under it — wrong on four of eight
|
||
steps, including an outright contradiction on `Done`, where the bar says `Esc`
|
||
cancels and `Esc` goes *back*. It is static because it is derived from the
|
||
`ImportWizard` keymap, while the wizard's real keys are resolved per step inside
|
||
`HandleWizardKey`; so the derivation is sound and its *input* is the wrong thing.
|
||
`draw_status` therefore suppresses the hint fallback for that one mode.
|
||
|
||
The asymmetry is the point: **keep whichever table is derived from the thing it
|
||
describes.** For tile select that is the status line; for the wizard it is the
|
||
overlay.
|
||
|
||
### the_tile_bar_advertises_no_keys_of_its_own
|
||
|
||
Renders the bar in Normal and TileSelect at 140 columns and requires that neither
|
||
row names `Ctrl`, `Enter`, `Esc`, `←→` or `move tiles`. An absence assertion, so
|
||
it is paired with the two below that require the information to still exist
|
||
elsewhere.
|
||
|
||
Two faults injected, both caught here and by nothing else:
|
||
|
||
- The original `else` branch restored — fails in Normal on `"Ctrl"`, quoting the
|
||
whole false line.
|
||
- Only the `else` branch deleted, keeping the TileSelect literal (the narrow
|
||
reading of finding 1) — fails in TileSelect on `"Enter"`. This is the rival
|
||
that matters, because it is the fix a reader of finding 1 alone would write,
|
||
and it leaves two disagreeing dialects stacked on adjacent rows.
|
||
|
||
Note it would also fail against the ticket's own proposed replacement string,
|
||
which still contains `Ctrl`. That is deliberate rather than accidental: the
|
||
property asserted is "this widget names no keys", not "this widget's keys are
|
||
currently right".
|
||
|
||
### the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to
|
||
|
||
The counterweight. Deleting a `set_string` at the end of a render function is
|
||
the kind of edit that takes a neighbour with it, so this pins the tiles and the
|
||
`Tiles:` prefix at 80 and 140. It also covers the dead `x += 1` the deletion
|
||
orphaned — clippy caught that one, but the test is what says the drawing still
|
||
lands.
|
||
|
||
### the_wizard_overlay_is_the_only_key_hint_on_screen
|
||
|
||
The wizard half. Requires the bottom row in `ImportWizard` to name none of
|
||
`Space:toggle`, `c:cycle`, `Esc:cancel`. Fails against the unfixed code quoting
|
||
the full row, including the `Esc:cancel` that contradicts the overlay.
|
||
|
||
### suppressing_the_wizard_hint_leaves_the_rest_of_the_bar
|
||
|
||
The direction the suppression could overshoot: the mode label `IMPORT` and a real
|
||
`status_msg` are not hints and must survive. A fix that blanked the bar in that
|
||
mode, or that suppressed the *message* along with the hint, fails here.
|
||
|
||
### the_status_bar_still_advertises_the_tile_keys
|
||
|
||
The counterweight to the tile bar deletion, and the test that rejects the
|
||
ticket's own AC: Normal must still say `T:tiles`, and TileSelect must still carry
|
||
`Enter/Space:cycle`, `r:row` and `Esc/Tab:back`.
|
||
|
||
Fault injected: suppressing the hint fallback unconditionally
|
||
(`true => String::new()`) rather than for `ImportWizard` alone. All three wizard
|
||
tests pass — including both of the ones *aimed* at the suppression — and only
|
||
this one fails. That is the shape design-principles §7 names: the assertion next
|
||
door is satisfied by the wrong fix, and the discriminating input is the mode the
|
||
change was not supposed to touch.
|
||
|
||
### the_help_footer_names_the_keys_the_keymap_binds
|
||
|
||
**What was wrong (improvise-17ak).** `src/ui/help.rs::render_footer`
|
||
hand-wrote the popup's paging keys: `l/Tab: next`, `h/S-Tab: prev`,
|
||
`q/Esc: close`. No check read them, since `every_hint_key_resolves_in_its_mode`
|
||
cannot see a string literal in a widget (the blind spot improvise-rr8n and
|
||
improvise-wk18 name). The literal was also already incomplete: the keymap binds
|
||
`help-page-next` to `n` and `→` as well, and `?` closes help, and the footer
|
||
advertised none of them.
|
||
|
||
**Derive rather than delete.** rr8n's rule is to keep whichever table is
|
||
derived from the thing it describes, and the derived one here is the status
|
||
row's `hint_line(Help)`. The popup's margins always leave that row uncovered,
|
||
which argues for deleting the footer's keys. But `draw_status` prints
|
||
`status_msg` *instead of* `hint_text()` whenever the message is non-empty, and
|
||
nothing clears the message on entering Help. So after any message ("Saved to
|
||
…", an error), a user who presses `?` would see no paging keys at all. The
|
||
footer therefore now draws the page indicator followed by `hint_line(Help)`
|
||
itself, truncated in display cells (gotcha 50).
|
||
|
||
**The test.** It renders the popup at 140×30, wide enough that the whole hint
|
||
fits (the rr8n lesson: an 80-column render can hide the very thing asserted),
|
||
on every page. It requires the footer row to contain `hint_line(&AppMode::Help)`
|
||
exactly, and first asserts that hint is non-empty and pages.
|
||
|
||
**Red, and the rival that matters.**
|
||
- At base it fails on page 0: `"page 1 of 5 l/Tab: next q/Esc: close"` is
|
||
not `"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close"`.
|
||
- A footer holding a literal copy of today's derived string passes today, so it
|
||
was injected together with a rebinding (`n` removed from `help-page-next` in
|
||
the protocol keymap). The test then fails: the copy still advertises `n`,
|
||
which the keymap no longer binds. That rebinding case is exactly the one the
|
||
ticket said the old footer would lie through.
|
||
|
||
### a_status_message_gives_way_to_the_hint_on_the_next_key
|
||
|
||
**What broke (improvise-cq3d).** Nothing in production ever cleared
|
||
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
|
||
first message of a session, the TUI never drew the key hint again, in any mode.
|
||
|
||
**The case, in three steps, each guarding one direction.**
|
||
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
|
||
the one the message was written in, the row must hold TileSelect's hint and
|
||
not the stale message. This is the ticket's assertion.
|
||
2. `Space` cycles an axis and writes a message: that message must be on the row
|
||
after its own key. This guards against expiring too eagerly.
|
||
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
|
||
|
||
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
|
||
than `App::handle_key`, because the expiry is the front end's rule.
|
||
|
||
**Red, and the rival.**
|
||
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
|
||
at step 1: `" TILES Saved to x.improv …"`.
|
||
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
|
||
must say what it did", since every message is wiped before it can be drawn.
|
||
|
||
### a_minibuffer_refusal_stands_while_the_user_fixes_it
|
||
|
||
improvise-su4u. It drives `draw.rs::press`, the function `run_tui` calls,
|
||
because the expiry is the TUI loop's rule. In the formula panel it presses `n`,
|
||
types `Total = (` and `Enter`. The formula does not parse, so the editor stays
|
||
open with `Formula error` on the bottom row. Then:
|
||
|
||
1. `Backspace`: still editing, and the error must still be drawn. It failed here
|
||
at base 202e7eac: `"formula: Total = ▌"`, with the error gone.
|
||
2. `Enter` on the still-bad `Total = `: a new refusal, which must differ from
|
||
the first, and whose text must be on the row. A key's own message beats the
|
||
stale one.
|
||
3. `1` and `Enter`: the fixed formula commits and leaves the editor, and the
|
||
error must be gone.
|
||
4. A second run: `Esc` straight after the refusal must drop the error too.
|
||
|
||
Rivals, each injected into `press` and restored:
|
||
- **Never restore** (#233's behaviour) fails step 1.
|
||
- **Restore even when the key wrote its own message** fails step 2.
|
||
- **Restore whatever the mode after the key** fails step 4, because the error
|
||
outlives `Esc`. It does not fail step 3, because a successful commit writes
|
||
its own message. That is why step 4 exists.
|
||
|
||
### a_message_does_not_follow_the_editor_onto_the_next_cell
|
||
|
||
improvise-sx7h, a regression #257 introduced against #233. Equal modes stood for
|
||
"the same editor", but `Editing`/`RecordsEditing` commit by re-opening an equal
|
||
mode on the next cell. The test drives `press` through four scenarios:
|
||
1. **Records.** `R`, then `o` ("Added new record row", in `RecordsEditing`),
|
||
then `5`. The message stands while typing in the same editor. Then `Tab`: the
|
||
editor re-opens on the next cell, and the message must be gone.
|
||
2. **Records.** `R`, `o`, `Esc`, then `o` again, which is refused with "That
|
||
record row is already here…". Then `5`, with the refusal still drawn. Then
|
||
`Tab`, which fills the row in, and the refusal must be gone.
|
||
3. **A plain grid.** `i` on `(0, 0)`, with a note set while editing. Then `7`,
|
||
with the note kept. Then `Enter`: the editor re-opens a row down, and the
|
||
note must be gone.
|
||
4. **The last column.** `i`, a note, `7`, `Tab`. The cursor cannot advance, so
|
||
the editor re-opens on the **same** cell, and the note must be gone.
|
||
|
||
Scenarios 1 and 2 are the reviewer's repro.
|
||
|
||
Rivals, each injected into `EditorSession::of` and restored:
|
||
- **mode only**, which is #257's rule as pushed at 2b1c5bf7, fails at
|
||
scenario 1;
|
||
- **no cursor cell** fails at scenario 1, because the new record's cells both
|
||
seed `""`, so the seed alone cannot tell them apart;
|
||
- **no seed** fails at scenario 4, the only place the cell does not change.
|
||
|
||
### an_editor_on_another_category_is_another_editor
|
||
|
||
improvise-sx7h's pinning test, asked for by the review. The mode is compared
|
||
whole, not by variant. Two `ItemAdd` sessions for different categories must be
|
||
different editors, and the same category must be the same. No key reaches that
|
||
transition, so it is asserted on `EditorSession::of` directly. The rival that
|
||
compares `std::mem::discriminant` fails it, and passes every other test.
|
||
|
||
### moving_right_keeps_the_selected_cell_drawn_beside_any_side_panel
|
||
|
||
improvise-gtn7.1, the grid half. The user's report was that "the cursor can go
|
||
behind the category screen". The keypress path counted `visible_cols` from
|
||
`term_width`, while `draw_content` gave the grid `term_width - 32` whenever a
|
||
side panel was open. So `viewport_effects` scrolled only after the cursor had
|
||
passed a column the widget never drew. The reasoning, and why it read as a
|
||
regression, is in `context/code/tui.md`.
|
||
|
||
**The test is a boundary test, on purpose.** The defect was a disagreement
|
||
between two measurements, one in `improvise-command` and one in the frame
|
||
layout. A unit test of either would pass against the other. So the test renders
|
||
the real `draw` into an 80×24 `TestBackend`, presses `Right` eleven times across
|
||
twelve columns through `press`, and after each press requires the selected
|
||
cell's text to be on screen. Each column holds a distinct value (`101 + i`), and
|
||
the selection is read off the cells drawn in the selection style (black on
|
||
cyan). So "the cursor is somewhere" and "this column is drawn" cannot be
|
||
confused.
|
||
|
||
It runs four times: no panel, and each of the three panels alone. The no-panel
|
||
run is the control, and it passes on the unfixed code.
|
||
|
||
**The fixture was nearly vacuous.** The first version's "no panel" run failed
|
||
too, because `ViewState::default()` opens the category panel at launch
|
||
(`50e4a372`). So the control was a fourth copy of the category case. All three
|
||
panels are now closed before one is opened. That launch default is also why a
|
||
defect from April read as a regression in September.
|
||
|
||
Red at `ae5cf408`: `formula panel: column 4 is selected (offset 0) but not
|
||
drawn`. Seven 10-cell columns fit beside a 4-cell row header in 78 cells, but
|
||
only four fit in 46.
|
||
|
||
Rivals, each injected and restored:
|
||
- **`cmd_context` counts on `self.term_width` again:** red, `formula panel:
|
||
column 4`.
|
||
- **`draw_content` splits 8 columns narrower than `grid_width`**, a second
|
||
derivation that disagrees with the first: red, `no panel: column 6`.
|
||
- **`side_panel_open` counts only the category panel:** green, and correctly.
|
||
`draw_content` asks the same function whether to draw a panel, so it draws no
|
||
formula panel, the grid gets the full width, and the two sides still agree.
|
||
This is the benefit of one derivation: a wrong answer there is wrong
|
||
everywhere at once, not a disagreement.
|
||
|
||
Not covered: columns of mixed width with no panel open still lose the cursor,
|
||
for a different reason (improvise-gtn7.2).
|
||
|
||
### the_selected_tile_is_drawn_at_every_width_it_fits_in
|
||
|
||
improvise-gtn7.1, the tile half: "the cursor goes off screen completely when
|
||
navigating the tiles". `TileBar::render` chose its first tile against
|
||
`area.width - prefix`, but drew from `prefix + 1`, and from `prefix + 2` once
|
||
scrolled (for the `◀`). When the selected tile fitted within the one or two
|
||
columns the scroll had over-counted, the scroll stopped early and the draw loop
|
||
`break`s before it, so the tile vanished. Red at `ae5cf408`: `width 42: tile 3
|
||
(Category_0) is selected but not drawn: " Tiles: ◀ [_Measure Pag] ▶"`.
|
||
|
||
The test enumerates rather than picks. It uses eight regular categories, which
|
||
with the three virtual ones gives eleven tiles. It checks every width from 40 to
|
||
200 and every tile as the selection, and requires the selected tile's label on
|
||
a cyan cell. The failing band is one or two columns wide at each scroll
|
||
position, so a hand-picked width would very likely have missed it.
|
||
|
||
Rivals, injected into `first_tile` and restored:
|
||
- **Measure from the end of the prefix**, as the old loop did: red at width 42.
|
||
- **Forget only the `◀` column** (`prefix + 1`): red at width 43. So the test
|
||
sees an off-by-one, not only the two-column case.
|
||
|
||
### wrapping_keeps_every_word_within_the_width
|
||
|
||
improvise-cgqt: the Confirm step drew each warning with `truncate`, so the
|
||
user's 31-cell collision warning read as one line ending in `…`. The test wraps
|
||
a warning-shaped string, with a double-width run and an accented word, at six
|
||
widths from 1 to 200. It asserts every word survives in order and no line is
|
||
wider than asked. The exemption is a lone character wider than the line, which
|
||
is emitted rather than dropped; the test's first version asserted otherwise,
|
||
which is impossible at width 1. Rivals, each injected and restored:
|
||
**wrap truncates an over-long word** fails it, and **wrap drops the indent**
|
||
fails `wrapping_keeps_a_leading_indent_on_every_line`.
|
||
|
||
### a_warning_longer_than_the_panel_is_drawn_whole
|
||
|
||
The property the user asked for, through the real path: a colliding import is
|
||
summed and built, and the Confirm step is rendered from
|
||
`project::wizard_cache`. It asserts every word of the (over 80 character)
|
||
warning is on screen in order. It was red against a stub `wrap`. The rival
|
||
**warnings truncated to one line each**, which is the old draw, fails it. Also
|
||
`a_message_longer_than_the_panel_is_drawn_whole`, for the bottom-line message.
|
||
Its rival **message drawn on one line** fails it.
|
||
|
||
### scrolling_reaches_the_last_collision
|
||
|
||
Fourteen colliding tuples, more than fit. The first frame must show the first
|
||
tuple and a header naming the range shown, and not show the last one. After scrolling,
|
||
the last tuple must be drawn. Since the Codex fix, the header row names the
|
||
range shown (`Colliding records 14–14 of 14`) in place of the in-list markers.
|
||
The rivals **list ignores the scroll offset** and **the header claims every item
|
||
shown** each fail it.
|
||
`keeping_separate_at_the_cap_is_drawn_unavailable_with_its_reason` fails under
|
||
**disabled reason not drawn**.
|
||
|
||
### a_multibyte_message_is_drawn_whole_at_every_width
|
||
|
||
Review B1 on PR #304. Messages were wrapped in cells and then drawn through
|
||
`set_bottom_line`, which truncated with the widget's **byte**-measured
|
||
`truncate`. A wrapped line holding a `←` (three bytes) that fit in cells was
|
||
still cut, with `…`. At 80 columns the category-cap reason lost `(←)`, and
|
||
words were lost at 22 panel widths from 30 to 100. The earlier
|
||
`a_message_longer_than_the_panel_is_drawn_whole` was ASCII-only and could not
|
||
see it. This test drives the real cap reason and a message of accents, CJK
|
||
and emoji across every width from 30 to 100. It asserts no `…` and every word
|
||
in order. It was red before the fix. The rival **bottom row measured in bytes
|
||
again** fails it.
|
||
|
||
Its first run exposed a bug in the test's own helper. `buf_text` read the
|
||
padding cell after a double-width character as a space, so `日本語` read as
|
||
`日 本 語`. The helper now skips that cell. This changes nothing for the ASCII
|
||
pinned frames.
|
||
|
||
### wrapping_measures_cells_not_bytes
|
||
|
||
Review B1's second half. A byte count is never smaller than a cell count, so
|
||
byte-wrapping only ever wraps early, and "no line wider than asked" plus "no
|
||
word lost" cannot tell the two apart. The reviewer swapped `.width()` for
|
||
`.len()` in `wrap_words` and the lib stayed green. This test takes strings that
|
||
fit in cells but not in bytes (`←←← éé` is 6 cells and 14 bytes; `日本 ab` is 7
|
||
cells and 10 bytes) and asserts each stays on **one** line. That rival now
|
||
fails it. It was green on arrival, since `wrap` was already right.
|
||
|
||
### no_step_panics_on_a_small_terminal
|
||
|
||
Review B2 on PR #304. The duplicates step wrote at an unbounded row, and
|
||
ratatui panicked with index-outside-buffer at 80×≤10, 60×≤12 and 50×≤14. It
|
||
sweeps every step the wizard can reach at every size from 20 to 100 wide and
|
||
3 to 20 high:
|
||
|
||
- Preview, SelectArrayPath, ReviewProposals and ConfigureDates;
|
||
- ResolveDuplicates, both plain and at the cap with its message;
|
||
- NameDuplicateCategory;
|
||
- DefineFormulas, empty and with formulas and an open editor;
|
||
- NameModel, and Done with and without warnings.
|
||
|
||
It was red before the fix with 3,513 panics, and not only in the new steps.
|
||
NameDuplicateCategory panicked up to 13 high, and ResolveDuplicates at every
|
||
height to 20. Preview, SelectArrayPath, DefineFormulas and NameModel panicked
|
||
at heights 3 to 5 on `u16` underflow in `inner.height - N`, which predates
|
||
this ticket.
|
||
|
||
The fix is `Panel`, one clipped writer, plus saturating arithmetic. Its rivals
|
||
are **writes not clipped to the panel** and **height arithmetic not
|
||
saturating**. The second one **survived** at first, because the sweep's
|
||
DefineFormulas step had no formulas, so the loop holding `inner.bottom() - 5`
|
||
never ran. A DefineFormulas step with formulas was added, and the rival now
|
||
fails.
|
||
|
||
The steps run in parallel threads, each over its own prebuilt `WizardCache`.
|
||
Run serially the sweep took 3.8s, over the 2-second binary limit, and 0.55s
|
||
for the whole lib in parallel on an Apple M4 Max.
|
||
|
||
### a_first_item_that_fills_the_list_still_says_more_follow
|
||
|
||
The Codex P2 on PR #304 at `92233cff`. When the first visible item's wrapped
|
||
height exactly filled the list, the "more below" marker was written on the next
|
||
row, the hint row, and the hint drew over it. The fix moves the scroll status
|
||
to the list's header row, which no item can cover. This test fills three rows
|
||
with a three-line item and a second item after it. It asserts the first item
|
||
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
|
||
every item shown** fails it.
|
||
|
||
### the_category_add_prompt_draws_as_the_item_add_prompt_does
|
||
|
||
ADR 004 clause 8's one declared exception, decided by the user (Q1 on PR #122:
|
||
the green/yellow split between item-add and category-add is an accident). It
|
||
draws both prompts through the real `draw_bottom_bar` into a `TestBackend` and
|
||
compares the style of the prompt's first cell, after checking that cell really
|
||
holds the prompt's `n`.
|
||
|
||
**Failed first**, at `gh/main` `b639266b` before any face existed: `left:
|
||
...yellow().bg(Indexed(235)).bold()`, `right: ...green()...` — the category-add
|
||
prompt was yellow. **Injected fault** after the migration: set the shipped
|
||
theme's `minibuffer.category-add` back to `Named(Yellow)`; this test fails and
|
||
nothing else in the TUI does, which is the point of naming the exception in a
|
||
test of its own rather than leaving it out of the no-op table.
|
||
|
||
### the_shipped_theme_reproduces_every_migrated_literal
|
||
|
||
ADR 004 clause 8's property: migrating a call site onto a face is a no-op. The
|
||
oracle is the literal tables as they stood before the migration — axis
|
||
accents, wizard field kinds, `mode_style`'s exhaustive match (kept verbatim as
|
||
`mode_bg_before_faces`) and the minibuffer's accent-on-`Indexed(235)`-bold —
|
||
and every one is compared with `face_style` of its face. Category-add is left
|
||
out on purpose (the entry above). It was first written against the old
|
||
functions themselves and passed, then the functions were deleted and their
|
||
tables moved into the test.
|
||
|
||
**Injected faults**, each failing here by the case name in the message:
|
||
`axis.column` changed from Blue to Cyan (fails at `Column`); `face_style` made
|
||
to add `BOLD` for `Weight::Normal` instead of `Bold`; `Theme::resolve` folded so
|
||
that the parent wins over the child (fails at `Editing`, which draws DarkGray
|
||
instead of its own Green); and `Face::of_mode(TileSelect)` returning
|
||
`mode.resting` (also caught by `the_status_line_is_drawn_in_the_mode_face`).
|
||
|
||
### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows
|
||
|
||
improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer
|
||
category, with a year open two levels, a quarter closed and a year folded. The
|
||
precondition pins the header sequence, so the agreement cannot pass on a frame
|
||
that drew no nested header. What makes the two ends agree is that neither
|
||
derives membership: both walk the same `FrameEntry` list. Rival: the browser's
|
||
`render.rs` skipping a `Group` entry whose `depth > 0`. This test fails on the
|
||
row count (the TUI draws the quarter line, the browser does not); the column
|
||
test does not, since the band shows only each column's innermost group, which
|
||
the rival leaves alone. Group names are short because the TUI
|
||
truncates a band label to its column's width, which the browser does not: the
|
||
first version named its years `Y2024` and the column test failed on `▶ Y2…`
|
||
against `▶ Y2025`, a truncation, not a disagreement.
|