improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether a regular category sat on Axis::None; it never asked whether the coordinate named a formula target. So a formula cell was a Record, render.rs::editable said true, and a write to it landed, was stored, and was then shadowed forever by formula_cache — not a revert, a permanent silent shadow, on the demo's front page. Rather than a second "but not really" flag beside CellId (the two-answers shape ADR 002 exists to forbid), a formula-owned cell now projects a different kind of identity. Derived carries the formula's target and no surrogate, so the write is unconstructible rather than refused. The predicate is Model::formula_for(&CellKey), one home, which already existed three times open-coded inside model/types.rs. The TUI lands in the same change: EditOrDrill calls the same predicate and refuses, or the two front ends diverge. Two orderings are load-bearing and tested: Derived is asked before the record branch (the coordinate is stored), and after the aggregate branch (gotcha 10 — an aggregating formula cell is still drillable). Two tests were passing while their names were false for the Profit column and are renamed and two-sided now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 KiB
improvise-command — test evidence
Why each of these tests exists, what bug it reproduced, and what fault was
injected to prove it can fail. Keyed by exact function name; a rename
orphans the entry, and scripts/check-comment-runs.py --check-evidence reports
that (improvise-9c8c, design-principles §7).
Entries cover tests and the helpers whose shape is the evidence.
ui/reduce.rs
view_slice_commands_land_in_the_authoritative_view_state
The command stream is the only way a client's view slice moves, and it moves the same way on both ends.
Helper — checked
Not a test — the fixture every reduce.rs test below reaches the door through.
For the tests whose subject is not staleness: a command the current state
accepts, which every command built by caught_up is by construction. The tests
whose subject is staleness call Checked::against themselves and assert on the
Rejection, so neither kind can pass by accident through the other's
convenience.
switch_view_moves_the_document_and_the_client_together
The two copies of "which view is active" must move together (gotcha 29). Folding
SwitchView through reduce_view alone would move the client's and leave the
document's behind, and the cursor map would then be read under the wrong key.
a_wire_view_switch_records_history_the_way_a_key_switch_does
A wire-driven view switch owes the same history a key-driven one leaves behind.
ModelCommand::ViewBack pops view_state.view_back_stack, and the only thing
that ever pushes onto it is effect::SwitchView. While apply_command performed
the switch itself — calling App::switch_to_view directly rather than running
the effect — the stack stayed empty, so a client that switched over the wire
could not go back, though the same two keypresses could. Wave 4 is what made that
reachable: before ModelCommand there was no wire way to ask.
Nothing else in the suite could see it.
switch_view_moves_the_document_and_the_client_together checks where a switch
landed; this checks what it recorded.
Bug demonstrated 2026-09-19 (Copilot, PR #11): the ViewBack left the document
on "Second".
switching_views_projects_the_axis_widgets_only
Switching views changes what the axis-bearing widgets read, so they are told; the list of views itself did not change, so it is not.
an_inbound_projection_is_ignored
Projections travel one way. A client that sends one back must not be able to write it into the server's state.
a_keypress_that_changes_the_model_projects_it
The key path is the model's door, and it reports the widgets it touched so a host can project them.
a_view_only_keypress_projects_nothing
A keypress that moves only this client's cursor reaches no other client (gotcha 3).
every_model_command_names_an_effect_that_declares_a_model_change
The derivation, stated as a check. This is what "derived, not invented" means
operationally (doctrine(derive_the_enum_from_the_effects)). A variant pointed
at a ViewOnly effect would be a wire message that changed the document for the
client that sent it and for nobody else — Dirty::of would report NOTHING, and
no projection would ever correct the others (gotcha 35).
Helper — model_app
Not a test — the fixture the three every_model_command walks run against.
app() with a cell actually stored, so a surrogate for it resolves through the
index rather than only through the layout. The walks over every_model_command
run against this: a SetCell whose record cannot be resolved produces no
effects, and every one of those tests asserts on the effects, so the fixture has
to name something real or they would all fail for a reason that is not theirs.
no_model_command_commits_another_clients_session_local_state
improvise-3x3u: the wire vocabulary is the addressable model effects, not all of them.
Sessions::apply routes a Command::Model with no SessionId, which is right
exactly when the change is addressed to no one session (gotcha 36).
ApplyAndClearDrill and AcceptWizardModel are addressed to precisely one, and
it is not the sender: an unaddressed ApplyAndClearDrill commits another
client's private typing (improvise-tom1) and an unaddressed AcceptWizardModel
swaps the document out of a dialogue the sender never opened. Both were
demonstrated against Sessions::apply before the variants were removed.
Re-adding either variant means making it addressed — carrying a SessionId,
with the staging map moved onto the session — not restoring the nullary form this
test forbids.
SESSION_LOCAL_EFFECTS is the list of the two effect names.
the_session_local_effects_are_real_model_effects
The guard above is only worth having while the effects it names still exist and
are still model changes — otherwise it would pass by naming nothing
(doctrine(check_the_checker)).
the_effect_name_reader_reads_all_three_debug_shapes
The reader above is itself a check, so it gets the injection too
(doctrine(check_the_checker)): one effect of each Debug shape, because the
leading identifier is followed by a different delimiter in each — nothing, (,
and {.
Helper — effect_type_name
Not a test — the reader that test injects against, used by the two guards above.
The type name of an effect, read off the leading identifier of its derived
Debug. Effect is a trait object, so std::any::type_name is not reachable
through it; every effect derives Debug, and a derived Debug opens with the
type's own name for a unit struct, a tuple struct and a braced struct alike. So
the name is already on the value — this only reads it back.
every_model_command_names_the_effect_of_the_same_name
The mapping is name-for-name by construction, so state that: every
ModelCommand variant names the effect that carries its own name.
This is the check that separates an effect from its inverse.
every_model_command_names_an_effect_that_declares_a_model_change cannot:
HideItem and ShowItem, AddItem and RemoveItem, CreateView and
DeleteView, SetCell and ClearCell are each two effects of the same
EffectKind, so a swapped pair clears that bar while inverting what the wire
message does. And a_model_command_lands_where_its_keypress_lands cannot reach
them either — most variants have no single keypress to compare against, so it
drives three of twenty-eight.
Proven by injection 2026-09-19: HideItem → effect::ShowItem, CreateView →
effect::DeleteView and AddItem → effect::RemoveItem all left
cargo test --workspace at 918 passing, 0 failing. This test fails on each.
The name-for-name rule is a design constraint, not a coincidence, and it is what
makes the derivation checkable at all
(doctrine(derive_the_enum_from_the_effects)): a variant whose effect must be
named something else is a variant that stopped being derived, and it should fail
here and be argued about rather than pass quietly.
the_enumeration_names_each_variant_once
The every_model_command list covers the enum once. A duplicated entry would
leave a variant untested while the count still looked right.
Helper — every_model_command
Not a test — the enumeration that test, and the three walks above it, cover.
One value per ModelCommand variant. The enum is the derived set, so a test that
walks "the set" has to walk all of it. variant_name is the completeness guard:
it matches exhaustively, so a variant added to the protocol fails the build here
until it is listed in this vector as well.
a_model_command_lands_where_its_keypress_lands
The point of the whole ticket: a model Command applied authoritatively leaves
the document exactly where the keypress bound to the same mutation leaves it.
That equivalence is what lets the two entry points share one door.
The Dirty is compared too, because a host with connected clients projects off
it: a command that produced the right document and the wrong Dirty would leave
every other client stale.
Its pivoted() fixture has two categories on different axes, because the
one-category fixture cannot tell a transpose from a cycle: with only Region on
Row, swapping the axes and rotating Row→Column land on the same view, and an
equivalence test that cannot distinguish the wrong effect from the right one
proves nothing (doctrine(check_the_checker)).
a_stale_wire_model_command_cannot_be_applied
A wire Command::Model cannot reach the document without its basis being checked
(ADR 001, improvise-ondj).
Bug demonstrated 2026-09-19: reduce_full and apply_command — the functions
this module's own doc points every future host at, the browser worker included —
took a &Command and matched Command::Model { cmd, .. }, so a batch composed
against a document that had since moved landed anyway. The .. discarded the
basis with no compile error.
The regression guard is the signature, not this test. Both now take a
Checked, whose only constructor runs stale_basis, so the version of this test
that reproduced the bug — reduce_full(&mut app, &wire) with a stale wire — no
longer compiles. What is left to assert is that the constructor refuses, and that
refusing leaves the document alone: there is no half-applied batch, because the
type is obtained before anything is applied.
a_model_commands_dirty_comes_from_its_effects_kind
The Dirty a model command reports is the one its effect declares, not a second
table kept alongside the enum (gotcha 35).
a_wire_model_command_marks_the_document_dirty
improvise-gv9r. The bug: a client's edit left the authoritative document reporting clean, on every transport.
MarkDirty used to be a separate effect that the command layer composed
alongside the mutation, and improvise-kmgs derived ModelCommand from the
effects one at a time — so the wire got SetCell without its companion. A
derivation correct per element and wrong for a set. "Save if dirty" was therefore
a trigger that never fired, and a host built on it loses every client edit
silently; the browser worker found that out the hard way and had to
over-approximate off the command variant instead.
The fix is the unrepresentable one: dirtiness is raised by App::apply_effects
off the same EffectKind the Dirty and the two basis tiers are read from
(gotcha 40), so no path can compose a document change without it. There is
nothing left to forget, and MarkDirty is gone rather than left as a second
answer.
The three levels are asserted separately because they are three different
effects: an effect that is only ever reached by one of them would otherwise pass
on a sibling's evidence. The negative arm — that a ViewOnly effect leaves the
document clean — is a_view_only_batch_leaves_the_document_clean in ui/app.rs,
and it has to live there: a view-slice Command never reaches apply_effects at
all (apply_command folds it straight into ViewState), so a negative written
against this door would pass however unconditional the raise was.
a_wire_view_switch_marks_the_document_dirty
SwitchView is the one command with a foot in both halves, and it is the reason
this matters beyond cell edits: Workbook::active_view persists as the
Initial View line, so a switch really does put the document out of step with
the file.
It is tested on its own because the browser worker's persist trigger now depends
on it (improvise-gv9r): the worker used to name SwitchView explicitly in a
table of variants and now asks the document. If this effect were ever
reclassified ViewOnly, the worker would silently stop writing view switches
back.
ui/effect.rs
the_document_surface_runs_on_a_non_filesystem_backend
improvise-fuj: every effect that touches a document goes through App::storage,
so the whole save/load/import/export surface works on a backend that is not a
filesystem. This is what a browser worker supplies (improvise-i34); here
MemoryFs stands in for it, and the test would fail if any of these effects
reached for std::fs.
view_only_effects_leave_shared_state_alone
improvise-mae: a declared kind() is only worth having if it matches what the
effect actually does. Rather than restate each declaration, this applies the
effect and checks the shared state it claims not to touch is genuinely unchanged
— a ViewOnly effect that quietly wrote a cell would never reach connected
clients.
No exemptions: since improvise-8uz moved the cursor out of View,
SetSelected/SetRowOffset/SetColOffset touch nothing shared, so every effect
in the list must clear the same bar.
structural_effects_declare_their_kind
The structural effects, each pinned to the kind it must declare.
The bug this guards: AddCategory and RemoveCategory declared CellData, but
both go through Workbook, whose on_category_added / on_category_removed
rewrite every view's axis map. That is a re-layout, so a client told only
"cell data changed" would keep rendering the old grid shape. The two "actually
change" tests beside it cannot see this — a category change moves model data
and view config, so it passes either list. Only pinning the declaration does.
add_category_past_the_limit_reports_the_failure
Bug (improvise-djn): AddCategory discarded the Result from
Workbook::add_category, so pressing n in the category panel with the
12-regular-category limit already reached reported the optimistic
Added category "X" its command had queued and added nothing. The effect runs
last precisely so its failure can overwrite that message (improvise-6t3) — it
just never wrote one.
add_formula_adds_target_item_to_category
Regression: AddFormula must add the target item to the target category so the
grid layout includes cells for it. Without this, a formula like
Margin = Profit / Revenue would be registered but invisible in the grid.
abort_chain_short_circuits_apply_effects
AbortChain must cause subsequent effects in the same apply_effects batch to
be skipped, and the flag must reset so the next dispatch starts clean.
save_without_file_path_aborts_the_chain
BUG (improvise-8tk): Save reported failure only through the status bar, so the
Quit that wq/ZZ chains after it landed anyway and the whole session was
discarded without the user seeing the error. A failed save must abort the rest of
the batch.
failed_save_as_aborts_the_chain
BUG (improvise-lae, found reviewing it): improvise-8tk fixed one arm of a rule
with two. Save aborts the batch when the write fails; SaveAs reported the
error to the status bar and let the batch continue, which is the same silent
discard 8tk closed — the shape
doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names,
and the three tests above it are evidence about Save only.
It is reachable today: the : line composes with ., so :w /bad/path . q
builds exactly this batch, and ExecuteCommand concatenates the two commands'
effects into one. improvise-lae's save prompt also commits to SaveAs, so the
arm was about to carry more traffic.
Note what the abort does and does not buy the prompt: the typed path survives for
the user to correct, but the message does not reach the screen, because
draw_bottom_bar draws the minibuffer instead of the status line while a prompt
is open. That is pre-existing and reported separately; the data-loss half is what
this fixes.
successful_save_as_lets_the_chain_continue
The positive arm: a SaveAs that succeeds must not abort, or every :w <path>
would swallow whatever followed it. Without this, "always abort" passes
failed_save_as_aborts_the_chain.
enter_edit_at_cursor_uses_target_mode_not_app_mode
EnterEditAtCursor must use its target_mode field, not whatever
app.view_state.mode happens to be when applied. The previous implementation
branched on app.view_state.mode.is_records(); the parameterized version trusts
the caller (keymap or composing command).
enter_edit_at_cursor_reads_post_batch_layout
improvise-ehz: the edit buffer must be loaded from layout state that already
reflects earlier effects in the same batch. apply_effects only rebuilds after
the batch, so a preceding RebuildLayout is what makes the cursor resolve to the
post-mutation cell. Removing the row-0 item slides a different cell under the
cursor; the buffer must show that cell's value, not the pre-batch one.
set_buffer_does_not_special_case_the_search_name
SetBuffer no longer treats "search" as an alias for the search query
(improvise-gdl): it is a plain buffer write whatever the name says, and the query
is reached through SetSearchQuery. Commands pick between the two in
cmd::core::write_buffer.
apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row
improvise-c5v: in wide format a records row groups one cell per measure, so renaming a coordinate has to move every one of them. The long-format code moved only the cell under the cursor, which would now split the row in half.
import_headless_csv_short_rows_surface_warning_in_status
BUG (improvise-k8i): ImportJsonHeadless parsed CSVs with parse_csv, which
sends short-row warnings to stderr — invisible inside the TUI. Short rows must
surface in the status message, naming the affected column, never silently
succeed.
Note — the drill tests drive apply_effects, not apply
Several ApplyAndClearDrill tests call app.apply_effects(vec![...]) rather
than effect.apply(&mut app). That is deliberate: the dirty flag is raised off
the EffectKind now (improvise-gv9r, gotcha 45), so driving the effect directly
would assert a flag nothing in production sets.
ui/app.rs
only_quit_has_no_hint
improvise-2hi: the old hint_text had a _ => "" arm, so the modes without
hints were invisible. The match is exhaustive now; this pins which modes are
deliberately blank.
Bug (improvise-1c9): ExportPrompt was blank too, so the status bar went empty at
the one moment the user has to be told what to type. Only Quit may be blank — it
is never rendered.
model_state_and_view_state_are_constructible
improvise-3vr: ModelState and ViewState are the named slices of App state
introduced by the vb4 refactor. Step 1 only requires that the types exist and are
constructible; subsequent steps move fields in.
Note — the ViewState: Send static assertion
improvise-h0b: ViewState is the portable slice — the one thing serialized into
the Snapshot a newly-connected client receives (improvise-q08). Send is the
cheapest compile-time proxy for "portable": a field that cannot cross a thread
boundary certainly cannot cross a serialization one. Before h0b this failed to
compile, because drill_state: Option<DrillState> holds an Rc<Vec<RecordRow>>.
Session-local state lives on App instead. It is a const _: fn() in the test
module, not a named test, so it has no heading of its own.
app_view_state_owns_ui_session_fields
improvise-ew0: ViewState owns the UI session slice — mode, status, search,
panels, navigation, drill, yanked, buffers. App accesses them through
view_state.
view_state_survives_a_json_round_trip
improvise-a2x: ViewState is what a newly-connected client receives as a Snapshot
(improvise-q08), so it has to survive a round trip through the wire format.
Populated with non-default values in every field family — a mode carrying a
MinibufferConfig, a navigation frame, a yanked cell, a keyed buffer, an armed
prefix — because a roundtrip of ViewState::default() would pass while proving
nothing.
the_two_active_view_names_never_diverge
improvise-cqi: "which view is active" is stored twice — on the Workbook (the
document's) and on ViewState (the client's, which keys the cursor map). Every
path that changes one must change the other, or the cursor map is read under the
wrong key. App owns both writers: switch_to_view and install_workbook.
moving_the_cursor_does_not_touch_the_shared_workbook
improvise-8uz: moving the cursor must leave the shared document untouched, so that
one client scrolling cannot move another's viewport. Before the move these three
effects wrote into model_state.workbook, and this assertion would have failed.
app_owns_the_session_local_fields_not_view_state
improvise-h0b: the two session-local fields moved off ViewState and onto App.
Each fails both membership tests: ImportWizard is an improvise-io type (not
portable) and a half-open dialog (not snapshot-safe); DrillState holds an Rc
(not portable — see the Send assertion) and stages uncommitted edits (not
snapshot-safe).
staged_drill_edits_are_unreachable_from_view_state
improvise-h0b: a fresh client must not inherit another session's staged,
uncommitted drill edits. Those edits are unreachable from ViewState, so a
Snapshot built from it cannot carry them.
switching_views_restores_each_views_own_cursor
improvise-8uz: the cursor is per view. Written as a characterization test
against the old View-owned fields and kept verbatim across the move to
ViewState::cursors, because a refactor about where state lives must not change
what the app does.
the_improv_format_does_not_persist_the_cursor
improvise-8uz: the ticket asserted these fields were "PERSISTED in .improv". They were not — the grammar has no cursor production and the writer emitted none. That is what made the move free: there was no restore-where-you-left-off behaviour to lose, and no format change to make. The assertion still guards the format going forward.
entering_records_mode_sorts_existing_data
Pressing R to enter records mode sorts existing data by CellKey, so display
order is deterministic regardless of insertion order.
Note: this test's doc comment carried a stray first line from a neighbouring
test ("Regression: pressing o in an empty records view should create the") that
was never finished. It was dropped in the comment-cap wave; the sentence above is
what the test actually asserts.
the_new_category_column_creates_a_category_and_its_column
improvise-2zq step 2: the +cat column creates a category and its column, without
leaving the grid.
Driven through real keys. The assertion that earns its place is the last one: the
new column is usable immediately, which is the whole point —
records_columns filters on kind.is_regular() and never requires items, so a
category with no items is a column the moment it exists.
enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation
improvise-2zq, found by Copilot on PR #27: creating a category from +cat shifted
the grid under the advance that followed it.
add_category inserts the new coordinate column immediately before +cat, so
every column index from +cat rightwards moves by one. CommitAndAdvance then
computed its advance from ctx, whose layout is the pre-commit one (gotcha 23:
apply_effects rebuilds only after the whole batch). On the last row Enter
wraps to the next column — and the index it wrapped to was +cat again after
the rebuild.
So the failure is not "lands one column off". It reopens category creation: a
user naming two categories in a row never reaches the column they just made, and
gets an unbounded +cat loop instead. The last row is the arm that wraps, so it
is the arm tested.
tab_on_plus_cat_does_not_reopen_category_creation
The same stale index reached Tab, which advances right rather than wrapping.
Both directions read the pre-commit layout, so both are covered — one arm of a
two-arm rule is the shape
doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names.
the_new_category_column_creates_the_column_but_writes_no_coordinate
Committing the +cat name creates the column and does not write the
coordinate on the row the cursor was on.
The deliberate answer to the ticket's third question, and the reason is gotcha 1:
setting a coordinate moves every measure cell in the row. That is a data
migration across N cells, and attaching it to the same Enter that made a schema
change — in an app with no undo — is a bigger single keystroke than the measure
+ asks for. The follow-up edit is one column away.
the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it
MAX_CATEGORIES is a reachable refusal on this path, and the refusal has to be
visible.
draw_bottom_bar renders the minibuffer instead of the status line, so a message
written while an editor is open is never drawn. The commit path therefore drops
out of editing before running the add, and AddCategory aborts the batch on
refusal so the trailing EnterEditAtCursor cannot re-open the editor over the
message.
The limit itself is checked in exactly one place — Model::add_category — and
nothing here restates it; the test fills the document to the limit by asking for
categories until it refuses.
naming_a_category_in_records_mode_does_not_eject_the_user
improvise-2zq step 1: naming a category in records mode used to eject the user from the grid they were standing in.
Records mode is _Index on Row and _Dim on Column, with every regular category
on Axis::None. View::on_category_added seated a new regular category on the
first axis no regular category held — Row — and bumped the virtual out of the
way to get there. So N, a name, Enter moved _Index off Row, records
detection stopped matching, and the grid silently became a pivot.
Driven through the real keys rather than through on_category_added, because that
is how the bug was found and it is what makes this evidence rather than a
restatement of the fix.
i_and_o_diverge_on_an_empty_records_grid
improvise-6sq: i and o want opposite behaviour on an empty records grid, which
is why edit-or-drill and enter-edit-at-cursor cannot be merged into one
command. Entering records mode puts every regular category on Axis::None, so
EditOrDrill's aggregation check is true unless the cursor is on a synthetic
record — and on an empty grid there is none, so i reports "cannot drill". o
must still open an edit there (see
add_record_row_in_empty_records_view_creates_first_row), so routing it through
the same drill check would break it.
improvise-ct5 changed the message and deliberately not the divergence. The
outcome — i opens no editor where there is nothing to edit — is right, and o
one key away is the affordance for adding a row. What was wrong is that the
message said "cannot drill", a pivot-mode concept in a mode whose model is "a
table I type into". Making i behave like o here (the ticket's option (b))
would collapse exactly the divergence improvise-6sq exists to record, so the
assertion still pins that i does not enter editing; only the wording assertion
moved, and it now also names what must not appear.
the_empty_grid_message_is_not_the_records_one_in_pivot_mode
The negative arm of improvise-ct5, and the reason the wording is keyed off the mode the keymap supplies rather than reworded once.
Without this, "say the records sentence everywhere" passes
i_and_o_diverge_on_an_empty_records_grid — and it would promise o, which adds
no row in pivot mode, trading one misleading message for another.
The fixture is an empty pivot grid that still counts as aggregated: Region on
Row with no items makes cell_key() None, and Extra cycled to Axis::None is
the regular category that makes EditOrDrill take the drill branch.
blank_model_bootstraps_its_first_measure_from_the_new_measure_column
A blank model has no categories and no measures, so wide format shows exactly the two creation columns. Typing a name into the new-measure one creates the measure, which gives it a real column to type values into — records mode stays self-sufficient for onboarding (improvise-c5v).
improvise-2zq widened that: a blank grid now offers +cat beside +, so both
halves of a schema can be bootstrapped without leaving the grid. This test still
pins the measure half; the category half is
the_new_category_column_creates_a_category_and_its_column.
Inside it, the cursor lands at column 0, which in a blank grid is now +cat
rather than + (improvise-2zq) — a record needs coordinates before it needs
measures, so that order is deliberate. o leaves the test editing column 0, so
the Esc before moving to the new-measure column is load-bearing.
ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows
improvise-lae end to end: Ctrl-W, a path, Enter.
The failing case is the interesting one, and it pins an ordering (found by
Copilot on PR #27). SaveAs aborts the batch on a failed write, so whatever
CommitSave emits after it is skipped. A first version emitted
[SaveAs, ChangeMode(Normal)], which left the app in SavePrompt — and
draw_bottom_bar renders the minibuffer instead of the status line, so the
"Save error:" the effect had just set was never drawn. The save appeared to do
nothing whatsoever.
Leading with the mode change puts the error in Normal, where the bottom row is
the status bar. This test therefore asserts the mode left the prompt, which is
the opposite of what it asserted before — deliberately, and it is the only thing
keeping the two effects in that order.
every_minibuffer_mode_entered_from_records_returns_to_plain_normal
Copilot round 2 reported that Ctrl-W from a records grid returns to Normal,
losing the records keymap while the layout is still a records layout. It is right,
and it is a class: every minibuffer mode reachable from RecordsNormal exits
to Normal, because the exit is a constant baked into
TextEntry::new(..., "normal") and into each commit command.
The point of this test is that the class predates SavePrompt: : and
Ctrl-E do exactly the same thing and always have. That is why the fix is not
"make SavePrompt remember its origin" — that would leave two of the three arms
broken, which is the shape
doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names.
The binding table is not involved and was checked rather than reasoned about: an
IMPROVISE_BINDING_DUMP diff shows RecordsNormal gains Ctrl-W and loses
nothing, so with_parent inheritance (gotcha 13) is intact. What is lost is the
runtime mode.
a_failed_colon_write_lands_in_normal_where_its_error_is_drawn
A failing : command must not strand the user in CommandMode.
Found by Copilot on PR #27, and it is the sharper form of gotcha 21. The abort is
supposed to stop the chain — :w /bad . q must not quit. What it must not also
stop is the bookkeeping: the mode restoration that ExecuteCommand appends.
Aborting before that ran left the app in CommandMode with the stale command line
still drawn, and draw_bottom_bar renders the minibuffer instead of the status
line — so the "Save error:" set one effect earlier was invisible. A failed save
looked exactly like a key that did nothing.
The answer is ordering, not classification: a batch puts its tidy-up where an abort cannot reach it, which is in front. No effect needs a new "runs anyway" flag, and the rule generalises to any command composing bookkeeping with something that can fail.
Both arms are here because they fail in different places: no path at all is
Save's guard (aborting since improvise-8tk, so this arm was broken before that
branch), and a bad path is SaveAs's write (aborting since f0f400d).
a_failed_colon_write_still_refuses_to_quit_after_it
The other half of the same rule: the abort must still stop the chain.
:w <bad> . q is improvise-8tk's data loss spelled on one : line, and moving the
mode restoration to the front must not have bought the fix above at the price of
that one.
a_sequence_whose_first_step_aborts_does_not_run_the_second
Gotcha 21 claims a failed Save aborts across Binding::Sequence steps as well
as within one command's effect list. Nothing tested it: no shipped Sequence has
a first step that can abort, and ZZ is a prefix resolving to the single wq
command, so prefix_z_then_z_reaches_save_and_quit exercises the
within-a-command case only. The claim was structurally true and unguarded.
It is true because Keymap::dispatch flattens a Sequence into ONE effect vec and
App::apply_effects runs that as one batch, breaking on abort_effects. It is
load-bearing, because it is the reason a [w, force-quit] Sequence would be
safe — the argument the grooming note unblocked on.
And it is exactly what improvise-0hb would remove. Command-level transactionality
means applying effects between steps, and apply_effects resets
abort_effects at entry and again on abort, so step 2 would be dispatched into a
fresh batch with the flag cleared — the Quit after a failed save would land,
silently reopening the improvise-8tk P1 data loss. This is the guard that would
catch it.
unmatched_key_after_prefix_is_swallowed
A prefix is consumed whether or not the next key matches, so a stray key after g
does nothing rather than being treated as a fresh normal-mode binding.
renaming_a_coordinate_moves_every_measure_in_the_row
improvise-c5v: a records row groups one cell per measure, so editing a category column has to move every one of them. The long-format code moved only the cell under the cursor, which in wide format would tear the row apart — half its measures under the old coordinate, half under the new one.
leaving_records_mode_cleans_empty_key_cells
improvise-3zq (bug #2): AddRecordRow creates a cell with an empty CellKey when
no Page-axis categories supply coords — that cell serialises as = 0 in
.improv and re-appears on every records toggle. Leaving records mode must clean
up any such meaningless records (the inverse of the SortData that runs on
entry).
enter_at_bottom_right_of_records_view_exits_editing
improvise-3zq (bug #1): Enter on the bottom-right cell of records view should
commit and leave edit mode. Previously CommitAndAdvance pushed an
EnterEditAtCursor effect unconditionally, so the cursor stayed put and editing
re-opened on the same cell.
Helper — records_model_with_two_rows
Not a test — the fixture for every Tab/Enter boundary test in this file.
A records-mode app with two data rows, for testing Tab/Enter behaviour at
boundaries. In wide format a row is one coordinate tuple, so the two rows are two
Region items, each carrying both measures (improvise-c5v).
tab_on_bottom_right_of_records_inserts_below
improvise-hmu: Tab on the bottom-right cell of records view inserts a new record
below and moves to the first cell of the new row in edit mode.
Note — the wizard_key_tests module
Behavioural coverage for the import wizard's key handling (improvise-2wm). The
wizard is driven entirely through handle_key, so these tests pin the
user-visible contract independently of whether the step/key decisions live in an
effect or in a command.
name_model_typing_edits_the_name_and_enter_confirms_the_import
Enter at NameModel builds and parks on the Done confirmation; a second
Enter there installs the workbook and closes the wizard (improvise-907, from
improvise-c6j).
review_proposals_blocks_when_no_category_is_accepted
BUG (improvise-907, from improvise-gfq): an import whose fields all classify as
Measure used to walk the user through DefineFormulas and NameModel before
build_model refused. The refusal now happens at ReviewProposals, where c can
still change a field's kind.
left_walks_back_through_the_steps_taken
BUG (improvise-907, from improvise-o1c): reaching a later step and wanting to
change a field kind meant Esc, which discarded every decision. Left walks back
through the path actually taken (gotcha 22).
done_esc_returns_to_name_model_and_drops_the_built_model
Esc at Done goes back to NameModel rather than discarding the import, and
the workbook built for confirmation is dropped so a later accept cannot install
stale field decisions.
Note — the basis_tests module fixture
The two lower tiers of a batch's basis (ADR 001, improvise-xwi6). Fixture note:
two regular categories, two measures and two records, deliberately. A one-category
fixture cannot tell TransposeAxes from CycleAxis, a one-record fixture cannot
tell "the record it touched" from "every record", and a one-measure fixture cannot
tell a record from a cell — the three distinctions these tests exist to draw.
a_records_two_measures_share_one_record_key
The record is the coordinate tuple with _Measure gone, and both measures of one
coordinate name the same record, which is the whole point of the unit.
a_layout_change_moves_the_layout_version_and_no_record
Tier 2 moves, tier 3 does not. A shape change invalidates every in-flight
CellData batch through layout_version alone; it must not also masquerade as a
write to any record.
a_loaded_document_is_dirty_because_its_path_still_names_the_old_one
A loaded document is dirty, and that is a behaviour change with a reason (improvise-gv9r, raised by the independent review of PR #26).
LoadModel is Model(WholeWorkbook), so apply_effects raises the flag — where
before that branch :e other.improv left the document reporting clean. Clean was
the wrong answer: LoadModel does not move model_state.file_path, so after
it the in-memory document is a different one from the file the path names, and the
bytes on disk and the bytes in memory really have parted company. That is all
dirty claims.
It is worth a test rather than a comment because of what it gates:
autosave_if_needed and the :q refusal both read this flag, so a user who loads
and quits is now asked rather than silently dropping a document whose path is
stale. :q! still discards. The path-staleness itself is pre-existing and is
reported separately; this pins only that the flag follows from it.
a_view_only_batch_leaves_the_document_clean
The negative arm of improvise-gv9r's raise, and it must be aimed at
apply_effects rather than at a Command: a view-slice command is folded
straight into ViewState and never reaches this loop, so a negative written
against the session door would pass however unconditional the raise was. It is a
ViewOnly effect run through the loop that discriminates.
What it pins: a client's cursor, status line or panel does not put the document out of step with the bytes on disk, so it must not arm an unsaved-changes prompt or an autosave.
a_cell_edit_moves_only_the_record_it_touched
Tier 3 moves for the record written and for no other, and tier 2 stays put: a cell edit does not invalidate batches that merely share the document.
a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure
The unit is the record, not the cell. Renaming a coordinate in wide records mode moves every measure cell in the row (gotcha 1), and that is ONE write to the record it left and ONE to the record it arrived at — not one per measure.
an_aborted_batch_bumps_nothing_it_did_not_run
Gotcha 21: a batch that aborts halfway must bump nothing after the abort — in
either tier. apply_effects accumulates the Dirty inside the loop for exactly
this reason, and the versions are read off the same place.
a_write_that_changes_nothing_moves_no_version
A write that lands the value already there invalidates nobody's basis, so it moves
no version. This is a deliberate choice of semantics — observed change, not "a
CellData effect ran" — and the safe direction, because the alternative rejects
batches for nothing.
the_version_tiers_are_not_in_the_view_state
The counters are shared document state on App, never on the portable session
slice (gotcha 30): a client inheriting another's idea of how far the document has
moved is exactly the stale basis this machinery exists to catch.
ui/project.rs
a_window_reaching_the_last_row_carries_the_trailing_group_header
A collapsed group at the bottom of the grid has no items to hang off, and is still the only row that says those rows exist. A window that reaches the last row therefore carries it.
It must be seen; it is not yet actionable in a browser. web/app.js registers no
listener on a group <th>, and toggle-group-under-cursor is still in
the_command_names_that_still_need_the_host_are_these, so z resolves Upstream
into Host::handle's empty vec — a black hole, not a slow path
(doctrine(an_upstream_key_is_a_black_hole)). The TUI can expand it today; the
browser can only show it.
Helper — app_with_a_trailing_group
Not a test — the fixture for that test and for its stops-short arm.
Region on Row with East in a group that comes last, so the group header is
the final entry on the axis and nothing follows it. That is the shape
axis_frame's trailing rule is about.
a_window_that_stops_short_does_not_carry_what_is_below_it
The other arm, and the one that would make a browser scrolled to the middle of a document draw the bottom of it: a window that stops short of the last row carries nothing below its last item.
a_window_opening_inside_a_group_names_only_the_nearest
A window opening inside a group still names it — otherwise a browser could not
say which group a column or row belongs to — while body_from declines to paint a
header that sits above the window.
And only the nearest one comes. The fixture puts two headers above the
window (Hidden is collapsed, so it emits a header and no items, and Late's
follows it immediately) because that is the only way to tell "the nearest header
comes" from "every header above comes". With one group above, deleting the
compaction leaves this green — it is a cost property, O(the groups a deep scroll
crossed), and correctness alone cannot see it: group_of still finds the nearest
and body_from still starts at the first item either way.
Helper — app_with_consecutive_headers
Not a test — the fixture for that test and for consecutive_headers_between_two_rows_all_come.
Two consecutive group headers between two rows: Hidden is collapsed, so it emits
a header and no items, and Late's header follows it immediately.
consecutive_headers_between_two_rows_all_come
Headers between two in-window rows all come, not just the nearest.
Only the nearest wins group_of, so a compaction that kept one would still answer
every question about membership correctly — and would silently lose the Hidden
header, which is the only thing on screen that says those rows exist at all, and
the only thing that will be clickable once the browser can toggle a group. It
cannot today: z is Upstream and answered with nothing.
Above the window the rule is the opposite, and that arm is
a_window_opening_inside_a_group_names_it_without_painting_it.
folding_what_the_server_emits_reproduces_what_the_server_has
The contract of the whole module: a client that folds what the server sends ends up
holding what the server has. Not the fold axiom — the yardstick (server_truth) is
built independently of the projections.
Helper — server_truth
Not a test — the yardstick that test and its siblings measure a fold against.
What the server believes the client should be holding, built straight from the
authoritative state — the yardstick every fold is measured against, and built
independently of the projections so the fold tests are not vacuous
(doctrine(fold_tests_must_test_order_not_the_axiom)).
removing_a_category_empties_the_clients_formula_panel
Removing a category removes every formula that targets it —
Model::remove_category ends with formulas.retain(|f| f.target_category != name).
RemoveCategory is LayoutGeometry, and Dirty::of(LayoutGeometry) reported
formulas: false, so the formula panel was never told: a client's FormulaCache
kept showing a formula the document no longer has, indefinitely, with nothing to
bring it back short of a reconnect. (Gotcha 35.)
a_late_client_converges_with_one_that_followed_from_the_start
Bootstrap is a compaction, not a replay: a client that connects late and folds only the seed lands exactly where one that followed the whole stream is.
the_projected_extent_is_the_whole_grid_not_the_window
The extent is the whole grid, not the window (improvise-bpq2).
A client resolving its own cursor keys clamps against this, so a producer that read it off the projected ranges would stop every browser cursor at the bottom of the screen — and would look right in the TUI, whose cache covers the whole grid at offsets zero (gotcha 44). The fixture is deliberately not square (3 rows, 2 columns) and the window is smaller in both directions, so it separates "the whole grid" from "the window" and from "rows and cols swapped".
a_reshaped_grid_folds_to_the_desired_state
Reshaping is the case the ordering exists for. Growing is the half that bites: cells at positions the client does not have yet are dropped, so the labels that create those positions must go first. Shrinking is here for the other direction — cells that no longer fit must not be sent into a grid that has already been cut down.
a_renamed_column_whose_total_did_not_change_still_gets_its_total_back
improvise-hkt6, found by Copilot on PR 20. The diff must carry the mirror to
desired, and it stopped doing so for one shape: a column renamed while its
total stayed the same number.
apply blanks the total under a relabelled column (gotcha 33(b)), so the client's
total went blank; but the diff compared mirror.totals against desired.totals,
found them equal, and sent no Totals. ClientSession::project_diff then assigns
self.grid = desired, so the server believed the number was still on screen — a
cache wrong forever, with nothing in the stream to correct it, which is
precisely the direction gotcha 35 keeps Dirty::of sloppy to avoid.
The assertion is the one a_reshaped_grid_folds_to_the_desired_state makes — fold
the updates onto the mirror and require the result to be desired — because "a
Totals update was emitted" would pass for a producer that emitted the wrong
totals.
The fixture renames the middle column and keeps every total equal, so it separates "re-sends what the relabel blanked" from "re-sends whenever the labels move at all".
renaming_an_axis_category_resends_every_cell
The producer half of the category-rename rule, and the reason the apply half is
not cosmetic: the two compose into "wrong forever" without it.
grid_updates diffs the cells against the framed mirror — the mirror with the
frame updates already folded in. So an apply that kept the cells through a cats
change made the producer agree they matched and send nothing,
ClientSession::project_diff then assigned self.grid = desired, and the client
held cells answering the old coordinates with no update in the stream that would
ever correct them.
An axis swap is the case that bites: two categories can legally share an item name,
so Region = [North] becoming Department = [North] leaves the labels identical
and the CellIds valid and pointing at the other record.
a_session_that_switches_views_is_projected_the_view_it_switched_to
ClientSession::apply folded straight into the session's mirror, so a
Command::SwitchView moved the mirrored active_view while the workbook's stayed
put (gotcha 29). viewport() then read the cursor under the new view's key and the
next project() built the grid from the other view — the switching client was
sent rows it had not asked for and could never be corrected, because nothing
re-synced the pair.
Helper — app_with_two_views
Not a test — the fixture for every lockstep and rebase test in this file.
A second view whose axes are the first one's transposed, so the two grids cannot be confused for each other: "Default" rows are regions, "Second" rows are measures.
a_view_switch_moves_every_sessions_mirror_not_just_the_switchers
Lockstep is a property of every session, not of the one that asked.
Sessions::apply moved the workbook's active view and then moved the mirror of the
addressed session alone, so a bystander went on naming the view the document had
left. Two things followed. viewport() read that bystander's cursor under the
stale key while grid_of built the grid from the workbook's new active view, so
the bystander was projected the new view's rows positioned by the old view's scroll
— the cursor-vs-geometry mismatch of gotcha 24, arriving through another client.
And its own client-side slice, which folds the very same commands so that the two
"cannot drift" (ClientSession::fold), was never handed the switch to fold.
Helper — diff
Not a test — the accessor every diff-subject test in this file reads its answer through.
The two halves of an Applied::Diff, for the tests whose subject is not the
rebase. It panics rather than defaulting, so a command that starts replacing the
document is a loud failure here and not a silent Dirty::NOTHING.
Note: before the comment-cap wave this helper's doc comment was preceded by an orphaned paragraph ("One connected client, as its host really drives it…") describing a type that no longer exists at that site. Dropped.
a_model_command_that_moves_the_active_view_binds_every_mirror
The same lockstep, reached through the model half of the vocabulary
(improvise-kmgs). ViewBack names no view, but it ends in App::switch_to_view
just as SwitchView does, so every mirror owes the accepted name —
doctrine(lockstep_binds_every_session_not_the_asker) is about the value moving,
not about which command moved it.
The fixture walks forward through the effect, which is what fills the history stack, and only then connects — so both mirrors start out naming "Second" and the step back is something they have to be told about.
a_model_commands_view_switch_reaches_the_asker_too
The asker's own slice owes the switch too, and ToOthers never gave it one.
apply_switch is right to relay ToOthers: a client that typed
Command::SwitchView folded it optimistically, so sending it back would re-apply a
move it has already made. A Command::Model is the one kind a client cannot
fold optimistically — it does not know what the document will accept, which is the
very argument Broadcast::ToAll is written on for ResetCursor — so the asker is
owed the accepted view on exactly the same terms as everybody else.
With ToOthers the asker's mirror was moved to the accepted view by
adopt_active_view while its own slice stayed on the outgoing one: the server and
the client disagree about which view keys the cursor map, which is gotcha 24's
mismatch arriving through the asker's own keystroke. Nothing else in the suite
could see it — a_model_command_that_moves_the_active_view_binds_every_mirror
reads to_others, which by construction looks away from the asker.
Helper — to_others
Not a test — and its blind spot is exactly why that test was needed.
diff().1 as the tests that predate Broadcast asked it: what a bystander is
told, and nothing about the asker. That is exactly why it cannot see
a_model_commands_view_switch_reaches_the_asker_too's bug.
a_geometry_command_invalidates_every_clients_cursor_including_the_askers
TransposeAxes and CycleAxis call app.reset_cursor() because renumbering every
row and column makes the old indices name different cells (gotcha 24) — but that
reset only reached the authoritative App's own view slice. Each ClientSession
keeps its own cursor in its mirrored ViewState, and nothing reset those, so every
client was left indexing a grid that had changed shape under it: Viewport::window
clips, so what a bystander actually saw was a blank grid, until it happened to
scroll (improvise-gvnb).
The asker is checked alongside the bystander, and that is not symmetry for its own
sake. A Command::Model is the one kind a client cannot fold optimistically, so
the asker is in exactly the same position as everyone else, which is why the reset
is Broadcast::ToAll and not the ToOthers a SwitchView correction is.
a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone
The other arm. A model command that leaves the grid's shape alone must not throw anyone's cursor away: where a client is looking is its own UI, and only a change to the shared geometry may invalidate it. Without this, "reset on every model command" would pass the test above.
a_bystanders_own_slice_follows_the_invalidation
And the bystander really is shown the new grid from the origin, rather than a
window clipped to nothing. This is the half the mirror reset alone cannot give: the
client's own slice is driven by what the host relays, so the test drives it the way
Host::command does.
Helper — host_dispatch
Not a test — the relay stand-in that test and its siblings drive a client's own slice with.
A stand-in for Host::command's relay, driving one client's own slice. to is the
client being driven and from the one that asked, because that pair is exactly
what Broadcast's audience is about.
a_model_command_that_leaves_the_active_view_alone_broadcasts_nothing
No client folded an active view, so there is nothing to correct. What the others
are told is the Dirty it produced.
a_clients_view_only_command_moves_only_that_clients_mirror
The other half of the door: a command that is nobody else's business must stay that
way. A client's scroll must not move the authoritative App's own view slice — the
model is shared, the UI is not.
a_document_swap_reseeds_every_session
Replacing the document must clear the cursor map, because the incoming document's
view names have nothing to do with the outgoing one's (gotcha 24).
install_workbook does that for the App's own view slice, but nothing touched the
session mirrors: a connected client went on naming the old document's view and
scrolling by the old document's cursor, so it was projected a window past the end of
a document it had never seen — an empty grid, with nothing that would ever bring it
back.
Helper — other_document
Not a test — that test's incoming document, and the one whose rename hides a bug.
A different document, with a differently-named active view, so a mirror still keyed to the outgoing one is visible rather than coincidentally right.
rebase_hands_every_session_a_snapshot_of_the_new_document
The reseed is reached from the projection path itself, not from a hook a host must
remember to call — Sessions is not reachable from the effect that installs a
workbook, so a convention there would be unenforceable. rebase exists for the
other half: the client's own view slice lives on the client, and only a fresh
Snapshot resets it.
a_document_swap_over_the_wire_rebases_every_client
A document swap asked for by one client must reach every other client's own view slice, and the host must not be able to miss it.
Command::Model put the WholeWorkbook mutations on the wire (improvise-kmgs), so
LoadModel is now something another client can ask for. The server stayed honest —
the generation bump reseeds every mirror on the next project — but a bystander
went on holding cursors["Default"] = row_offset 3 from a document that no longer
exists, and Applied { dirty, broadcast } said nothing that would tell its host to
send a Snapshot. Sessions::rebase, the only thing that produces one, had no
non-test caller in the tree.
That is gotcha 24's cursor-vs-geometry mismatch, arriving through another client's
command — the same shape doctrine(lockstep_binds_every_session_not_the_asker)
names, one layer out: the obligation was addressed to a host in prose instead of
being carried by the value it gets back.
The server's own mirror self-heals off the generation bump
(a_document_swap_reseeds_every_session covers that half); this is the other one,
the client's slice, which only a Snapshot resets.
Helper — a_document_shaped_differently_under_the_same_view_name
Not a test — the fixture that test needs instead of other_document.
A different document whose active view has the same name as the outgoing one, and a different shape: one row where the outgoing document had three.
other_document renames the active view, and that rename is what makes the bug
below invisible: apply_model broadcasts a SwitchView when the name moves, a
client folds it, and the stale cursor is then read under a key nothing has written
yet — right by accident. Same name, different geometry is the case with nothing to
hide behind, and it is the ordinary one: two revisions of a document both call
their first view Default. (Gotcha 39's fixture warning.)
Helper — app_over_a_loadable_document
Not a test — the App that makes a wire LoadModel in that test really replace the document.
An App whose storage already holds that document, so a Model(LoadModel)
arriving over the wire really does replace the document rather than setting an
error status.
a_command_that_leaves_the_document_in_place_is_not_a_rebase
The other arm of the same rule, so the check is not one that always says "rebase"
(doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)). A model
command that mutates the document in place, and a view-only one, are both diffs —
the question apply_model asks is whether the generation moved, not which variant
arrived.
a_load_that_fails_is_not_a_rebase
A LoadModel naming a document the storage does not hold leaves the document
alone, so it is a diff too. This is why apply_model asks the generation rather
than the variant: "is this one of the three WholeWorkbook commands" is not the
same question as "was the document replaced", and a client would be reseeded onto
the document it was already holding, losing its cursors to a failed load.
Note — the cell_identity module
Emitting and resolving the surrogate a client names a cell by (ADR 002). The two halves are tested together on purpose: an id that is emitted and never resolved, or resolved by re-deriving it the same way it was emitted, would agree with itself while agreeing with nothing else.
the_two_measures_of_a_row_share_a_record_and_differ_by_measure
Gotcha 1's unit, projected: two measure columns of one row are one record and two
cells. Both halves matter — a surrogate that also varied by measure would break
record_versions, and one that did not vary by measure at all would make the two
columns the same cell.
a_projected_record_id_resolves_to_the_cell_it_was_projected_for
The round trip that makes the surrogate usable: what the server projected for a position resolves back to the key that position has. Every row is checked, because one row cannot tell "resolves correctly" from "resolves to the first record".
Helper — raw_app
Not a test — the fixture for every stored-cell surrogate test in cell_identity.
Region on Row, _Measure on Column. Nothing regular sits on None, so every cell
is a stored cell. Three rows and two measure columns, so "the record" cannot be
confused with "the row" or with "the cell".
a_projected_aggregate_id_resolves_to_the_same_constraint
An aggregate names a coordinate constraint, and it must come back as the same
constraint — including the page and filter coordinates baked into the layout's key
(gotcha 27), which is why this resolves against cell_key rather than against the
row and column labels.
Helper — aggregated_app
Not a test — the fixture for the aggregate half of cell_identity.
The same, with a second regular category left on None, so every visible cell sums
over Product rather than being one stored cell. That is the fork EditOrDrill
makes, and the one the wire projects.
a_surrogate_for_a_removed_row_fails_to_resolve_and_the_others_do_not
The first arm of staleness. The identifiers are held constant and only the thing
that makes the id stale is varied (doctrine(a_rename_can_hide_a_stale_key)):
"East" is removed, so its surrogate must fail to resolve — while "North"'s, taken
from the same projection, still resolves to the same cell. Without that second
assertion a resolve_cell that returned None for everything would pass.
an_empty_cell_can_be_written_through_its_projected_id
improvise-1ey is "type a number into a cell and see the value update", and the cell a user types into is usually empty — so it is named by the grid and by nothing in the store. The index cannot answer it, which is why resolution falls back to the layout, and this is the case that makes that fallback load-bearing rather than defensive.
a_write_by_surrogate_lands_on_exactly_that_cell
Two rows and two measure columns, so a write that went to "the first cell", "the whole row" or "the whole column" is visible.
a_write_through_a_dead_surrogate_changes_nothing
The second half of "reject, nothing to write to": a surrogate that does not resolve must apply nothing, not fall back to some other cell. The document is compared whole, because a write to the wrong place is exactly the failure being excluded.
It goes through model_effects rather than the wire form, because the session door
reaches the same conclusion first and typed — Checked::against refuses this
command with Rejection::RecordGone, so a Checked over it does not exist. What
is asserted here is the floor underneath that door: the key path and any host
resolving a command locally get an empty batch rather than a misdirected write.
a_records_mode_cell_is_named_by_its_coordinates_not_by_its_position
The trap gotcha 1 sets. GridLayout::cell_key returns a synthetic (_Index, _Dim)
key here, carrying positions — the very thing ADR 002 rejected _Index for,
because position names a different record after a filter, an insert or a delete.
The projected id must come from RecordRow::coords instead, which is what
resolving it back to the real cell proves.
Helper — records_app
Not a test — the fixture for every records-mode surrogate test.
Wide format: one row per coordinate tuple, a column per raw measure, the coordinate
columns in front and a + column behind. Two records and two measures, so neither
"the row" nor "the column" can stand in for the record by accident.
the_measure_columns_of_a_record_row_share_its_record
Two measure columns of one row are two cells of one record, and the next row is a different record — the same shape the pivot case has, checked here because records mode reaches it by a different path.
a_coordinate_column_is_projected_with_no_identity
A coordinate column is not a cell to write into: editing it renames that coordinate
across every measure cell in the row (gotcha 1), which is a different intent from
SetCell. So it is projected with no id at all, and a client cannot compose a
write against it.
a_write_moves_the_version_under_the_clients_own_surrogate
The version counter is keyed by the same surrogate the client holds, which is what lets one identity answer both ADR 002's question and ADR 001's. A write must move the counter the client would check.
Helper — apply_caught_up
Not a test — and note it is #[cfg(test)] fn, not #[test] fn.
Sessions::apply for a client that is fully caught up, for the tests whose subject
is not the basis. The staleness tests build the Basis by hand, so neither kind
can pass by accident through the other's convenience.
Note — the basis_check module
ADR 001's basis check: a batch composed against a picture of the document that has since moved is refused, and one composed against the current picture is not (improvise-xwi6, improvise-fkfg).
Both arms, per tier. A check nobody has watched reject is indistinguishable
from one that always accepts, and a check nobody has watched accept is
indistinguishable from one that always rejects (doctrine(check_the_checker)). So
every tier appears twice.
The fixtures hold every identifier constant and vary only what makes the basis
stale (doctrine(a_rename_can_hide_a_stale_key)): the record a batch names is
projected once and reused, so a rejection can never be an artifact of the test
naming something different the second time.
a_cell_edit_is_rejected_when_the_layout_moved_under_it
The class-2 case, and the one test to keep if only one survives.
A CellData batch is stale when the layout moved, even though no record it names
was written: the coordinates its key hangs off changed underneath it. This is A
removing an item while B commits an edit keyed by it, and it is corruption rather
than a lost keystroke — which is exactly what a cell-level lock would not have
covered, because holding a cell does not stop anyone deleting the row.
Helper — basis_for
Not a test — the claim every basis_check test moves exactly one tier of.
The basis a caught-up client holds for one record. Built by hand rather than
through caught_up so each test can move exactly one tier of it and leave the rest
true.
a_cell_edit_composed_after_the_reshape_is_applied
The other arm of the same tier. A batch composed after the reshape is applied — so the rule is "the layout moved since you looked", not "the layout has ever moved".
a_write_is_rejected_when_the_record_it_names_moved
Class 1: somebody wrote the record since this client read it. Data staleness — the record still resolves, so force is coherent and the reason says so. The identifiers do not move, only the value, so a rejection cannot be a renamed key in disguise.
a_write_survives_another_record_moving
The arm that keeps the tier from being "reject whenever anything was written". A record moving does not invalidate a batch that names a different one — which is the whole reason the tier is per-record rather than a single counter.
a_write_to_a_record_that_no_longer_exists_says_so
Class 2 by the other route, and the reason ADR 002 put a maintained index in
DataStore rather than regenerating one from the layout: "the record is gone" and
"the record changed" must not collapse into one answer. There is nothing to write
to here, so nothing to force — force never creates structure.
The test brings the layout claim up to date so that, whatever kind RemoveItem
wears, only the record tier is left to answer. It is CellData today — removing an
item drops its cells and moves no view's axes — so that line is currently a no-op;
it is kept because a reclassification to LayoutGeometry would otherwise reject on
the tier above and hide the case this test is about, silently turning it into a
second copy of a_cell_edit_is_rejected_when_the_layout_moved_under_it.
a_claim_that_omits_the_record_it_writes_is_not_a_free_pass
The force-by-omission hole, closed. Which records are checked is the server's decision, read off the command, so a client that simply left its record out of the claim does not skip the tier — it is told it never stated one. Without this, ADR 001's "force is a re-composition, not a bypass" would have a bypass sitting in the message format.
a_document_swap_invalidates_every_claim
The top tier invalidates everything, whatever the other two say. It is checked first so the answer names the largest thing that moved: a client told "a record moved" after a document swap would go looking for a record that is not there.
a_rejected_batch_changes_nothing_anywhere
Rejection is a normal outcome: nothing is applied, nobody else hears about it, and no basis moves. A rejection that dirtied something would tell every other client to redraw for a change that did not happen.
a_published_basis_follows_the_grid_updates_it_describes
The order ClientSession::basis_update claims is load-bearing, pinned.
The basis names record counters for the cells the grid updates in the same batch
carry, so a Projection::Basis that overtook them would let a client claim a
version for a value it had not been shown — the mirror image of gotcha 33's rule
that labels precede the cells they frame, and unlike that rule it had nothing
holding it but the order of two statements in project_diff. Proved by injecting
the swap (gotcha 43).
a_client_that_folds_the_stream_composes_an_accepted_batch
The check is only satisfiable because the server publishes the basis — which ADR 001 does not mention, and without which a client could only forge one. So: a client that folds what it is sent can compose a batch that is accepted, end to end, with no test-only constructor anywhere in the path.
re_composing_against_the_republished_basis_is_accepted
And the other side of publication: after somebody else writes, the stream carries a new basis, so the client that folds it can re-compose and be accepted. That is ADR 001's force — a re-composition against the current basis — reachable without any mechanism that skips the check.
ui/host.rs
connecting_hands_that_client_a_snapshot_that_draws_the_document
AC 2: the bootstrap is sent on connect, addressed to the new client, and it is not
empty — a Snapshot whose seed carried nothing would satisfy "a snapshot was sent"
while leaving the client blank.
the_reported_extent_bounds_the_snapshot
The extent is what the snapshot is built at, so a client that says it can see one
row is not sent two. This is the property that makes Viewport the handshake rather
than an afterthought.
without_the_command_the_cell_is_empty
doctrine(check_the_checker): the preceding assertion would also pass if the seed
happened to contain the value already. Inject the fault — never send the command —
and it must fail.
a_bystander_is_projected_another_clients_edit
A cell edit is the whole document's, so a client that did not ask for it is told about it too. The projection is per-session because the viewports differ, not because the change does.
Helper — model
Not a test — and note it is #[cfg(test)] fn, not #[test] fn.
A model command from a client that is fully caught up, for the tests whose subject
is not ADR 001's basis. It goes through handle like any other frame; only the
basis is supplied rather than folded, and caught_up can do that because it holds
the App (a client cannot). The staleness tests build their own Basis.
Helper — addressed
Not a test — how every host.rs test names a cell the way a client would.
The same cell as a client names it: the opaque surrogate the server projected on
CellDisplay::id, which is the only handle a client has (ADR 002). A CellKey is
not constructible over there.
a_scroll_reaches_only_the_client_that_scrolled
A view-only command reaches nobody else. The counterpart to the test above: if every command fanned out, that one would pass for the wrong reason.
a_batch_of_commands_is_applied_whole_and_in_order
One keypress resolved on a client is up to three commands, and the host must take all of them (improvise-bpq2).
The fixture is the shape that actually arrives: a cursor moved past the bottom of a
one-row window, so the selection and the offset that follows it travel together.
The assertion is the offset, not the selection, because a host that applied only the
first command would still look right on selected — the window is what goes wrong,
and it goes wrong silently.
a_batch_is_abandoned_at_the_first_rejection
A refused batch applies nothing after the refusal. The stronger half — nothing
before it either — is a_refused_batch_leaves_the_commands_before_it_unapplied;
this is kept because the two halves fail independently.
a_refused_batch_leaves_the_commands_before_it_unapplied
improvise-955n / improvise-08wb. The bug: ADR 001 is emphatic that a stale batch
is never applied, not even partially, and improvise-ondj went to the trouble of a
type (Checked) to make the unchecked path unconstructible. But Checked is per
command, and a ClientFrame::Commands is a sequence of them: Host::commands
applied in order and abandoned at the first rejection, so a frame whose second
command was refused had already applied its first. The guarantee was true of a
single command and false of a batch.
The earlier command is a view command, which is not a concession: it is the
shape the one real producer emits (keys::commit — one mutation among view
commands), and it is still observable, because the host folds it into that client's
mirror. A frame may carry more than one mutation; what it may not do is apply some
of them and refuse the rest, which is clause 4 and open.
A refusal test needs an accepting one
(doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing): pair a
refusal with an acceptance, or the refusal can pass by refusing everything). The
second half runs the same leading SetSelected followed by a command whose basis is
current, and requires both to land. Without it, a SetSelected that simply never
worked would clear the first assertion and prove nothing at all.
Inside it, the stale claim is built from caught_up and then has a single tier
bumped, so the refusal can only be about the tier that was moved — a hand-built
basis that is stale in three ways at once cannot tell which check answered.
a_later_write_in_a_chain_still_answers_for_its_own_record
improvise-955n. The regression 90ea26e introduced, and the reason clause 2 cannot
be applied to all three tiers.
Hoisting the basis check to the frame hoisted all of it, so every mutation after
the first was admitted with no record check at all. Two clients, one record: A
composes [SetCell(North), SetCell(South)] against an honest current basis, B
writes South, A's frame arrives — and A's write to South landed on top of B's
silently, which is exactly the lost update ADR 001's record tier exists to
prevent. At the base commit the second command was checked and the frame refused.
The per-command check was doing two jobs and the commit noticed one. "Is the
client's picture of the document current" is a frame-level fact and hoists
correctly. "Is the client's picture of this record current" is not: which records
are at stake is a property of the command, which is why records_named is an
exhaustive match over ModelCommand (gotcha 43). The incapability argument that
motivated clause 3 is true of records a frame creates and was over-generalised
to records it merely writes — and those are the ones the tier is for.
Three arms, because a fix that refuses everything would pass the first alone and a fix that checks nothing would pass the last two.
In arm 2, B moves South with a SetCell, which is CellData: neither the document
nor the layout tier moves, so only the record tier can discriminate — which is what
makes this a test of the record tier and not of the hoist in general.
Arm 3 — the same record twice in one chain. Legitimate and likely (two measures
on one record is one record) and it works for a reason worth pinning rather than
rediscovering: every check happens before any command is applied, so the second
write is compared to the record's pre-frame counter, not to the one its own
sibling just bumped. Were the checks interleaved with the applies, this frame would
refuse itself with RecordMoved.
Read this before closing clause 4. Interleaving is tempting there, because it
would close the silence: [RemoveItem(North), SetCell(North)] under interleaving
is refused RecordGone instead of silently half-applying, which is half of what
clause 4 owes. So check-before-apply is not a free win — it buys the legitimate
double-write and sells the loud failure on a broken chain, and clause 4 has to
pay that back some other way. Measured rather than argued: injecting the
interleaving fails exactly two tests, this one and
a_frame_whose_later_mutation_fails_is_not_yet_undone, and the second failing is
the trade. Arm 3 is what stops someone taking the trade without noticing they made
it.
a_data_dependent_chain_lands_in_order
The capability the contract is for: a chain of data-dependent commands, applied in order, each seeing what the previous one left.
[AddItem("East"), SetCell(the East record)] is the archetype, and it is the case a
per-command basis check cannot admit however it is ordered: a client cannot
claim a counter for a record its own chain is about to create, because
BasisCache::claim filters to records the cache has been told about. So the basis
is answered once, on the first command that carries one, and the rest of the chain
runs against what its predecessors left.
The second assertion is the one that earns the test. Asserting only that the item exists would pass against a host that applied the first command and dropped the second — which is exactly what the previous rule did, loudly, and what the pre-frame check did, silently.
a_frame_whose_later_mutation_fails_is_not_yet_undone
Characterization of the open hole (clause 4), not an endorsement.
The contract says a frame is all-or-nothing. It is not yet: App::apply_effects
aborts without undoing (gotcha 21 — an abort is not a rollback), so a chain whose
later mutation fails leaves the earlier one standing.
[RemoveItem("North"), SetCell(the North record)] is the shape: the removal lands,
the write then resolves to nothing, model_effects returns an empty batch, and
the frame is reported accepted having applied half of itself. Both the partial
application and the silence are open.
This test did not pin that, and the way it failed is the repo's own signature defect sitting on the one test whose job is to fail loudly if anyone believes the hole is closed. It asserted that North was removed and that nothing was rejected — both true of any wholly successful frame. The independent reviewer retargeted the second command from the removed North to the live South, making the frame succeed completely, and the test still passed. It could not tell half-applied from fully-applied, so it would never have inverted when clause 4 landed; it would have gone on passing. The missing assertion is the one that names the hole: the second mutation did not land.
It is unreachable from any client here — the only producer of a multi-command frame
is keys::commit, which emits one mutation among view commands — which is what
makes it safe to document rather than rush.
This test must be inverted when clause 4 lands: all three assertions flip, because then nothing at all happens.
The frame claims North at its current counter, so it passes the check and fails at apply time — which is the only way to reach the hole. A frame refused by the check never applies anything and would be testing clause 2 instead.
Helper — frame_basis
Not a test — the frame-level claim that test and the 955n tests state.
The basis a caught-up client would state for a frame naming regions: the two
document tiers plus each named record's current counter.
reduce::caught_up cannot stand in for this, and the difference is exactly what a
frame is: it builds the claim for one command, so a frame's records beyond the
first are simply absent from it and come back RecordUnstated. A real client calls
BasisCache::claim once with every record its frame names, which is what this
reproduces.
Name only records that exist. claim omits a record the cache has never
heard of; this helper always includes one, so pointed at a record that is not there
it fabricates (hash, 0) — a claim no real client could compose, and precisely the
input that flips the create exemption in CheckedBatch. For a record the frame is
creating, leave it out, which is what a client would do.
a_bystander_is_sent_the_broadcast_and_the_asker_is_not
The bug Copilot found on PR #10: a bystander must fold the accepted command itself,
because its own view slice lives on the client and only reduce_view over the same
command keeps it in step with the server's mirror (gotcha 36).
a_geometry_command_relays_the_cursor_reset_to_the_asker_too
The other audience, and the reason Broadcast carries one rather than the relay
deciding. A geometry change invalidates every cursor into the document
(improvise-gvnb), and the asker's is not exempt: a Command::Model is the one kind
a client cannot fold optimistically, so nothing has reset it locally and skipping it
would leave the very client that asked positioned in a grid it no longer has.
a_stale_batch_is_refused_to_the_asker_alone
A refused batch is answered to the asker and nobody else (ADR 001). Nothing happened, so there is nothing to tell the others — and the asker is told only which class of staleness it was, because it already holds what is current.
folding_the_broadcast_moves_the_bystanders_active_view
And what the broadcast is for: a bystander that folds what it is sent ends up naming the view the document accepted, so the cursor its next grid is positioned by is read under the right key (gotcha 24).
a_document_swap_sends_every_client_a_fresh_snapshot
AC of gotcha 39: a command that replaced the document owes every client a fresh
Snapshot, not a diff — including the client that did not ask.
Helper — host_over_a_loadable_document
Not a test — the host that test and a_load_that_fails_is_not_a_rebase drive.
A host whose document can be replaced out from under its clients: a second document
sits in the storage backend under a name LoadModel can reach.
a_client_can_ask_its_host_to_save
improvise-368h. The bug: effect::Save is EffectKind::ViewOnly — correctly,
since it writes this host's path — so the derivation that builds ModelCommand
out of the EffectKind::Model effects could not reach it (gotcha 38). There was no
save on the wire at all, so no client on any transport could cause its host to
write: a websocket session edited the document in memory and the file on disk
never moved unless a human was at a TUI that was not there.
The derivation was not wrong; it was complete over the mutations and saving is not one. So the variant is added rather than derived, and it carries no path: a client cannot see the host's storage, and which document a host is hosting is the host's own fact.
a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker
improvise-368h: the "what if there is no path" half. Not a panic and not a silent no-op — the two answers the ticket ruled out — but the vocabulary that already exists for "nothing happened and here is why", addressed to the asker alone (gotcha 39).
The second client is not decoration: a refusal that leaked to the bystanders would be telling them about a failure of somebody else's host-local request, and this asserts it does not.
a_write_that_fails_is_refused_rather_than_reported_as_saved
improvise-368h: a write that was attempted and failed must not be reported as a
success. Save signals failure by aborting its batch (gotcha 21), and the batch
here is exactly [Save], so an abort is that failure and nothing else.
The fixture fails for a reason that is real rather than mocked: a PhysicalFs
pointed at a directory that does not exist. It creates nothing, so the test leaves
no trace on disk.
every_frame_survives_a_json_round_trip
The wire is JSON to start with (the ticket's choice, kept), so both directions must
survive a round trip through it — including the one point at which ViewState is
serialized (gotcha 25).
The inbound half carries a payload-bearing variant on each side of the envelope
(doctrine(populate_before_roundtrip)): a Viewport and a Command carrying a
CellValue::Text, which is the arm the outbound half cannot reach — a text cell
aggregates to nothing under a None-axis category, so it never becomes a
CellDisplay.
Note: host.rs also carries a second a_load_that_fails_is_not_a_rebase
(doctrine(check_the_checker): inject the fault the test targets — a load that does
not happen — and the snapshot must not appear; a test that saw Snapshot on every
command would pass a_document_swap_sends_every_client_a_fresh_snapshot). It shares
its name with the ui/project.rs test of the same name, which is why there is one
heading for both above.
ui/cat_tree.rs
the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets
improvise-vjms: the panel lists the virtual categories alongside the regular ones,
and _Measure's items are derived — explicit items ∪ formula targets (gotcha 7).
An implementation reading category("_Measure").items passes every other assertion
in the module and silently drops Profit, which is the failure improvise-ete was
closed for proposing.
command/keymap.rs
f1_and_question_mark_both_open_help
improvise-th7m: F1 was bound twice on consecutive lines — first to a bare
enter-mode with no args, then to enter-mode ["help"]. The first was dead,
because Keymap::bind inserts and the later binding wins; had it ever been the
surviving one it would have failed require_args at dispatch and made F1 do
nothing.
The risk in deleting a line of a duplicated pair is deleting the wrong one, so this
pins which binding F1 is meant to carry: the same one ? carries, which is what
identified it as the intended survivor.
every_bound_command_name_is_registered
improvise-9cn: Keymap::dispatch resolves a binding through
registry.interactive(name, args, ctx).ok()?, so a binding is a silent no-op key in
two ways, not one: the name may not be registered, or the args may not parse. Both
walks live in this test.
The args half matters because improvise-s13 moved bindings out of the nullary class
the name check covers: enter-mode ["export-prompt"] and
enter-mode ["formula-edit"] carry a mode name that parse_mode_name must accept,
and a typo like "export_prompt" compiles, registers, and produces a dead
Ctrl-e. (Gotcha 15.)
It builds the context in the mode whose keymap it is walking, because a few parsers
read ctx.mode — commit-buffer item resolves its target category off
AppMode::ItemAdd.
every_hint_key_resolves_in_its_mode
improvise-2hi: AppMode::hint_text is the status bar's promise about what the keys
do, and nothing tied it to the keymaps. only_quit_has_no_hint asserts a hint is
present, never that it is accurate, so rebinding or dropping a key leaves the
hint quietly lying to the user. This walks every Key:verb token in every hint and
requires the key to resolve in that mode's keymap.
The hint is the source of truth for which keys are worth mentioning; the keymap is
the source of truth for what they do. Only the first half is checkable from here
— a hint that says i:edit where i enters records mode still passes.
(Gotcha 14.)
Helper — hint_keys
Not a test — the tokenizer that test walks each hint with.
Every key a hint mentions. Anything it cannot classify is a panic, not a skip: a hint token it does not understand is exactly the token that would go unchecked. Everything after the em dash is prose ("— type a category name"). A colon-less word continues the previous hint's verb ("Enter:add & continue", ":q quit"); one with nothing to continue is a token the test does not understand.
prefix_keymaps_are_flat
improvise-jc8: an armed prefix is stored as the key that opened it and resolved
against the CURRENT MODE's keymap. That is only correct while prefixes are flat — a
Prefix binding nested inside a sub-keymap would be resolved against the mode map,
where it does not exist, and the second half of the sequence would silently do
nothing. Adding a nested prefix must fail here rather than in the terminal.
every_panel_keymap_shares_the_same_spine
improvise-4pg: the three panel keymaps were written out separately, so a spine
binding could be changed in one and forgotten in the others. They share
PanelKeymap now; this pins what "the spine" is, including that each panel's cursor
keys move its own cursor. (Gotcha 12.)
every_mode_with_panel_toggles_binds_all_of_them
Every panel's toggle key is bound in Normal and in all three panel modes, so focus
can move between any two and the focused one closes. A fourth panel gets all four
keymaps from one Panel::ALL entry.
panel_toggle_keys_dispatch_to_real_effects
Panel::name() has to spell what the registry's panel parser accepts. A lookup-only
assertion would not catch a mismatch — dispatch swallows an unresolvable command
with .ok()? — so the toggle key must come back with real effects.
every_text_entry_mode_types_into_the_buffer_its_prompt_names
improvise-k8h: the buffer name used to be written on both sides of the crate —
AppMode's MinibufferConfig names the buffer the prompt describes, and the keymap
names the buffer append-char edits. If those disagree the mode prompts over one
buffer while every keystroke edits another, with nothing to notice. TextEntry now
reads the name off the mode, and this pins the result for every text-entry mode.
(Gotcha 11.)
Helper — text_entry_modes_with_exits
Not a test — the list that test and the two below it walk.
The seven modes that carry a MinibufferConfig, each with the keys that commit or
leave it. SearchMode is not among them: it is Normal plus a flag, so it has no
AppMode variant and no minibuffer config to agree with.
every_typing_keymap_belongs_to_a_text_entry_mode
The list in text_entry_modes_with_exits must stay complete: every keymap that
types into a buffer belongs to a mode whose minibuffer names that same buffer. Only
SearchMode, which has no AppMode variant, is exempt.
text_entry_modes_clear_the_buffer_they_type_into
Every minibuffer mode types into exactly one buffer, and every key that commits or
leaves must clear that same buffer on the way — a buffer left behind pre-fills the
next entry with the last one. SearchMode is excluded on purpose: it has no commit
and its query is owned by exit-search-mode, not by clear-buffer.
The buffer is derived from each mode rather than restated in the test —
every_text_entry_mode_types_into_the_buffer_its_prompt_names owns pinning what it
should be (improvise-k8h).
minibuffer_enter_dispatches_to_a_registered_commit
improvise-6t3: the three commit commands collapsed into a single
commit-buffer <kind>. dispatch swallows an unresolvable command name with
.ok()?, so a lookup-only assertion would still pass had the rename missed the
registry — Enter has to come back with real effects.
dump_every_binding
The instrument doctrine(binding_dump_diff) asks for: every (mode, key, modifier)
lookup across all the modes, written to the file named by IMPROVISE_BINDING_DUMP.
Most bindings have no direct test, so a green suite is not evidence that a keymap refactor preserved them. Dump before, dump after, diff. It is inert without the variable: a test that writes a file nobody asked for is a surprise, not a check.
This is what proved the keymap move to improvise-protocol changed nothing — 4021
resolved lookups, byte-identical either side — and the diff was shown to be
discriminating by renaming one binding and watching eight lines move.
command/cmd/commit.rs
invalid_formula_reports_the_parse_error_not_success
improvise-6t3: the commit pushed set_status("Formula added") after AddFormula,
so the parse error that AddFormula::apply writes to the status bar was immediately
overwritten. An unparseable formula was dropped while the UI reported success.
The add effect is the only party that knows whether it worked, so it has to get the last word on the status line.
commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor
CommitAndAdvance must thread its edit_mode through to the trailing
EnterEditAtCursor effect so the post-commit re-edit lands in the mode the keymap
requested. The command never reads ctx.mode.
commit_save_writes_the_model_to_the_typed_path
improvise-lae: the save prompt has to commit to SaveAs and not to ExportCsv,
which is what makes it a separate mode from ExportPrompt rather than a second
binding onto it — ExportCsv would write CSV under whatever .improv name the user
typed.
SaveAs and not Save, because Save writes the path the document already has,
and the whole reason this prompt exists is that there isn't one.
commit_save_on_an_empty_path_writes_nothing
An empty prompt must not reach SaveAs: SaveAs(PathBuf::from("")) asks the
backend to write to "", and the user then reads whatever I/O error that produces
instead of "you typed nothing".
command/cmd/fit.rs
the_command_installs_a_formula_the_document_evaluates
The end-to-end claim of improvise-vx3, asserted against a real document: a command
fits, the formula lands through the ordinary add_formula path, and the new measure
evaluates to numbers.
Helper — workbook
Not a test — the document every fit.rs test fits against.
A document a tree can learn exactly — Score steps at Cost 50 — in the units a
real document uses.
Every category is a Label, so the only feature is the numeric measure Cost
and the champion cannot reach an unresolvable one. An earlier version left Region
regular with one item and claimed simplify would prune the constant one-hot; it
does not, because build_categorical records (0.0, 1.0) for every one-hot
whatever the column holds — so these tests were passing on seed luck (Copilot on
PR #25). See improvise_gp::fit's own fixture for the same correction.
the_status_line_reports_quality_against_the_baseline
improvise-vx3 is emphatic that the rule alone is not the deliverable: a tidy tree that generalizes no better than the mean is worse than useless. So the status line carries the held-out comparison, what the rule leans on, and the seed that reproduces it.
a_refusal_names_what_went_wrong_and_stores_nothing
A refusal reaches the user whole. "fit failed" is not actionable; naming the measure that does not exist is (improvise-8xhy's argument, arriving at the place a user meets it).
the_seed_argument_changes_the_search
Gotcha 37b: the seed is half of what reproduces a fit, so it is an argument — and a different one really does search differently.
It asserts on the rule, never on the batch's debug string. The first version
compared effects_debug over the whole vec, which was vacuous: status_line prints
seed {:#x}, so the two strings differ because the seed was printed, and
fit_measure could have ignored request.seed entirely with the test still green
(doctrine(check_the_checker)).
AddFormula has exactly two fields — the formula text and the target category — and
the seed reaches neither, so its Debug cannot differ for the reason the batch's
did. (A substring guard was tried and is wrong: the seeds 1 and 99 occur inside
perfectly ordinary thresholds.)
the_seed_the_status_line_prints_is_a_seed_this_command_accepts
Copilot on PR #25. The status line advertises the seed as {:#x} and tells the user
that is what reproduces the fit, so the one instruction it gives has to work on the
value it just showed them — a decimal-only parser made copying it back an error.
Asserted as a round trip through the status line, not against a hand-written
"0x…": a literal would still pass if status_line changed how it spells the
number, which is the half that breaks.
command/cmd/grid.rs
drill_into_formula_cell_returns_data_records
Drilling into a formula cell (Profit = Revenue - Cost) must return the underlying
data records, not an empty result set. The formula target coordinate is stripped
from the drill key so that matching_cells finds the raw data backing the formula
(gotcha 10).
drilling_fixes_its_coordinates_on_filter_not_page
improvise-dz8: a drilled coordinate is a constraint of the drill, not a dimension
the user may page through, so it lands on Filter. It used to land on Page, which
put it in the tile bar and under [/] as if the user could move it (gotcha 27).
command/cmd/mode.rs
enter_mode_export_prompt_sets_mode_and_clears_its_buffer
The enter-export-prompt shim is gone (improvise-s13); the generic enter-mode
carries the mode name instead, and clears the prompt's buffer on the way in.
enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer
improvise-gdl made write_buffer the single accessor for a runtime-named buffer,
because "search" is not an entry in the buffers map but its own ViewState field.
EnterMode built a SetBuffer directly, so a mode whose buffer_key is "search"
would clear a map entry nobody reads and leave the previous query on screen — the
exact desync gdl closed. No shipped AppMode names that buffer yet, so the mode here
is constructed by hand; that is what makes the test a demonstration rather than a
restatement.
edit_or_drill_passes_records_editing_mode_through
EditOrDrill must trust its edit_mode parameter rather than checking ctx.mode —
the records-normal keymap supplies records-editing, but the command itself never
inspects the runtime mode. This is the parallel of the deleted
enter_edit_mode_produces_editing_mode test for the records branch.
enter_edit_at_cursor_cmd_passes_target_mode_to_effect
EnterEditAtCursorCmd must hand its target_mode straight through to the
EnterEditAtCursor effect — the keymap (records o sequence or
commit-and-advance) decides; the command never inspects ctx. It fronts the pair
with RebuildLayout so the cursor is read against a layout that includes whatever
ran earlier in the sequence (improvise-ehz, gotcha 23).
command/cmd/navigation.rs
page_cycling_skips_filter_coordinates
improvise-dz8: [ and ] page through Page-axis categories. A coordinate the drill
fixed sits on Filter and must be invisible to them, or the user could page out of
the drill they are inside.
enter_advance_at_bottom_right_emits_abort_chain
At bottom-right EnterAdvance has no place to go, so it emits a single AbortChain
effect. Trailing effects in a CommitAndAdvance batch (EnterEditAtCursor) are
then skipped, which is how "Enter at bottom-right commits and exits editing" is
realised.
command/cmd/panel.rs
cycle_panel_focus_advances_from_formula_to_category
Bug improvise-dqn: CyclePanelFocus always picked the first open panel, ignoring
the currently focused panel. With formula + category open and focus on the formula
panel, cycling must move to the category panel.
create_and_switch_view_names_incrementally
improvise-aaw asked for this command to be replaced by a keymap
Binding::Sequence [create-view X, switch-view X, enter-mode normal]. It cannot be:
a Sequence carries literal args, and the name here is computed from ctx — so
a Sequence would hard-code one name and the second n in the view panel would
collide with the first.
The test was called ..._names_incrementally and asserted only that
CreateView/SwitchView/Normal appeared — it never looked at the name, which is
the one thing that makes this command a command and not a composition. Now it does,
at two different view counts, because a single count cannot tell "derived from the
workbook" from "constant".
command/cmd/registry.rs
every_entry_constructs_a_command_with_its_registered_name
improvise-9cn / improvise-61f: every registration derives its name from a prototype
instance, so a typo in the name is impossible. What the types do not catch is the
commands whose name() branches on a field — ViewNavigate, TileAxisOp,
ToggleGroupAtCursor, CommitAndAdvance. Their prototype and the value their
constructor builds are the same type, so registering ViewNavigate { forward: true }
while building forward: false compiles cleanly and files the command under the
wrong name, where dispatch silently misses. Constructing every entry both ways and
comparing names is the only check that reaches that class. (Gotcha 15.)
Some entries are deliberately one-path — commit-and-advance-right reads the edit
buffer and cursor, so its parse fn always errors — but every entry must be reachable
by at least one path, or its name goes unchecked.
registered_names_are_unique
A variant mix-up does not show up as a name mismatch — it shows up as a collision.
Registering ViewNavigate { forward: false } where forward: true belongs files a
second entry under "view-back" and removes "view-forward" from the registry
entirely; parse takes the first match, so every surviving entry still agrees with
its own name. Uniqueness is what catches it.
command/cmd/text_buffer.rs
writing_the_search_buffer_targets_the_search_query
Writing the "search" buffer means writing the search query, and write_buffer is
the one place that knows it (improvise-gdl). Every buffer-writing command routes
through it, so clear-buffer search clears the query rather than parking an unread
entry in the map.
colon_wq_saves_then_quits
improvise-git proposed deleting the SaveAndQuit command and binding ZZ to a
Binding::Sequence [w, force-quit] instead — the repo's "do not name a command
AAndB" principle.
That premise is incomplete, and this test is why. wq is not only a keybinding: it
is a registered command name, so :wq reaches it through the registry, and
Quit's own unsaved-changes message advertises it ("Use :q! to force quit or :wq to
save+quit"). A keymap Sequence is not reachable from the : line, so deleting the
command deletes the ex-command with it.
Keeping the command and adding the Sequence would leave two independently-written spellings of "save then quit", which is the duplication this codebase keeps curing rather than creating. So this pins the surface, and the conversion is declined on it.
colon_enter_edit_at_cursor_is_not_snapped_back_to_normal
improvise-6sq: ExecuteCommand appends change_mode(Normal) unless some effect
reports changes_mode(). EnterEditAtCursor sets view_state.mode but did not
override it, so :enter-edit-at-cursor editing produced
[RebuildLayout, EnterEditAtCursor(Editing), ChangeMode(Normal)] — the trailing
snap-back undid the edit mode and the user never got a cursor in the minibuffer.
Asserting on changes_mode() alone is not enough here: the appended
ChangeMode(Normal) reports true itself, so the assertion has to name the
snap-back it is ruling out.
colon_import_stays_in_the_wizard_on_success
improvise-toc: StartImportWizard swaps the mode only when the file parses, so a
per-type changes_mode() cannot describe it. Reporting false made
ExecuteCommand append a return-to-Normal on top of the success path:
:import d.json opened the wizard and was immediately snapped back, leaving the
wizard drawn under the Normal keymap. (Gotcha 31.)
colon_import_falls_back_to_normal_on_failure
The other half of the same bug: reporting true unconditionally would strand the
user in CommandMode when the import fails. A failed :import must still fall back
to Normal.
colon_view_back_respects_whether_history_exists
The ViewBack half of improvise-toc: it restores the stored frame's mode only when
the back-stack is non-empty. With history present, :view-back must keep the mode it
restored; with an empty stack it falls through to Normal like any other no-op
command.
command/cmd/wizard.rs
name_model_enter_builds_the_model
Enter on the name step builds the workbook into the wizard rather than advancing the step machine; every other printable key edits the name (gotcha 22).
done_confirms_on_enter_and_steps_back_on_esc
On the confirmation step Enter installs the built workbook and Esc steps back — it does not throw the import away, and no key closes the wizard outright (improvise-907, from improvise-c6j).
a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on
whether a regular category sat on Axis::None; it never asked whether the
_Measure coordinate named a formula target. So a formula cell was a Record,
render.rs::editable said true, and a write to it was accepted, stored and
then shadowed forever by formula_cache.
The fault injected: replacing the derived lookup with None, which is the
pre-fix code exactly. The test then reports Record(RecordCell { measure: Some("Profit") }) where it wants Derived { target: "Profit" }. The siblings
are asserted in the same test so a producer that answered Derived for every
cell could not pass.
an_aggregating_formula_cell_is_still_projected_as_an_aggregate
The negative arm of the same change, and the one a careless fix breaks: gotcha
10 says drilling into a formula cell is supported, so Aggregate must still
win where the cell aggregates. Proved discriminating by returning derived
before the aggregate branch — the cell then reads Derived and becomes
undrillable, with nothing else in the suite noticing.
edit_or_drill_refuses_a_formula_owned_cell
The TUI half. EditOrDrill never asked the question either, so the terminal
offered the same editor over the same shadowed cell. Landing only the
projection half would have made the two front ends disagree, which is the
defect design-principles §5 is about. Fault injected by deleting the guard: the
command returns [SetBuffer{edit}, ChangeMode(Editing)] again.