group_runs coalesced adjacent positions by group NAME, so two runs under two different headers sharing a name read as one and both front ends drew a single span across the boundary. THE TICKET'S PROPOSED FIX DOES NOT WORK, and that is worth more than the fix. h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat for exactly that reason. But its own reachability case is ONE category -- Month grouped into Q1 nested under Region, giving North/Q1 and South/Q1 adjacent -- so both headers are (Month, Q1), equal as values, and the pair separates nothing. A test pins that so the next reader does not re-propose it. What does separate them is that they are two distinct FrameEntry::Group entries, which groups() already borrows from self.entries: std::ptr::eq on the header is the literal statement of "the same header opened both". THE PRODUCT QUESTION, decided here and reversible in one comparison. Whether two adjacent same-named runs are one span a user wants spanned is a matter of taste, which is why #77 filed rather than fixed. What settled it is not the span but the indicator: the existing wasm-client test merged Month/Q1 (expanded) with Fiscal/Q1 (COLLAPSED) and its own message said the merged span is drawn with the first one's indicator -- a collapsed group rendered as expanded, with no affordance saying otherwise. A span boundary is taste; a wrong collapse glyph is a wrong fact. That test's justification was also spent: it said the rule must be name-only "because that is what src/ui/grid.rs uses", and since improvise-8qlr grid.rs calls group_run_heads. There is no second copy left to agree with, which is the general point -- a consolidation's frozen behaviour reads as decided afterwards, and nothing says it was frozen only for the duration of the move. The agreement fixture gains the case it could not see: two_level_rows minus the ungrouped Sep, so the two Q1 runs abut, end to end through a real GridLayout and both renderers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
27 KiB
Test evidence — improvise-protocol
What each test's bug was, what fault it can see, and why it can fail. Keyed by
exact test name (design-principles §7). A renamed test orphans its entry;
python3 scripts/check-comment-runs.py --check-evidence catches that.
Tests whose name already states the whole property are not listed here. An entry earns its place by carrying something the assertion cannot: the bug, the injected fault, or the reason a negative arm exists.
src/render.rs — the fold
cell_updates_outside_the_cache_are_dropped
A projection naming a cell outside the current viewport must be dropped, not
panic: frames and cell updates race on the wire, and a client that crashes
on a stale index is a client that crashes. This is the totality half of the
WidgetCache contract, stated as an example; the ordering half is
a_cell_update_that_overtakes_its_labels_is_lost.
a_cell_update_that_overtakes_its_labels_is_lost
The fold is not commutative, and a client must not be written as though it
were. The frames frame the cells, so a Cells update that overtakes the
ColFrame/RowFrame naming its positions is dropped. Reordering the
identical three updates gives a different cache, and the assert_ne! is the
load-bearing line — without it, a fold where order did not matter would pass.
a_compacted_seed_folds_to_the_same_cache_as_the_whole_history
Bootstrap is not a second code path, but a server does not replay history to a late client either — it sends the state it holds now. So the property is compaction: folding the seed lands exactly where folding the whole history landed, including the history's relabels and overwrites.
The history is built to be worth compacting — overwritten, relabelled,
populated and grown, with the extent moving twice so only the last survives —
and seed.len() < history.len() is asserted, because a "seed" that is not
smaller than the history is not a compaction and the equality would be vacuous.
Note the general rule this is an instance of: asserting
fold(prefix) + fold(rest) == fold(whole) is vacuous, being the
definition of a left fold (doctrine(fold_tests_must_test_order_not_the_axiom)).
a_snapshot_seeds_every_widget_from_its_compacted_state
The same property across the envelope, with two subscribers each folding only what is addressed to it — the dispatch a client does for real.
a_totals_update_that_overtakes_its_columns_is_cut_to_fit
The totals strip is framed by the columns on the same terms the cells are: a
strip that overtakes the ColFrame naming its positions is cut down, because
a renderer zips it with the columns and a length mismatch indexes off the end.
an_empty_totals_strip_is_not_padded_into_blanks
An empty strip says "this grid has no totals" — records mode, or an empty
grid — and must not be padded into a row of blanks by the re-fit. Padding it
would draw an empty total row under a records-mode grid that has none, which
is the one thing the emptiness is for. This is the asymmetry that separates
totals from cells and col_widths, which are always padded.
a_recategorized_axis_blanks_the_totals_too
The totals are contents, not frame: a reframing blanks them for the same reason it blanks the cells. A stale sum under fresh cells is the one number on the screen a user would take at face value. The length assertion is the other half — the frame survives, the numbers do not.
Renamed by improvise-n89z, which deleted GridUpdate::Invalidated. The test
used to reach the blanking through that arm; it now reaches it through a
cats change, which is the thing that really produces it. Proved
discriminating by pinning recategorized to false.
a_relabelled_row_is_blanked_and_the_frame_survives
The other half of the same deletion. invalidation_blanks_contents_but_keeps_ the_frame asserted what the retired arm did; this asserts what
invalidate_relabelled does, and it is strictly stronger, because the old
arm blanked everything and so could not tell "blanks what it reframes" from
"blanks the lot". Row 1's label does not change and row 1 keeps its value.
Proved discriminating by weakening the stale filter from || to &&.
the_page_bar_outlives_a_reshape
page_coords is framed by nothing: it names categories, not positions. So it
survives a reshape that drops every row and column. Without this, "re-fit
everything on a frame update" would look correct.
the_first_rendered_entry_always_shows_its_level
Bug (improvise-jjx), moved here with the rule (improvise-anr9): header
suppression compared against the entry at idx - 1 even when that entry was
scrolled off-screen, so scrolling blanked the group label at the top of the
viewport. The first rendered entry always shows its full tuple.
The fixture is two-level so the level-0 prefix of index 1 genuinely matches index 0's — a single-level frame could not express the case.
a_repeated_prefix_below_the_first_rendered_entry_is_suppressed
The negative arm of the above, and it has to stay: without it, "always show
every level" would pass the_first_rendered_entry_always_shows_its_level and
the multi-level header would repeat its outer label on every row.
a_data_index_skips_group_headers_and_still_names_its_group
Group headers do not consume a data index, and a data index resolves to the
group whose header precedes it — which is what makes a column group span
drawable by a client that holds no GridLayout. The collapse state travelling
with the group is the third assertion, and it is why FrameGroup::collapsed
is on the wire rather than re-derived.
shared_ancestry_widens_as_the_level_rises
The underline rule: at the leaf level only the selected column, at a higher level every column under the same ancestor. Both levels are checked because a version comparing whole tuples would pass the leaf arm and light only one column at the outer level, and a version comparing only the outermost label would pass the outer arm and light a sibling at the leaf.
body_from_starts_at_the_item_not_at_the_header_above_it
A window opening mid-group must be able to name its group without painting
that group's header as a row inside the window. The second assertion —
body_from(0) is everything, headers included — is what stops the fix being
"drop all group headers".
header_widths_are_per_level_and_measured_in_display_cells
The one width rule (improvise-1125): per level, the widest label plus a gap,
clamped. Measured in display cells, so 日本 counts four and not two — a
chars().count() would make level 1 two cells wide, clamp it to
MIN_ROW_HEADER_W, and draw the header short. Both clamps are exercised
("ab" is under the floor, "x" * 100 over the ceiling), because a rule
whose clamps are never reached is a rule nobody has tested.
a_column_without_its_width_yet_is_measured_in_display_cells
Raised by Copilot on PR #23. The fallback width a column is drawn at
between its ColFrame and its ColWidths must be in the same unit the
authoritative widths are in. chars().count() would draw a wide label half as
wide as it needs. The fixture is deliberately a two-scalar / four-cell string,
which is the smallest input that separates the two counts.
an_axis_with_no_categories_still_has_one_level
An axis carrying no category still draws one header line, and its item tuples
are empty — reading a level off one must answer "" rather than panic. This
is the shape a view with nothing on Row produces, so it is a live layout and
not a degenerate fixture.
a_relabelled_column_does_not_keep_the_old_columns_value
A column dropped from the middle used to slide the cells to its right one
place left, because fit_to_labels truncates by position: [A,B,C] shrinking
to [A,C] left the column now labelled C showing B's value. Nothing on the
wire ever forced a producer to declare it first, so apply blanks what it
reframes.
a_label_change_blanks_only_the_positions_it_reframes
The same rule on the row axis, and the case a bare label shrink gets right by accident: only the relabelled positions are blanked, so an unchanged row keeps what the server already sent for it.
renaming_a_row_category_invalidates_every_cell_under_it
Renaming the category re-identifies every cell on the axis, even where
every label is byte-identical: a coordinate is cats zipped with the item
tuple, so Region = [North] and Department = [North] are different cells
wearing the same name.
Frame-only, and that word is the test. No Cells and no Totals follow
the frame update, because the producer's follow-up is exactly what would hide
the bug — apply has to be right with nothing behind it, the same shape as
a_cell_update_that_overtakes_its_labels_is_lost.
The id assertion is the sharp half and is separate from the text on purpose.
A stale CellId::Record under a re-identified coordinate is an editable cell
whose write addresses the record the old coordinate named — silent,
invisible in any rendering, and the same family of defect as gotcha 18's drill
snapshot.
The precondition assertion (the items are identical) is load-bearing:
without it the test is just the ordinary relabelling case, and cats proves
nothing.
renaming_a_column_category_invalidates_every_cell_under_it
The column half of the same rule. Here the per-position arm already blanks each total as its column goes stale, so this pins the cells and the ids; the row arm above is the one that needed the totals handled separately.
an_unchanged_category_leaves_the_cells_that_still_fit
The negative arm, and it is load-bearing: without it, "blank everything on every frame update" would pass both renaming tests above, and the fold would resend the whole grid after a group collapse.
a_relabelled_column_drops_its_total
improvise-hkt6. invalidate_relabelled blanked the cells under a renamed
column and left its total standing, so a client that heard ColFrame
without a Totals behind it drew the old column's sum under the new column's
blanks.
Unreachable from grid_updates, which always sends the changed totals in the
same batch — which is the point: the rule is that apply does not trust a
producer to remember, and the only way to test it is to send the update the
producer never sends alone.
Three columns and a rename in the middle, so "blanked the right one" is separable from "blanked all of them".
a_relabelled_row_leaves_the_totals_alone
The row half is the negative arm and has to stay: a total spans every row,
so no single row frames it. Without this, "blank the totals on any relabel"
would pass a_relabelled_column_drops_its_total.
col_widths_always_span_the_column_labels
col_widths is framed by the columns: a renderer zips the two, so a cache
where they disagree in length is one that indexes off the end. ColWidths is
its own update and may be a frame behind the frames.
a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by
The same invariant seen from the other arm. ColWidths used to be assigned
verbatim, so a widths update that disagreed with the frame put the cache
straight back into the state col_widths_always_span_the_column_labels exists
to forbid — the frame arm was fitted and the widths arm was not.
A short update is the ordinary case: the server resized a column the
client has not been told about yet. A long one means the widths overtook
the ColFrame that frames them, and the fold drops what overtakes its frame —
the same rule Cells is already held to. Both directions are asserted,
because fitting only one of them is exactly the bug.
item_updates_touch_only_the_named_category
Plus the dropped-update arm at the end: an Items update for a category the
cache has never heard of is dropped, not inserted — the totality rule again,
on a different widget.
an_axes_update_moves_several_categories_at_once
A transpose moves two axes at once, which is why Axes takes a list rather
than a single pair. The third assertion — membership is undisturbed — is what
stops the fix being "resend the whole category list".
every_projection_round_trips_through_json
One populated value per CellDisplay shape: numeric decides the
alignment and error the styling, so a round trip that only ever carries them
at their default proves nothing about either
(doctrine(populate_before_roundtrip)).
a_populated_snapshot_round_trips_through_json
The Snapshot is the only thing on the wire that is not an event, and the
only point at which ViewState is serialized (improvise-q08) — so it is the
one message whose round trip nothing else covers. Populated on both
halves: a default ViewState beside an empty seed would round-trip through
almost no code.
src/basis.rs
a_client_that_has_folded_nothing_cannot_claim_a_basis
The floor. A client that has been told nothing cannot compose a basis, so it
cannot compose a write. Basis has no Default and no public constructor, so
BasisCache::claim is the only door — which is what this asserts is shut
before the server has published anything.
an_unknown_record_is_omitted_rather_than_guessed
A record the client has never been told about is left out, not invented.
Defaulting it to zero would be a claim the client cannot support, and the
server would read it as "untouched" — the exact forged-basis case
Rejection::RecordUnstated exists to catch.
a_claim_carries_the_counter_for_each_record_it_names
The second assertion is the one that earns its place: a claim naming one
record must not carry another's counter. A claim that returned the whole
cache would pass the first assertion and fail this.
the_published_record_list_is_stable_under_traversal_order
The published list is deduplicated and ordered, so a client is not sent a
fresh basis every time a row moves on screen. Two maps built in opposite
insertion order must compare equal — which is the property the BTreeMap
parameter buys and a Vec would not.
src/frame.rs
the_retired_spellings_do_not_parse
The names the deleted envelopes used, which must not resurrect as serde aliases: an envelope accepting two spellings of one variant is two homes again, wearing one type.
Not a hypothetical rename — Bootstrap and Command are exactly what the
worker pair (improvise-wasm-client::wire) spelled these two payloads before
improvise-xks6. ClientFrame::Command joined the list with improvise-bpq2,
and it is the sharper of the two: the singleton spelling was this crate's own
until that commit, so an alias would be a revival rather than a
resurrection, and the payload shapes differ only by a pair of brackets —
{"Command":{...}} against {"Commands":[{...}]} — which is exactly the
difference a permissive alias would paper over.
The injected-fault discipline is the part to keep
(doctrine(check_the_checker)): the mutated input is asserted well-formed
JSON first, and the failure is asserted to be about the name — serde's
unknown variant — not merely that an error occurred. A rewrite that also
breaks the JSON makes this pass against an enum that really does have the
variant.
src/reduce.rs
a_model_command_leaves_the_view_slice_untouched
A model command is the document's business, not this client's. Folding one into the view slice would be a client writing state it does not own — the document's answer comes back as projections. The cursor is set to a non-default value first, so "the whole state is still default" cannot stand in for "the state did not move".
folding_the_same_stream_twice_gives_the_same_state
Both ends fold the same commands, so the same stream must produce the same
state — otherwise the server's mirror drifts from the client it is mirroring
(improvise-m91). A Projection is included in the stream, to pin that it
passes through reduce_view untouched rather than being a case it handles.
every_command_round_trips_through_json
One populated value per payload family
(doctrine(populate_before_roundtrip)), not per variant: the exhaustive walk
over the 26 ModelCommand variants is every_model_command in
improvise-command, where the effect each one names can also be checked.
The modelled() helper composes its basis through a seeded BasisCache
rather than building a Basis literal — not because this test is about the
basis, but because that is the only constructor there is, and a test that
needed a second one would be evidence the door had been left open.
cursor_commands_land_in_the_active_views_entry
The second assertion is the point: the view we switched away from never gained an entry. An implementation writing a single cursor and copying it per view would pass the first assertion.
switching_views_restores_each_cursor
The point of keying the cursor map: switching back restores where you were,
and the client can do that without a Workbook.
help_pages_stop_at_both_ends_and_set_clamps
Both bounds and the clamp, because going back from page 0 is the one that
underflows if written as help_page - 1, and Set out of range is the one
that panics if written as an index.
It used to be named ..._wrap_in_both_directions_... and asserted wrapping,
which was a second derivation of the effects it came from and disagreed with
them: effect::HelpPageNext clamps at the last page and HelpPagePrev
saturates at zero, while this arm wrapped modulo HELP_PAGE_COUNT. Nothing
compared the two, because no client had ever sent Command::HelpPage — the
help overlay's h/l were still upstream keys. improvise-e24v made them a
client's, and walking_off_the_end_of_the_help_pages_agrees_with_the_host
failed on the first press past the end (client 0, host 4). The wire was
changed to match the effect rather than the other way round: the effect is
what ships in the TUI, and changing that is a product decision nobody asked
for. Whether wrapping is wanted on both ends is filed separately.
src/lib.rs
the_wire_types_need_nothing_but_core_and_serde
improvise-cqi AC 2: this crate crosses a process boundary, so it must not drag
a terminal backend with it. A cargo tree check would be external; this is
the part the compiler can hold — the types here resolve with only
improvise-core and serde in scope, and adding a ratatui or crossterm
dependency would have to be written into Cargo.toml deliberately rather than
arriving through a use. The CI wasm job is the other half: a crate pulling
in crossterm would stop building for wasm32.
src/viewmodel.rs
expanding_an_absent_category_is_harmless
An expanded name the cache does not carry must not shift the flattening:
expanded_cats is per-client and survives a document swap, so it routinely
names categories that are gone. A flattening that indexed by expanded-name
would go off the end here.
only_quit_advertises_nothing
improvise-2hi, carried over from only_quit_has_no_hint when the hints moved
out of AppMode (improvise-k099). The old hint_text had a _ => "" arm, so
the modes without hints were invisible; ExportPrompt was blank too
(improvise-1c9), and the status bar went empty at the one moment the user has
to be told what to type. Only Quit may say nothing — it is never rendered.
What it asserts now is one step stronger, because hint_line is derived: a
mode goes quiet not by someone forgetting a string but by every command it
advertises becoming unbound, which
every_advertised_hint_names_a_binding_that_exists catches first.
every_advertised_hint_names_a_binding_that_exists
The replacement for every_hint_key_resolves_in_its_mode (improvise-2hi),
which is deleted, and the deletion is the point
(doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).
That test existed because AppMode::hint_text was a hand-written string
promising what the keys do, with nothing tying it to the keymaps. It walked
every Key:verb token in every hint through a 60-line tokenizer that had to
know the hint dialect — named keys, Ctrl-<c>, F/C/V alternatives, runs like
hjkl, the em-dash prose tail, CommandMode's ex-commands — and panicked on a
token it could not classify, because a token it skipped was exactly the one
that would go unchecked.
None of that is needed now. A hint names a command, and its keys are looked
up in Keymaps::default_keymaps(), so "the hint names a key that resolves" is
true by construction and cannot be otherwise. What remains checkable, and is
what this test checks, is the other direction: a mode advertising a command
that nothing binds. That was never checkable before.
Note what neither version reaches, and gotcha 14 stated for the old one: that
the caption is accurate. i:edit where i enters records mode still passes.
The caption is English beside a command name, and only a reader can judge it.
the_retired_invalidated_spelling_does_not_parse
improvise-n89z deleted GridUpdate::Invalidated, and the one way it can come
back without anyone noticing is as a serde alias — a wire spelling that
parses and that nothing produces, which is the state the deletion was for.
It keeps the discipline the_retired_spellings_do_not_parse earned in
frame.rs: assert the mutated input is still well-formed first (a
ColWidths projection of the same shape parses), and assert the error
kind — serde's unknown variant — rather than merely that an error
occurred, because a rewrite that also broke the JSON would make the check pass
against an enum that really did have the variant.
Proved discriminating by re-adding the variant with an empty apply arm: the
retired spelling parses again and the test fails on expect_err.
boundary_tests::the_app_mode_cycle_reaches_every_variant_serde_declares
basis::tests::the_rejection_cycle_reaches_every_variant_serde_declares
improvise-5ubs. Both all() functions walk a cyclic after(), and both doc
comments claimed a new variant could not be omitted. Answering after() for a
new variant is compiler-forced; pointing an existing arm at it is not, so a
variant nothing names is never walked. The reviewer of PR #78 measured it:
AppMode::all().len() came back 17 against 18 variants, suite green.
The independent enumeration is serde's — the Deserialize derive builds the
variant-name list from the enum definition and publishes it in its
unknown variant ... expected one of ... message. variants.rs reads it back
and diffs it against the names the cycle reached, each name taken off the
variant's own serialization rather than off Debug.
Fault injected 2026-09-21: a Rejection::Probe variant added to the enum, with
an after() arm returning DocumentReplaced and nothing pointing at it —
precisely the shape the reviewer probed. the_rejection_cycle_reaches_every_variant_serde_declares
fails naming ["Probe"]; the other 120 tests in improvise-protocol's lib pass,
including the_rejection_cycle half of every basis test. (The workspace does not
compile with the probe, because improvise-wasm-client/src/socket.rs matches
Rejection exhaustively — which is a different guard and does not catch the
omission, since the omitted variant there is one it was forced to name.)
variants::tests::a_message_that_is_not_an_unknown_variant_is_refused
The checker's own checker. A parse of serde's message that silently fails would
return an empty name list, and an empty list is a subset of everything — every
cycle would pass and the guard would be worse than absent. Pointing declared
at Basis, a struct, produces a message with no expected one of clause; the
helper must panic rather than return. Asserting the panic message and not
merely that it panics, because a helper that panicked for some other reason
would satisfy a bare should_panic.
render::tests::a_frame_that_lost_its_origin_does_not_claim_to_lead_the_axis
improvise-1wl0, found by the adversarial review of PR #77. AxisFrame::first
carried #[serde(default)], so a frame whose first was dropped in transit
deserialized as 0 — which is not an absence but the claim that the frame
leads the axis, and exactly what body_from acts on:
match self.first + idx == 0. A scrolled browser would then draw the carried
group header above its first row, at every scroll position, silently: the
mirror image of the improvise-0o6x bug the field was added to fix.
The test serializes a frame built for a scrolled window (first: 3), removes
the key, and asserts the refusal. Three things make it discriminating rather
than a check that something went wrong:
- It asserts the key was there before removing it, so a rename of the field turns this into a failure rather than a vacuous pass.
- It asserts the mutated value is still a well-formed AxisFrame minus one
field (
catsandentriespresent), so the refusal cannot be about broken JSON — the check-the-checker hole gotcha 42 records fromthe_retired_spellings_do_not_parse. - It asserts the error kind is
missing field, not merely that an error occurred. Afirstthat had been retyped would fail differently and this would notice.
Run against the pre-fix tree 2026-09-21: it fails at the expect_err, printing
AxisFrame { .., first: 0, .. } — the forged claim itself. The fix is the
removal of one attribute; the whole workspace stays green (1464), which is also
the evidence that nothing was relying on the permissive parse.
render::tests::two_runs_under_two_headers_of_one_name_are_two_runs
render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them
improvise-h0iv. group_runs coalesced adjacent positions by group name, so
two runs under two different headers sharing a name read as one and both front
ends drew one span across the boundary.
The first test is the demonstration, at the level the rule lives: a frame with
[Group(Month/Q1), North/Jul, North/Aug, Group(Month/Q1), South/Jul, South/Aug]
and nothing ungrouped between the runs. Before the fix it returns
[(Q1, 4)]; after, [(Q1, 2), (Q1, 2)]. It asserts the frame really holds
two FrameEntry::Group entries first, so a fixture that lost one would fail
at the precondition rather than pass with a single run.
The second test exists because the ticket proposed the wrong fix and a reader
will propose it again. h0iv says FrameGroup carries cat and "comparing the
pair would fix it" — but its own reachability case is a single category nested
under an outer one, so both headers are (Month, Q1), equal as values. The test
asserts that equality directly. What actually separates them is that they are
two distinct entries in self.entries, which std::ptr::eq on the borrowed
header states exactly.
Fault injected 2026-09-21: restoring prev.map(|p| p.name.as_str()) == ...
fails the first test with left: [(Some("Q1"), 4)], and fails
two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends end to end. The
second test passes under both rules, deliberately — it is a statement about the
data, not about the comparison.