A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.
Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.
So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.
The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).
Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.
Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
46 KiB
Test evidence: browser client, hosts, harness
What each of these tests proves, what fault was injected to prove it can fail,
and — where it applies — the bug it was written against. Keyed by exact test
name; a renamed test orphans its entry, which
scripts/check-comment-runs.py --check-evidence reports.
The design argument each of these belongs to is in context/code/clients.md.
improvise-worker-server/src/worker.rs
an_edit_is_persisted_without_the_client_asking_to_save
The persistence half of improvise-9x6's acceptance criterion 2, and the reason
the worker saves on its own behalf: there is no ModelCommand::Save for a
client to send, because effect::Save is ViewOnly and the wire vocabulary is
derived from the model effects. So an edit must reach storage with the client
having asked for nothing but the edit. The test writes one cell over the wire
and reopens the document through the same VFS.
a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing
improvise-955n's second-order effect, which nobody had written down until it
was found while declining the ticket: on_frame skipped persist() when any
frame in the answer was Rejected, so a batch that did apply its earlier
command before being refused left the document moved in memory and not written
back — disk and worker disagreeing, silently.
Two changes close it independently, which is why this is worth a test of its own rather than trusting either: the batch no longer applies partially at all (improvise-955n), and the persist trigger is now the document's own dirtiness rather than "was anything rejected" (improvise-gv9r), so a batch that did change something would be written whatever else the answer contained.
The fixture is a refused frame with a command behind the refusal, and the assertion is that the axes on disk and the axes in memory agree. Under the settled contract the refusal comes from the frame's basis — answered once, on the first command that carries one — so that is where the staleness is injected; a later command carries no answer of its own.
an_explicit_save_is_not_retried_by_the_automatic_write
Found by Copilot on PR #26. A failed write leaves model_state.dirty set —
deliberately, so the next frame retries — and the automatic trigger runs after
every frame. A save frame therefore attempted the same write twice: extra I/O
on a permanent failure, and on a transient one something worse, because the
answer is composed from the first attempt, so the client is told SaveFailed
about a document that is now on disk.
The fix is a per-frame question rather than a per-document one, and that is the right shape precisely because the automatic write exists for edits nobody asked to save; one that was asked for has already been answered.
The fixture is a Storage backend whose writes always fail and that counts
them. Counting is the point: "it was refused" is true whether the write was
attempted once or twice, so a test asserting only the refusal would pass against
the bug. Failing is the point too: a successful save clears dirty, so the
redundant second attempt never happens and the bug is invisible from the happy
path.
a_key_resolved_on_the_host_moves_state_no_client_can_hear
Why the Key arm answers nothing, demonstrated rather than asserted in prose.
A worker can call reduce_key — it holds the authoritative App, which is the
thing a socket server lacks. What neither has is a way to tell a client that the
host's own ViewState moved: a client's slice advances only by folding
Commands, and resolving a keypress produces none. So the two slices diverge,
and ClientSession::viewport goes on reading the mirror's cursor, which means
the grid does not even follow. That is improvise-bpq2's real cost, and it is a
ticket rather than plumbing. The test asserts all three: the authoritative
cursor moved, the mirror did not, and nothing reduce_key emits would let a
client catch up.
improvise-ws-server
distinct_stems_both_open
doctrine(check_the_checker) for colliding_stems_are_fatal: inject the fault
that test rules out — distinct stems — and both documents must open. Otherwise
an open_all that failed on every second file would pass the collision test.
a_bystander_on_the_same_document_is_projected_the_edit
doctrine(check_the_checker) for
an_edit_is_projected_only_to_the_edited_documents_clients: put the bystander
on the same document and the assertion must flip. Without it, an isolation
test that passed because nobody is ever projected anything looks identical.
the_other_document_would_also_have_a_cursor_to_invalidate
doctrine(check_the_checker) for a_broadcast_does_not_cross_a_document_boundary.
The fault to rule out is the other document being incapable of producing the
broadcast at all — then a server-wide relay would be silent over there for the
wrong reason and the isolation test would pass without proving isolation. So the
checker drives the same TransposeAxes against beta and asserts it does produce
a ResetCursor there; beta's silence in the isolation test is the routing and
nothing else.
Both tests moved from Command::SwitchView to ModelCommand::TransposeAxes with
improvise-jytm: a view switch is now addressed to one session and relays nothing,
so it can no longer carry an isolation test. ResetCursor is the surviving
Broadcast::ToAll.
a_load_that_fails_rebases_nobody
doctrine(check_the_checker) for a_rebase_stops_at_the_document_boundary:
inject a load that cannot happen. The generation does not move, so nobody is
rebased — which is also what a routing bug that sent Snapshot to nobody would
look like, so the rebase test needs this one beside it to mean anything.
a_reopened_document_would_have_lost_the_edit_and_the_basis
doctrine(check_the_checker) for
the_last_session_leaving_does_not_close_the_document: inject the eviction — a
document freshly opened from the same fixture, which is exactly what closing on
last-disconnect would hand the next client. Both assertions in the lifecycle
test fail against it, so neither passes for a reason other than the document
having stayed put.
a_rebase_stops_at_the_document_boundary
Fixture note, and it is gotcha 39's warning: the swapped-in document keeps the
same active view name and changes only its shape. A rename would make
apply_model broadcast a SwitchView, the client would fold it, and the stale
cursor would then be read under a key nothing has written — right by accident,
with the bug invisible.
a_client_on_a_socket_is_seeded_and_gets_its_edit_back
(crates/improvise-ws-server/tests/socket.rs, and its neighbours in that file.)
Everything the socket test asserts is already asserted in host.rs without a
socket, and deliberately so: its subject is the shell, not the decisions. It
earns its place by covering what the unit tests structurally cannot — the
handshake rule, JSON framing in both directions, the routing from a SessionId
back to the connection it came from, and the one fact that lives only in a URL:
which document the connection asked for. If it ever starts being the place a
behaviour is pinned, that behaviour belongs in host.rs or registry.rs.
Two disciplines inside it are load-bearing rather than stylistic. The write is
composed from the surrogate the server projected (ADR 002) — a CellKey there
would be the test knowing something a browser does not — and the basis is
composed from what the server published, because a basis the test built itself
would be a client claiming to have seen something it was never sent.
improvise-wasm-client/src/keys.rs
text_entry_modes (a fixture, not a test)
Not a test — a fixture, and its limitation is the evidence. The comment here
used to claim a new mode "shows up as a missing line rather than as untested
behaviour". It does not: this is a hand-written list and nothing checks it
against AppMode, so a new text-entry mode is simply absent and silently
uncovered — which is what happened to save_prompt (improvise-lae) until it was
added by hand. There is a second hand-written copy in the command crate's
keymap::tests::text_entry_modes_with_exits. Deriving both from one place is
filed, not done.
a_modified_character_now_types_because_the_fallback_is_the_shared_one
The old classifier sent a modified character upstream in a text-entry mode,
because the host's AnyChar fallback matches under any modifier and a
hand-written rule could only safely be a subset of it. Reading the same table
removes the question: Ctrl-x falls through to AnyChar here exactly as it
does on the host.
search_typing_writes_the_query_and_not_a_buffer
Search typing resolves on the client for the first time, and only because the
keymap is shared: SearchMode binds append-char ["search"], and the name
"search" is ViewState::search_query rather than an entry in buffers
(improvise-gdl). The old classifier sent every search key upstream into an empty
vec. The negative half of the test matters too: Esc stays upstream, because
exit-search-mode owns the query and is the host's.
the_command_names_that_still_need_the_host_are_these
The command names this client cannot execute, one by one, so that closing one of these gaps has to come here and say so. This replaced a list of keys, and that is the whole gain of sharing the keymap: a rebinding no longer invalidates the list, and a new binding for an already-executable command works in a browser with no change to this crate. Each entry is driven through the key that reaches it, because a name that is not bound anywhere would otherwise sit in the list forever looking like a gap.
a_prompt_this_client_cannot_commit_is_never_entered
The refusal list above is only half the property. Upstream says the key
produced no Command; it does not say the client stayed put, and the bug being
guarded is precisely entering a room with no exit.
Found by Copilot on PR #27. Without strands_the_user, Ctrl-W resolved
enter-mode save-prompt locally — ChangeMode(SavePrompt), a perfectly good
Command — and the user then typed a path into a prompt whose Enter goes
nowhere. Ctrl-E did the same into ExportPrompt and had done since before
that branch.
The negative arm has two halves, and both are needed. Half one: a mode that
is not refused is still entered — the half that goes through
strands_the_user, so the only thing that can fail when the predicate
degenerates to "refuse everything". ? is honest here where : was not: Help
has no commit at all, so nothing a user types into it can be swallowed, and Esc
closes it locally. (Its page keys are upstream, which makes page navigation
inert — but inert navigation loses no work, which is the line the predicate
draws.) Half two: the property is about finishing, not entering, so something
has to press Enter and observe. The first version of this test asserted only
that : entered its mode, and : is itself a dead room — so that arm was
certifying the very behaviour it existed to rule out.
the_command_line_cannot_be_committed_by_this_client
The evidence behind listing CommandMode in strands_the_user, rather than the
assertion that it is listed. : opens locally and typing appends locally, but
Enter is [execute-command, clear-buffer] and step has no execute-command
arm — so the whole sequence fails to resolve, Host::handle drops the frame,
and every ex-command behind : is inert. That is what makes refusing the mode
cost no working surface. When improvise-e24v gives this client
execute-command, this is the test that should start failing, and the
CommandMode arm of strands_the_user is what it points at.
a_commit_in_the_last_cell_exits_editing_instead_of_advancing
At the bottom-right corner EnterAdvance returns AbortChain, so the re-entry
is skipped and the commit leaves the user in normal mode. That abort is a fact
about the host's effect chain and it has to be mirrored, or the browser would
sit in an editor on a cell the terminal has left. The fixture is a one-by-one
grid, so the only cell is the corner.
a_move_that_goes_nowhere_follows_the_command_it_mirrors
Which widgets say nothing about a move that goes nowhere is not a rule of
keys.rs — it is whatever the Cmd being mirrored does, and the three
differ: Move emits even when it clamps, MovePanelCursor returns nothing when
the cursor does not move, MoveTileCursor emits regardless. The grid arm
carries its own check-the-checker inline (a j that does move), so "emits when
clamped" cannot pass against a fixture that is simply inert.
i_on_an_aggregate_declines_out_loud
i on an aggregate must not go upstream: Host::handle answers a Key frame
with an empty vec, so that would be a keypress the user sees do nothing at all
(doctrine(an_upstream_key_is_a_black_hole)).
enter_commits_the_cell_advances_and_re_enters_editing
Enter in Editing is [commit-cell-edit editing, clear-buffer edit], and every
part of that sequence shows up in what the client composes — including the
trailing clear that empties the buffer EnterEditAtCursor has just seeded.
That ordering looks wrong and is exactly what the TUI does; mirroring it line
for line is the contract.
improvise-wasm-client — lib, socket, state, render
a_client_with_no_basis_composes_nothing
The fourth None, and the one ADR 001 adds: a client that has not been told the
document's basis has nothing truthful to say about what it composed against, so
it composes nothing. That is the property that makes the basis check unforgeable
from a browser — BasisCache::claim is the only constructor, and it withholds.
The other three None shapes — an aggregate, id: None, and a position outside
the grid — are pinned against the same composition in socket.rs's
typing_into_a_cell_that_is_not_a_record_composes_nothing. They were asserted
twice, once against each of two compositions, until improvise-r4lo left only one.
a_refused_batch_is_kept_as_a_rejection_and_not_only_as_a_sentence
A refused batch must reach the person who composed it, and it must reach them as
more than a sentence. ADR 001 makes rejection a normal outcome, and the
obligation that comes with it is that the client keeps the typing and can
re-compose. Force needs the Rejection itself — RecordGone cannot be forced at
all where RecordMoved can — so collapsing it into status_msg would throw away
the input to the very feature rejection exists to enable. Both halves are
asserted, because setting the string alone is the plausible half-fix, and the
revision bump is asserted too: a renderer polls the revision, and a rejection it
cannot see is a rejection it cannot draw.
every_rejection_message_says_the_edit_did_not_land
Found by the PR #12 Copilot review: DocumentReplaced read "the document was
replaced — reloading", and nothing in fold_frame reloads anything. A message
that names an action the code does not take is worse than a vague one, because
the user waits for it. The per-variant wording still differs — the classes are
different things to be told — but the one fact they share is the one the ADR
guarantees, so it is asserted over all of them rather than one at a time.
a_typed_string_is_classified_as_the_host_classifies_it
The three-way split, matching commit_regular_cell_value: a number is a number,
anything else is text, and empty clears rather than writing an empty string.
This test states an obligation it does not discharge: it asserts the three
outcomes by hand rather than comparing with the host's function, so the two
copies could drift and stay green. See context/code/clients.md for why the
duplication exists and what the real fix is.
a_cell_projection_that_overtakes_its_labels_is_lost
Gotcha 33, carried across this crate's dispatch. The protocol crate pins the
drop inside GridCache::apply; what is this crate's is that nothing between the
socket and that cache buffers, sorts or retries — so the same early Cells is
still lost after routing. The second half of the test feeds the same three
updates in frame-first order and requires them to land, so "everything is
dropped" cannot pass it.
bootstrapping_from_a_compaction_lands_where_the_stream_lands
doctrine(fold_tests_must_test_order_not_the_axiom): a client that bootstraps
from a compaction of the host's state must end up where a client that folded the
whole stream is. The seed is the host's current state expressed as projections,
not the head of the history.
the_selection_is_placed_by_subtracting_the_scroll_offset
(render.rs.) The conversion that looks right until the first scroll: the grid
is viewport-indexed, the cursor is not. The offsets in the fixture are nonzero
and unequal, which is what separates "reconciles" from "ignores the offset"
and from "swaps the two offsets".
column_groups_coalesce_by_name_as_the_tui_does
(render.rs.) The run boundary must be the name alone, because that is what
src/ui/grid.rs uses (group_name != prev_group) and the two ends must not
disagree about where a span begins. Collapse is category-scoped, so two adjacent
groups from different categories may legally share a name and differ in it.
Keying the run on the pair would start a second run — a second indicator the TUI
suppresses — which is a front-end divergence of exactly the kind improvise-anr9
exists to remove (raised by Copilot on PR #23).
a_keyboard_edit_in_progress_is_part_of_the_render_model
(render.rs.) A keyboard edit has to be visible, and what is visible is
decided in Rust rather than in the page. The failure this rules out is quiet: a
client that resolved i locally would change its mode label, put the typing into
buffers["edit"], and draw the cell's old value the whole time — so the user
sees a keystroke do nothing, types over it, and commits something they never
read. None outside an editing mode is the other half: a stale buffer must not
draw an editor that is not open.
improvise-wasm-client/tests/browser_round_trip.rs
The file's scope is improvise-1ey's, quoted: "open an existing document
server-side, view it in the browser, type a number into a cell, see the server
value update and the projection arrive with the new value." Everything in that
sentence except the DOM is Rust on both sides, so the test drives the whole of
it: a real Host over an authoritative App, and a real Client reached only
through the string calls the page makes. The socket is the one thing left out,
and it is covered by improvise-ws-server's own tests/socket.rs; what this
adds is the half that crate cannot reach — that the browser client seeds,
renders, composes and folds correctly against it.
The discipline that makes it evidence rather than decoration: the client is
touched only through Client::viewport, Client::on_frame,
Client::render_model and Client::typed_cell — strings in and out — and it
learns the cell to write from its own render model, never from the fixture. A
CellKey anywhere in it would be the test knowing something a browser does not
(ADR 002).
a_grouped_row_axis_does_not_shift_the_write
A group header is a row of the render model and not a row of the grid, and a
write composed against the wrong one lands on a different record — silently,
because both are usize. FirstEditable therefore returns two numbers.
A fixture with no groups makes them coincide, which is exactly how an earlier
version of the first_editable helper handed a visual index to typed_cell
and passed (Copilot, PR #23). This test is the fixture the main round trip cannot
be: a Coastal header sits between the two rows, so for the second row
visual_row and data_row differ. Injecting the confusion — passing
at.visual_row to typed_cell — leaves East unwritten. Note the ordering of
the fixture is load-bearing: West comes first and ungrouped, because a header
before the first row is dropped by AxisFrame::body_from and the two
coordinates would coincide again.
a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted
improvise-955n: the one real producer of a multi-command frame must still be
accepted. CheckedBatch::against answers a frame's basis before applying any
of it, and every mutation answers for its own records. Getting that check right
is the easy half; the expensive half is not tightening it until the browser stops
working. A commit keypress is exactly the shape at risk — one SetCell plus the
mode change, the scroll offsets and the re-seeded buffer around it — and it is
produced by keys.rs::commit, which no test in the command crate can reach.
So it drives a real keypress through a real Host and asserts the frame
is accepted and lands: ServerFrame::Rejected anywhere in the answer, or an
unchanged cell, is the regression. Asserting only "no rejection" would pass
against a host that dropped the frame on the floor. The "exactly one mutation"
assertion is a characterization of what keys::commit emits, not a protocol
constraint — a frame may carry more than one — so that if commit ever
started batching two, the test would be exercising something else and should be
re-read rather than silently re-scoped. And the fixture types 888 after i
has seeded the buffer, so the assertion is on the appended value: 888 alone
would also be produced by a client that ignored the seed.
improvise-wasm-client/tests/lockstep.rs
src/keys.rs decides a small set of keypresses without the document. The claim
that makes that safe is not "the rule looks the same" but the two ends land in
the same ViewState, and that is checkable: the host's reduce_key runs the
real keymap, registry and effect pipeline against an authoritative App, and
ViewState is the protocol's own type on both sides. So every local outcome is
driven through both and compared.
This is why the command crate is a dev-dependency. Dev-dependencies are not
built for --target wasm32-unknown-unknown, so nothing the host owns reaches the
browser bundle.
a_locally_typed_character_lands_where_the_host_would_put_it
Driving one mode would say nothing about the others
(doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)), so
every text-entry mode is driven, and each with a non-empty buffer so an
append that silently replaced the buffer would be visible.
a_forwarded_key_reaches_the_host_unchanged
An unresolvable key must reach the host as a keypress, and what the host then
does to its copy of this client's view slice is what the client will be told.
Pinned as the shape the transport owes: the message carries the code and
modifiers, and they survive the round trip. Enter rather than an arrow key,
because since improvise-bpq2 the arrows resolve locally and enter-advance is
one of the movements deliberately left upstream — it aborts the rest of its batch
at the bottom-right corner, which is a fact about the host's effect chain and not
a cursor position.
a_locally_resolved_cursor_move_lands_where_the_host_would_put_it
The headline claim for improvise-bpq2's first gap: a cursor key resolved on the client lands where the host would have put it — including the scroll that follows it, and including the clamp at the end of the grid.
The fixture is six rows in a three-row window, and the keys walk past the bottom
of the window (so row_offset must move) and then past the bottom of the grid
(so the clamp must hold). A client reading its bound off the row frame's length
would stop at row 2 and never scroll; one that clamped against nothing would run
off the end. Both are visible. Eight presses for six rows: the last three are the
clamp.
The fixture's one column is deliberate. The two ends measure their windows
differently: the host's visible_rows is term_height - 8 and its
visible_cols is computed from column widths, while a browser announces both
outright. term_height can be chosen to match the rows; nothing can be chosen to
match the columns, so the fixture removes the question rather than guessing at an
answer.
a_locally_resolved_panel_cursor_agrees_with_the_host
improvise-bpq2's second named gap, which turned out not to be one: the category
panel's flattening depends on expanded_cats, but the derivation already lives
in improvise_protocol::viewmodel and both ends call it (improvise-edp). So
this is not two derivations agreeing by luck — it is one derivation, and the test
says so by expanding a category, which changes the flattened length the
cursor clamps against. A client that counted categories would stop several rows
early.
the_buffer_i_seeds_is_the_value_the_host_would_seed
i seeds the edit buffer from the cell's display value, and the two ends
must agree on what that is: the host reads App::display_value_at_cursor, the
client reads the text the server projected into its GridCache. Those are two
paths to one string, and this is the assertion that they are the same string.
entering_typing_and_cancelling_an_edit_agrees_with_the_host
Esc rather than Enter for the cancel arm, because it is a
Binding::Sequence and so exercises the all-or-nothing rule: half of it — a
cleared buffer in a mode the user has not left — is worse than none.
a_locally_committed_edit_leaves_both_ends_in_the_same_place
What is not compared is the document, and deliberately: the client answers with
a Command::Model, which reduce_view drops, while reduce_key writes through
the effect pipeline. The write's composition is asserted in keys.rs against
typed_cell's own — the shared one — so what is left to prove here is the half a
unit test cannot see, which is that the advance and the re-entry land in the same
place.
client_ready_to_edit (a fixture, not a test)
The published basis a real client folds before it is allowed to compose a write
(ADR 001). The tiers are the ones lockstep never checks — the host applies the
keypress directly rather than the client's Command::Model — but
BasisCache::claim withholds until it has something, so a client without one
resolves no commit at all.
harness/ — the browser specs
These are Playwright tests, so the checker's --check-evidence pass (which
scans Rust fn names) cannot see their names. They are recorded here under
their exact test("…") titles anyway, because they are where the JavaScript
evidence lives.
harness/tests/shell.spec.js — the file's own scope
The three assertions improvise-hwpi could not write. That ticket fixed four
things and said of itself that the fixes were "as unverified as the bugs",
because three of them are one-assertion tests that needed a browser. These are
those three, plus the watchdog — which was found by reading, and only because
someone went looking. Until this file, web/app.js and web/worker.js had
never been rendered or run.
Two fixtures are load-bearing. NO_WEBSOCKET_HERE is a peer that accepts the
TCP connection and then refuses the WebSocket upgrade — the harness's own static
file server — deterministic where a closed port is not: a refused port's
close timing is a property of the host's network stack, and on the GitHub
runner it did not arrive inside the window. The page cannot tell the two apart
and neither can web/app.js: in both cases the socket never opened.
HANDSHAKE_TIMEOUT_MS restates web/app.js's own constant, because two tests
have to outlast it and a number that silently disagreed with the page would make
them pass for the wrong reason. logStatus records every distinct #status,
not just the last one, because several of these tests are about a message being
replaced, which the final value cannot see.
"a frame the client cannot fold says so on screen"
improvise-hwpi: on_frame returns a Result, which wasm-bindgen throws. Thrown
in a bare listener it reached the devtools console and nothing else — the grid
simply stopped updating with nothing on screen saying why. The fault is
injected at the peer, not in the page — a mocked socket that answers the
handshake with a frame the client cannot fold — so this exercises the real
on_frame and the real catch.
"a peer that cannot be reached is named, and the watchdog stays out of it"
improvise-hwpi: without an error listener, a connection that never opened left
#status blank, and "the server is not running" read exactly like "still
connecting". The watchdog half is asserted on the same page load rather than in
its own test, because it needs the same nine-second wait. It holds for a reason
its own comment does not lead you to expect: armWatchdog runs only after
transport.ready resolves, which happens on open — so on a socket that never
opened the watchdog is never armed at all.
"a socket that opened and then dropped reads as disconnected, and stays that way"
The other arm, which must keep working when the first is fixed: a socket that
really did open and then dropped IS a disconnect, and saying so is the whole
reason close cannot simply be suppressed.
It runs past HANDSHAKE_TIMEOUT_MS on purpose. On this arm the watchdog IS
armed — armWatchdog runs after transport.ready resolves, i.e. on open — so
"disconnected" is written while a timer is pending, and the only reason it
survives is that close goes through setShellFailure, which clears it. That
was argued in a comment and asserted nowhere, and the argument was got wrong
from the outside: a bug report (improvise-xps8) claimed the timer fired 8s
later and overwrote "disconnected" with "sent no document". It had been measured,
honestly, but against a tree predating PR #21 — and the "confirmation" that it
survived PR #21 simulated only that PR's opened flag and not its other half,
routing BOTH close arms through setShellFailure. A simulated fix that is not
the fix you named reproduces a bug the real code does not have, and reads exactly
like a confirmation. The ticket was filed and closed invalid. Hence this
assertion rather than another paragraph — proved discriminating by making
setShellFailure skip its clearTimeout, which makes it fail. Same rule as
doctrine(an_injected_fault_must_be_the_fault_you_named), one level up.
The measurement that could not become a CI guard. Both of the above were once
asserted as test.fail expected-failures and had to be removed: neither
reproduced on the GitHub runner, where close does not follow error within the
window. An expected-failure is green when the bug is present and red when it is
absent, so one whose trigger is platform-dependent does not merely flake — it
inverts CI's meaning, and it failed the build twice saying "expected to fail,
but passed". The lesson stands even though the second bug did not: assert the
PROPERTY THAT MUST HOLD, which is portable, rather than the fault, whose trigger
is the host's network stack.
The first of the two, for the record, was real: the socket's close listener was
unconditional, so on a connection that never opened it overwrote "cannot reach …"
with "disconnected". FIXED by PR #21. An opened flag is the only discriminator
— readyState at close time cannot tell the two apart, and neither can the
CloseEvent: both cases are code 1006, wasClean: false.
"a peer that accepts and then says nothing trips the watchdog"
The first-frame watchdog, found by reading. The condition is one no per-transport check would catch: the peer accepted the connection and then said nothing. The mock accepts and never answers.
"the page draws the demo document out of the worker"
No ?ws=, so web/app.js spawns the in-page worker over OPFS: the whole
application, with no server process. If this draws a grid, then worker.js,
bindings.rs, OpfsFs, vfs::open and the seed fetch all ran.
"committing an edit sends one frame and shows the host's value"
A commit round-trips through the host, and what lands on screen is the host's
value rather than the string that was typed. 20000 is typed and 20,000
asserted: the number format is applied by the host and relayed back, so a page
that echoed the input would fail.
This is deliberately NOT named for improvise-hwpi's third fix (the closed
re-entry guard in edit), which Copilot asked for on PR #24. That guard is not
observable from the DOM, measured rather than assumed: with it removed, close
still runs exactly twice, the frame is still sent exactly once, #grid still
takes exactly two childList mutations, and the final text is identical. The only
difference is one extra entry into repaint() that produces no output — because
the second close is close(false), so it does not re-send, and everything else it
does is idempotent. That is precisely why hwpi called it "harmless before, but
only by accident". Counting repaint() entries would need a test hook inside
web/app.js, which would be a page that knows it is being tested. So the test
name says what is checked and this entry says what is not. What is observable is
asserted: the editor is gone and did not come back, so no stray <input> is left
behind and the host's value is not overwritten by the original text the second
close writes before repainting.
"the harness can see a failure"
Check the checker, and it runs first: a green run against a worker that never
started is the failure this repo has a doctrine about. run_canary drives the
same reporting path every real check uses, and the spec asserts it is reported
as a failure with its message intact.
"every check in the battery runs and passes"
The battery is asserted complete before any of it is believed: the report's
names must equal EXPECTED exactly, so a build that dropped a check fails rather
than passing with fewer.
"what OPFS held survives a reload, and a committed delete does not"
The one thing no in-page check can establish. A delete is visible immediately and
lands in OPFS at the end of the batch — so "commit_deletes actually removed it"
is only answered by a fresh page. This is also the storage half of improvise-bck's
persistence criterion.
The opfs-contract checks (harness/opfs-contract/src/lib.rs)
an_attached_but_unwritten_entry_is_not_a_document—attachmust create the file to obtain a sync access handle for a document that does not exist yet, and that created file is zero bytes, which is NOT a document.Storage::listpromises document names, so an attached-but-unwritten name must not be listed. Indexing it is exactly the improvise-9x6 browser failure: the seed was skipped as "already there" andload_fromgot an empty buffer. The check also opens a second instance, which shares no index, because the filter has to be inrefreshand not only in the omission fromattach— and then writes through the handle to show it is real and the name IS then a document.a_directory_entry_is_not_a_document—keys()yields DIRECTORIES as well as files, andgetFileHandleon a directory rejects withTypeMismatchError. Treating that as "could not measure, so assume a document" would list an entry that can never be attached or read — the same picker bug the zero-byte filter removes (Copilot, PR #24).OpfsFsnever creates a directory, so the harness makes one itself, and asserts a real file beside it IS listed — the precondition, so the check cannot pass by the directory never having been created, and evidence thatkeys()walked the root at all.refresh_walks_every_key_and_sorts_them— the hand-rolledkeys()async-iterator walk inrefreshis the least certain code inopfs.rs(improvise-xf1 named it). One entry could pass by accident; several, out of order, cannot. It wipes first, like every other check: a battery whose steps depend on the leftovers of the step before breaks when one is inserted, which is exactly what inserting the directory check did.read_and_write_are_absolute_at_offset_zero—FileSystemReadWriteOptions'athandling, the other thing xf1 called out. Awritethat appended instead of overwriting, or areadthat started anywhere but 0, is invisible on a single short write and obvious across a long one followed by a short one.storage_contract_at_…— the point of the whole exercise.attachis hoisted out because acquiring a handle is the async half andStorage::writecannot await; the contract itself is the shared function, not a transcription of it.a_delete_is_visible_at_once_and_lands_on_commit— both halves, because either one alone would pass against a backend that got the timing exactly backwards.
a_locally_cycled_panel_focus_agrees_with_the_host
cycle-panel-focus is bound to Tab in Normal only: PanelKeymap's spine binds
Tab to enter-mode normal inside every panel (gotcha 12), so the wrap arms of
CyclePanelFocus — the ones that start from the focused panel — are
unreachable by keypress. The test therefore asserts the one reachable path and
says so, rather than looping three times and passing on a mode that never moved.
The first draft did loop, and failed on step 1 with the mode back at Normal;
that failure is the evidence for this paragraph.
a_locally_resolved_tile_axis_change_agrees_with_the_host
Both of TileAxisOp's names in one walk, because the command names itself off
its axis field: r takes the Some arm (SetAxis) and Enter the None
arm (CycleAxis), and a test that drove only one would leave a whole Cmd
unmirrored. Injecting parse_axis("row") => Axis::Column fails it.
the_category_panel_bindings_agree_with_the_host
The fixture sits on a header row for Space/a and on an item row for
Enter/d, and then on a header again for a second d. Both of
FilterToItem's arms and both of DeleteCategoryAtCursor's arms are therefore
reached. That is not decoration: the first version sat only on the item row,
and injecting a wrong sentence into the category arm of
delete_at_cat_cursor did not fail it. One fixture position, one arm —
and a check that passes against an arm it never enters is the defect this repo
keeps finding.
the_view_panel_bindings_agree_with_the_host
Two views in the fixture, and the cursor moved to 1 before Enter, because
view_at_cursor reading .get(0) is the natural wrong implementation and a
single-view fixture cannot tell it from the right one. Injecting .get(0)
fails it.
It presses d as well, and that was added after the fact: the first version
said "create, switch and delete" in its doc comment and pressed only n and
Enter, so delete_view_at_cursor — whose only decision is the cursor
step-back it makes away from the top of the list — was never run. The
adversarial review of PR #50 deleted that step-back and the workspace stayed at
1322 passing. d is pressed at index 1 so the decision is reached, and
both_ends now compares every cursor (see below), without which the deletion
would still be invisible.
a_locally_committed_minibuffer_agrees_with_the_host
commit-buffer for all three kinds, driven as keystrokes so the
TextEntry spine's clear-buffer weld is part of what is compared.
CommitKind::Item carries its category, resolved from AppMode::ItemAdd —
so the mode the test starts in is load-bearing, not scene-setting. Injecting
AddCategory in place of AddItem fails it on the document comparison.
Formula was missing from the first version, and it is the kind that matters
most: it is the only one whose on_commit() is Some, so a test of the
other two cannot see the mode a commit returns to. The review of PR #50 broke
both CommitKind::status's and on_commit's Formula arms and nothing failed.
Its text is seeded rather than typed, and valid, for the reason the next
entry gives.
a_formula_the_client_cannot_parse_diverges_on_the_status_line
The one thing a client cannot mirror about commit-buffer formula, found by
widening the test above and kept as a pinned divergence rather than a fix
(improvise-cl7y).
CommitBuffer emits its optimistic status first and the add effect last, so
on the host a parse failure overwrites "Formula added" with "Formula error: …"
(improvise-6t3). The client reproduces the command line for line — which is
right — but it cannot parse, so it folds "Formula added" and stops. The user
sees a browser confidently report a formula it did not add.
This asserts both sides, so a fix has to come here and change it. That is
the difference between a pinned divergence and a comment: a_locally_committed _minibuffer_agrees_with_the_host would otherwise have to use a valid formula
forever with no record of why.
panel_focus_cycles_from_the_focused_panel_not_the_first
CyclePanelFocus's wrap arms, driven directly rather than through a key.
PanelKeymap's spine binds Tab to enter-mode normal inside every panel
(gotcha 12), so no keypress reaches them — which is why the lockstep test
asserts only the reachable path and why breaking the start index survived
the review's injection pass. Mirroring a Cmd faithfully includes the arms
the keymap cannot reach today; testing them takes a direct call.
The fixture leaves the category panel closed, so "the next open one" and "the next one" give different answers and a mirror that walked the array blindly would fail.
a_locally_resolved_clear_and_paste_agree_with_the_host
The two writes ADR 002 lets a thin client name. Note the fixture's cell key has
three coordinates: Workbook::add_category puts a new category on
Axis::Column, so Product is part of the cell's address and a two-coordinate
key names a cell the grid never shows. The first version asserted on the
two-coordinate key, and passed the per-key comparisons while asserting against
a cell nothing had touched.
walking_off_the_end_of_the_help_pages_agrees_with_the_host
The bug this was written to demonstrate: effect::HelpPageNext clamps and
HelpPagePrev saturates, while reduce_view's Command::HelpPage arm
wrapped. Two derivations of one fact, in disagreement, invisible because
h/l in the help overlay were upstream keys and no client had ever sent the
command. It failed on the first press past the end — client 0, host 4 — and the
wire was changed to match the effect. Whether both should wrap instead is
improvise-trd3.
"? opens help, and its rows are the shared ones"
improvise-ktv1's acceptance in one place: a browser shows help. ? had
entered AppMode::Help since the keymap was shared, and nothing appeared —
the mode changed and the page had no overlay, which is worse than a key that
does nothing because the user thinks they missed it.
It asserts the shape, not merely that something is visible: the active tab is
the shared module's page title (Welcome, then Navigation after l, which
also exercises Command::HelpPage folding on the client), and the body has a
key row. Two faults were injected and each failed it. Dropping the
paintHelp(model) call fails toBeVisible; removing the mode != Help guard
in help_rows — so the overlay is never empty and therefore never hidden —
fails toBeHidden. The second is the one worth having: an overlay that is
always open passes any check that only ever opens it.
It runs against the assembled site like every other test here, so it
covers render.rs, the serialized model, web/app.js and the CSS together.
harness/tests/widgets.spec.js — the file's own scope
A user reported three things as broken: "I can't expand categories, and the Tile + View UIs don't work." None of the three was a regression. Driving the assembled site under Chromium separated them, and the separation is the point: two of the three had every layer but the last one, and the layer they were missing was not the same in each case.
Category expand already worked, by keyboard. Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse: categoryList builds plain <li>s with no listener, so a
click on the triangle does nothing at all. That half is improvise-7ybz, and it
is a product decision about the whole page's pointer model rather than a defect
in an implemented feature — #grid offers a click only on an editable cell, so
there is no click-to-select either.
The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before the fix, render_state() in the
browser carried five TileBarCache entries and both ViewListCache views;
Shift-T entered TileSelect and ArrowRight moved tile_cat_idx; r/c/p
reached the host and really did move a category between axes. RenderModel
simply had no field for either, and web/index.html had no element. So the
keys worked and the user was aiming them at nothing — which reads exactly like
"the Tile UI doesn't work". The formula panel was the third of Panel::ALL in
the same state and is fixed by the same mechanism.
The fault injection was the unfixed bundle itself. Run against the tree at
a3bef6e, the category test passes and the other four fail — #tiles,
#views and #formulas resolve to zero elements. That is the evidence the
four tests are discriminating, and it is why the category test stays: it is the
control that says the harness really is driving the keyboard.
Two assertions in the first draft were wrong, and both corrections are worth
keeping. Axis::short_label() is three columns, so a Page tile reads
Quarter Pag, not Quarter Page — a test asserting the long spelling would
have forced the page to invent a label instead of echoing Axis's own. And
switching views leaves the panel open: switch-view-at-cursor returns to
Normal and never closes the panel, exactly as src/draw.rs keeps drawing it
off view_panel_open alone. Asserting it hidden would have pinned the browser
to behaviour the TUI does not have.
The gating rule itself — an empty list is what says a panel is closed, the same
rule help follows — is asserted in Rust as well, by
a_closed_panel_projects_no_rows_and_an_open_one_does and
the_tile_bar_is_always_present_and_only_its_cursor_is_modal, because a browser
test cannot distinguish "closed" from "the cache is empty" and those two tests
fill the caches first.
What these tests do not cover: the pointer (improvise-7ybz), and the
inconsistency that #views/#formulas honour their open flags while
#categories is drawn unconditionally because app.js has never read
category_panel_open (improvise-wsaw).