9156b48f12
The menu's early return sat ABOVE `app.js`'s `metaKey` guard, so with the menu open `Cmd-R` reached `menuKey`, was answered `ignore`, and was then defaulted away by `if (!e.ctrlKey && !e.altKey) e.preventDefault()` — true for Meta. Reload was blocked. `Ctrl-j`/`Alt-Home` had the mirror fault and MOVED the menu, two lines below a comment saying Ctrl and Alt stay the browser's. Ordering plus one bail: the `metaKey` guard goes back above the menu branch, `pageMenuKeydown` returns on `ctrlKey || altKey`, and what remains claims unconditionally — every unmodified key, `ignore` included, which is the rule the menu exists to keep. The harness assertion installs its `defaultPrevented` probe AFTER load so it runs second and sees what the page decided, and ends on an unmodified `j` asserting `["j", true]` — without which "nothing was prevented" is satisfied by a page that claims nothing. Proved by restoring the reviewed code: it fails on `Control+j` moving the active row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
3490 lines
198 KiB
Markdown
3490 lines
198 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-worker-server/src/vfs.rs`
|
||
|
||
### an_entry_that_holds_no_document_is_seeded_over
|
||
|
||
The first browser run of the deployed page failed here, and no host test could
|
||
see it: `attach(create: true)` leaves a **zero-byte** entry behind, `documents`
|
||
listed it, and `open` therefore took the seed as already applied and read an
|
||
empty file. The precondition is asserted before the act, because a test that
|
||
only opens cannot tell "the seed landed" from "there was nothing in the way".
|
||
The rule it pins is the one gotcha 45 states for the index: what is indexed is
|
||
a document, and an entry nobody has written bytes to is not one.
|
||
|
||
This entry exists because the test cites this file and `--check-evidence` now
|
||
requires that (improvise-lyyc); it was written from the test, so read the test
|
||
first if the two ever disagree.
|
||
|
||
---
|
||
|
||
## `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_what_it_refused
|
||
|
||
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.**
|
||
|
||
Renamed and re-scoped by improvise-8uj8, which is the more interesting half. The
|
||
test was called `every_rejection_message_says_the_edit_did_not_land` and its own
|
||
doc said the fact "is asserted over all of them rather than one at a time" — while
|
||
it drove a **hand-written list of five**, written before PR #26 added
|
||
`NoDocumentPath` and `SaveFailed`. So two variants were silently exempted from a
|
||
rule the test called universal, which is worse than no test: the claim was false
|
||
about its own subject.
|
||
|
||
The re-scope is that the claim, not the messages, was wrong. A save failure did
|
||
not fail to *apply* anything, so "the edit did not land" is the wrong sentence for
|
||
it. `Rejection::refused_an_edit` (exhaustive) splits the two classes and
|
||
`Rejection::all` (a cyclic walk, the same shape `AppMode::all` uses) drives them,
|
||
so a new variant fails the build rather than slipping out of the list.
|
||
|
||
Fault injected: wording `SaveFailed` as `"hmm"` fails the test — the arm the old
|
||
five-item list could not have seen. Adding a `Rejection` variant fails the build
|
||
at `basis.rs`'s `refused_an_edit` and `after`.
|
||
|
||
### a_typed_string_composes_the_frame_that_carries_it
|
||
|
||
Was `a_typed_string_is_classified_as_the_host_classifies_it`, a test that **stated
|
||
an obligation in its name and did not discharge it** — it asserted three outcomes
|
||
by hand rather than comparing against the host's function, so the two copies of
|
||
the rule could drift and stay green. improvise-j7v6 removed the duplication rather
|
||
than the drift: `commit_regular_cell_value` now calls `improvise_protocol::typed`,
|
||
which the browser's compose path already used.
|
||
|
||
Per `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
|
||
the test could not survive as a comparison — type identity replaced its claim, and
|
||
a hand-written comparison beside the shared function would be the copy coming
|
||
back. It is re-scoped to what this crate still owes, which nothing else covers:
|
||
the **frame** a typed string composes — basis, cell and command.
|
||
|
||
The classification's own obligation moved to `tests/typed_string_agreement.rs`,
|
||
where it is checked against the thing it could really disagree with: what
|
||
`.improv` calls a number (gotcha 34).
|
||
|
||
### 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_per_header_as_the_tui_does
|
||
|
||
(`render.rs`.) Was `column_groups_coalesce_by_name_as_the_tui_does`, and its
|
||
justification was consumed by the consolidation it predates. It read: 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". Since
|
||
improvise-8qlr `src/ui/grid.rs` uses `AxisFrame::group_run_heads`, so there is
|
||
no second copy to agree with — the rule can change once and both ends follow.
|
||
|
||
**Its fixture is what settled improvise-h0iv.** It merges `Month/Q1`
|
||
(expanded) with `Fiscal/Q1` (**collapsed**), and its own message said the merged
|
||
span is "drawn with the FIRST one's indicator". That is a collapsed group
|
||
rendered as expanded, with nothing on screen saying otherwise. Whether two
|
||
adjacent same-named runs are one span is a question of taste and is why h0iv was
|
||
filed rather than fixed; a wrong collapse glyph is not. The expectation is now
|
||
three runs, and the collapse flag of each is asserted — which is what makes this
|
||
test see the fault at all, since the labels and spans alone cannot distinguish a
|
||
merge that keeps the first indicator from a split.
|
||
|
||
### 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.
|
||
|
||
### the_page_bar_names_every_fixed_category_and_what_it_is_fixed_to
|
||
|
||
(`render.rs`, improvise-p7lh.) `GridCache::page_coords` was projected to every
|
||
session, folded correctly by every client, and read by **no** front end — so the
|
||
shipped front page (`web/demo.improv` is `Quarter: page, Q1`) drew a grid
|
||
filtered to Q1 with nothing anywhere saying so. Worse, `[`/`]` resolve *locally*
|
||
in the browser off that same `page_coords`, so a user could press `]`, watch
|
||
every number in the grid change, and have no way to tell which quarter they were
|
||
looking at or how to get back. The numbers were right and the frame was a lie by
|
||
omission.
|
||
|
||
Proved able to fail by deleting the `page_coords:` line from `render_model`,
|
||
which is the state the bug shipped in.
|
||
|
||
Why this is the assertion and not "the field exists": the bar's whole job is to
|
||
name *which item*, so `selected` is what discriminates. A bar that drew the
|
||
category name alone would satisfy a presence check and still leave the user
|
||
unable to tell Q1 from Q2.
|
||
|
||
### only_a_navigable_page_axis_offers_a_cycle
|
||
|
||
(`render.rs`, gotcha 27.) Page is navigable; Filter constrains without being
|
||
navigable, and a client must compose no cycle for one. The one home of that
|
||
split is `PageAxis::cycle`, and the plausible wrong answer is `items.is_some()`
|
||
— which agrees with it on the two common cases and disagrees on the third.
|
||
|
||
Three axes in one fixture, because two would not separate the readings:
|
||
|
||
- a Page axis with three items, selected at index 2. **Injected** `items.is_some()`
|
||
with `at: 0` and watched it fail on the index — a cycle that always reports
|
||
position 1 reads as correct on a freshly-opened document and is wrong the
|
||
moment anyone pages.
|
||
- a Filter axis (`items: None`). Both readings agree here, which is why it
|
||
cannot be the only case.
|
||
- an axis with `items: Some(vec![])`. `PageAxis::cycle` filters the empty list
|
||
and says `None`; `items.is_some()` says there is a cycle of length zero. This
|
||
is the case that makes the test discriminating rather than decorative.
|
||
|
||
### the_totals_strip_is_relayed_and_an_empty_one_stays_empty
|
||
|
||
(`render.rs`, improvise-p7lh.) The same shape as the page bar: `GridCache::totals`
|
||
projected, folded, and painted by nothing. The TUI draws it (`src/ui/grid.rs`);
|
||
the browser had no field for it.
|
||
|
||
Proved able to fail by replacing `totals_strip`'s body with `Vec::new()` — the
|
||
state the bug shipped in — and watching it fail on the relayed text.
|
||
|
||
Two halves, and the second is the one gotcha 33 is about: an **empty** strip
|
||
means "this grid has no totals" (records mode, or an empty grid) and must stay
|
||
empty rather than becoming a row of blanks. A client that padded it would draw a
|
||
blank total row under a records grid that has none.
|
||
|
||
What is deliberately **not** asserted anywhere: that the numbers are right. They
|
||
are the host's, summed over every row *including ones this viewport scrolled
|
||
past*, which is exactly why they are projected. A client-side check of the
|
||
arithmetic would be the second derivation the projection exists to prevent — so
|
||
the arithmetic is pinned end to end instead, in `contract.spec.js` below, against
|
||
a real document where the sum is a fact.
|
||
|
||
---
|
||
|
||
## `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` is **imported from `web/shell.js`** and restates nothing
|
||
(PR #65), and **one** test outlasts it — this sentence said the opposite of both
|
||
for a while, which is gotcha 48's third clause: a fact stated twice is changed by
|
||
a grep, not an edit. Since improvise-669b the spec also compares the *running
|
||
page's* window against the one it imported, because the single home is in the
|
||
TREE and the page loads `harness/serve/app/shell.js` — see
|
||
`outlastTheWatchdog` below. `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 not called a disconnect"
|
||
|
||
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". **This test no longer says anything about the watchdog, and the
|
||
paragraph below is why it never could.** `shell.arm` runs only after
|
||
`await transport.ready`, which resolves on `open` — so on a socket that never
|
||
opens the watchdog is **never armed at all**, and denying its message here
|
||
denies an unreachable event.
|
||
|
||
**That sentence was already in this file, five lines above an assertion that
|
||
contradicted it.** PR #65 added the watchdog clause *and edited this very
|
||
paragraph*, inserting "`runFor(10s)` advances the watchdog" immediately before
|
||
the sentence saying it is never armed. Reverting PR #21's routing, or stripping
|
||
`fail()` of its `sawFrame()`, both left that clause **passing**. So a PR whose
|
||
subject was "a deleted guard went unnoticed" planted the same shape one level
|
||
up: three injections, three distinct test names, and two of them proving
|
||
nothing. What the test guards now is the **naming** — `close` on a socket that
|
||
never opened must not read as "disconnected" (improvise-taxx) — which is the
|
||
half injection 3 really does exercise.
|
||
|
||
**The mechanism, so it cannot come back.** `outlastTheWatchdog` now asks the
|
||
page `timesArmed() > 0` before jumping, and a watchdog denial re-added to this
|
||
path **fails by name**. It asks *ever armed* rather than *armed now*: by the
|
||
time the disconnect test jumps, a correct page has already disarmed — that is
|
||
the property — so `armed()` would have denied the passing case. Building that
|
||
guard is what surfaced the distinction, which no amount of reading had.
|
||
|
||
#### "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** — through `page.clock`, not
|
||
through a real wait. 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.
|
||
|
||
**And it was lost a third time, by the PR that was arguing about exactly this.**
|
||
PR #61 split the browser tests into boundary contracts (Playwright) and unit
|
||
tests (`node --test`), and merged these two tests into one that kept only the
|
||
last-value assertions. `shell.test.js`'s "a loud failure DISARMS the watchdog"
|
||
covers `createShell` in isolation and **cannot see whether `app.js` routes the
|
||
close event through `fail()` rather than `setShell()`** — which is the half
|
||
PR #21 fixed and the half that keeps getting lost. Reverting both socket arms
|
||
to `setShell` left the node suite 26/26 and Playwright 13/13 green. Restored
|
||
under their original names, and re-proved with that exact revert: the two-arm
|
||
revert and the close-arm-only revert each fail "…and stays that way", and
|
||
dropping the `opened` flag fails "…the watchdog stays out of it". **Three
|
||
injections, three different tests, none of them the unit test.**
|
||
|
||
**And the helper guards its own premise.** `page.clock.runFor` on a page where
|
||
`clock.install()` was never called does **nothing and throws nothing**, so a test
|
||
that forgot the install would assert "the watchdog never spoke" against a
|
||
watchdog never given the chance — vacuous, and green. `outlastTheWatchdog` reads
|
||
`Date.now()` inside the page either side of the jump and requires it to have
|
||
moved. Demonstrated both ways: with the install removed the suite passed 6/6,
|
||
and with the guard in place the same omission fails by name.
|
||
|
||
**And it guards its second premise since improvise-669b: that the page runs the
|
||
constant the spec imported.** PR #65 single-homed the handshake window in
|
||
`web/shell.js`, which closed the *understated spec constant* — but the home is
|
||
single in the **tree**, not in what the page runs: the spec imports
|
||
`../../web/shell.js` and the page loads `harness/serve/app/shell.js`, the copy
|
||
`web/assemble.sh` put there. A stale `serve/` restores the exact vacuity, and
|
||
review demonstrated it — shrink the constant in source only, revert both socket
|
||
arms, 6/6 green. `createShell` now exposes `timeoutMs()` beside `timesArmed()`
|
||
and `outlastTheWatchdog` reads the running page's value and requires it to equal
|
||
the import. Injected with the ticket's own recipe (`8000` → `100` in
|
||
`web/shell.js`, no rebuild): the run goes to 5 passed / 1 failed, on
|
||
"the page's handshake window is not the one this spec imported", where the same
|
||
staleness was previously invisible.
|
||
|
||
Note what this does and does not reach: it guards the tests that *call the
|
||
helper*. `"a peer that accepts and then says nothing trips the watchdog"` also
|
||
names `HANDSHAKE_TIMEOUT_MS`, but as a Playwright `timeout:` margin rather than
|
||
as a window to outlast — a stale value there costs patience, not evidence.
|
||
|
||
**The window is single-homed, and that is the second half of the same
|
||
finding.** `HANDSHAKE_TIMEOUT_MS` lived twice — once in `web/app.js`, once in
|
||
the spec — with a comment saying a silent disagreement "would pass for the
|
||
wrong reason" and nothing checking it. Setting the spec's copy to 1000 against
|
||
the page's 8000 made the whole suite green **against the reverted routing**:
|
||
the jump landed inside the window, the watchdog had not yet fired, and the
|
||
denial held vacuously. It is now exported from `web/shell.js` and imported by
|
||
both, so the two cannot disagree; shrinking it in its one home still catches
|
||
the routing revert, which is the demonstration that the guard did not go quiet.
|
||
|
||
**The faked clock is what makes the restoration cheap without weakening it**,
|
||
and the injections are the proof of that too: if `page.clock.runFor` did not
|
||
really advance the page's timers, a broken routing could not surface the
|
||
watchdog's message and those injections would have passed. They failed. The
|
||
test keeps the real page, the real socket, the real listeners and the real
|
||
`setTimeout` call — only the *passage of time* is simulated, and 9 seconds of
|
||
wall clock became 75 milliseconds.
|
||
|
||
**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 carry the keys and the words, not empty frames"
|
||
|
||
This entry was demoted to prose as Retired when PR #61 deleted the spec. PR #72
|
||
restored the coverage under a new name, so the claim is live again and the
|
||
heading now names the test that exists. What follows is the original evidence;
|
||
improvise-igxp's stronger assertions are recorded separately below.
|
||
|
||
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.
|
||
|
||
**It was gone for a release, and that is why the entry reads oddly.** `36ade35`
|
||
deleted it with the rest of `harness/tests/widgets.spec.js` and nothing replaced
|
||
it, so the heading promised coverage that did not exist -- exactly the cost
|
||
improvise-5ray names, and invisible because the evidence checker then read only
|
||
`###` headings and only Rust names. PR #69 taught it to see both; PR #72
|
||
restored the coverage under the current name, with the stronger content
|
||
assertions improvise-igxp asked for.
|
||
|
||
The injections above are the original two and still describe the property. The
|
||
present test is `harness/tests/help.spec.js`, and its own entry below records
|
||
what igxp added: the rows are read as `dt`/`dd` pairs and asserted as text, so
|
||
blanking `keys.textContent` fails it where `toBeVisible` passed.
|
||
|
||
### 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
|
||
|
||
**Both files were deleted by PR #61**, which moved the pointer's and the
|
||
widgets' browser halves into `harness/tests/contract.spec.js` and the rest into
|
||
`node --test web/tests`. What follows is the history of how these assertions
|
||
were arrived at; the assertions themselves now live there.
|
||
|
||
**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
|
||
|
||
**Both files were deleted by PR #61**, which moved the pointer's and the
|
||
widgets' browser halves into `harness/tests/contract.spec.js` and the rest into
|
||
`node --test web/tests`. What follows is the history of how these assertions
|
||
were arrived at; the assertions themselves now live there.
|
||
|
||
**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 used to fail differently, and that asymmetry was 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`) was a name `resolve::step` did not handle, so it became
|
||
`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 did nothing whatsoever: no status, no mode change, nothing.
|
||
That is why this read as "never confirmed working" rather than as a refusal.
|
||
|
||
**That silence is now closed, and the closing is what this file ships.** `step`
|
||
answers `"drill-into-cell"` with a `SetStatus` saying drill is not available in
|
||
the browser yet — an interim the wording of which is load-bearing, since it must
|
||
be true both today and on the day improvise-gd28 lands. The wire vocabulary is
|
||
still absent; only the *silence* was fixed. Note for gd28: when the real drill
|
||
arrives, a leftover `"drill-into-cell"` arm in `step` would **shadow** it with
|
||
nothing failing, because a name that resolves never reaches the host.
|
||
|
||
**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** — against the tree *before* the interim, which is what
|
||
made them two faults rather than one. Replacing the aggregate arm with `None` —
|
||
silencing the honest refusal — failed **both** tests, and the second failed 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 — failed **only** the second test and left the
|
||
first green. Two faults, two different failure signatures, neither test passing
|
||
for the other's reason. That second injection is now the shipped code, which is
|
||
why the test it broke was rewritten rather than kept.
|
||
|
||
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.
|
||
|
||
### the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons
|
||
|
||
The Rust half of the interim, and the assertion that carries the whole weight.
|
||
Two keys on the same aggregate cell must both refuse and must **not** refuse
|
||
with the same sentence: `>` says drill is unavailable, `i` says drilling into an
|
||
aggregate needs the host. Collapsing them into one message would be the
|
||
tempting tidy-up and would lose the distinction between "this key does nothing
|
||
here yet" and "this cell is not one you may write".
|
||
|
||
**The injection, and why it is not the obvious one.** Replacing `>`'s sentence
|
||
with the *aggregate* sentence looks like it should fail on the first assertion —
|
||
and does not: that sentence contains both "drill" and "browser", so it clears
|
||
the substring check. What fires is the anti-aggregate assertion lower down. The
|
||
claim that the second assertion is the load-bearing one was doubted on review,
|
||
tested, and confirmed; record it here because the first assertion reads like the
|
||
discriminating one and is not.
|
||
|
||
The harness sibling (`drill.spec.js`) asserts the same distinction in a browser,
|
||
but its `not.toContainText("aggregate")` is **decoration under the only
|
||
injection that exists** — Playwright throws at the positive assertion above it
|
||
first, so that line is never reached. The Rust assertion is the guard; the
|
||
harness one is end-to-end confirmation that the string survives the wire.
|
||
|
||
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
|
||
|
||
improvise-iioi, and it is the **same trap** as the category click one level
|
||
over: the activation must be resolved against the row **clicked**, never
|
||
`view_panel_cursor`, because the `SetPanelCursor` ahead of it has not been
|
||
folded when the resolver runs. Injecting exactly that — reading the name at the
|
||
cursor instead of at `index` — yields
|
||
`SwitchView("Default")` beside `SetPanelCursor { cursor: 1 }`, i.e. the cursor
|
||
lands on `Plan` and the document switches to `Default`. Silent, and wrong in the
|
||
half a user does not watch.
|
||
|
||
The fixture therefore holds **two** views and clicks the **second**. With one
|
||
view, or with a click on the row already under the cursor, the bug is invisible
|
||
— the same reason `a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row`
|
||
clicks Product rather than `_Index`.
|
||
|
||
### a_formula_row_click_navigates_and_activates_nothing
|
||
|
||
The negative arm, and it is a statement about the *rule* rather than about the
|
||
formula panel. The rule is "go there, then do what `Enter` does there";
|
||
`ModeKey::FormulaPanel`'s keymap binds `a`/`n`/`o` to `enter-mode formula-edit`
|
||
and `d`/`Delete` to delete, and **no `Enter` at all**. So the rule yields
|
||
navigation and nothing else, and that is the rule applying rather than the fix
|
||
stopping half way.
|
||
|
||
This test asserts the *exact* command vector, which is the whole of what it can
|
||
say. It once carried a second, explanatory `assert!` that no `FormulaEdit` mode
|
||
change appears — **which could never have been the failing assertion**, because
|
||
the `assert_eq!` above it pins the same three commands and aborts first. A
|
||
reviewer demonstrated it by injecting a `ChangeMode(FormulaEdit)` and watching
|
||
the panic land on the `assert_eq!` with the explanatory message never printed.
|
||
It was a comment wearing an `assert!`, and the argument now lives in
|
||
`context/code/clients.md` where a comment run cannot hold it.
|
||
|
||
### the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does
|
||
|
||
The guard that the removed assertion was pretending to be. The asymmetry — a
|
||
view-row click activates, a formula-row click does not — is a fact about the
|
||
**keymap**, and nothing tied the click to it. The reviewer proved the gap by
|
||
adding `Enter -> enter-mode formula-edit` to `ModeKey::FormulaPanel` and running
|
||
the whole workspace: **1381 passing, nothing failed**, while `click_formula_row`
|
||
still activated nothing. The click and the keyboard then disagree about what a
|
||
formula row does, silently.
|
||
|
||
Injecting that same binding now fails this test and only this test. The failure
|
||
message names `click_formula_row` as what must change, because the person who
|
||
adds an `Enter` binding is not the person reading `resolve.rs`'s click arms. The
|
||
positive half (`ViewPanel` *does* bind `Enter`) is there so the test cannot pass
|
||
by asserting that no panel binds anything.
|
||
|
||
#### The view and formula row clicks in the browser (improvise-iioi)
|
||
|
||
These began as two tests in `harness/tests/pointer.spec.js`. **That file no
|
||
longer exists**: PR #61 deleted it and moved the pointer's browser half into
|
||
`harness/tests/contract.spec.js`, whose stated scope is the wiring — the handler
|
||
is on the element and passes *that* element's index — while what a click MEANS
|
||
is `improvise_protocol::resolve`'s and tested there. The assertions were merged
|
||
into that file's own click test rather than restored, because resurrecting a
|
||
file a merge deleted is how a deliberate removal gets undone by reflex.
|
||
|
||
They leave the panel **open but unfocused** — the only state in which the
|
||
*focus* third of the click is observable.
|
||
|
||
**History, not current evidence:** the original pair was proved against the
|
||
bundle built immediately before the fix, both failing there while the
|
||
pre-existing pointer assertions passed. That run was against
|
||
`harness/tests/pointer.spec.js`, which no longer exists. What holds the
|
||
relocated assertions honest is the injection below, run in their new home.
|
||
|
||
The view test asserts three things — `#view-name`, the `active` marker and the
|
||
`selected` cursor — because switching ends in `NORMAL` and a test that only
|
||
checked the mode would pass against a click that did nothing but leave the
|
||
panel. **The relocation initially dropped two of the three**, and a reviewer
|
||
caught it; they are restored.
|
||
|
||
**And the fixture was vacuous in a way the prose had already warned about.**
|
||
`Default` is index 0 and holds `view_panel_cursor` by default, so clicking it —
|
||
which both the old `pointer.spec.js` and the first relocated version did — makes
|
||
`index` and the cursor the same number, and reading either gives the same name.
|
||
A reviewer injected `switch_view_at(state, state.view.view_panel_cursor)` into
|
||
`click_view_row` and **the whole contract suite passed 3/3**: the exact fault
|
||
this test, the ticket and the Rust test all exist for was invisible to the
|
||
browser half. The fixture now switches to `Default` by **keyboard**, leaving the
|
||
cursor there, then **clicks `Plan`** — so the two readings name different rows
|
||
and the outcomes differ. Re-running that injection now fails it.
|
||
|
||
The blindness was inherited rather than introduced: the deleted file clicked
|
||
`Default` too. It became worth fixing because the relocated test is now the
|
||
*only* browser assertion on that surface.
|
||
|
||
**One fact the reviewer's report supplied and this fix leans on**: shipping the
|
||
cure for one panel and not its two siblings *reproduces the original report*. A
|
||
user who has just learned category rows are clickable clicks a view row and
|
||
reads the silence as broken — which is literally what "I can't expand
|
||
categories" meant.
|
||
|
||
#### The harness port: reuse is now opt-in
|
||
|
||
`reuseExistingServer: !process.env.CI` became `=== "1"` on
|
||
`IMPROVISE_HARNESS_REUSE`. A default-on reuse adopts a server another checkout
|
||
left on the port and then tests **that tree**, which fired in both directions in
|
||
one session — a false negative and, worse, a false *positive*. A path-hash
|
||
default was considered and rejected: it still silently reuses when two agents
|
||
share a worktree path, where an explicit opt-in cannot. CI is unaffected (it
|
||
never reused) and a developer wanting the old speed sets the variable.
|
||
|
||
### a_click_that_cannot_compose_its_activation_composes_nothing
|
||
|
||
improvise-hkr8. `click_category_row` ended with
|
||
`filter_to_item_at(state, index).unwrap_or_default()`, and that `None` arm is
|
||
not "nothing to do" — it is `BasisCache::claim` answering that the server has
|
||
published no basis yet (gotcha 43), so the document change cannot be composed.
|
||
The click therefore kept its **navigation prefix** (focus the panel, move the
|
||
cursor), dropped its activation, and reported success. The keyboard handles the
|
||
identical `None` correctly, by mapping `step(..) == None` to
|
||
`KeyOutcome::Upstream`.
|
||
|
||
The fixture has to do two things a careless one would not. It **expands
|
||
`Region`** so that row 1 is an `Item` row — a header row activates via
|
||
`ToggleCatExpand`, which needs no basis and so cannot show the bug — and it
|
||
asserts both preconditions (`cat_tree_entry_at` is an `Item`, `claim` is `None`)
|
||
rather than assuming them, because an empty `ClientState` makes every
|
||
state-dependent refusal look like the one under test.
|
||
|
||
Injecting the old `unwrap_or_default()` fails this test and only this test.
|
||
|
||
### the_same_click_composes_everything_once_a_basis_is_published
|
||
|
||
The positive arm of the one above, and it is what stops the fix from being
|
||
"return nothing more often". With a basis published, the same click composes all
|
||
six commands: open, focus, move the cursor, two `Command::Model`s and the
|
||
status. Without this, deleting the activation entirely would pass hkr8's test.
|
||
|
||
### a_first_tile_click_selects_without_touching_the_document
|
||
|
||
The user's ruling: a click selects, a second click cycles. The fixture publishes
|
||
a basis **deliberately**, so the absence of a `Command::Model` is a decision
|
||
rather than a client that could not compose one — pointed at an unpublished
|
||
basis this test would pass for hkr8's reason instead of its own.
|
||
|
||
Both halves are asserted, because "the axis did not change" passes against a
|
||
page where nothing happened at all: the positive half requires `SetTileCatIdx`
|
||
plus `ChangeMode(TileSelect)`, the negative half requires no `Command::Model`.
|
||
|
||
**Four injections, four distinct signatures.** "First click also cycles" fails
|
||
this test, `the_first_click_on_tile_zero_selects_it_like_any_other` and
|
||
`clicking_a_different_tile_selects_it_rather_than_cycling_it` — and **not**
|
||
`a_second_click_on_the_selected_tile_cycles_its_axis`. "Second click does not
|
||
cycle" fails only that last one. "`already_selected` reads the index alone"
|
||
fails only the tile-zero test. Reverting hkr8 fails only the hkr8 test. No
|
||
injection passes for another's reason.
|
||
|
||
### t_then_a_click_on_the_leftmost_tile_cycles_it_immediately
|
||
|
||
Found on review, and it is a defect in the *wording* rather than the code. `T`
|
||
enters `TileSelect` leaving `tile_cat_idx` at **0**, so a click on the leftmost
|
||
tile is a click on the already-selected one and cycles on the **first** gesture.
|
||
Both loose summaries of the ruling — "a click never mutates on its own" and "a
|
||
**second** click cycles" — are false against that probe.
|
||
|
||
The behaviour is right: a click does what `Enter` does there, and `Enter` in
|
||
`TileSelect` cycles the tile under the cursor. The resolver cannot see how the
|
||
state arose and must not try to. So the test exists to **name** the case rather
|
||
than to change it, and it asserts the neighbouring tile answers the identical
|
||
gesture by selecting — because the surprising half is that two adjacent tiles
|
||
differ, and a reader who meets that in the wild should find it written down.
|
||
|
||
Note what this means for its sibling `a_second_click_on_the_selected_tile_cycles_its_axis`:
|
||
that test sets the state directly rather than by clicking, so it **already was**
|
||
the keyboard-entry case. Nothing was untested; the name and the argument were
|
||
missing.
|
||
|
||
### enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream
|
||
|
||
`switch_view_at` returns `Option`, and its two callers answer the `None`
|
||
differently on purpose. **From a `click_*`, `None` means "this gesture composed
|
||
nothing"** — a dynamic fact, and hkr8's rule. **From `step`, `None` means "this
|
||
client cannot execute this command *name*"** — a *static* fact about the
|
||
vocabulary, which `the_command_names_that_still_need_the_host_are_these` exists
|
||
to keep static. Two questions sharing one type, which is why the asymmetry reads
|
||
wrong at first. (`delete_view_at_cursor` already answered `Some(Vec::new())` the
|
||
same way, so this follows the file rather than inventing.)
|
||
|
||
Nothing held the keyboard side to it until this test. A reviewer removed the
|
||
`unwrap_or_default()` so out-of-range propagates `None` — making `Enter` in an
|
||
empty view panel `KeyOutcome::Upstream`, gotcha 47's black hole — and **the whole
|
||
workspace passed under `--no-fail-fast`**. That would make membership of the
|
||
host-needed list state-dependent, the one property that list denies. Injecting
|
||
it now fails this test and only this test.
|
||
|
||
The assertion is `Ignored`, not `Local(vec![])`: `KeyOutcome::of` maps an empty
|
||
vec to `Ignored` by construction, so `Local` is never empty. What matters is
|
||
that it is not `Upstream`.
|
||
|
||
### the_first_click_on_tile_zero_selects_it_like_any_other
|
||
|
||
The trap in the ruling's own wording. "A second click on the already-selected
|
||
tile" reads as an index comparison, and `tile_cat_idx` is **0 before anything
|
||
has selected anything** — so an index-only test would read tile 0 as already
|
||
selected and cycle it on its very first click, making the non-mutation
|
||
guarantee true of every tile but one. The condition is `mode == TileSelect`
|
||
**and** `tile_cat_idx == index`.
|
||
|
||
### clicking_a_different_tile_selects_it_rather_than_cycling_it
|
||
|
||
The other half of the same trap, from the opposite side: without it, "a click
|
||
does not mutate" would hold only for the first click of a session, since every
|
||
click after the first is a "second click" in the loose reading. Clicking A then
|
||
B must select B.
|
||
|
||
#### How to know a launch default has stopped disarming things
|
||
|
||
The question this left open was **how to know you have exhausted the class**.
|
||
Grep does not answer it: grep finds sites that *name* the flag, and the hazard
|
||
is a test that merely **starts from a default-built state** and asserts
|
||
something downstream, mentioning nothing. Three disarmed tests were found by
|
||
grepping `category_panel_open`; that was luck, not method.
|
||
|
||
**Mutation answers it, because it does not care what a test mentions.** The
|
||
recipe, which a reviewer ran across five sites in about twenty minutes:
|
||
|
||
> For every production site that **writes, propagates or reads** the value,
|
||
> replace it with the constant the default supplies, and run
|
||
> `--no-fail-fast`. Sites where nothing fails are suspects. Then **re-run each
|
||
> suspect with the default inverted.** If it now fails something, the test was
|
||
> **vacuous** — the default disarmed it, and the fix is the fixture. If it
|
||
> still fails nothing, it was simply **unguarded** — a different problem,
|
||
> pre-existing, and a bead rather than a fixture.
|
||
|
||
**The second step is the one that matters**, and without it the two classes are
|
||
indistinguishable: both look like "nothing failed". It is what established that
|
||
the class is closed at **two** (`set_panel_open_and_cursor`,
|
||
`panel_commands_address_the_named_panel_only`) and that two further silent sites
|
||
are older problems this ruling did not cause:
|
||
|
||
- `ui/app.rs`'s `App::cmd_context` propagator — hardcode it `true` and the whole
|
||
workspace passes. `registry.rs` reads it for `toggle-panel-and-focus`'s
|
||
open-vs-close decision, so `C` on a closed panel would decide to *close* and
|
||
nothing would fail. Invisible because `cmd/mod.rs`'s test helper sets the
|
||
field explicitly, so no command test reaches the propagator. Filed.
|
||
- `improvise-wasm-client/src/render.rs`'s `RenderModel` field — hardcode it
|
||
`true` and **Rust sees nothing at all**; only the Chromium harness catches it,
|
||
at `contract.spec.js`'s `toBeHidden`. That is literally improvise-wsaw
|
||
reintroduced one layer up, and **a Rust-only CI run cannot see it** — which is
|
||
the sharpest argument on file for why the browser job must be able to fail the
|
||
build.
|
||
|
||
**And a repair needs its own probe, because a repair can be shaped like a fix.**
|
||
The two-step recipe above finds a *disarmed* test; it does not tell you whether
|
||
the repair you then write is real. That gap is not hypothetical — the first
|
||
repair of `panel_commands_address_the_named_panel_only` folded
|
||
`[open:false, open:true]`, read as a fix in review, and was still vacuous.
|
||
|
||
The probe that settles it is a **one-directional** fault: make the writer
|
||
`if open { set true }`, so the flag can be raised but never lowered. **A vacuous
|
||
repair cannot see this**, because a test that only ever asserts the `true`
|
||
direction is satisfied by a writer that only ever writes `true`. Injected into
|
||
both copies at once, it fails `set_panel_open_and_cursor` **and**
|
||
`panel_commands_address_the_named_panel_only`, each in its own copy — which is
|
||
what establishes both repairs are genuine rather than merely differently
|
||
spelled. Prefer it to re-reading the diff: the diff contained `open: false` in
|
||
the version that was vacuous.
|
||
|
||
Note `resolve.rs`'s `panel_open` reader is **well** guarded by contrast:
|
||
hardcoding it `true` fails two click tests. So the picture is not "everything
|
||
touching this flag is untested"; it is specifically the propagators.
|
||
|
||
### the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it
|
||
|
||
**Prospective rather than present-tense, and the doc comment says so.** With
|
||
three panels, `the_terminal_app_launches_with_the_category_panel_open` and
|
||
`the_other_two_panels_are_shut_at_launch` already cover every `Panel::ALL`
|
||
member, so this test cannot currently fail without one of them failing too. A
|
||
reviewer confirmed that by control.
|
||
|
||
It earns its place the way gotcha 12 describes `Panel::ALL`: a **fourth** panel
|
||
reaches it from one `ALL` entry, and at that moment it becomes the only test
|
||
asserting that `App::new` did not quietly give the TUI its own launch state —
|
||
the divergence improvise-wsaw closed. Recorded here so nobody later reads a
|
||
green tick as evidence of something it is not yet checking.
|
||
|
||
### the_terminal_app_launches_with_the_category_panel_open
|
||
|
||
The user ruled (2026-09-20) that the category sidebar is visible at launch in
|
||
the **TUI as well as** the browser, so `improvise model.improv` opening with it
|
||
showing is intended. This is the guard on the front end the ruling newly
|
||
changes: `a_fresh_session_opens_with_the_category_panel` asserts
|
||
`ViewState::default()` in the *protocol* crate, and nothing downstream of it.
|
||
|
||
Why not a rendered-frame test: design-principles §7 says not to test ratatui
|
||
`Widget::render`, and there is no `TestBackend` anywhere in this repo. The
|
||
honest target is the TUI's own constructor. `src/draw.rs`'s one-line read of the
|
||
flag stays argued-not-tested, as gotcha 48 has it.
|
||
|
||
Three injections. Putting the shared default back to `false` fails this test and
|
||
`a_fresh_session_opens_with_the_category_panel`. Making `App::new` override the
|
||
default (`app.view_state.category_panel_open = false`) fails this test **and**
|
||
`the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it` —
|
||
which is the one that would catch a TUI-only launch state being reintroduced, the
|
||
divergence improvise-wsaw closed. `the_other_two_panels_are_shut_at_launch`
|
||
stops "open everything at launch" passing the first.
|
||
|
||
### set_panel_open_and_cursor
|
||
|
||
**The launch default silently disarmed this test, and that is the finding.** It
|
||
did `SetPanelOpen { Category, open: true }` then asserted the flag was true — but
|
||
`test_app()` builds from `ViewState::default()`, where it is *already* true. Its
|
||
Formula and View siblings still default to `false`, so only the category arm
|
||
went vacuous.
|
||
|
||
What that cost, demonstrated: replacing `Panel::Category =>
|
||
app.view_state.category_panel_open = self.open` with a **no-op** — so no effect
|
||
could open or close the TUI's category sidebar at all — left the entire
|
||
workspace green. `C` could be made to do nothing in the terminal with 1391 tests
|
||
passing.
|
||
|
||
The fix is the fixture stating its own precondition: close the panel first and
|
||
assert it closed, so the effect has to move the flag before the `true` direction
|
||
is asserted. Injecting the no-op now fails this test.
|
||
|
||
Note the harness cannot see this one, and that is correct rather than a gap: the
|
||
browser folds `Command::SetPanelOpen` through `reduce_view`, a *different*
|
||
three-way match in a different crate from `effect::SetPanelOpen`'s. Two
|
||
derivations of one mapping — which is why the `reduce_view` guard needed the same
|
||
repair independently. **Since improvise-56ek there is one derivation**, so that is
|
||
history rather than a standing hazard; see below.
|
||
|
||
### a_panel_command_writes_that_panel_and_no_other
|
||
|
||
The `reduce_view` half of the one above, and **the first repair of it was still
|
||
vacuous** — worth recording because the mistake is subtle. Folding
|
||
`[open: false, open: true]` and asserting the final state is *true* passes
|
||
against a reducer that folds nothing at all: the default is already true, so
|
||
neither command needs to land. Only asserting the **`false`** direction
|
||
discriminates, because that is the one the default cannot supply. Caught by
|
||
injection rather than by reading — the no-op fold reported NONE failing after the
|
||
first fix.
|
||
|
||
improvise-56ek then removed the second derivation entirely: `ViewState::panel_open_mut`
|
||
and `panel_cursor_mut` (plus their read halves) are the one place a `Panel` becomes
|
||
a field, and both `effect::SetPanelOpen::apply` and `reduce_view` call them. The
|
||
cure is one home, not a test holding two copies in step
|
||
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`), so
|
||
there is deliberately no "the two agree" test — a wrong field in the one home is a
|
||
failure in every caller.
|
||
|
||
This test replaces `panel_commands_address_the_named_panel_only`, which drove
|
||
`Panel::Category` only and so could not tell one panel from another. It keeps that
|
||
test's lesson by zeroing every panel before opening one.
|
||
|
||
Injected fault: point `Panel::Category`'s arm at `formula_panel_open`. Fails by
|
||
name for every panel in `Panel::ALL`.
|
||
|
||
**That injection alone was not enough, and the gap is gotcha 3's shape.** The first
|
||
version asserted only through `panel_open`/`panel_cursor` — the read halves of the
|
||
mapping it was testing. A *consistent* permutation of all four accessors is a
|
||
self-consistent bijection: injecting Formula↔Category into `panel_open_mut`,
|
||
`panel_open`, `panel_cursor_mut` and `panel_cursor` together left the test passing,
|
||
while two unrelated tests that read raw fields failed. It caught only a
|
||
**non-injective** mapping, which is exactly the injection first recorded here. The
|
||
test now reads the three field pairs by name once per panel, and the consistent
|
||
permutation fails it: "Formula is not the field Formula names".
|
||
|
||
Also corrected here: the claim that consolidation left "no second copy to agree
|
||
with" was false when written. `resolve.rs` carried a fourth copy of the same
|
||
`Panel` → `ViewState` three-way match, **in the same crate on the same type**; it
|
||
is deleted and its three call sites go through `ViewState::panel_open`. What
|
||
remains is *not* the same mapping: `registry.rs` maps `Panel` to `CmdContext`
|
||
fields, a different type whose values are copied from `view_state`, and
|
||
`resolve.rs`/`registry.rs` each map `Panel` to a (cursor, collection length) pair,
|
||
which is a second derivation of something else and is `lockstep.rs`'s subject.
|
||
|
||
### a_fresh_session_opens_with_the_category_panel
|
||
|
||
The user's ruling that the category panel is visible at launch, implemented as
|
||
the **shared** `ViewState::default()` rather than a browser special case — a
|
||
browser-only default is "the page overrides the flag", which is the same defect
|
||
as the "the page ignores the flag" bug improvise-wsaw fixed. The consequence,
|
||
accepted deliberately, is that the TUI opens with the panel too.
|
||
|
||
The test also asserts the *other two* panels stay shut, so it cannot pass
|
||
against a default that simply opens everything.
|
||
|
||
Two existing tests had to change, and both were pinning the old decision by
|
||
**inheriting** it rather than stating it.
|
||
`panel_focus_cycles_from_the_focused_panel_not_the_first` is about skipping a
|
||
*closed* panel and took "the category panel is closed" from the default; it now
|
||
spells that precondition. `with_categories()` is used by tests about a click
|
||
*opening* the panel, so it now shuts it explicitly. Neither is the decision
|
||
being re-made — the decision is re-made only in the harness, deliberately.
|
||
|
||
#### The launch state and the tile ruling in the browser
|
||
|
||
Also merged into `harness/tests/contract.spec.js` rather than left in the
|
||
deleted `pointer.spec.js` — see the note above.
|
||
|
||
Two states, one mechanism: the sidebar is visible with no keypress, **and** `C`
|
||
still hides it. Proved by injection in a real browser: setting
|
||
`el.categories.hidden = false` (the page ignoring the flag) fails on
|
||
`toBeHidden`; setting the shared default back to `false` fails on `toBeVisible`.
|
||
Different signatures, different halves.
|
||
|
||
**Only the second is a guard, and an earlier wording here got that wrong.** It
|
||
claimed "either alone passes against the bug wsaw fixed" — but under the new
|
||
default, visible-at-launch passes for correct code *and* for a sidebar drawn
|
||
unconditionally. The launch assertion states the **ruling**; the `toBeHidden`
|
||
after the toggle is the only thing that still catches wsaw.
|
||
|
||
Note the first `C` now **focuses** rather than opens, since the panel is already
|
||
open — `toggle-panel-and-focus` closes only when the panel is both open and
|
||
focused — so the test presses it twice and the hide is the second press.
|
||
|
||
The tile tests were rewritten rather than adjusted, because the previous one
|
||
asserted the behaviour the ruling reverses; its failure against the new code was
|
||
itself the injection. The first-click test waits out a timeout before asserting
|
||
the axis did not move, since a cycle would arrive asynchronously and an
|
||
instantaneous assertion would pass against a slow one.
|
||
---
|
||
|
||
## `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:
|
||
|
||
**What `shell.test.js` cannot reach, and the PR found out the hard way:** it
|
||
tests `createShell` in isolation, so it sees the watchdog RULE and never the
|
||
`app.js` wiring that calls it. That distinction is now written into
|
||
`context/code/clients.md` and guarded in `shell.spec.js`.
|
||
|
||
**`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 totals strip** (improvise-p7lh) — `plan.totals` is
|
||
`null` where the strip is empty and never an empty row, because an empty strip
|
||
means "this grid has no totals" rather than "not caught up yet" (gotcha 33). The
|
||
label spans every row-header level, and the cells carry no coordinates at all —
|
||
a total is not a cell a click can name, which is what keeps the delegated
|
||
`td[data-row]` handler off the `tfoot` without a second rule saying so.
|
||
|
||
**`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.
|
||
|
||
**`dispatch.test.js`** (improvise-b8ot, improvise-p7lh) — the delegated
|
||
handler's operands are read back off the node, so the round trip
|
||
`cellAction(cellAttrs(cell), …)` is the property, and the fixture puts a group
|
||
header between its data rows because that is the one input where "the data row"
|
||
and "the table row" differ. Three injections, three failures, each run and read:
|
||
|
||
- `gridPlan` takes the row's position in `model.rows` rather than counting data
|
||
rows, so a group header consumes an index. This is the click-coordinate bug
|
||
gotcha 1 is about, and it is **invisible on any ungrouped grid** — which is
|
||
why the fixture has a header between its rows. Fails both round-trip tests.
|
||
- `whole()` drops its empty-string guard. `Number("")` is `0`, so a header with
|
||
no `data-row` reads as row 0 and every click on the frame moves the selection
|
||
to the origin — which is exactly why the guard is not a truthiness test.
|
||
Fails the two "means nothing at all" tests.
|
||
- `pageEntries` reports `navigable: true` unconditionally. Fails the Filter
|
||
test alone, and nothing else — the Page case cannot see it, which is the
|
||
point of seating a Filter axis in the fixture at all.
|
||
|
||
**`render_surface.test.js`, the cache direction** (improvise-p7lh) — the
|
||
existing guard is closed under *what `RenderModel` chose to expose*, so a fact
|
||
that never reaches it is invisible to it. That is precisely how `page_coords`
|
||
and `totals` sat projected and unpainted from improvise-n10 onward with a green
|
||
suite. The new assertion is one level up: every `pub` field of every widget
|
||
cache on `ClientState` must be read somewhere the browser actually runs, or be
|
||
named as debt.
|
||
|
||
Two things it got wrong first, both found by injection rather than by reading:
|
||
|
||
- The whole-cache escape hatch was a substring test for `&state.grid`, and
|
||
`&state.grid.row_frame` **contains** that string — so every `GridCache` field
|
||
read as handed-over and the check was vacuous for the one cache the bead is
|
||
about. It is now anchored with `(?![.\w])`.
|
||
- rustfmt breaks `state.grid.totals` across three lines, so a substring search
|
||
reported a field that *is* read as unread. The sources are whitespace-stripped
|
||
before the search.
|
||
|
||
The residual hole, stated because it is real: where a whole cache is handed to a
|
||
shared derivation (`flatten_cat_tree(&state.categories, …)`), every field of that
|
||
cache counts as reached. A second field added to `CategoryTreeCache` and ignored
|
||
by the derivation would pass. Closing that needs the *consumer* checked, not the
|
||
call site, and is not attempted here.
|
||
|
||
**`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.
|
||
|
||
### every_advertised_command_answers_in_its_own_mode
|
||
|
||
improvise-rmvx. Gotcha 47 says an upstream key is a black hole, and
|
||
`the_command_names_that_still_need_the_host_are_these` is the guard that was
|
||
supposed to keep the list of them honest. It was built with
|
||
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
|
||
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
|
||
were outside its reach entirely and could never have failed it. **A named list
|
||
that silently scopes itself to one mode is a guard that looks broader than it
|
||
is.**
|
||
|
||
This asks it of every mode, and it asks it the only way that is not
|
||
over-reporting. The naive version resolves every advertised name against an
|
||
EMPTY `ClientState` and flags `edit-or-drill`, `commit-cell-edit`,
|
||
`create-and-switch-view` and `page-prev`/`page-next` — but those are *handled*
|
||
names refused by STATE, which is correct behaviour and not this bug. So the
|
||
fixture is `furnished`: every widget has something in it, a basis is published,
|
||
and a window is announced, so an `Upstream` can only mean the name is
|
||
unhandled.
|
||
|
||
Two things it does NOT do, each because the first attempt did and was wrong.
|
||
It does not call `step` with the advertised args: `advertised` carries `None`
|
||
for "any binding of this command", and `step`'s numeric and mode arguments are
|
||
mandatory, so that reports `move-selection`, `move-panel-cursor` and
|
||
`move-tile-cursor` as gaps in every mode. And it does not tokenise the rendered
|
||
hint string: `help::advertised` is a table of command NAMES, so the
|
||
cross-reference is exact and anything reading the rendered text is solving a
|
||
harder problem than exists. The keys come off `Keymap::keys_running`, which
|
||
`labels_for` now shares so "which keys" and "how are they spelled" cannot give
|
||
two answers.
|
||
|
||
**It watches BOTH silent variants, and the first version did not.** `KeyOutcome`
|
||
has two ways of saying nothing: `Upstream`, and `Ignored` — which
|
||
`KeyOutcome::of(vec![])` produces whenever a resolver composes an empty vector.
|
||
To a user they are the same black hole. The first version asserted
|
||
`resolve(..) == Upstream(event)`, so **three of the six commands this whole
|
||
change exists to make audible could go completely silent with the entire
|
||
evidence base green**: degrading only `add-record-row`, `delete-formula-at-cursor`
|
||
and `view-back` to `Step::Declined(Vec::new())` left 1397 Rust tests, 19 harness
|
||
tests and 26 node tests passing, because no browser spec presses those three
|
||
keys. Found on review of PR #71. The check is now `!matches!(.., Upstream(_) |
|
||
Ignored)`.
|
||
|
||
**The generalisation is worth more than the fix.** `KeyOutcome::of` turning an
|
||
empty `Local` into an honest `Ignored` is good design and it is also a
|
||
**laundering** step: every wrong answer that composes nothing arrives wearing
|
||
the variant that is usually innocent. So a guard's discriminator has to be
|
||
*state* — did anything the user can see change — and never the shape of the
|
||
return value. That is exactly why the `+cat` injection in
|
||
`a_locally_committed_creating_column_agrees_with_the_host` failed (it compares
|
||
documents and cursors) and the decline injection passed everywhere (nothing
|
||
compared a screen).
|
||
|
||
Widening the check costs ten more entries, all one legitimate boundary case:
|
||
`move-panel-cursor` mirrors `MovePanelCursor`, which returns nothing when the
|
||
cursor does not move, so a one-row panel or a cursor on an edge answers
|
||
`Ignored` for a reason that is not this test's subject. `furnished` therefore
|
||
carries **three** of everything with all three panel cursors seated at index 1.
|
||
Note what that rules out: seating them at 0 and calling the `Up` results
|
||
expected would have hidden the real defect in the same list as the boundary
|
||
case.
|
||
|
||
**The injection**: delete the `"toggle-prune-empty"` arm from `declined`. The
|
||
test fails naming `NORMAL: Char('P')` and `RECORDS: Char('P')` — both modes,
|
||
which is the widening the ticket was about, and a build that ran rather than a
|
||
compile error. The review's own injection is also kept as a check: degrading the
|
||
three unpressed declines to empty now fails naming `FORMULAS: Char('d')`,
|
||
`FORMULAS: Delete`, `RECORDS: Char('<')` and `RECORDS: Char('o')`.
|
||
|
||
What it found that the ticket did not predict: `view-back` is advertised in
|
||
RecordsNormal (`<`, captioned "back"), so it was a seventh instance of the same
|
||
class. The ticket had it filed under the Normal-mode host list instead, where
|
||
being upstream is not a defect.
|
||
|
||
The five that remain are listed by name with a reason each. Three of them —
|
||
`commit-export`, `commit-save`, `execute-command` — are reachable only from a
|
||
mode `declined` refuses at the door, so a browser never stands in front of the
|
||
hint; the other two are improvise-gcvb's records-cell commit, which needs
|
||
`record_col`.
|
||
|
||
### a_locally_committed_creating_column_agrees_with_the_host
|
||
|
||
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
|
||
mode, and the reason given was sound for the coordinate and measure columns —
|
||
a category column renames every measure cell in the row, which needs
|
||
`record_col` (gotcha 1). The guard was broader than its reason: a cell at the
|
||
cursor now projects `CellId::Create`, and committing one is a single existing
|
||
`ModelCommand` over the typed NAME that reads no `record_col` and writes no
|
||
cell.
|
||
|
||
Both halves are proved by injection, and the second is the one that matters.
|
||
Putting the broad `is_records()` early return back makes the test fail at "the
|
||
client must answer this key locally". Deleting the `CreateKind::Category` early
|
||
return — so `+cat` advances like any other column — fails with the client in
|
||
`RecordsEditing` where the host is in `RecordsNormal`: the TUI returns straight
|
||
after a `+cat` commit with a `RebuildLayout` and no cursor move, because an
|
||
inserted column renumbers every index to its right and the advance was computed
|
||
from the pre-commit layout (gotcha 23). A client that advanced anyway would land
|
||
one column off, silently.
|
||
|
||
`both_ends` compares the document as `.improv` text, the mode, the status and
|
||
every cursor, so neither half can pass by agreeing on the wrong thing. The
|
||
fixture drives *both* hosts into records mode with the `R` key rather than
|
||
hand-building a `_Records` view, because `twin_of` copies only the session
|
||
slice and a twin without the view would refuse the commit for the wrong reason.
|
||
|
||
### every_minibuffer_mode_paints_what_is_being_typed
|
||
|
||
improvise-fdt6. `commit-buffer formula` already resolved on the client — that
|
||
half of the ticket was closed by an earlier wave — so the browser could enter
|
||
FormulaEdit, type a formula and commit it. What did not exist was any way to
|
||
SEE it: `RenderModel::editing` is populated only when `mode.is_editing()`, which
|
||
is the two *cell*-editing modes, and it reads the `"edit"` buffer by name. Every
|
||
character typed into FormulaEdit, CategoryAdd, ItemAdd, SavePrompt,
|
||
ExportPrompt or CommandMode went into a buffer nothing painted. The mode opened,
|
||
the user typed, and the screen did not move — which is exactly the dead-room
|
||
shape the ticket names, arrived at from the rendering side rather than the
|
||
resolution side.
|
||
|
||
The value is read with `improvise_protocol::read_buffer` rather than
|
||
`buffers.get`, so the `"search"` special case (gotcha 11) keeps its one home.
|
||
|
||
**The injection**: `.filter(|_| state.view.mode.is_editing())` on the new
|
||
field, which is the pre-fix behaviour exactly. It fails naming `"formula"`.
|
||
|
||
**And the static check was not enough, which is the part worth keeping.**
|
||
`web/tests/render_surface.test.js` asserts that every `pub` field of
|
||
`RenderModel` appears as `model.<field>` somewhere in `app.js`. It passed while
|
||
the page was still broken: the string `model.minibuffer` was inside
|
||
`paintMinibuffer`, and `paintMinibuffer` was never called and `el.minibuffer`
|
||
was never looked up. A field can be *mentioned* by a function nothing invokes.
|
||
`harness/tests/keys.spec.js` caught it on the first run, in the browser, which
|
||
is the whole argument for that directory existing (gotcha 48).
|
||
|
||
### nothing_declined_is_also_composed
|
||
|
||
`step` is `declined` then `composed`, and until this test the claim that the two
|
||
lists cannot overlap was a **doc comment**. The dangerous direction is not a
|
||
typo: it is a future ticket implementing `view-back` (or `toggle-prune-empty`,
|
||
which is one projection field away) in `composed` and not thinking to delete its
|
||
`declined` arm. The browser then goes on saying "not available in the browser
|
||
yet" while the working code sits unreachable, and nothing notices — the classic
|
||
shape of a check passing while the thing it names is broken. Raised on review of
|
||
PR #71.
|
||
|
||
It is **derived from `declined` itself**, not from a list beside it: it walks
|
||
every `(name, args)` bound in every mode's keymap — parents and prefix
|
||
sub-keymaps included, sequence steps included — and for each one `declined`
|
||
answers, asserts `composed` does not. A seventh decline is covered without
|
||
coming here, which is the property a hand-written list could not have.
|
||
|
||
**The injection**: add `"view-back" => Some(vec![Command::SwitchView(..)])` to
|
||
`composed`. It fails naming `NORMAL: view-back []` and `RECORDS: view-back []`.
|
||
|
||
It also found a real overlap on its first run. `enter-mode` is answered by
|
||
`declined` for the three stranding modes *and* by `composed` for every mode, so
|
||
`composed`'s arm would have opened `SavePrompt`, `ExportPrompt` or `CommandMode`
|
||
if `declined` were ever bypassed. Rather than moving the check, **both halves now
|
||
hold the rule** — `composed` returns `None` for a stranded mode — so the failure
|
||
direction if one is removed is the pre-PR `Upstream`, not a dead room.
|
||
---
|
||
|
||
## `harness/tests/help.spec.js` — the overlay, asserted as words
|
||
|
||
PR #61 deleted `"? opens help, and its rows are the shared ones"` and nothing
|
||
replaced it, so `paintHelp`, `help`, `help_page` and `help_titles` reached no
|
||
assertion at all (improvise-78v6). What `contract.spec.js` carried in its place
|
||
said only that a `dt` was VISIBLE — and an empty `dt` is a visible `dt`
|
||
(improvise-igxp), which is the same shape as the caret bug above: a row is a
|
||
frame, and a frame is not a word.
|
||
|
||
**The injection both tickets name**: blank `keys.textContent` in `paintHelp`,
|
||
patched in `harness/serve/app/app.js`. Before this work it left all three
|
||
contract tests passing. It now fails both tests in this file (the Welcome
|
||
banner reads empty; the advertised key reads empty) **and** `contract.spec.js`,
|
||
whose two help lines were strengthened from `toBeVisible` to the banner's text
|
||
and the first `dt.help-keys`'s text.
|
||
|
||
**What the pairs helper is for.** The overlay is a `<dl>`, and the association
|
||
between a key row and its description is document order — `dt` then `dd`. Read
|
||
as two lists they zip wrong, because a prose row emits **no** `dd` at all
|
||
(deliberately: an empty one would leave a blank grid cell). Welcome is prose end
|
||
to end, so `dd` count zero there is an assertion about that rule and not a
|
||
coincidence.
|
||
|
||
#### "a key the overlay advertises does what the overlay says it does"
|
||
|
||
The assertions above are still about a string on a screen; this one asks whether
|
||
the string is **true**. The key for `Jump to last row` is read OUT OF THE DOM,
|
||
the overlay closed, the key pressed, and the selection asserted onto the last
|
||
row. A page drawing a plausible key it does not have — what a hand-spelled table
|
||
decays into — fails here, and the spec holds no second copy of the keymap.
|
||
|
||
It presses the FIRST key the row names because a row is a compaction of every
|
||
binding that reaches the command (`h j k l ← ↑ → ↓` is eight).
|
||
|
||
## `harness/tests/grouped.spec.js` — the first browser run of improvise-anr9
|
||
|
||
`web/demo.improv` has no groups and one category per visible axis, deliberately
|
||
(gotcha 46), so the multi-level header, the group span, the collapse indicator
|
||
and the inline group row — exactly the code improvise-anr9 added — had never
|
||
been rendered anywhere a test could see (improvise-plcx). The fixture is
|
||
`harness/fixtures/grouped.improv`, served beside the shipped demo by
|
||
`harness/build.sh`, and the shipped document is unchanged.
|
||
|
||
**A missing seed is not an error**: `web/worker.js` answers a fetch that 404s
|
||
with an empty model. So a typo in the filename yields a page that draws a grid,
|
||
and "a grid is drawn" would pass on it. Every assertion here is about labels the
|
||
fixture really carries.
|
||
|
||
**`openGrouped` waits with retrying assertions and the tests then read once.**
|
||
The head and the row labels are read in a single `evaluate` each, because what
|
||
these tests assert is the RELATION between the lines — and a one-shot read
|
||
cannot wait. The grid is painted from the first projection and reframed by the
|
||
viewport the page then announces, so the helper gates on the head's shape, its
|
||
first group and the first row's innermost label before anything is read.
|
||
|
||
**Five injections, five distinct failures**, patched in `harness/serve/` so no
|
||
rebuild stands between the fault and the run:
|
||
|
||
- `gridPlan` slash-joins the level tuples into one line (the exact defect anr9
|
||
fixed) — fails all three, because `openGrouped` waits on the head's shape
|
||
before any test reads it;
|
||
- `th.colSpan = 1` on every column group header — fails the span test alone;
|
||
- a stray `tr.insertCell()` on the inline group row — fails the group-row test
|
||
alone, which is the DOM half of `RenderRow::Group` carrying no cells;
|
||
- `indicator(false)` hard-coded — fails on `▶ Upland` while `▼ Inland` still
|
||
passes, which is why the fixture has one collapsed group and one open one;
|
||
- `collapsed: Region/Upland` removed from the served document — fails on the
|
||
rows that reappear, so the assertions see the collapse and not just the glyph.
|
||
|
||
**A bug this fixture surfaced rather than predicted.** `Coastal` is the row
|
||
frame's FIRST entry and its header is never drawn: the spec asserts **two**
|
||
group rows where the document has three, with improvise-0o6x named on the line.
|
||
That count is a record of an open bug, not an endorsement — when 0o6x lands it
|
||
becomes 3 and this line is where the change must be argued.
|
||
|
||
## `harness/tests/landing.spec.js` — the front door
|
||
|
||
`docs/index.html` is copied to the site root by `web/assemble.sh`, so the
|
||
harness has been serving it all along with nothing asserted about it
|
||
(improvise-bgtf).
|
||
|
||
**Counting the four `.ap-player` elements proves nothing**, and that is the
|
||
whole reason this is a spec rather than a one-liner: `autoPlay` is false, so
|
||
asciinema-player fetches no cast until play is pressed. A page whose every cast
|
||
path 404s constructs four players, throws no page error, and looks identical to
|
||
a working one. Injected while the page was being written (`./casts/` ->
|
||
`./cats/`) the construction-only check passed on the broken page.
|
||
|
||
Re-injected into `harness/serve/index.html` now, the spec fails at the first
|
||
player: `.ap-line` elements exist only once there is a cast to lay out. The spec
|
||
also asserts, BEFORE pressing anything, that no `.cast` has been requested —
|
||
without which the network evidence below could be satisfied by a page that
|
||
fetched them eagerly, which is not the page we ship.
|
||
|
||
Two assertions where one looks sufficient: the rendering says a cast played, and
|
||
the recorded responses say **four distinct** casts answered 200. Three players
|
||
showing the same recording satisfies the first and fails the second.
|
||
|
||
`"the front door reaches the app it advertises"` follows the footer link rather
|
||
than asserting its text, because whether `./app/` resolves is a fact about the
|
||
assembled tree. Injecting `href="./aplication/"` fails it.
|
||
|
||
## `harness/tests/contract.spec.js` — the ninth injection, and a lost guard
|
||
|
||
**The lost guard.** improvise-78v6 asks for the test-name diff over PR #61's
|
||
range, and it finds a fourth loss beyond the help test: `"a click on an item row
|
||
filters, as Enter does"`. `contract.spec.js` asserts the CATEGORY arm of
|
||
`click_category_row` (the row expands) and nothing asserts the ITEM arm, whose
|
||
rule is "do what Enter does here" — filter. Only `#status` distinguishes them,
|
||
so a click wired to the wrong arm passed the whole file. Restored inside the
|
||
click test, and proved by injecting `click_category(row.is_category ? index :
|
||
0)`, which fails that line alone.
|
||
|
||
The diff that finds this is two commands, and is worth running on any PR whose
|
||
subject is a lost guard:
|
||
|
||
```sh
|
||
M=$(gh pr view 61 --json mergeCommit -q .mergeCommit.oid)
|
||
for r in "$M^1" "$M"; do
|
||
git ls-tree -r --name-only "$r" -- harness/tests web/tests |
|
||
while read -r f; do git show "$r:$f" | grep -oE 'test\("[^"]*"'; done
|
||
done
|
||
```
|
||
|
||
The other two losses are deliberate: `pointer.spec.js`'s sidebar test was
|
||
superseded by the open-at-launch ruling, and the widgets file was merged into
|
||
this one.
|
||
|
||
## `harness/tests/contract.spec.js` — the page bar, and the pointer's delegation
|
||
|
||
**"the page bar names the quarter, and both it and the totals follow `]`"**
|
||
(improvise-p7lh) is the end-to-end half the Rust tests deliberately do not
|
||
attempt. It runs against the shipped `web/demo.improv`, where `Quarter` really
|
||
is on `Axis::Page` with four items, and asserts the *arithmetic*: Q1's Revenue
|
||
total is `63,700` and Q2's is `66,400`, each a sum over all four products. That
|
||
is why the totals must be **projected** — the number is a fact about every row,
|
||
and a client that summed what it had been sent would be right only by accident
|
||
and wrong the moment a viewport scrolled.
|
||
|
||
Pressing `]` is what makes it a test of the pair rather than of two fields: a
|
||
bar that showed a stale quarter over fresh numbers is the precise lie the bead
|
||
names, and it passes any assertion that reads the two independently.
|
||
|
||
**The three delegation tests** (improvise-b8ot) each pin a property a naive
|
||
rewrite loses *silently* — the page still paints, and only the pointer stops
|
||
working, which no unit test and no render-model contract can see:
|
||
|
||
- **mousedown, not click.** Asserted by dispatching `mousedown` **alone**, with
|
||
no click behind it. A handler bound to `click` does nothing at all here. This
|
||
is improvise-0x9u's mechanism from the other side: from an editing state the
|
||
input's `blur` repaints first and the `<td>` is detached before `click` would
|
||
fire, so the event never arrives. `locator.click()` — which every other
|
||
pointer test uses — dispatches both and therefore cannot separate them.
|
||
- **preventDefault, and only over a cell.** Read off `window`, which is last in
|
||
the bubble path and so sees what `#grid`'s handler did. The second half is
|
||
what makes it discriminating: a header carried no listener before delegation,
|
||
so a blanket handler on the root that prevents *everything* is a behaviour
|
||
change, and asserting only the cell would pass for it.
|
||
- **The coordinates come off the node.** The test rewrites a `<td>`'s
|
||
`data-row` in the DOM and requires the click to land where the *attribute*
|
||
says. A per-node closure over `gridPlan`'s answer ignores the rewrite and
|
||
fails. This is the assertion that says the handler is a **lookup** rather than
|
||
a second derivation of which data row was clicked — the thing the bead is
|
||
actually about, and the thing a "does clicking still work" test cannot tell
|
||
apart from the old code.
|
||
|
||
**The fourth property — closing a live editor first — is not a new test.** It is
|
||
`"a click moves the selection from an editing state too"`, already in this file,
|
||
which clicks a second cell with no Escape between and asserts the first cell's
|
||
`<input>` is gone and the second's is open. Adding a fifth spec asserting the
|
||
same ordering would have been a second copy, not a second guard.
|
||
|
||
**And the repaint-skipping branch is asserted by survival**: after a mousedown
|
||
on an editable cell the `<input>` must still be there three hundred
|
||
milliseconds later, because a repaint would call `replaceChildren` and take it
|
||
away. That is the one of the four whose failure is *most* invisible — the editor
|
||
opens and vanishes within a frame, which reads as a click that did nothing.
|
||
|
||
### the_keys_that_compose_a_switch_view_are_these
|
||
|
||
improvise-lltt is deferred on a **premise**, not a judgement: its own note says it
|
||
is "unreachable today ONLY because no shipped client emits `Command::SwitchView`",
|
||
and asks for a guard asserting exactly that, so the silent failure becomes loud
|
||
the moment the premise stops holding.
|
||
|
||
The guard failed on its first run. Two live paths in this client's own resolver
|
||
(`resolve.rs`): `switch-view-at-cursor` and `create-and-switch-view`. The other
|
||
half was already live — the same resolver composes `ModelCommand::TransposeAxes`.
|
||
So the scenario lltt describes is reachable today from the shipped view panel, and
|
||
the ticket was escalated P2 → P1 rather than fixed here, the honest fix being the
|
||
protocol change it describes.
|
||
|
||
**It is an `assert_eq` against those two names, not an assert-empty, and the list
|
||
is not an exemption list.** It pins what is true so a *third* live path fails, and
|
||
its message says so — because a reader who found this passing would otherwise
|
||
conclude the premise holds. When lltt is fixed the list empties and this test goes
|
||
with it.
|
||
|
||
Why the enumeration and not a read: the bug was already written into a passing
|
||
test's comment elsewhere (`"Aimed at the host's own view… HideItem writes
|
||
view_mut()"`) and had been read as a fixture note for as long as it had been there.
|
||
#### "a click moves the selection from an editing state too"
|
||
|
||
improvise-0x9u, and the fault was injected by running this assertion against the
|
||
bundle as it stood: `cells.nth(4).locator("input")` came back **0**, with the
|
||
selection still on cell 0, exactly as the bug report measured in Chromium.
|
||
|
||
Two things about it are worth more than the assertion. First, **what it does not
|
||
do**: it never presses Escape between the two clicks. The sibling test does, and
|
||
that single line is why the property it names ("a click moves the selection") was
|
||
asserted for a whole wave while the neighbouring state was broken — the click
|
||
path from an *editing* page was never exercised.
|
||
|
||
Second, **the fix's own first attempt failed this same test**, which is the part
|
||
to remember. Moving the handler to `mousedown` is necessary and not sufficient:
|
||
`close` still repainted, and removing a focused `<input>` fires `blur`
|
||
**synchronously**, so `close` rebuilt the table from inside itself and detached
|
||
the `<td>` the handler was about to open an editor on. The assertion could not
|
||
tell that apart from the original bug — same symptom, different cause — so it is
|
||
the debug run rather than the test that separated them. `close` therefore no
|
||
longer repaints, and the `blur` arm checks `closed` before dismissing.
|
||
|
||
Third — and this is the edit the repair nearly shipped unguarded — **the fix is
|
||
three edits, not two**, and the third is the one with the most careful comment:
|
||
`close` restores `td.textContent` (the *drawn* value) instead of `seed` (the
|
||
*stored* one), because `close` no longer repaints and `repaint()` early-returns
|
||
for as long as `editing` is set. Reverting that one line to
|
||
`td.replaceChildren(original)` left the whole suite green, and the defect was on
|
||
the front page: `web/demo.improv` carries `format: ,.0` over `18400`, and this
|
||
test's own two clicks — no Escape between them — leave cell 0 reading `18400`
|
||
where the page had drawn `18,400`, for as long as the second editor stays open.
|
||
The assertion added for it is `toHaveText("18,400")` after the second click, and
|
||
the injection is exactly that revert: it fails with `unexpected value "18400"`,
|
||
naming this test and no other.
|
||
|
||
The pattern is worth carrying past this entry. A repair of N edits whose N−1
|
||
guarded ones pass is not a guarded repair, and the unguarded edit is reliably
|
||
the one the author understood best — which is why it carries the clearest
|
||
comment and why it felt covered. For each distinct edit, name the test that
|
||
fails when only that edit is reverted; a line with a comment and no answer to
|
||
that question is the line to inject.
|
||
|
||
#### "the widgets the demo document fills are drawn, in the state that fills them"
|
||
|
||
The `#doc-name` row is improvise-5jna, and the reason it belongs in a **browser**
|
||
test rather than in `render_surface.test.js` is that the latter is a substring
|
||
search over the page's source: `doc_name` mentioned by a function nothing calls
|
||
reads as painted. That is how `model.minibuffer` once shipped dead, and how
|
||
z754's three title-bar fields sat projected and unpainted for a wave while the
|
||
static check was green. Proved by adding the assertion first: `element(s) not
|
||
found` for `#doc-name`.
|
||
|
||
The `[+]` marker is deliberately **not** asserted here. The standalone deployment
|
||
persists on every accepted batch and `effect::Save` clears the flag in the same
|
||
round trip (gotcha 45), so the marker is transient in a browser and any timing
|
||
assertion over it would be flaky rather than discriminating. Its spelling — the
|
||
order `name (stored_as) [+]`, the `null` `stored_as` writing no empty parens — is
|
||
`web/tests/shell.test.js`, over `documentTitle` directly.
|
||
|
||
#### "a selection driven below the fold scrolls itself into view"
|
||
|
||
improvise-jq8l, and the expensive half was the **fixture**, not the assertion.
|
||
Neither shipped document can reach the state: `web/demo.improv` is four rows and
|
||
`harness/fixtures/grouped.improv` is eight, and a probe measured both at
|
||
`scrollHeight === clientHeight` in a 484px box — nothing to scroll, so the
|
||
assertion would have passed against a page that never scrolls. That is the
|
||
vacuity this entry exists to record, and it is why the spec asserts
|
||
`scrollHeight - clientHeight > 0` **before** it presses a key.
|
||
|
||
`harness/fixtures/tall.improv` is thirty rows in three groups, which overflows for
|
||
a reason worth stating: `ROW_PX` is 26 and a real row measures 28.6px, and inline
|
||
group rows are not charged for at all, so the page renders more rows than its own
|
||
box can show. Measured against the unfixed page: 105px of overflow, the selected
|
||
cell 96px below the visible bottom, `scrollTop` still 0 after twenty-five
|
||
`ArrowDown`s.
|
||
|
||
Both directions are asserted, and neither alone is enough: the cell being inside
|
||
`#grid`'s rect would also hold if the selection had never moved, and `scrollTop >
|
||
0` would also hold if the page scrolled to the wrong place.
|
||
|
||
#### "the casts are legible on a phone, and the page still does not scroll sideways"
|
||
|
||
improvise-o31u, and **the instrument is the finding**. `getBoundingClientRect`
|
||
reports a height for a `content-visibility`-hidden child, and heading text
|
||
overflows its box without widening it, so neither "the element is present" nor a
|
||
box measurement can see this defect at all. What a reader sees is the **computed
|
||
font size**, and that is what is asserted.
|
||
|
||
Measured against the unfixed page, and it reproduces the ticket's figure to five
|
||
digits: `pivot.cast` renders its terminal at **4.83258px** on a 390px viewport.
|
||
The arithmetic is not subtle — the player is told `cols: 120`, the container is
|
||
~356px inside the page's 16px gutters, and the player DERIVES its font size from
|
||
the width it is given. After the fix: **11.9457px**.
|
||
|
||
Two assertions guard the trade this fix must not make. The page's own
|
||
`scrollWidth - clientWidth` must stay **0** — a document that scrolls sideways
|
||
on a phone is a worse bug than one unreadable section — and `#cast-pivot` must
|
||
be the thing that scrolls, which is what separates "contained the overflow" from
|
||
"removed the overflow by shrinking something else".
|
||
|
||
#### "a desktop viewport is unchanged by the phone fix"
|
||
|
||
The negative arm, and **it is forward-looking: no fault available today makes it
|
||
fail.** The entry previously named two mechanisms and both were measured, at a
|
||
1280px viewport, against the shipped page:
|
||
|
||
- `@media (max-width: 900px)` → `@media all`: this test **passes** (13.1131px,
|
||
`scrollWidth === clientWidth`).
|
||
- `min-width: 880px` moved from `.player > *` onto `.player`: this test
|
||
**passes**; the *phone* arm fails instead, on `scrolls.page === 0`.
|
||
|
||
The arithmetic is why, and it is worth stating rather than re-deriving: at
|
||
1280px `#cast-pivot` measures `clientWidth === scrollWidth === 966`, so an 880px
|
||
floor is inert at desktop wherever it is placed. The floor would have to exceed
|
||
the ~966px the player actually gets — or the content column would have to
|
||
narrow — before either assertion here could move. That is a real future fault
|
||
and the reason to keep the test: it pins 13.11px and "nothing to scroll" against
|
||
a floor raised past the player's own width. It is not a reason to claim the arm
|
||
currently discriminates between the two placements, because it does not.
|
||
|
||
Note also that the **phone** arm is stronger than this entry used to credit it
|
||
with: the misplaced-`min-width` fault, attributed here to the desktop arm, is
|
||
caught there. `scrolls.page === 0` is the assertion that separates "contained
|
||
the overflow" from "moved it onto the document".
|
||
|
||
#### "no cast carries the recording machine's shell, clock or geometry"
|
||
|
||
improvise-2oxt, and **both clauses were injected separately**, which matters
|
||
because the first would otherwise mask the second. Serving the pre-2oxt
|
||
`pivot.cast` fails on `env` — `Received: {"SHELL": "/bin/zsh", "TERM":
|
||
"screen-256color"}` — and stops there. Stripping `env` and `timestamp` from that
|
||
same header and serving the body unchanged then fails on `pivot.cast sets a
|
||
window title`, which is the clause that actually watches for the leak: an OSC
|
||
window-title sequence, emitted by a shell once per prompt and carrying the cwd,
|
||
and emitted by `improvise` never.
|
||
|
||
Asserted over the cast **file** rather than over the rendering, and that is the
|
||
finding. A player draws a shell prompt as ordinary terminal text, so a
|
||
screenshot or a `toContainText` assertion would have to know what the hostname
|
||
and home directory had been — a check that can only be written by someone who
|
||
has already seen the leak. The file-level signature can be written by anyone.
|
||
|
||
What no assertion here reaches, and how it was checked instead: whether each
|
||
cast SHOWS what its section claims. That is not a property of the bytes. It was
|
||
verified by replaying each recording through a small ANSI reconstruction and
|
||
reading the frames as text, which found two scenarios that recorded cleanly and
|
||
demonstrated nothing — a drill into a cell with no records behind it, and a `c`
|
||
pressed on a category already on the column axis. Both are true frames about the
|
||
wrong thing, which is the failure mode this repo keeps meeting, and neither was
|
||
visible from the keystroke list.
|
||
|
||
---
|
||
|
||
## `improvise-ws-server` — the `Origin` check (improvise-kc7n)
|
||
|
||
The bug: `grep -rni origin crates/improvise-ws-server/src/` returned nothing.
|
||
The server inspected no `Origin` header and accepted every handshake, and a
|
||
websocket handshake is exempt from the same-origin policy — no CORS preflight —
|
||
so any page a user visited could open a socket from that user's own allowed
|
||
address and read and edit every document served. An address check cannot see
|
||
it; the argument is `context/code/clients.md` §Origin.
|
||
|
||
Five edits, five guards. Each was reverted alone and the named test failed;
|
||
nothing here is covered only by a comment. Review added a sixth — see
|
||
`two_origins_state_none_this_policy_can_answer_for`.
|
||
|
||
### a_handshake_from_a_foreign_origin_is_refused
|
||
|
||
The reproduction, written first and watched fail on `2afbf37`: a handshake
|
||
carrying `Origin: https://evil.example` connected, and the test's
|
||
`connect_async(...).err()` was `None`. After the fix the upgrade is refused with
|
||
a 403 and the assertion is on the status, not merely on "an error happened" —
|
||
a refused upgrade and a dropped connection are different outcomes and only one
|
||
of them is the fix.
|
||
|
||
**Injection:** replacing the handshake callback's `admitted(&origins, req)` with
|
||
`Ok(())` — the transport collecting a policy it never asks — fails this and
|
||
`a_named_origin_reaches_the_document`, and nothing else in the workspace.
|
||
|
||
### a_handshake_with_no_origin_is_accepted
|
||
|
||
The case most likely to be wrong later, so it is named rather than left to a
|
||
match arm. Non-browser clients send no `Origin`: curl, a native client, this
|
||
file's own other tests. **Injection:** flipping `OriginPolicy::permits`'s `None`
|
||
arm to `false` fails this, `an_absent_origin_is_allowed`,
|
||
`the_handshake_refuses_what_the_policy_refuses`, and *both pre-existing socket
|
||
tests* — which is the point: the repo's own instrument is the non-browser client
|
||
this arm is about.
|
||
|
||
### a_handshake_from_a_loopback_page_is_accepted
|
||
|
||
The shipped local recipe — the page served by `python3 -m http.server 8080`,
|
||
the socket on another loopback port — must keep working with no flag, or the
|
||
default is a different bug from the one being fixed. **Injection:** making
|
||
`is_loopback` return `false` fails this and
|
||
`a_loopback_page_is_allowed_by_default` and leaves the refusal tests green,
|
||
which separates "allows loopback" from "allows everything".
|
||
|
||
### a_named_origin_reaches_the_document
|
||
|
||
`--allow-origin` end to end over a real socket, plus the negative arm in the
|
||
same test: widening for one page does not widen for its neighbours. Without
|
||
that second assertion the test passes against a policy that permits everything,
|
||
which is the fault E1a injects.
|
||
|
||
### allow_origin_widens_the_policy_and_nothing_else_does
|
||
|
||
The wiring, which no socket test can reach: the other tests build an
|
||
`OriginPolicy` directly, so a flag collected by clap and dropped on the floor is
|
||
invisible to every one of them. This drives `Args::parse_from` and asserts both
|
||
directions — bare refuses the deployed https origin and still allows loopback;
|
||
widened allows the named origin and not its neighbours. **Injection:** making
|
||
`Args::origin_policy` return `OriginPolicy::default()` fails this test alone.
|
||
|
||
### two_origins_state_none_this_policy_can_answer_for
|
||
|
||
Found by review rather than by the ticket. `HeaderMap::get` is **first-wins**,
|
||
and the deployment this README recommends puts a reverse proxy in front of the
|
||
socket — a proxy that *appends* `Origin` rather than setting it leaves two, and
|
||
reading the first lets the second be anything at all. No browser sends two, so
|
||
two is not a state any policy can answer for and the handshake is refused.
|
||
**Injection:** restoring the first-wins read (`(Some(value), Some(_))` answering
|
||
from `value`) fails this test alone, across the whole workspace. The test also
|
||
asserts the allowed origin *by itself* is still allowed, so the refusal is
|
||
demonstrably the second header rather than the first.
|
||
|
||
### a_refusal_is_a_forbidden_response
|
||
|
||
The refusal has to be a refused *upgrade*, and the socket test asserts the
|
||
`Error::Http` **kind** and the status rather than matching "403" in a
|
||
`Display` string: a dropped connection and a refused handshake are different
|
||
outcomes and only one of them is this fix. **Injection:** changing `forbidden()`
|
||
to `UNAUTHORIZED` fails this and `a_handshake_from_a_foreign_origin_is_refused`,
|
||
which is what shows the socket test reads the status rather than merely
|
||
observing that connecting failed.
|
||
|
||
What the signature covers instead of a test: `transport::serve` **takes** the
|
||
policy, so a host cannot forget to pass one — `doctrine(an_invariant_is_made_
|
||
unrepresentable_not_validated)`. What remains uncovered and is stated rather
|
||
than guarded: a proxy that strips `Origin` turns the check off, which is why the
|
||
proxy's own Origin allow-list stays in `README.md` as defence in depth.
|
||
|
||
|
||
### the_fixtures_basis_tracks_a_host_whose_layout_has_moved
|
||
|
||
improvise-1l9o. `furnished_client` and `client_ready_to_edit` built their
|
||
`BasisCache` by walking the client's own grid for `Record` cells and calling
|
||
`basis::published(0, 0, records)` with every version hard-coded to zero — a
|
||
small re-implementation of `project.rs::basis_update`'s record selection, in a
|
||
file whose entire purpose is to rule out re-implementations. Both now take the
|
||
`Projection::Basis` out of a real `Sessions::connect` snapshot.
|
||
|
||
The reason the ticket calls it a nit is the reason it needed a test: a fresh
|
||
`App` really is at generation 0, layout 0 and every record at version 0, so the
|
||
fabrication and the publication are **indistinguishable** on every existing
|
||
fixture. All 23 lockstep tests pass under either. So the change alone is
|
||
structural and untested by construction, and this test is what makes it
|
||
observable: it moves a tier first — a `HideItem` through `apply_on`, which
|
||
`observe_reshape` sees as a shape change — and then asserts the fixture's claim
|
||
names the host's current tiers.
|
||
|
||
Note `SetNumberFormat` does **not** work here and was the first attempt.
|
||
It declares `EffectKind::Model(ModelChange::LayoutGeometry)`, but
|
||
`layout_version` is **observed** from `view_shapes` rather than read off the
|
||
declaration (improvise-pdvm), and a number format is not part of a view's shape
|
||
hash. A command being classified `LayoutGeometry` is therefore not a promise
|
||
that it moves `layout_version` — which is the observed-not-declared design
|
||
working as intended, and easy to misread as a bug.
|
||
|
||
Fault injected 2026-09-21: re-fabricating the basis inside `published_basis`
|
||
(`published(0, 0, real_records)` — keeping the real record set, so only the two
|
||
document tiers are forged) fails this test with `left: (0, 0)` against
|
||
`right: (0, 1)`, and fails nothing else in the workspace.
|
||
|
||
**One residual, found in self-review and not closed.** `published_basis` asks
|
||
`Sessions::connect` for a window of `window.0 x window.1`, and a fresh session
|
||
sits at offsets zero; `client_of` builds its grid at the **cursor's** offsets.
|
||
Every fixture here has its cursor at the origin, so the two windows coincide and
|
||
the published record set is the one the client's grid names. A future fixture
|
||
that scrolls before building its client would get a basis naming records its grid
|
||
does not show — which `claim` would then silently omit, and
|
||
`Rejection::RecordUnstated` is what the server answers with. The fixtures no
|
||
longer fabricate, which was the ticket; they do still assume a cursor at the
|
||
origin.
|
||
|
||
### leaving_a_panel_in_records_mode_lands_where_the_host_lands
|
||
|
||
improvise-e5pf. `AppMode::Normal` is settled by the host against the layout the
|
||
batch ends on; the wire carries `Command::ChangeMode(AppMode::Normal)` and
|
||
`reduce_view` folds it verbatim. So a client that opened a panel from records
|
||
mode and pressed Esc landed in `Normal` while its host landed in
|
||
`RecordsNormal` — and `RecordsNormal` has its own keymap, so the divergence
|
||
compounds on the next keypress.
|
||
|
||
The reachable path, which the ticket did not name: `Tab` in `RecordsNormal`
|
||
resolves locally to `ChangeMode(CategoryPanel)`, and `Esc` there resolves to
|
||
`ChangeMode(Normal)`. `c`/`f`/`v` are `Ignored` in `RecordsNormal` and `R`
|
||
itself is a `stranding` decline, so the panel keys a first draft of this test
|
||
used never separated anything — the test panicked on "the client must answer
|
||
this key itself" rather than on the drift.
|
||
|
||
**Why lockstep could not see it before.** Its records coverage (`typing_at`,
|
||
`records_pair`) set `host.view_state.mode` by assignment and seeded the client
|
||
with `client.view = app.view_state.clone()`, so it started where the mode and
|
||
the layout already agree, and every divergence here requires them to disagree —
|
||
`doctrine(a_guard_seeded_on_agreement_cannot_detect_drift)`. `in_lockstep`
|
||
drives the key on both ends and then refreshes the client's grid from a **real
|
||
projection**, never from the host's slice, which is the only seeding that can
|
||
show the two apart.
|
||
|
||
**Fault injected**: `resolve::changed_mode` reduced to
|
||
`Command::ChangeMode(mode)`. The test fails on `close: the client and the host
|
||
disagree about the mode`, `Normal` against `RecordsNormal`.
|
||
|
||
Two existing `resolve.rs` fixtures had the same shape and had to be corrected
|
||
with the fix: `furnished` and `a_records_mode_commit_is_the_hosts` put the
|
||
client in a records *mode* over a non-records *grid*, which is a state no host
|
||
can produce. Both now project `GridUpdate::Records` as well. That they had to
|
||
change is the evidence that the substitution was load-bearing rather than
|
||
incidental.
|
||
|
||
### a_records_commit_is_declined_off_the_grid_and_not_off_the_mode
|
||
|
||
improvise-e5pf's damage half. `resolve::commit` is written to **decline** a
|
||
records commit it cannot compose (it needs `record_col`, gotcha 1). It asked
|
||
`state.view.mode.is_records()`; with the mode drifted to plain `Editing` over a
|
||
records grid it did not decline, and composed `typed_cell_command` plus a
|
||
`Basis` claim against a grid whose columns are `[coords…, +cat, measures…, +]`.
|
||
A wrong write, strictly worse than the black hole it exists to produce.
|
||
|
||
The test states the drift rather than reaching it — reaching it takes the key
|
||
sequence the test above drives — and asserts no `Command::Model` is composed.
|
||
|
||
**The control is the point.** A first version of this test passed against the
|
||
*broken* code, because `client_of` publishes no basis and `BasisCache::claim`
|
||
returns `None` until one arrives, so `commit` returned `None` for a reason that
|
||
had nothing to do with records mode:
|
||
`doctrine(an_injection_that_does_not_fail_names_an_untested_path)`, caught by
|
||
injecting the old spelling and watching the test still pass. `records_client`
|
||
now publishes a basis naming every record the grid projects, and the test
|
||
carries a positive control — the same `Editing` mode over a **regular** grid
|
||
must still compose a write — so "this client never writes" cannot pass it.
|
||
|
||
**Fault injected**: the decline restored to `state.view.mode.is_records()`. The
|
||
test fails on `a records commit this client cannot compose was composed anyway`.
|
||
|
||
### the_normal_pair_is_settled_in_both_directions
|
||
|
||
`App::settle_normal_mode` settles **both** `Normal` and `RecordsNormal` against
|
||
the layout, so a `RecordsNormal` over an ordinary grid becomes `Normal`. The
|
||
first version of the client's settling mapped only `Normal → RecordsNormal`,
|
||
which left the mirror-image drift live. Found in review of this branch.
|
||
|
||
The test now aims at `improvise_protocol::settled_mode`, which is the **one**
|
||
rule all three ends call — `ClientState::fold`, `ClientSession::fold` and
|
||
`App::settle_normal_mode`.
|
||
|
||
**Fault injected**: the `(Normal | RecordsNormal, false)` arm narrowed to
|
||
`(Normal, false)`. The case `records=false, holding RecordsNormal` fails. The
|
||
fourth row and the pass-through cases are the negative arms — without them a
|
||
rule that answered `RecordsNormal` always, or `Normal` always, would pass, and
|
||
the pass-through loop is what pins that `Editing`/`RecordsEditing` are **not**
|
||
settled (the reason a records decision asks the grid and never the mode).
|
||
|
||
### switching_between_a_records_view_and_a_pivot_view_settles_both_ways
|
||
|
||
**The P1 ChatGPT Codex found on PR #98, and the more useful half is why the
|
||
first fix missed it.** That fix settled the mode at each *composition site*
|
||
(`resolve::changed_mode`), against the grid the client held when it resolved the
|
||
key. `switch_view_at` composes `[SwitchView(target), ChangeMode(Normal)]`, so it
|
||
settled against the **outgoing** view: records → pivot emitted `RecordsNormal`
|
||
and pivot → records emitted `Normal`, recreating the very drift the PR closed.
|
||
|
||
The hand-back had *predicted* this as follow-up 3 and concluded it was
|
||
unreachable **because `R` is a `stranding` decline in the browser**. That
|
||
reasoning is right about the toggle route and wrong about this one: records-ness
|
||
is auto-detected from the axes (gotcha 1), so a **view switch** changes it with
|
||
nothing in the batch saying "records". One door was enumerated and there were
|
||
two — `doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)`.
|
||
Codex had flagged the same path on PR #91, where the host side was fixed and the
|
||
client side was not, so it was tracking one defect across three PRs.
|
||
|
||
The remedy is general rather than a patch to `switch_view_at`: `changed_mode` is
|
||
deleted, every site emits the plain `AppMode::Normal`, and `ClientState::fold`
|
||
settles after **every** fold against the grid it holds. Route-independent, so no
|
||
enumeration of composition sites is load-bearing any more.
|
||
|
||
**The fixture drives the real path**: a `Host` in records mode, a session
|
||
connected (which opens the document's *default* view, gotcha 29 — the first
|
||
draft asserted the opposite and failed its own precondition), the composed pair
|
||
folded locally and sent as a `ClientFrame::Commands`, and every reply frame
|
||
delivered in arrival order. `deliver` takes the whole reply rather than a
|
||
hand-picked projection.
|
||
|
||
**The composed `ChangeMode` carries the STALE answer deliberately** —
|
||
`settled_mode(&Normal, client.grid.records)` off the outgoing grid — because
|
||
that is what `switch_view_at` really emitted, and composing the friendly value
|
||
instead would make the records → pivot direction pass under the fault.
|
||
|
||
**Fault injected**: `ClientState::settle_mode` reduced to a no-op. Both
|
||
directions fail, each proved separately because the loop stops at the first:
|
||
|
||
- pivot → records: `_Records: the client settled against the view it LEFT`,
|
||
`Normal` against `RecordsNormal`.
|
||
- records → pivot, reached by relaxing the first iteration's expectation under
|
||
the same injection: `Default: the client settled against the view it LEFT`,
|
||
`RecordsNormal` against `Normal`.
|
||
|
||
Records-ness and the mode are asserted as **two separate facts** rather than one
|
||
derived from the other — a first version computed the records precondition from
|
||
the mode expectation, so relaxing the mode silently relaxed the precondition too
|
||
and the test failed for the wrong reason.
|
||
|
||
**It has a host-side twin, and the two are a pair rather than a duplicate.**
|
||
improvise-4dpj landed `switching_views_across_records_ness_settles_against_the_view_switched_to`
|
||
in `ui/app.rs` while this was being written: the same defect, the same batch
|
||
(`[SwitchView(to), ChangeMode(Normal)]`), the same crossing in both directions,
|
||
and independently the same phrase in its message — "settled against the view it
|
||
LEFT". One is the host settling after the batch against the layout it ends on;
|
||
this one is a client settling after the fold against the grid it holds. Neither
|
||
is redundant: nothing in the projection stream carries the host's `ViewState`
|
||
(gotcha 47), so a host-side guard cannot see a client's mode and the reverse.
|
||
Delete either and that side goes unwatched.
|
||
|
||
### the_mode_a_switch_lands_in_is_the_one_a_host_on_that_view_holds
|
||
|
||
The lockstep half of the entry above: it asserts the host itself answers
|
||
**differently** per view (`Normal` on the pivot, `RecordsNormal` on the records
|
||
view). Without it, "the client settles to X" is a claim about the client alone —
|
||
a host that answered `Normal` everywhere would make the switch test's
|
||
expectations arbitrary rather than lockstep.
|
||
|
||
## The browser boot diagnosis channel (improvise-1wft and the five it blocked)
|
||
|
||
Five beads filed by three reviewers were one failure: **nothing carried a boot
|
||
failure's REASON from the worker to the page.** `web/worker.js` caught its own
|
||
`start()` rejection and logged it, a worker that rejects in `start()` fires no
|
||
`error` on the main thread (gotcha 46), and `onFrame`'s `if (!server) return`
|
||
dropped every frame after that — so the only survivor was a generic 8s
|
||
watchdog, which improvise-vep8 showed did not even arm on one path.
|
||
|
||
The channel is one rule: **a protocol frame crosses the port as a STRING and a
|
||
shell notice crosses as an OBJECT.** Nothing else crosses, so the discriminator
|
||
is total and costs no protocol change — `ServerFrame` JSON is a string by
|
||
construction of `WorkerServer::on_message`. `shell.js::classifyWorkerMessage`
|
||
is that rule and `web/tests/shell.test.js` pins it, including the case that
|
||
would break it: a notice *serialized* would classify as a frame and be fed to
|
||
the fold.
|
||
|
||
**A boot failure is a STATE, not a rejected promise.** That is what improvise-i0v9
|
||
needs: the page must open and paint read-only when another context holds the
|
||
document's exclusive handle. So `bindings.rs::boot` matches on `attach`'s
|
||
failure rather than propagating it — a locked document falls through to
|
||
`read_only`, which reads the bytes the non-exclusive way and opens over a
|
||
backend that refuses every write. `BootFailed` is the only fatal kind;
|
||
`ReadOnly`, `SeedMissing` and `SaveFailed` are all open, painting sessions,
|
||
which is what `shell.js::isFatal` says and what its test asserts in both
|
||
directions.
|
||
|
||
### The `getFile()` probe (`harness/`, not a cargo test)
|
||
|
||
**The measurement the read-only path rests on, taken before it was promised.**
|
||
`Storage::read` goes through the exclusive sync access handle, so the read-only
|
||
session needed a path that does not: `FileSystemFileHandle.getFile()` yields a
|
||
`File` snapshot, which is a `Blob`, which reads without a handle. Whether that
|
||
works *while another context holds the handle* was unprobed, and the whole
|
||
design turns on it, so it was measured rather than assumed.
|
||
|
||
Measured 2026-09-21, `harness/tests/opfs.spec.js`, both engines that have OPFS:
|
||
|
||
| engine | `getFile()` + `arrayBuffer()` under a foreign sync handle | the refusal that proves the lock |
|
||
|---|---|---|
|
||
| Chromium | **32 bytes read, correct** | `NoModificationAllowedError: Failed to execute 'createSyncAccessHandle' on 'FileSystemFileHandle': Access Handles cannot be created if there is another open Access Handle or Writable stream associated with the same file.` |
|
||
| Firefox | **32 bytes read, correct** | `NoModificationAllowedError: No modification allowed` |
|
||
| WebKit | not measured — Playwright's WebKit provides no OPFS at all | — |
|
||
|
||
**The precondition is the half that makes it evidence.** Without it the probe
|
||
passes vacuously in any engine that does not actually enforce exclusivity: the
|
||
read would succeed because there is no lock, not because `getFile()` bypasses
|
||
one. So the probe first tries to take a *second* `createSyncAccessHandle` and
|
||
`ensure!`s it is **refused**, and reports the refusal text alongside the byte
|
||
count. Both halves are in the attached `opfs-probes.json`.
|
||
|
||
This is filed as a probe rather than a contract check on purpose: it records
|
||
what the engines do, so a decision taken on it can be re-checked rather than
|
||
re-argued. It runs per engine, so a regression in either is a failure.
|
||
|
||
### the_exclusive_handle_refusal_is_recognised_in_both_engines
|
||
|
||
`boot::is_locked` decides whether an `attach` failure means "somebody else has
|
||
it" (open read-only) or "something else is wrong" (fail). It matches on
|
||
`NoModificationAllowedError`, which is a **string match on a DOMException
|
||
name**, and the two engines word the rest of the message completely
|
||
differently — so the fixtures are the strings the harness probe above actually
|
||
produced, not invented ones. That is the only reason the match is trustworthy.
|
||
|
||
The discriminating half is the negative: a `SecurityError` from `getDirectory()`
|
||
must **not** classify as a lock, or every boot failure would offer a read-only
|
||
session over a document it never read. An empty rendering is not a lock either.
|
||
|
||
### a_read_only_backend_reads_the_document_and_refuses_every_write
|
||
|
||
`vfs::ReadOnly` is what makes improvise-i0v9's "open and paint" true rather than
|
||
aspirational. The test asserts both directions, which matters because a wrapper
|
||
that forwarded everything would pass a reads-only assertion: the document opens
|
||
and reads back, `write` and `delete` are both refused, the refusal **carries the
|
||
reason** rather than a bare error, and the refused writes left the readable
|
||
bytes intact.
|
||
|
||
`delete` is refused as well as `write`, which is easy to miss — it is a write.
|
||
|
||
### a_document_created_without_its_promised_seed_says_it_was_created
|
||
|
||
improvise-iobc: a bad or missing `?seed=` produced an empty document and a
|
||
normal-looking shell, with the 404 only in the console. The page cannot tell an
|
||
*intended* empty document from one that is empty because its starting copy
|
||
never arrived — and neither could anything else, because `seedBytes()` returned
|
||
`undefined` for both.
|
||
|
||
The fix is not in the page: `web/worker.js` now returns `{bytes}` or `{error}`
|
||
and hands **both** to Rust, and `vfs::open` reports an `Origin` — `Stored`,
|
||
`Seeded` or `Created`. Only `Created` **with** a seed error is a notice, so a
|
||
reload of an existing document says nothing, which is the common case. That
|
||
conjunction is the point: either fact alone over-reports.
|
||
|
||
The test's discriminating half is that the *same call* reports `Seeded` when a
|
||
seed is supplied, so `Created` is not a constant.
|
||
|
||
### a_name_that_is_not_one_flat_entry_is_refused_rather_than_truncated
|
||
|
||
improvise-9cma reported `?doc=` values like `` and `///` giving a blank UI and
|
||
generic watchdog text. Probing it turned up something worse than the reported
|
||
symptom: `OpfsFs::entry_name` takes `Path::file_name`, so **`?doc=a/b/c.improv`
|
||
was not rejected at all — it silently opened `c.improv`**, a different document
|
||
from the one the URL named. An accepted-and-wrong is worse than a refusal,
|
||
because the diagnosis looks identical and the repair is not.
|
||
|
||
`boot::document_name` answers before anything touches storage, so the reason is
|
||
specific and arrives early rather than as a late failure deep in the handle
|
||
acquisition.
|
||
|
||
### a_persist_that_fails_raises_a_notice_naming_the_document
|
||
|
||
improvise-zre1: the worker auto-persists on its own behalf, and a failed write
|
||
left its message in `view_state.status_msg` — which the worker has no UI for —
|
||
while `dirty` stayed true. The client saw `dirty: true` and could not tell
|
||
"save failed" from "save pending", which is a data-loss risk on page close if
|
||
the user trusts autosave.
|
||
|
||
**The trigger is an observation, not a prediction**, in the same shape gotcha 45
|
||
gives for the persist trigger itself: a save that worked clears the flag, so
|
||
*the flag surviving `persist()` is* the failure. No second table of which
|
||
effects can fail.
|
||
|
||
The fault is injected with `vfs::ReadOnly` — a real backend that reads and
|
||
refuses writes — rather than a mock, and the test asserts the precondition
|
||
(`dirty` really did survive) before believing the notice. The notice is
|
||
**drained**, not read, so one occurrence is reported once; asserting the second
|
||
`take_notice()` is `None` is what pins that.
|
||
|
||
Its companion `a_persist_that_succeeds_raises_no_notice` is not padding: without
|
||
it the first test passes against a worker that reports a failure on every single
|
||
persist.
|
||
|
||
### a connection that hangs forever is still caught by the watchdog
|
||
|
||
improvise-vep8: `shell.arm` ran **after** `await transport.ready`, so a socket
|
||
that never fires `open` never armed the watchdog at all — the page sat on
|
||
"connecting…" forever with no timeout. Arming now precedes the await.
|
||
|
||
Two consequences worth recording, because both were invisible until the move.
|
||
|
||
**The message had to change.** It said `accepted the connection but sent no
|
||
document`, which arming before `ready` makes a claim the page no longer knows
|
||
to be true. It now says `never answered`, which covers a peer that never opened
|
||
*and* one that opened and went quiet — the one condition the watchdog is really
|
||
for, and the reason gotcha 46 gives for it not being per-transport. The existing
|
||
spec asserted the old wording and failed, which is the right way round.
|
||
|
||
**Two comments in `shell.spec.js` stated the opposite of the new code** — both
|
||
said `shell.arm` runs only after `transport.ready` and that the watchdog was
|
||
"not on this path". Corrected in the same change rather than left, per gotcha
|
||
48's third clause: when a fact is stated in more than one place, changing it is
|
||
a grep and not an edit.
|
||
|
||
The disarm half of gotcha 48 is **not** re-broken, and this is the thing to
|
||
check first on any future change here: every notice goes through `shell.fail`,
|
||
which disarms, so a precise reason is never replaced 8s later. `a socket that
|
||
opened and then dropped reads as disconnected, and stays that way` still passes
|
||
and still runs past the window.
|
||
|
||
### an invalid ?doc= is named on screen, and not blamed on the browser
|
||
|
||
The boundary test for the channel: a `?doc=` Rust refuses produces a
|
||
`BootFailed` notice carrying the refusal text, the page renders it, and it
|
||
**stands past the watchdog window**. The negative assertion uses
|
||
`outlastTheWatchdog`, which asks `timesArmed() > 0` first — mandatory here,
|
||
since denying the watchdog's message on a path where it never armed denies
|
||
nothing.
|
||
|
||
### a seed that 404s says so rather than showing a silent empty grid
|
||
|
||
The other half, and it asserts the thing that separates it from a boot failure:
|
||
the page **still draws a grid**. An unfetchable seed is a degraded session, not
|
||
a dead one, so `#grid table` must be visible *and* `#status` must say `EMPTY`
|
||
and name the seed that failed.
|
||
|
||
### The silent-drop shape, grepped rather than taken one site at a time
|
||
|
||
`ociy`, `y01k` and `r4nf` are all "an error swallowed into a value that reads as
|
||
'nothing to do'", so the workspace was grepped for the shape rather than only
|
||
the sites the tickets named.
|
||
|
||
- **`serde_json::(to_string|from_str)…\.ok()`** across `crates/` and `src/`:
|
||
three instances, all in `improvise-wasm-client/src/lib.rs`, all named by
|
||
improvise-r4nf. **Zero remain** after the fix, which is the check that the
|
||
grep and the ticket agreed. They now return `Result<Option<String>, JsError>`
|
||
— the `Option` still means "nothing to send", and serialization failure
|
||
throws, so JS's `if (frame)` is unchanged at every call site.
|
||
- **Discarded results in the two hosts**: the remaining `let _ = …` in
|
||
`transport.rs` are channel sends whose receiver being gone *is* the normal
|
||
disconnect path, and are correctly ignored. The three improvise-y01k named
|
||
are now loud.
|
||
- **`postMessage` with no guard** in `web/app.js`'s worker transport: not a
|
||
second instance of improvise-ociy. `postMessage` on a live dedicated worker
|
||
does not throw and the page never calls `terminate()`; the analogous silent
|
||
drop is worker-side (`if (!server) return`), and that is improvise-86nw,
|
||
answered by the `BootFailed` notice rather than by a send guard.
|
||
|
||
One thing the send guard had to be split over: a `ResizeObserver` firing after a
|
||
drop would have overwritten the precise `disconnected` with `not sent`, breaking
|
||
the property gotcha 48 says keeps getting lost. **A viewport announcement is not
|
||
a user edit**, so it sends `{ loud: false }` and only user intent reports a
|
||
failed send.
|
||
|
||
### The render circuit breaker (improvise-7gf8)
|
||
|
||
`repaint()` returned early when `render_model()` threw, leaving `painted`
|
||
unchanged — so the `model.revision === painted` guard could never short-circuit
|
||
and every subsequent event retried the same broken render forever. It is now
|
||
tripped once: a render model that cannot be built is a permanent state, so the
|
||
breaker is a flag rather than a counter, and the message says to reload, which
|
||
is the only recovery the page has. It routes through `shell.fail`, so it also
|
||
disarms the watchdog rather than being replaced by it.
|
||
|
||
### `OpfsFs::open`'s scope guard (improvise-7gip)
|
||
|
||
`dyn_into::<WorkerGlobalScope>()` is satisfied by a `SharedWorkerGlobalScope`,
|
||
so a shared worker sailed past a check whose message named the right
|
||
requirement and failed later as a raw `TypeError` in the glue.
|
||
`DedicatedWorkerGlobalScope` is the type that actually means what the message
|
||
says.
|
||
|
||
**The fix needed a `web-sys` feature, which is why it had not been made
|
||
cheaply**: `DedicatedWorkerGlobalScope` is feature-gated and absent by default,
|
||
so the one-line change does not compile until
|
||
`crates/improvise-io/Cargo.toml` names it. That is the whole cost, and it is
|
||
worth stating because "just use the narrower type" reads as free.
|
||
|
||
`a_dedicated_worker_satisfies_the_narrow_scope_guard` is the guard that the fix
|
||
did not break the shipped path: in a real dedicated worker the narrow cast
|
||
succeeds. It is deliberately **not** a proof that a SharedWorker is rejected —
|
||
that needs a SharedWorker page, which this harness does not have, and
|
||
improvise-12ha owns the multi-tab question. Recorded as a gap rather than
|
||
implied.
|
||
|
||
### a second tab on the same document opens read-only and says why
|
||
|
||
improvise-i0v9's headline, end to end, in one browser context: two tabs on one
|
||
document, which is how it was reported. Measured before this change, the second
|
||
tab read `in-page worker · two-tab.improv` at 13ms — which looks like success —
|
||
then sat blank for eight seconds and was told the browser probably would not
|
||
allow it, while the code was holding the exact `NoModificationAllowedError`
|
||
naming the exact document.
|
||
|
||
Three things are asserted, and the first is the one the whole design is for:
|
||
**the second tab PAINTS.** `#grid table` is visible, because boot now succeeds
|
||
without a handle. It is not "dead but apologetic", it is a read-only session
|
||
over bytes read through `getFile()`.
|
||
|
||
The precondition is that tab one really is up and really is holding the handle,
|
||
so it waits for tab one's own grid first. Without that the second tab might be
|
||
passing because there was never a lock.
|
||
|
||
The third is the gotcha 48 property, and it needs `timesArmed() > 0` first or it
|
||
denies an unreachable event: the precise reason must still be on screen after
|
||
the 8s window, not replaced by the watchdog. `page.clock` walks past it.
|
||
|
||
It runs on Chromium and Firefox — the two engines with OPFS — rather than
|
||
Chromium alone, because the exclusivity semantics are the engine's and the two
|
||
word the refusal completely differently.
|
||
|
||
**What this does not cover, stated rather than implied.** The ticket's note
|
||
records a second trigger found independently: the same failure on a *reload*
|
||
after a prior session left a handle open, which is a race between context
|
||
teardown and handle release and cannot be driven deterministically from a spec.
|
||
And nothing here upgrades a read-only session to writable when the other tab
|
||
closes — the page has no retry, so "read-only until you reload" is still the
|
||
behaviour, now with an accurate message instead of a wrong one.
|
||
|
||
### an edit sent after the socket dropped says it was not sent
|
||
|
||
improvise-ociy, and **this test exists because the review of its own branch
|
||
found the fix had none** — `web/app.js`'s send guard was written, argued in a
|
||
comment, and never run. AGENTS.md is explicit that a fix needs a test that
|
||
failed before it.
|
||
|
||
Proven against the pre-fix silent drop (`if (readyState !== OPEN) return;`
|
||
alone): the status stays `disconnected` and the edit vanishes.
|
||
|
||
**The first version of this test was vacuous and passed for the wrong reason.**
|
||
It pressed `ArrowDown`, which on a client that never received a snapshot
|
||
composes *no frame at all* — `a_cursor_key_before_the_handshake_produces_no_message`
|
||
says so in `improvise-wasm-client` — so `transport.send` was never reached and
|
||
the guard under test never ran. It failed, which looked like the injection
|
||
working, and it would have kept failing for a reason that had nothing to do
|
||
with the guard. `Enter` is forwarded **upstream** and therefore composes a
|
||
frame regardless of client state, which is what makes the send actually happen.
|
||
|
||
That is `doctrine(an_injected_fault_must_be_the_fault_you_named)` seen from the
|
||
other side: the *test* must reach the path, not merely fail.
|
||
|
||
The precondition — the socket really opened and really dropped — is asserted
|
||
before the keypress, so this is a send on a CLOSED socket rather than on one
|
||
that never connected, which is a different arm with a different message.
|
||
|
||
And the last assertion is the one that keeps improvise-taxx intact: the drop
|
||
must still have been **named first**. The loud send is allowed to add "your
|
||
edit did not go", never to replace "disconnected" with something vaguer. This
|
||
is why the guard takes `{ loud }` at all — a `ResizeObserver` firing after a
|
||
drop sends a viewport announcement, and if that shouted it would overwrite the
|
||
precise close message eight seconds of PR #21 went into protecting. **A
|
||
viewport announcement is not a user edit.**
|
||
|
||
---
|
||
|
||
## `improvise-protocol/src/resolve.rs` — `N`, the panel cursor, and a silent axis
|
||
|
||
Three defects found by driving the shipped page under Chromium, all in the
|
||
category panel or the keys that reach it. Headings are module-qualified: three
|
||
of these leaf names are answered by a test in `command/cmd/panel.rs` as well,
|
||
and an unqualified heading would be satisfied by whichever the checker found
|
||
first (gotcha 48, improvise-rm4s).
|
||
|
||
### resolve::tests::n_in_normal_opens_the_category_prompt_rather_than_falling_through
|
||
|
||
improvise-koh6, and the reason it is a P1 rather than a papercut. `N` is
|
||
advertised on the browser's **own** help overlay ("Quick-add a new category,
|
||
from anywhere") and resolved `Upstream`, which `Host::handle` answers with an
|
||
explicit empty vec — so the full DOM was byte-identical before and after.
|
||
The user then typed the category name into NORMAL, where `i` is bound to
|
||
`edit-or-drill` and opens the grid editor **pre-filled with the cell's current
|
||
value**: measured on `web/demo.improv`, `N R e g i o n Enter` turned the cell
|
||
`18,400` into the text `18400on`, with no category created.
|
||
|
||
So the fault to inject is not "does `N` compose the right commands" but
|
||
**"does `N` compose anything at all"**: replace the `search-or-category-add`
|
||
arm of `composed` with `None` and this test fails at `Upstream(Char('N'))`,
|
||
which is exactly the shipped behaviour it was written against. The assertion
|
||
names the two commands in order because the arm mirrors `SearchOrCategoryAdd`
|
||
line for line, including that it uses a plain mode change rather than
|
||
`EnterMode` — so it does **not** clear the minibuffer on the way in, and a
|
||
"tidier" arm that added a `SetBuffer` would fail here rather than silently
|
||
diverge from the host.
|
||
|
||
### resolve::tests::search_or_category_add_is_answered_here_whatever_the_search_query_holds
|
||
|
||
The companion, and the one that pins the *decision* rather than the behaviour.
|
||
`SearchOrCategoryAdd::execute` is a conditional: a non-empty `search_query`
|
||
delegates to `SearchNavigate`, which a browser cannot compose — it would have
|
||
to search a grid it only holds a viewport of. Composing one arm and returning
|
||
`None` for the other would make **whether the host is needed** depend on
|
||
client state, which `enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream`
|
||
exists to forbid.
|
||
|
||
This test loops both states and demands `Local` from each: the empty query
|
||
gets the two commands, the non-empty one gets a decline with its own true
|
||
subject through `not_yet`. Inject the fault by returning `Step::Upstream` for
|
||
the search arm — the `"North"` iteration fails and the `""` one passes, which
|
||
is precisely the asymmetry the rule is about. Note what it deliberately does
|
||
*not* assert: that the two arms agree on anything. The **outcome** varying
|
||
with state is what `cycle-axis-at-cursor` two arms above already does.
|
||
|
||
### resolve::tests::deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category
|
||
|
||
improvise-uej8. `d` on the last item of an expanded category shrank the tree
|
||
under a cursor that did not move, so the row beneath it became the **next
|
||
category's header** — and one more `d` removes a whole category, which is
|
||
`LayoutGeometry`: `Workbook::on_category_removed` rewrites every view's axis
|
||
map and `Model::remove_category` drops every formula targeting it (gotcha 35).
|
||
High blast radius, one keypress away, no confirmation and no undo.
|
||
|
||
The test asserts the pre-state it depends on — that row 3 really is a
|
||
`Category` entry — before asserting the retreat, so it cannot pass by sitting
|
||
somewhere the escalation was never possible. Inject the fault by deleting the
|
||
`commands.extend(retreat_from_deleted_item(...))` line.
|
||
|
||
### resolve::tests::deleting_a_categorys_only_item_does_not_arm_the_next_d_with_remove_category
|
||
|
||
**Found by a ChatGPT Codex review of PR #114, and it is the PR's own argument
|
||
turning against it.** The first fix retreated one row whenever no sibling item
|
||
followed. A category with exactly **one** item is the configuration where that
|
||
degenerates into the unconditional step-back the PR had just argued was *worse
|
||
than the bug*: no sibling below, so it steps back — onto the category's own
|
||
header, one `d` from `RemoveCategory`. The escalation the change exists to
|
||
prevent, in the one shape the predicate did not exclude.
|
||
|
||
**The enumeration, which is what makes the second fix total.** `CatTreeEntry`
|
||
has two variants and `flatten_cat_tree` emits a header then its items, so for
|
||
an item at `i` the row above is a sibling item or *that category's own header*,
|
||
and the row below is a sibling item, the *next* category's header, or nothing.
|
||
Deleting `i` leaves three cases:
|
||
|
||
1. below is a sibling → stay at `i`; the list closes up. Safe.
|
||
2. above is a sibling → `i-1`. Safe.
|
||
3. neither → **the category is now empty, so both neighbours are `Category`
|
||
rows.** There is no safe *adjacent* landing at all.
|
||
|
||
Case 3 is why the adjacent-only rule could not be repaired in place, and why
|
||
"land on the own header" is not an option: it is the escalation. The landing is
|
||
therefore the nearest `Item` row, preferring above, then below — never a
|
||
header. The cost, stated rather than hidden: the cursor may cross into another
|
||
category. Held `d` then deletes **one item per press**, each with its own
|
||
status line, walking upward; it never reaches `RemoveCategory`, which is
|
||
exactly the criterion the review asked for.
|
||
|
||
**The residual**, unavoidable and stated: if the deleted item was the only item
|
||
in the whole tree there is no `Item` row anywhere, `safe_landing` returns `None`
|
||
and the cursor does not move. At that point every category is empty, so
|
||
`RemoveCategory` carries no cells and no formulas — a non-measure formula
|
||
target *is* an item, so an empty category has none.
|
||
|
||
**The fixture is fenced on purpose.** Both `Region` and `Product` are expanded
|
||
so `Product`'s single item has its own header above and the end of the tree
|
||
below — the two `Category`-row neighbours case 3 describes. A first version
|
||
expanded only `Product`, and the cursor fell off the end of the shortened tree,
|
||
where `cat_tree_entry_at` is `None` and the delete arm answers "No category to
|
||
delete" — **passing for a reason that has nothing to do with the fix**.
|
||
|
||
The assertion is not the landing index: it rebuilds the post-deletion tree,
|
||
seats the cursor where the command put it, and asks whether a second
|
||
`delete_at_cat_cursor` composes a `RemoveCategory`. That is the consequence the
|
||
review named rather than a proxy for it. Injecting the pre-review rule
|
||
(`closed_up.or(Some(cursor.saturating_sub(1)))`) fails this test on both paths
|
||
while the two adjacent-case tests still pass — the singleton is the
|
||
discriminating input and nothing else in the suite sees it.
|
||
|
||
### resolve::tests::deleting_an_item_with_a_sibling_below_it_leaves_the_cursor_alone
|
||
|
||
The rival fix, which is why this entry exists. The obvious repair is the one
|
||
`delete-view-at-cursor` and `delete-formula-at-cursor` already use: step the
|
||
cursor back whenever it is above zero. On a **homogeneous** list that is right,
|
||
and on this one it is worse than the bug — the first item of a category would
|
||
retreat onto that category's own header, which is the same escalation arriving
|
||
one row earlier, and holding `d` would walk backwards instead of clearing the
|
||
category out. This test fails under that unconditional repair and passes under
|
||
the conditional one, so the two are separated by an input rather than by
|
||
argument.
|
||
|
||
What makes the conditional total: within a category's run an item's
|
||
predecessor is always either a sibling item or that category's own header —
|
||
`CatTreeEntry` has exactly two variants — so `cursor - 1` can never be a
|
||
*different* category's header, and `saturating_sub` covers a row 0 the flatten
|
||
cannot produce.
|
||
|
||
### resolve::tests::cycling_an_axis_from_the_category_panel_says_so_as_the_tile_bar_does
|
||
|
||
improvise-25bf, and it pays for improvise-z336's third finding as well. Four
|
||
`Space` presses moved a category through None/Row/Col/Page with `#status`
|
||
frozen on a stale `Filter: Product = Espresso` — so the one piece of feedback
|
||
about the filter was wrong exactly while the user was trying to get rid of it,
|
||
which can manufacture a bug report out of a working code path. The staleness
|
||
needed no separate fix: a route that writes a fresh status writes **over** the
|
||
stale one.
|
||
|
||
The test asserts the status is `last`, not merely present, because the
|
||
`Command::Model` must precede it. Inject the fault by dropping the `extend`,
|
||
or by computing the status from the category's *current* axis rather than
|
||
`Axis::next()` of it — the expected `Region → Col` reads `Region → Row` and
|
||
fails, so the test sees the rotation and not just the sentence.
|
||
|
||
### a_locally_resolved_view_step_lands_where_the_host_would_land
|
||
|
||
improvise-cfly, and the bug is a **wave-level residue** rather than a defect in
|
||
either branch that made it. PR #115 put `Command::ViewBack`/`ViewForward` in the
|
||
view half, made `reduce_view` fold all three view effects line for line, and
|
||
gave every client its own `view_back_stack`. `resolve.rs` was owned by a
|
||
different agent that wave, and its `declined()` still refused `view-back`. So
|
||
the merged wave held the capability and no key: a browser user pressed `<` and
|
||
was told the feature does not exist, over machinery that fully supported it.
|
||
|
||
**The decline's stated reason was false by the time it shipped**, which is the
|
||
half worth more than the code. It read *"it commits a drill first, so composing
|
||
it would commit another client's typing"*. Both clauses were checked against the
|
||
tree rather than repeated. The first is still true of the `Cmd` —
|
||
`ViewNavigate { forward: false }` emits `ApplyAndClearDrill` before `ViewBack`.
|
||
The second no longer follows, for two independent reasons: `ApplyAndClearDrill`
|
||
is off the wire entirely (gotcha 38), so a resolver **cannot** compose it even
|
||
by mistake; and no browser path stages a drill at all — `drill-into-cell` is
|
||
declined, and `edit_or_drill`'s `CellId::Aggregate` arm answers with a sentence.
|
||
The residual divergence from the `Cmd` is therefore confined to a state this
|
||
client cannot reach, which is what `mirror_the_cmd_line_for_line` costs here and
|
||
is stated rather than hidden.
|
||
|
||
**The asymmetry the ticket warned about is real, and it is not the one the
|
||
decline named.** `reduce_view`'s `stepped` returns early on an empty stack —
|
||
silently. `ViewNavigate` does not: it emits `set_status("No previous view")` /
|
||
`"No forward view"`. A client that sent the bare `Command` would therefore agree
|
||
with the host on the view and diverge on the status line in exactly the case
|
||
where the user needs to be told something. `step_history` mirrors both arms.
|
||
|
||
Three rival fixes, each of which fails:
|
||
|
||
- **Emit the step unconditionally**, dropping the sentence. The unit test reads
|
||
`[ViewBack]` where it expects `[SetStatus("No previous view")]`; `both_ends`
|
||
reports `<: client and host disagree about the status line`, `""` against
|
||
`"No previous view"`.
|
||
- **Read the other direction's stack** in each arm — the mistake `Rewind`
|
||
exists to prevent one level down. `<` with history answers `No previous view`;
|
||
the lockstep pair diverges on the mode, because the host's `ViewBack` restores
|
||
the frame's mode and the client never steps.
|
||
- **Reconstruct the step as `Command::SwitchView(previous)`**, which is the
|
||
fix that looks right and is the one improvise-ls3y already paid for. It lands
|
||
on the same view and then `switched()` pushes the view being left onto the
|
||
**back** stack and clears the forward one, so the next press goes the wrong
|
||
way. The unit test names `SwitchView("Default")` against `ViewBack`.
|
||
|
||
The lockstep test asserts **both stacks**, not the active view, for that last
|
||
reason: a step that pops without pushing agrees on where you are and disagrees
|
||
on where you can go next, and only the second press shows it. Its empty arm is
|
||
pressed first and asserted against the host's own status, so a fixture that
|
||
silently had history would be caught rather than quietly testing one arm twice.
|
||
|
||
### resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it
|
||
|
||
**`view-forward` is bound in no keymap, in either front end** — not a browser
|
||
gap, and not something this ticket predicted. `Keymaps::default_keymaps()` binds
|
||
`<` to `view-back` and nothing to `view-forward`; the command is registered, so
|
||
the TUI reaches it as `:view-forward` and a browser cannot, because `declined()`
|
||
refuses to enter CommandMode at the door. So the forward half of the history has
|
||
been unreachable by keypress since it was written, and pressing `<` twice is the
|
||
only way to observe the forward stack at all.
|
||
|
||
The arm is added anyway and tested by name, because `composed` keys on the
|
||
command name and the keymap is the *shared* table both ends read: the day a key
|
||
binds `view-forward`, nothing else must be touched. What keeps that from being
|
||
unreachable code with no guard is the second half of this test, which asserts
|
||
that no keymap binds `view-forward` **and** that one binds `view-back`. The
|
||
control matters: without it the check passes against a misspelling, which is the
|
||
neighbouring-question defect §7 names. When a binding is added, this test fails
|
||
and is where the gap stops being one.
|
||
|
||
### "the deployment notice starts collapsed and remembers being opened"
|
||
|
||
The page's `#backend-notice` was a static `<p>` carrying roughly ten dense
|
||
lines of configuration reference — `?doc=`, `?seed=`, `?ws=`, `wss://` versus
|
||
`ws://`, and the reverse proxy a plain-`ws://` server needs behind TLS — and it
|
||
was rendered above the tile bar, the category tree and the grid on **every**
|
||
visit. On the deployed page at 1280×820 it cost about 230px of the 820, which
|
||
is the difference between landing on an application and landing on its README.
|
||
Reported by the user against `fiddlerwoaroof.github.io/improvise/app/`.
|
||
|
||
The first-visit default is **collapsed**, and the argument is that nothing is
|
||
actually withheld by collapsing it: the one fact a first-time visitor needs —
|
||
the document lives in this tab, is saved to this browser's private storage, and
|
||
never leaves the machine — is the `<summary>`, which is never collapsed. What
|
||
is behind the disclosure is *configuration*, which is looked up rather than
|
||
read, and which nobody needs before they have decided they want a second
|
||
document or a shared one.
|
||
|
||
The test asserts **both** directions across a reload, which is what separates
|
||
"remembers" from its two neighbours. A page that never reads storage passes the
|
||
open-then-reload half only if it also defaults open, and fails the
|
||
collapse-then-reload half; a page that always opens fails the first assertion
|
||
outright. Inject either fault — drop the `noticeOpen(remembered(...))`
|
||
assignment, or drop the `toggle` listener — and exactly one half fails.
|
||
|
||
What it does **not** establish is that storage is *optional*. A private window,
|
||
or one with site data blocked, throws from `localStorage` rather than returning
|
||
null, and Playwright's default context stores normally; that path is covered at
|
||
the unit level instead, by `noticeOpen` reading every non-spelling as the
|
||
default, with the `try`/`catch` in `app.js` argued and not run.
|
||
|
||
### "the notice opens from the keyboard, which the grid's own handler must not claim"
|
||
|
||
A `<details>` is a real control and its `<summary>` takes Enter and Space. The
|
||
page also installs a `window` keydown listener that hands the key to
|
||
`Client::on_key_event` and calls `preventDefault()` whenever the client claims
|
||
it — and `Enter` is claimed (it is forwarded upstream, which is why
|
||
`shell.spec.js` uses it to compose a frame on a client that has no snapshot).
|
||
So without the `el.notice.contains(e.target)` guard, focusing the summary and
|
||
pressing Enter would send a frame and suppress the browser's own toggle: the
|
||
disclosure would be operable by pointer and dead by keyboard, which is the
|
||
whole accessibility argument for using `<details>` in the first place.
|
||
|
||
Inject the fault by deleting that one guard line; this test fails and nothing
|
||
else in the suite moves, because every other keyboard assertion drives `#grid`.
|
||
|
||
### "the minibuffer is raised over the grid rather than filed under it"
|
||
|
||
`#minibuffer` was the last flex item in the body column, drawn as a 14px line
|
||
in the page's bottom-left corner with a one-pixel top border. In a terminal
|
||
that position is a convention with decades behind it; in a browser window it is
|
||
where nothing is read, and the user reported exactly that — a prompt reading
|
||
"Display a menu" in small text at the very bottom-left, missed. With the grid
|
||
scrolled the cell being typed into can be 800px away from the text showing what
|
||
is being typed.
|
||
|
||
The content is unchanged and deliberately so: `RenderMinibuffer` is built in
|
||
`improvise-wasm-client/src/render.rs` from `state.view.mode.minibuffer()`, the
|
||
same semantic value `src/draw.rs` draws, so there is no second derivation here
|
||
of what a prompt says (design-principles §5). What moved is position, size and
|
||
contrast, which is paint.
|
||
|
||
Three assertions, and the middle one is the one that earns the test. Being
|
||
inside the viewport is necessary but not sufficient — a short document satisfies
|
||
it with the old layout too. **Overlapping `#grid`'s own rectangle** is what
|
||
cannot happen for a statically-positioned element that follows `main` in the
|
||
flow, because a flex column gives its items disjoint bands: that is the
|
||
assertion that separates "raised over the content" from "moved within the
|
||
strip", and it fails the moment `position: fixed` is reverted. The font-size
|
||
comparison is the only prominence claim a browser can check; it was equal to the
|
||
body's before and is now strictly larger.
|
||
|
||
The precondition matters: `#mode` must read `INSERT` before anything is
|
||
measured, because `getBoundingClientRect` on a hidden element returns zeroes
|
||
and every comparison below would then be about the origin.
|
||
|
||
### "an error cell stays an error cell when it is also dimmed or selected"
|
||
|
||
Found reviewing the styling change that introduced it, before it was pushed.
|
||
`td.error` and `td:not(.editable)` used to set **different** properties —
|
||
`color: var(--error)` and `opacity: 0.7` — so both applied and a dimmed error
|
||
cell was a dimmed red. The restyling made the dimming a `color` too, at the
|
||
same specificity, and put it **after** `td.error` in the file, so an error in
|
||
an aggregate cell rendered plain grey: measured `rgb(107, 103, 95)` where the
|
||
error colour is `rgb(168, 50, 45)`. `td.selected` and `td.typing` had the same
|
||
effect by setting `color: var(--fg)`, which is how a *selected* error lost its
|
||
colour as well.
|
||
|
||
That is the whole class of defect a stylesheet has and a program does not:
|
||
two rules that were independent become one property, and nothing in any
|
||
language fails. `#grid td.error` is the only place in this page where colour
|
||
carries meaning rather than emphasis, and it is the one a reader takes at face
|
||
value — a circular reference or a `mixed` aggregate reading as an ordinary
|
||
number is worse than it reading as nothing.
|
||
|
||
The fix is ordering plus a rule: the dimming goes **above** the semantic
|
||
colours, and a selection is a ring and a tint and never a colour. The test is
|
||
aimed at the stylesheet rather than at a document, deliberately — the question
|
||
is which rule wins, and a fixture per combination (a dimmed error, a selected
|
||
error, an error being typed over) would be three documents testing three
|
||
documents. It appends a `<td>` carrying each combination to the real page's
|
||
real table, reads `getComputedStyle`, and compares against two controls taken
|
||
the same way: a plain unsettable cell (what it must not be) and an error the
|
||
dimming cannot reach (what it must be). Reading absolute colours instead would
|
||
pin the palette, which is not the property.
|
||
|
||
Inject the fault by moving the `td:not(.editable)` block back below
|
||
`td.error`, or by restoring either `color: var(--fg)`; each makes exactly the
|
||
corresponding assertion fail.
|
||
|
||
## The page bar as a control (improvise-rw6l, improvise-771e)
|
||
|
||
The user, pointing at the deployed page: *"The page items here (e.g. Quarter)
|
||
should open a menu when clicked so you can pick a different option. Also have a
|
||
down-arrow indicating there's a menu."*
|
||
|
||
Almost nothing had to be derived for it. `PageAxis::items` was already
|
||
projected, `ModelCommand::SetPageSelection` was already on the wire, and the one
|
||
gap was that `RenderPageAxis` **threw the names away**: it kept a position and a
|
||
count, which is all improvise-p1ym needed for `[`/`]`. Widening
|
||
`RenderPageCycle` with `items` is a pass-through, and the items sit *inside*
|
||
`cycle` rather than beside it so there is exactly one predicate — a Filter axis
|
||
has no `cycle`, therefore no arrow, no menu and no items, all off the one home
|
||
`PageAxis::cycle` already was (gotcha 27).
|
||
|
||
### render::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to
|
||
|
||
Fault injected: `items: Vec::new()` in place of `items.to_vec()`. The menu then
|
||
paints empty on a real document and nothing in the browser can see it, because
|
||
the down-arrow and the chip's `<button>`-ness both follow `cycle`, not the item
|
||
count. The Filter row in the same test is what stops the fix being "just always
|
||
send `axis.items`".
|
||
|
||
### render::tests::a_page_tile_reads_as_the_whole_word
|
||
|
||
improvise-771e: the browser tile bar drew "Quarter Pag". `Axis::short_label`
|
||
returns three characters so the TUI's tile bar aligns in a fixed-width terminal
|
||
(gotcha 26); a browser has no column to fit, so the abbreviation buys nothing
|
||
and reads as a rendering defect. `plain_label` is the other vocabulary already
|
||
on `Axis`, so the decision stays in the shared layer and only the *choice* of
|
||
vocabulary is local — the `AxisColor → ratatui::Color` shape §5 blesses.
|
||
|
||
**The fixture is the whole test.** The two vocabularies differ on exactly two
|
||
of five axes — `Page` ("Pag") and `None` ("·") — so `client_with_panels`, whose
|
||
tiles are `Row` and `None`, could see the `None` half and **not** the `Page`
|
||
half the ticket is about. This test projects a `Page` tile and a `Column` one
|
||
beside it: the second is what says this is a change of vocabulary rather than a
|
||
rewrite, since `Col` is spelled the same either way.
|
||
|
||
**What actually pinned the truncation was not where the ticket said to look.**
|
||
improvise-771e says to check `tests/grid_frame_agreement.rs` before changing
|
||
anything. It does not mention the tile bar at all. The assertion that held
|
||
"Quarter Pag" in place was `harness/tests/drill.spec.js`, which asserts the tile
|
||
text exactly, twice — a Playwright spec, in a directory the ticket never names,
|
||
asserting the bug as the expected value. The lesson is the search, not the file:
|
||
a *rendered string* is pinned wherever anything reads the screen, so the grep is
|
||
for the string, not for the function that produces it.
|
||
|
||
### a page axis offers its items, and a filter axis offers none
|
||
|
||
`web/tests/dispatch.test.js`. `pageEntries` relays `cycle.items` and gives a
|
||
Filter axis `[]`. Fault injected: `items: axis.cycle ? ... : axis.items`, i.e.
|
||
falling back to the raw list — the Filter row then offers items nothing can act
|
||
on, which is the "a menu that cannot do anything" case the ticket names as
|
||
worse than no menu at all.
|
||
|
||
### a chip opens the menu and a menu row picks, one attribute apart
|
||
|
||
improvise-b8ot's shape: one delegated handler per surface on a stable root,
|
||
operands off data attributes, and **no structural inference**. A chip carries
|
||
`data-axis`; a menu row carries `data-axis` *and* `data-item`. That single
|
||
difference is the whole disambiguation, so `pageItemAction` is one function over
|
||
both surfaces and `app.js` registers the same listener on each.
|
||
|
||
The row worth having is `{ axis: "0", item: "" }` → `open`. `Number("")` is `0`,
|
||
so a naive read turns an absent item into item zero — which picks Q1 whenever
|
||
the user clicks the chip for an axis already on Q1, i.e. silently does nothing,
|
||
which is indistinguishable from the menu failing to open.
|
||
|
||
### the menu answers its own keys and swallows the rest
|
||
|
||
The menu is the **third** state of the kind `editing` already was: `app.js`'s
|
||
window `keydown` handler returns early for it. That is the same treatment and
|
||
not a new mechanism, and the reason is the same — the menu is anchored to a
|
||
chip above the grid, so `hjkl` reaching the grid would scroll the thing the menu
|
||
is pointing at out from under it.
|
||
|
||
`menuKey` therefore answers **every** key, with `ignore` as the default rather
|
||
than a fallthrough: an unlisted key is swallowed, not passed on. The
|
||
`count: 0` row is a real state, not defensive padding — a repaint can empty the
|
||
axis under an open menu — and it closes rather than wrapping modulo zero.
|
||
|
||
### the menu is driven by the keyboard and does not leak keys to the grid
|
||
|
||
`harness/tests/pages.spec.js`, and the assertion that earns the slow test is
|
||
the **negative** one: `j` moves the menu's active row *and* leaves
|
||
`#grid td.selected` where it was. A spec that only checked the menu moved would
|
||
pass with the key leaking, because both handlers would have run.
|
||
|
||
### the help overlay pages nothing behind itself
|
||
|
||
improvise-wiew's product call, executed. The overlay is open, `]` is pressed,
|
||
and the page bar still reads `Quarter = Q1 (1/4)`. Its sibling — a panel open,
|
||
`]` pressed, bar moves to Q2 *and the panel is still open* — is what stops the
|
||
first one passing because the key does nothing anywhere.
|
||
|
||
### a modified key is the browser's, and the menu neither acts on it nor claims it
|
||
|
||
Found by ChatGPT Codex on PR #123, and correct. The page menu's early return
|
||
sat **above** `app.js`'s `metaKey` guard, so with the menu open `Cmd-R` reached
|
||
`menuKey`, was answered `ignore`, and then hit
|
||
`if (!e.ctrlKey && !e.altKey) e.preventDefault()` — which is true for a Meta
|
||
combination. Reload was blocked. `Ctrl-j` and `Alt-Home` had the mirror problem
|
||
in the other direction: they *moved* the menu, although the comment two lines
|
||
below said Ctrl and Alt combinations stay the browser's.
|
||
|
||
The repair is ordering plus one bail: the `metaKey` guard moves back above the
|
||
menu branch (so the menu is not even consulted), `pageMenuKeydown` returns
|
||
immediately on `ctrlKey || altKey`, and what remains then claims
|
||
**unconditionally** — every unmodified key including `ignore`, which is the rule
|
||
the menu exists to keep.
|
||
|
||
**Why the assertion is shaped the way it is.** The listener that records
|
||
`defaultPrevented` is installed by `page.evaluate` *after* the page has loaded,
|
||
so it runs **second** on the same target and observes what `app.js` decided. One
|
||
registered first would read `false` for every key and pass against any code at
|
||
all. And the test ends by pressing an **unmodified** `j` and asserting
|
||
`["j", true]`: without that row, "nothing was defaulted away" is equally
|
||
satisfied by a page that claims nothing, so the modifier would not be what the
|
||
test is about.
|
||
|
||
Fault injected: the reviewed code restored exactly — menu branch above the
|
||
`metaKey` guard, no `ctrlKey || altKey` bail, conditional `preventDefault`. The
|
||
test fails on `Control+j` moving the active row from `✓ Q1` to ` Q2`.
|