The harness fixtures open their groups explicitly now that groups start closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
77 KiB
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:
Carolsits outside theFamilygroup and before it, so collapsingFamilystill 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.Monthhas one grouped quarter and one ungrouped month, so the column runs are not uniform — acol_groupsthat 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-ignoredharness/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>/*.improvlines from the root.gitignore, with a per-directory precondition. Red at705c92d:harness/fixtures/tall.improvreplaced with{"legacy": true}passed the old test. -
improvise-c0aj. That
.gitignorereading 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.gzanywhere, since*.improvnever 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
.gitignorewalk, each failing at itswalkedassertion 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. - a single-file un-ignore (
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.gzpathspec removed. Failsa_gzipped_document_is_walked, and nothing else. - A failed
ls-filesanswered with an empty list instead of a panic. Failsa_directory_that_is_not_a_checkout_fails_loudly. harness/fixtures/tall.improvreplaced with{"legacy": true}. Failsevery_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_walkedand one re-runsevery_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_DIRvisible, 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-varsloop; - the
core.excludesFileorcore.attributesFilenull; - each of
GIT_LITERAL_PATHSPECS,GIT_GLOB_PATHSPECS,GIT_NOGLOB_PATHSPECSandGIT_ATTR_SOURCE; - the trace prefix: no strip, the exact name only, the exact three names, and the prefix minus
GIT_TRACE,_SETUPor_PERFORMANCE; - the trace2
0override.
The ones green before are green now.
GIT_ICASE_PATHSPECSis green, as its section explains.- The prefix minus
GIT_TRACE2_EVENTis green, because the0override covers it. It was red against the per-setting test atddcdf93a, before #276 added the override. - Dropping
GIT_CONFIG_GLOBAL=/dev/nullorGIT_CONFIG_NOSYSTEM=1was 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/nulloutranks 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-creaches (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, becauseGIT_INDEX_FILEstill sends the add to the outer index. - Remove only
GIT_INDEX_FILE: red, becauseGIT_DIRdoes. - Rename the child test so the child runs nothing: red on
… did not run.rerun_with_envassertsrunning 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 initandgit adddie on it (fatal: bad boolean config value 'nonsense' for 'core.sharedrepository');git ls-filesdoes not read it, so the walk itself, which does not null the user's config, is unaffected;run_gitpasses no-cfor 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/ignoreholding*.gz; - once with
git/attributesholding*.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=1reads*.improvas a file literally named*.improv, so the walk finds nothing.GIT_GLOB_PATHSPECS=1stops*at/, so only a document at the root matches.GIT_NOGLOB_PATHSPECS=1reads*literally, like the literal setting. Every document these tests walk sits below the root, so both settings made both tests fail.GIT_ATTR_SOURCE=HEADmakes git read attributes fromHEAD. The scratch repository in the gzip shape test has no commit, sogit addfails withbad --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_PATHSPECSorGIT_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*.improvstill 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 /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 withGIT_TRACE. A prefix match, because the family is open-ended.git()builds on it, and so does therev-parse --local-env-varscall, whose stdout is parsed as well.- It then sets
GIT_TRACE2,GIT_TRACE2_EVENTandGIT_TRACE2_PERFto0. A user config'strace2.normalTarget,eventTargetorperfTargetis a config setting, which no environment removal reaches, and the walk's git does not null the user's config (onlyrun_gitdoes). An environment value of0outranks 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, minusGIT_TRACE_SETUP, and minusGIT_TRACE_PERFORMANCE: each of the three variables that the0override does not cover breaks the walk on its own; - no
0override (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
elsebranch restored — fails in Normal on"Ctrl", quoting the whole false line. - Only the
elsebranch 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 (
nremoved fromhelp-page-nextin the protocol keymap). The test then fails: the copy still advertisesn, 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.
- 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. Spacecycles an axis and writes a message: that message must be on the row after its own key. This guards against expiring too eagerly.Escback 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
pressdelegating straight tohandle_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:
Backspace: still editing, and the error must still be drawn. It failed here at base202e7eac:"formula: Total = ▌", with the error gone.Enteron the still-badTotal =: 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.1andEnter: the fixed formula commits and leaves the editor, and the error must be gone.- A second run:
Escstraight 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:
- Records.
R, theno("Added new record row", inRecordsEditing), then5. The message stands while typing in the same editor. ThenTab: the editor re-opens on the next cell, and the message must be gone. - Records.
R,o,Esc, thenoagain, which is refused with "That record row is already here…". Then5, with the refusal still drawn. ThenTab, which fills the row in, and the refusal must be gone. - A plain grid.
ion(0, 0), with a note set while editing. Then7, with the note kept. ThenEnter: the editor re-opens a row down, and the note must be gone. - 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_contextcounts onself.term_widthagain: red,formula panel: column 4.draw_contentsplits 8 columns narrower thangrid_width, a second derivation that disagrees with the first: red,no panel: column 6.side_panel_opencounts only the category panel: green, and correctly.draw_contentasks 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
breaks 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_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.