36ade35240
E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them (user, 2026-09-20). The browser half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS — and everything between them needed no browser at all. `web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every question `gridTable` used to answer while creating elements — the data-row index across group headers, what a group header spans, a null group label against an empty one, an empty edit buffer against no editor — and `app.js` is now a loop that builds elements from it. Painting stays untested by Node; what to paint stopped being untestable. 26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle, no npm install), plus three in `web/tests/bundle/` that read the generated .d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to 16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone, replaced by `contract.spec.js`, which asserts that EVERY field the render model projects reaches the screen. The four widgets those specs were written for were one failure at one boundary, and one assertion catches the fifth. It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element (improvise-5jna, already open). They are named in `UNPAINTED` with the ticket, so the list is the debt written down rather than an exemption. Thirteen faults injected into the Node suite and eight into the served page, each watched to fail the test it names; the last of those was strengthened because the first version passed. `web/assemble.sh` grew an import-completeness guard whose own first version was blind to the multi-line import it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1216 lines
67 KiB
Markdown
1216 lines
67 KiB
Markdown
# 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
|
||
`Command`s, 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`** — `attach` must 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::list`
|
||
promises 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" and `load_from` got an empty buffer. The check also opens a
|
||
**second instance**, which shares no index, because the filter has to be in
|
||
`refresh` and not only in the omission from `attach` — 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, and `getFileHandle` on a directory rejects with `TypeMismatchError`.
|
||
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). `OpfsFs` never 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 that `keys()` walked the root at all.
|
||
- **`refresh_walks_every_key_and_sorts_them`** — the hand-rolled `keys()`
|
||
async-iterator walk in `refresh` is the least certain code in `opfs.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`'
|
||
`at` handling, the other thing xf1 called out. A `write` that appended instead
|
||
of overwriting, or a `read` that 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. `attach` is
|
||
hoisted out because acquiring a handle is the async half and `Storage::write`
|
||
cannot 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.
|
||
|
||
### the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable
|
||
|
||
improvise-ir7l renamed this from `..._whose_every_cell_is_settable` and
|
||
narrowed what it claims. The old loop called `MainThread::cell` — which panics
|
||
on anything but a stored cell — over *every* column including `Profit`, which
|
||
`web/demo.improv` defines as `Revenue - Cost`. It passed only because a formula
|
||
target projected `CellId::Record`, which is the bug the ticket names: a test
|
||
whose name was false for a third of the grid it walked.
|
||
|
||
It now walks the two raw columns and asserts separately that the `Profit`
|
||
column is `CellId::Derived`, so it fails if either half moves. That second
|
||
assertion is what makes the narrowing honest rather than a retreat — dropping
|
||
the column would have left the page free to offer it again with nothing
|
||
noticing.
|
||
|
||
### the_page_keys_turn_the_odometer_as_the_command_does
|
||
|
||
improvise-p1ym. `GridCache::page_coords` carried `(category, selected item)`
|
||
and nothing else, so `[`/`]` resolved to a command name the client held but
|
||
could not compose an argument for. They reached `Host::handle`, which answers
|
||
an empty vec — `doctrine(an_upstream_key_is_a_black_hole)` — so an independent
|
||
smoke test found them inert against the live page, where `Quarter` sits on the
|
||
page axis precisely because paging is a thing a visitor tries.
|
||
|
||
The test asserts the *shape* composed, not that something resolved: two page
|
||
axes, the least significant at Q4, so one press must both wrap `Quarter` and
|
||
carry into `Year`. A resolver that moved only the first axis, or carried the
|
||
wrong way, fails it.
|
||
|
||
### a_filter_axis_is_projected_but_not_cycled
|
||
|
||
The gotcha-27 arm. Page and Filter both constrain which records are visible,
|
||
and `GridLayout::page_coords` is deliberately `filtering_cats()` (Page ∪
|
||
Filter) because the *layout* does not care which the user can navigate. The
|
||
projection copied that verbatim, so a client handed the list would have tried
|
||
to cycle a drill's fixed coordinate.
|
||
|
||
`PageAxis::items` is `Some` only for `Axis::Page`, so the difference is in the
|
||
type rather than in a rule each client remembers. The test adds a Filter entry
|
||
to a client that is otherwise paging normally and asserts the two Page axes
|
||
still move while the Filter one does not — an assertion that only the Filter
|
||
axis is skipped, not that paging stopped.
|
||
|
||
### a_locally_resolved_page_turn_lands_where_the_host_would_put_it
|
||
|
||
The lockstep claim, and **the first version of it was vacuous**, which is worth
|
||
recording because it looked exactly right. It rebuilt the client's cache from
|
||
the host after every press and then compared the client's cache against that
|
||
same host's document — two readings of one value. Reversing the odometer's
|
||
significance in `page_cycle` left it green.
|
||
|
||
It now drives two independent documents: one `Host` fed the commands the client
|
||
composed, one `App` fed the same keypress through `reduce_key`, compared on
|
||
`View::page_selection` — the document, never a projection. Proved discriminating
|
||
by that same injection (`for i in (0..axes.len()).rev()` → `for i in
|
||
0..axes.len()`), which now reports Q2 against Q3. The `assert_ne!` against a
|
||
fresh fixture is the second half: without it, a resolver that emitted nothing
|
||
at all would pass by moving neither.
|
||
|
||
|
||
#### harness/tests/widgets.spec.js — the file's own scope
|
||
|
||
**The file is gone; its subject is not.** All four widgets it named were one
|
||
failure at one boundary — a `RenderModel` field with no element painting it —
|
||
and that is now asserted once, from both sides, by
|
||
`web/tests/render_surface.test.js` and `harness/tests/contract.spec.js`. What
|
||
is *not* carried over is each key's meaning, which `improvise_protocol::resolve`
|
||
decides and tests. Everything below is the history of the bug, and still true.
|
||
|
||
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).
|
||
|
||
One assertion was added after the first green run and is worth naming: the tile
|
||
test originally asserted only that the *tile label* changed to `Product Col`.
|
||
That is the bar reporting itself, which is exactly the shape of test this repo
|
||
keeps catching — it would pass against a tile bar that displayed an axis the
|
||
document had not taken. It now also asserts the grid reshaped: `Espresso` moves
|
||
from a `tbody` row header to a `thead` column header. `.first()` is needed there
|
||
because Product then shares the column axis with `_Measure`, so the label
|
||
appears once per header level — which is itself a small confirmation that
|
||
gotcha 44's multi-level frame reaches the browser.
|
||
|
||
**Three review findings, applied.** The second is the one worth carrying.
|
||
|
||
**The page had two spellings of the same triangle, inside the file that
|
||
declares it has one.** `indicator()` carries the comment *"only this spelling is
|
||
the page's (gotcha 26)"* — singular — and `categoryList` did not call it: it
|
||
inlined U+25B8/U+25BE (the SMALL triangles) where `indicator()`, the page's own
|
||
grid group headers and `src/ui/category_panel.rs` all draw U+25B6/U+25BC. So the
|
||
browser's category panel disagreed with the TUI's *and* with its own grid.
|
||
`categoryList` now calls `indicator(!row.expanded)`.
|
||
|
||
**And the first version of this spec pinned the wrong spelling.** It asserted
|
||
`/▸ Product/` and `▾` — the divergent pair — so the obvious cure, which the
|
||
file's own comment implies should already be in place, would have *failed a test
|
||
this work added*. That is this repo's characteristic defect exactly: not a wrong
|
||
line, a test that cements a state the doctrine says is wrong, and it makes the
|
||
cure more expensive the longer it sits. The spec now asserts `▶`/`▼`, and the
|
||
change was proved discriminating by running it against the pre-fix bundle, where
|
||
it fails with `Received string: "▸ Product (4) R"`.
|
||
|
||
Note what this does **not** settle: whether glyphs should move to the protocol
|
||
the way `short_label()` did. That is **improvise-h04z**, not answered here —
|
||
what is fixed is the page contradicting itself. `viewRow` still spells `"▶ "`
|
||
as a literal duplicating `src/ui/view_panel.rs:62`; the two agree today, which
|
||
is precisely the state `categoryList` was in before it drifted.
|
||
|
||
**And the drift was already spreading, which is the evidence that ticket needs.**
|
||
`harness/tests/pointer.spec.js`, written on a different branch, had
|
||
*independently* grown the same `▸`/`▾` assertions — so the corrected
|
||
`indicator()` broke it too. Three files converging on a spelling nothing
|
||
compares is the failure design-principles §5 predicts, observed rather than
|
||
argued, and it is what answers "is one duplicated literal worth a protocol
|
||
change" with data instead of doctrine.
|
||
|
||
**`el.tiles.hidden` was a no-op**, removed with its comment. `#tiles` declares
|
||
`display: flex`, an author-origin rule that beats the UA sheet's
|
||
`[hidden] { display: none }` at any specificity, so the guard never hid
|
||
anything and "an empty tile list draws nothing at all" was false. `#views`,
|
||
`#formulas` and `#help` declare no `display` and so are genuinely hidden by the
|
||
attribute. Worth knowing before anyone "fixes" it: because `#tiles` is always
|
||
laid out, its height never changes, so it feeds nothing back into the
|
||
`ResizeObserver` — restoring the guard would introduce exactly the first-paint
|
||
height change the observer argument assumes away.
|
||
|
||
**The `ResizeObserver` loop is no longer "argued, not tested", and the proof is
|
||
worth more than the measurement.** It was measured: 820 combinations — four
|
||
panel states (none / views / formulas / tile-select) × heights 360-440 in 2px
|
||
steps × five widths straddling the `COL_PX = 110` boundary, each counting
|
||
`#grid` child replacements in a 500ms quiet window. Every size settled; nothing
|
||
repainted twice. But the reason is structural, so the measurement is a check on
|
||
it rather than the evidence itself: **the `frameCost` dependency graph is
|
||
acyclic.** `frameCost.cols` is `row_levels`, the number of row-axis header
|
||
*levels*, which does not depend on how many rows are projected.
|
||
`frameCost.rows` derives from `col_levels.length` and `col_groups`, which depend
|
||
on the projected *column* window, which depends on width and `frameCost.cols`.
|
||
So `cols → rows`, and nothing depends on `rows`. There is no cycle to oscillate.
|
||
A change that made either term depend on the projected row count would create
|
||
one, and that — not the pixel arithmetic — is the thing to check.
|
||
|
||
**A harness port collision can make a run test somebody else's tree, silently.**
|
||
`playwright.config.js` hard-coded 8099 with `reuseExistingServer: true`, so a
|
||
`python3 -m http.server` left by another worktree is *adopted*, and the run then
|
||
exercises that checkout with no warning. The port is now
|
||
`IMPROVISE_HARNESS_PORT`, defaulting to 8099 so CI is unchanged. The tell is a
|
||
result that disagrees with the file you just read: check
|
||
`lsof -a -p <pid> -d cwd` on the listener before believing it.
|
||
|
||
**It fires in both directions, and the dangerous one is the false PASS.** Two
|
||
agents hit it in one session. One got a false *negative* — a glyph fix reported
|
||
absent, because the adopted tree did not have it — which is merely confusing,
|
||
since a failure sends you looking. The other got `widgets.spec.js` reporting
|
||
**5 passed against a tree it had content-verified as having no `#tiles` and no
|
||
`paintTiles` at all**, which is impossible and was caught only because that
|
||
agent knew what the tree contained and refused to believe the number. **A false
|
||
pass prompts nobody.** That is what makes a hard-coded port with
|
||
`reuseExistingServer` a correctness bug rather than an inconvenience, and it is
|
||
the same rule as the rest of this file: a green result is evidence only once you
|
||
know what it measured.
|
||
|
||
### a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row
|
||
|
||
improvise-7ybz's whole difficulty in one assertion, and the reason the click
|
||
resolvers take an **index** rather than reading the cursor.
|
||
|
||
A click means three things at once — focus the panel, move its cursor to the row
|
||
under the pointer, activate that row — and the third is resolved in the *same
|
||
pure call* as the first two, before any of them has been folded. So
|
||
`filter_to_item_at(state, index)` must be asked about `index`; asking
|
||
`filter_to_item(state)` reads `view.cat_panel_cursor`, which is still wherever
|
||
the keyboard last left it. That is why `cat_tree_entry`, `toggle_cat_expand`,
|
||
`filter_to_item` and `tile_axis` each gained an `_at` twin and the cursor-reading
|
||
form became a one-line call of it: one definition, two entry points.
|
||
|
||
**Injected fault**, run: `commands.extend(filter_to_item_at(state, index)…)` →
|
||
`filter_to_item(state)`. The test fails with
|
||
`ToggleCatExpand("Region")` against `ToggleCatExpand("Product")` — the panel
|
||
cursor lands on the row clicked while the *expansion* happens to the row that
|
||
was selected before, which is the silent half. The fixture therefore has two
|
||
categories and clicks the **second**: with one category, or with a click on the
|
||
row already under the cursor, the bug is invisible.
|
||
|
||
The sibling `a_click_in_an_already_focused_panel_only_moves_and_activates` pins
|
||
the other half: `focus` emits only what is not already true, so a click inside
|
||
the focused panel is not a mode change on every click. Without it, "always emit
|
||
`SetPanelOpen` + `ChangeMode`" passes the first test.
|
||
|
||
### a_cell_click_is_absolute_even_though_the_page_paints_a_window
|
||
|
||
The pointer's version of `the_selection_is_placed_by_subtracting_the_scroll_offset`,
|
||
and it fails the same way: `GridCache` is viewport-indexed, `ViewState::selected`
|
||
is absolute, so a click at painted `(2,1)` under offsets `(12,3)` is `(14,4)`.
|
||
Offsets are **nonzero and unequal** for the same reason as its sibling — equal
|
||
offsets cannot separate "reconciles" from "swaps the two". The second half
|
||
asserts a click past the grid's extent resolves to nothing, which is what stops
|
||
a click on the padding below the last row selecting a cell that does not exist.
|
||
|
||
### a_click_past_the_last_row_resolves_to_nothing
|
||
|
||
`doctrine(check_the_checker)` for the two above: the empty-answer path. Without
|
||
it, a `click` that returned `Vec::new()` unconditionally would pass every
|
||
"resolves to nothing" expectation while failing silently everywhere else — so
|
||
this one is only meaningful *because* its neighbours assert non-empty answers.
|
||
|
||
#### harness/tests/pointer.spec.js — the file's own scope
|
||
|
||
**The file is gone; one test carries it.** `contract.spec.js`'s "each clickable
|
||
surface is wired to its own index" keeps what only a browser can say — the
|
||
handler is on the element, it passes *that* element's index, and the answer is
|
||
painted — against three surfaces. What each click *means* is
|
||
`improvise_protocol::resolve`'s, and its tests are named below. The six tests'
|
||
injection stands as recorded.
|
||
|
||
The user's ruling on improvise-7ybz ("build it — one entry point per widget")
|
||
and improvise-wsaw ("honour the flag"), executed in a real browser.
|
||
|
||
Six tests, and **all six fail against the bundle built immediately before them**
|
||
— the tree that already had the three widgets drawn (PR #55) but no click
|
||
handlers and no `category_panel_open` read. That is the injection: the sidebar
|
||
is visible when it should be hidden, and every `click()` resolves to a `<li>`
|
||
that has no listener, so nothing moves. The category test is the sharpest,
|
||
because the element it clicks *exists and looks interactive* in the unfixed
|
||
tree — which is exactly what the user hit.
|
||
|
||
Two properties here that the Rust tests structurally cannot reach. **The
|
||
pointer and the keyboard are one cursor**: `the keyboard continues from where
|
||
the pointer left the cursor` clicks a cell, closes the editor, presses
|
||
ArrowLeft, and requires the move to start from the clicked cell — if the click
|
||
had only opened an editor (the pre-fix behaviour) the arrow would move from the
|
||
origin instead. And **the tile click is asserted against the grid**, not only
|
||
against its own label: `Espresso` must appear as a `thead` header, because a
|
||
tile bar reporting an axis the document did not take is the failure shape this
|
||
repo keeps finding.
|
||
|
||
One fixture note worth keeping: `openPanelThenLeave` opens the panel with
|
||
`Shift-C` and then presses `Escape`, which returns to Normal **without closing
|
||
the panel** (`PanelKeymap`'s Esc is `enter-mode normal`, nothing more). That is
|
||
the only state in which the "focus" third of the click is observable — from a
|
||
freshly opened panel the mode is already `CategoryPanel` and the assertion
|
||
would pass without the focus commands existing at all.
|
||
|
||
#### harness/tests/drill.spec.js — the file's own scope
|
||
|
||
The fourth widget the user reported, and the one with a **different answer from
|
||
the other three**. The tile bar, view panel and formula panel were each one
|
||
`RenderModel` field short — cheap. Drill is not: **there is no wire vocabulary
|
||
for it**, which is improvise-gd28, and these tests document the refusal rather
|
||
than pretending the gap is a bug to be fixed here.
|
||
|
||
**Where it stops, by layer.** Everything below the command exists and was
|
||
verified in the browser, not read: `project::grid_of` emits `CellId::Aggregate`,
|
||
the client receives it (`{"Aggregate":"750ab1f5ffa96d5e…"}` in `render_state`),
|
||
`render.rs::editable` correctly answers false for it, and `resolve.rs` reaches
|
||
the aggregate arm. What is missing is one link: `reduce.rs` contains **no
|
||
`Drill` variant at all** — not `Command`, not `ModelCommand`. The seam is ready
|
||
and the sentence is not sayable.
|
||
|
||
**Why it was left out is a decision, not an oversight** (gotcha 38): drilling
|
||
creates the `_Drill` view and writes `drill_state`, which gotchas 30 and 1 rule
|
||
**session-local** — `pending_edits` are keyed by `(row, RecordCol)` and belong
|
||
to whoever staged them. A `Command::Model` is routed with **no `SessionId`**,
|
||
which is right exactly when a change is addressed to *no one* session; drill is
|
||
addressed to precisely one. That is the same argument that removed
|
||
`ApplyAndClearDrill` from the wire in improvise-3x3u, so adding
|
||
`ModelCommand::Drill` would reintroduce the bug that removal fixed. It needs the
|
||
**addressed** form — ADR 001 layer 2.
|
||
|
||
**The two entry points fail differently, and that asymmetry is the finding.**
|
||
`i`/`a` on an aggregate declines **out loud**: `edit_or_drill` answers the
|
||
`CellId::Aggregate` arm with a status the user can read. But `>`
|
||
(`drill-into-cell`) is a name `resolve::step` does not handle, so it becomes
|
||
`KeyOutcome::Upstream` → `ClientFrame::Key` → `Host::handle`, whose `Key` arm is
|
||
an explicit empty vec — **a black hole, not a slow path** (gotcha 47). Pressing
|
||
`>` in the browser does nothing whatsoever: no status, no mode change, nothing.
|
||
That is why this reads as "never confirmed working" rather than as a refusal.
|
||
|
||
**The TUI is the control and it works**: 12 drill tests pass across
|
||
`cmd/grid.rs`, `cmd/mode.rs`, `ui/effect.rs` and `ui/app.rs`. So this is
|
||
isolated to the client/wire, not a general regression.
|
||
|
||
**A fixture fact that is itself part of the answer.** `web/demo.improv` has **no
|
||
aggregate cell at all** — gotcha 46 puts exactly one category on each visible
|
||
axis precisely so every cell is a `Record`, and `tests/web_demo_model.rs`
|
||
asserts it. So on the shipped demo a user cannot even reach the refusal without
|
||
first changing an axis. `anAggregateGrid` therefore *makes* one, through the
|
||
tile bar, the way a user would: `Quarter` cycles Page → None and every cell
|
||
becomes an aggregate. The precondition is then **asserted** (`td.editable`
|
||
count 0) rather than assumed, so neither test can pass against a grid of
|
||
Records.
|
||
|
||
**Both injections, run.** Replacing the aggregate arm with `None` — silencing
|
||
the honest refusal — fails **both** tests, and the second fails on its
|
||
*`check_the_checker` control*, which is the point: with nothing answering, the
|
||
control can no longer tell "`>` is silent" from "the page is dead", and the test
|
||
correctly refuses to pass. Adding `"drill-into-cell" => Some(vec![SetStatus(…)])`
|
||
to `step` — making `>` resolve — fails **only** the second test and leaves the
|
||
first green. Two faults, two different failure signatures, neither test passing
|
||
for the other's reason.
|
||
|
||
That control is load-bearing and not decoration: a test asserting *nothing
|
||
happened* passes against a page that has stopped listening, so it presses `i`
|
||
afterwards and requires `#status` to change. Without that, this file would be
|
||
folklore with a green tick.
|
||
|
||
---
|
||
|
||
## `web/tests/` — the Node suite, and what was injected into it
|
||
|
||
No `###` headings here on purpose: `--check-evidence` resolves a heading to a
|
||
Rust `fn`, and a JavaScript test name under one would read as an orphan. These
|
||
are keyed by file.
|
||
|
||
Every test below was proved able to fail by patching the code it names,
|
||
checking the patch was on disk, running the suite and reading the failure —
|
||
never by the absence of an error. Thirteen injections, thirteen failures:
|
||
|
||
**`shell.test.js`** — `socketUrl` stops upgrading `ws://` on an https page;
|
||
`composeStatus` returns the model fact alone (which fails five tests, because
|
||
the watchdog's messages are shell facts and vanish with it); `fail()` stops
|
||
clearing the timer, so the watchdog *pre-empts* rather than being disarmed —
|
||
the bug PR #13 fixed and PR #21 half-lost; `extentOf` stops subtracting the
|
||
frame; `frameCostOf` stops counting the column-group line.
|
||
|
||
**`grid_plan.test.js`** — the data-row index is taken from the row's position
|
||
in `rows`, so a group header consumes one (the click coordinate bug gotcha 1
|
||
is about); the group label is tested for truthiness, so a group legitimately
|
||
named `""` draws as "in no group"; `typing` is `Boolean(model.editing)`, so an
|
||
empty edit buffer reads as no editor open.
|
||
|
||
**`serial.test.js`** — the chain is replaced by `Promise.resolve().then(...)`,
|
||
which is what an `async` listener does: the slow first frame is overtaken. The
|
||
suite also keeps that fault as a *passing* test in its own right, asserting the
|
||
unchained pair really does come back swapped, so the ordering test cannot be
|
||
green for want of a race.
|
||
|
||
**`render_surface.test.js`** — a `pub sparkline: String` added to `RenderModel`
|
||
is reported unpainted; deleting `paintTiles` reports `tiles` unpainted; a
|
||
`getElementById` that names an id `index.html` has not got is reported. The
|
||
first is the shape of the bug the four widgets shared, and the list it checks
|
||
against currently names `doc_name`/`doc_dirty`/`doc_stored_as` — improvise-5jna,
|
||
which this test found rather than remembered.
|
||
|
||
**`bundle/wasm_surface.test.js`** — `client.render_model()` renamed to
|
||
`client.render_modal()` fails both its tests. It reads the generated `.d.ts`, so
|
||
a missing bundle is an assertion failure and never a skip.
|
||
|
||
**`web/assemble.sh`** — dropping `web/shell.js` from the copy list, with the
|
||
page's own imports as the check. The first version of that guard anchored its
|
||
`sed` at `^import`, so it could not see the **multi-line** import that is the
|
||
only one it exists for, and the injection passed: the guard was written, landed,
|
||
and blind. That is what the injection was for.
|
||
|
||
## `harness/tests/contract.spec.js` — eight injections into the served page
|
||
|
||
Patched in `harness/serve/app/app.js`, which is what the browser loads, so no
|
||
rebuild stands between the fault and the run. The sidebar ignoring
|
||
`category_panel_open`; the tile bar, view panel, formula panel and help overlay
|
||
each never drawn; `click_cell` sending a constant row; `click_category` sending
|
||
a constant index; and the typed buffer drawn without its caret. All eight fail.
|
||
|
||
The last one did **not** fail at first: the test asserted the `typing` class and
|
||
not the text, so a cell drawn blank passed. The assertion was strengthened until
|
||
the injection failed, which is the only reason it is worth having — and it is
|
||
the same lesson as the eleven tests above: a class is a flag, and a flag is not
|
||
a pixel.
|