Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit

# Conflicts:
#	context/test-evidence/command.md
This commit is contained in:
Edward Langley
2026-09-22 22:31:19 -07:00
26 changed files with 1613 additions and 409 deletions
+6
View File
@@ -226,3 +226,9 @@ learns no coordinate space, and an invalid operation is unconstructible rather
than rejected. What the extension adds is that the enum is now **four** answers
to "what may be done here", so `None` finally means only what this ADR always
said it meant — no projected cell at all.
**A fifth kind (2026-09-22, improvise-exdg).** The records-mode coordinate cell
was the last `None` that named a visible cell. It is now `CellId::Coordinate`.
The variant carries no operand, because no client composes a coordinate rename.
The previous paragraph's "four answers" is therefore five. `None` now means
only a padded or blanked slot. Reasoning: `context/code/protocol.md` §3.
+50 -19
View File
@@ -161,7 +161,7 @@ limitation that is not new: `ExecuteCommand` returns one effect batch, so the
segments of `:a . b` all see the pre-batch cursor, exactly as a
`Binding::Sequence` does (gotchas 21 and 23).
## 8 — the interactive arm is `parse`, then context; it is never a second parser
## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser
#151 fixed three instances of *a registry with two construction arms demotes
whichever caller reaches the weaker one*, one patch each. A post-merge review
@@ -175,31 +175,59 @@ and the key from the cursor. So a script that wrote 5 to `Region/East`
The shape rather than the instances is what changed. `CmdRegistry::register`
used to take two free functions, `parse(args)` and `interactive(args, ctx)`,
and nothing related them; each command re-derived its arguments twice and every
disagreement was silent. It now takes three pieces, and the interactive arm is
**built from `parse`**, not beside it:
disagreement was silent. The interactive arm is now **built from `parse`**, not
beside it:
- `parse(args) -> C` — the only function that reads arguments a caller supplied.
- `contextualize(C, &CmdContext) -> C` — fills the fields that are context and
never argument (a `Move`'s origin cursor, a `MovePanelCursor`'s bound). It is
handed the built command and **not the args**, so it cannot discard them.
- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused.
This is where "no coordinates means the cursor" lives: `clear-cell` bare, and
`commit-cell-edit <mode>` bare, which is what the keymap binds.
- For `register_at_cursor` only, the registry itself then sets the command's
**cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the
live one. That is the whole of what the interactive arm adds to a parsed
command, and no registration supplies it.
Two consequences hold by construction rather than by test. **Whatever `parse`
accepts, the interactive arm accepts** — `contextualize` is infallible — so the
§7 gate cannot pass a segment the loop then refuses. And **an accepted argument
list cannot be overridden by the cursor**, because the only function that sees
both args and context never runs on an accepted list. `register_pure` and
`register_nullary` already had one arm; they are the `contextualize = identity,
fallback = None` case.
Three consequences hold by construction rather than by test. **Whatever `parse`
accepts, the interactive arm accepts** — nothing after `parse` can fail — so the
§7 gate cannot pass a segment the loop then refuses. **An accepted argument list
cannot be overridden by the cursor**, because the only function that sees both
args and context never runs on an accepted list. And **no field `parse` set can
be overwritten from context**, because nothing a registration writes can name
one: the cursor slot is typed `&mut CursorState`, and no command parses a
`CursorState` out of its arguments. `register_pure` and `register_nullary`
already had one arm; they are `register` with no fallback.
That last one is improvise-yf7p, and it is why the registry lost a parameter.
#171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the
built command and not the args, and stated honestly that it could still
**overwrite** a parsed field — naming the field was "a visible line in review".
Review then injected five such overwrites on commands other than the one with a
test (yank, paste and drill-into-cell taking their key from the cursor over the
parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor
clamping its parsed delta to ±1) and the whole workspace stayed green. A hole
guarded by review discipline plus one command's test is not guarded. Every
contextualizer in the tree fell into one of two shapes, and each had an answer
that removes the function rather than checking it:
- **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one
`CursorState` field, always `CursorState::from_ctx`. So the registry does it,
through a slot whose *type* is the guard: an `AtCursor` impl pointing at a
`CellKey` or a delta is a compile error, which was tried.
- **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s
`currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s
cursor and bound). These copied context into the command only to read it back;
`execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus`
got below. Those three are `register`/`register_nullary` now, so their
interactive arm is exactly `parse`.
What `execute` does with a parsed field is still the command's own logic and is
tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and
`move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or
the delta is ignored there. The difference is that the registry is no longer a
second place such a bug can be written.
What is still representable, stated rather than hidden:
- `contextualize` can overwrite a field `parse` set. It would have to name the
field, which is a visible line in review, where the old `|_args, ctx|` was an
invisible omission. `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`
fails if `commit-cell-edit`'s does.
- A `fallback` sees the args `parse` refused and could ignore some of them.
`commit-cell-edit`'s refuses anything but a lone mode, so the malformed
`commit-cell-edit editing 5` is an error on the `:` line — which has no pure
@@ -207,9 +235,12 @@ What is still representable, stated rather than hidden:
- The gate is **stricter** than the loop wherever a fallback exists: a bare
`clear-cell` is refused in a script. That is the safe direction — the whole
line is refused — and it is unchanged from before #151.
- A new command could declare a *second* `CursorState` field and parse it from
args; `cursor_mut` could then point at the wrong one. No command has one, and
a parsed cursor would be a new idea deserving its own argument.
`toggle-panel-and-focus` did not fit `contextualize`, because its pure arm
defaulted the omitted flags to `true` and a contextualizer cannot tell an
`toggle-panel-and-focus` did not fit #171's `contextualize` either, because its
pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an
omitted flag from an explicit one. So the omission is now in the type —
`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is
resolved in `execute` against the same context. That is the honest home: the
+25
View File
@@ -1706,6 +1706,31 @@ watchdog must be DISARMED and not merely pre-empted**: it fires later and would
overwrite the better message with "did not answer" (PR #13 review). The watchdog
is for silence only, and `setShellFailure` clears the timer.
**Success is claimed by the first frame, and only if nothing failed first**
(improvise-i0v9). The worker transport used to write `in-page worker · <doc>`
the moment it spawned the worker — 13ms after navigation, before anything had
answered — so a worker that could not start showed a success-looking status
over a blank page. It now writes `starting the worker · <doc>…`, and
`createShell().arm(message, { answer })` holds the claim until `sawFrame`. The
rule the claim obeys is **the watchdog's own state**: it is written only if the
watchdog is still armed when the first frame lands. That is not a convenience.
`worker.js` posts a `ReadOnly` (or `SeedMissing`) notice straight after
`start()` and **before** the Snapshot that answers the handshake, so an
unconditional "first frame ⇒ say we are up" would overwrite the precise reason
the notice just wrote. `fail` already disarms the watchdog, so "armed" answers
"has anything been said since arming" without a second flag to keep in step.
`fail` disarms **directly** rather than through `sawFrame`, or it would
briefly flash the success text on its way to the failure. The starting text
deliberately does not contain `in-page worker`, so the older specs asserting
that substring now need the first frame to pass. The socket path passes no
`answer`: `open` is already a real fact, and its status was never premature.
One consequence changes what the watchdog guards. In the read-only case a
Snapshot always follows the notice, and `sawFrame` disarms the watchdog either
way, so a notice that forgot to disarm would **not** be overwritten eight
seconds later. It is overwritten **at once**, by the claim. The eight-second
overwrite survives only where no frame follows: `BootFailed`.
**Did this socket ever open?** The page has to record that itself, because
nothing on the close event carries it: `readyState` is already CLOSED by the time
a close listener runs, whichever happened, and a refused connection and a
+78 -6
View File
@@ -553,6 +553,78 @@ status line.
one, and the limit is checked in exactly one place — `Model::add_category` — so
nothing in the effect restates it.
### A model effect that cannot do what it names refuses: `App::refuse`, `on_view` and `refuse_and_stop` (improvise-bok4, cz8d)
`AddCategory` was the only wire-reachable model effect that aborted when it
failed. The others did one of two things:
- **Narrated.** `AddItem`, `AddItemInGroup`, `AddFormula`, `LoadModel` and
`ImportJsonHeadless` wrote a sentence to the *host's* `status_msg` and
carried on.
- **Swallowed.** Every view-addressed effect (`SetAxis`, `SetPageSelection`,
`ToggleGroup`, `HideItem`, `ShowItem`, `TransposeAxes`, `CycleAxis`,
`SetNumberFormat`, `TogglePruneEmpty`) did nothing when `view_mut_of` found
no such view. `DeleteView` discarded "cannot delete the last view" with
`let _`, and answered `Ok` for a view the document did not have
(improvise-40j4). `AddFormula` stored a formula for a category that was gone,
through `let _ = add_item(…)` (improvise-40j4).
Either way the batch ran to its end, and `Host::mutating_frame` reported the
frame **accepted**. That is clause 4's partial-and-silent shape, one layer below
the record tier that `broken_chain` guards. The reviewer measured two cases.
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]` was accepted with
half of it a no-op. `[RemoveCategory("Region"), AddItem { category: "Region" }]`
was accepted too, and its reason went only to the host's own status line, so
the client was told nothing at all.
**One writer, and the abort is separate (improvise-cz8d).** improvise-c5ip
(PR #185) had meanwhile added `App::refuse`, which writes the reason, sets
`batch_refused` and does **not** abort. The combination is now:
- **`App::refuse` is the only writer** of a refusal. bok4's own `refused()`
helper, which aborted, is deleted.
- **`on_view(app, target, write)`** writes the named view, or calls
`App::refuse` naming the missing one.
- **`Host::mutating_frame` rejects on `last_batch_refused() ||
last_batch_aborted()`.** That is sound because each `ModelCommand` maps to a
one-effect batch, so either flag belongs to the mutation that raised it.
- **Aborting is an explicit extra step**, taken only where a later effect in a
key-path batch assumes the failed one happened. `refuse_and_stop` does it for
`LoadModel` and every `ImportJsonHeadless` arm, so `load x . q` and
`import-json x . q` do not quit having loaded nothing. `AddCategory` keeps its
own inline abort (gotcha 21). The item, formula and view effects only refuse:
the frame is rolled back by the refusal alone. On the key path, a failed
`AddItem`, `AddFormula` or view edit no longer stops the rest of its batch,
which is what it did before bok4.
In a client's frame, any refusal becomes `Rejected(MutationRefused)`. The frame
is undone whole, and the client is told, which is the channel the narrated
reason never had.
**This takes the ticket's first option, scoped to the effects the wire can
reach.** Failure is *reported* by the effect that knows it failed. The second
option, growing `broken_chain` to ask whether every view, category and item
target still resolves, would have been a second description of which targets
each effect needs. That second copy would be free to disagree with the effect
itself, which is gotcha 24's reason for observing rather than predicting.
Consequences:
- **A failed load is refused**, where it used to be accepted with a host status
line. So `a_load_that_fails_is_not_a_rebase` now witnesses the failure by the
refusal, not by `status_msg`. The frame's rollback also rolls the host's
status line back.
- **Every refusal site has a test that fails when it is reverted** to a
status-only write (improvise-9fpl). Most are one table,
`every_failing_model_command_refuses_its_whole_frame`. That matters because
these are exactly the lines a merge conflict could silently drop.
- **Not covered:** a no-op *inside* a view that does resolve. Examples are
`SetAxis` on a category the view does not have, `HideItem` of an absent
item, and `RemoveItem`/`RemoveCategory`/`RemoveFormula` of something already
gone. The removals are idempotent by intent. The others are the remaining
instances of this class, and each needs its own `View` method to say whether
it changed anything.
### `AddFormula` and non-`_Measure` targets
For non-`_Measure` targets the item is added to the category so it appears in the
@@ -2494,12 +2566,12 @@ available for the rare command that needs to enumerate all views, such as the vi
panel.
The interactive arm (keymap dispatch, scripts, the `:` line) is built **from**
`parse`: `register` takes `parse`, a `contextualize` that fills context-only
fields of the command `parse` built without seeing the args, and an optional
`fallback` reached only for argument lists `parse` refused. So an accepted
argument list is never overridden by context (improvise-2bac; the argument is
`context/code/cli.md` §8). `register_pure` is the case with nothing to
contextualize and no fallback: both arms are `parse`.
`parse`: `register` takes `parse` and an optional `fallback` reached only for
argument lists `parse` refused, and `register_at_cursor` additionally has the
registry set the command's `CursorState` to the live cursor. No registration
writes any other field, so an accepted argument list is never overridden by
context (improvise-2bac, improvise-yf7p; the argument is `context/code/cli.md`
§8). `register_pure` is the case with no fallback: both arms are `parse`.
`write_buffer` is the effect that writes a named buffer — the mirror of
`read_buffer`, and the only place that knows "search" is not an entry in the buffers
+70 -18
View File
@@ -230,11 +230,33 @@ guard below (improvise-c7mu). It removes three things a scan must not see.
composers, so no verdict was wrong yet. It also had a mirror: a comment
explaining that a `NOT_COMPOSED` variant is held back would have raised a
false "stale entry" alarm.
- **Test items inside a file.** A line scan that relies on rustfmt putting an
item's closing brace at the attribute's own indentation. It treats
`cfg(any(test, …))` as test code and `cfg(not(test))` as production.
Over-stripping **fails** the test rather than passing it, which is the safe
direction.
- **Test items inside a file.** Each test-only attribute's item is bounded by
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
follows. The first version was a line scan that relied on rustfmt putting an
item's closing brace at the attribute's own indentation. The #172 review
found three shapes it never bounded:
- an item ending in `];` or `);`;
- a `#[cfg(test)]` statement spanning lines;
- a one-line `#[cfg(test)] use x;`.
In each case the scan read on to the end of the file and **hid every line of
production code after it**, which is the silent direction for the forward
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
**Which `cfg` is test-only is evaluated, not substring-matched.** The
predicate is read off the file's *text*, because a `feature` value is a
literal and blanked in the code. It is evaluated in three-valued logic for a
build without `test`: `test` is false, and so is `feature = "test-support"`,
which this repo never ships (improvise-26dm). Everything else a scan cannot
know, such as a target or an ordinary feature, is unknown. An item is
test-only when its predicate is **false**. So
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
heading a file makes the whole file test code. One appearing anywhere else
is a panic, because this scan cannot bound an inline module's inner
attribute.
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
`x/` test code, even though nothing inside the file says so. Before
improvise-c7mu, this checker's own file and `improvise-io`'s
@@ -592,14 +614,37 @@ broader than its reason — improvise-gcvb, which also records the two things
that make it more than a one-liner: an inserted `+cat` column means the TUI
does **not** advance, and `AddCategory` can abort.
### `CellId::Coordinate` — a drawn coordinate is not "no cell" (improvise-exdg)
A records-mode coordinate cell (the `Region` column holding `North`) used to
project `None`. That was a **third** meaning of `None`, next to the padded slot
and the blanked position: a visible cell that holds text and whose write
belongs to the host. So absence was encoding a permission, the same conflation
`Create` removed for the creating columns (improvise-poia). The pinning test,
`a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is
gone.
`Coordinate` carries **nothing**. Editing a coordinate renames it across every
measure cell in the row (gotcha 1), and no client composes that operation: it
needs `record_col`, and the browser declines every records-mode edit before
reaching a cell. Giving the variant a `RecordHash` would hand a client a
surrogate with nothing to spend it on. The operand belongs with the command
that consumes it, whenever that command exists. Nothing a client can do has
changed. `render.rs::editable` is now an exhaustive `match` rather than a
`matches!`, so the next variant has to be classified. `typed_cell_command`,
`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it.
So paste onto a coordinate is still a silent `Upstream` in the browser. The
TUI has declined it out loud since PR #184. That divergence is
improvise-kkcy, and a named kind is what lets the browser decline deliberately
now.
### `CellDisplay::id` — `None` is not "aggregate"
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
of the enum. `None` means the slot holds **no projected cell**: a `GridCache`
position padded by `fit_to_labels`, blanked by a reframing, or a
records-mode column that is not a cell to write into at all (a category column
renames a coordinate across the whole row, gotcha 1, which is a different
intent from `SetCell`). Conflating the two either hides a cell or offers a
position padded by `fit_to_labels`, or one blanked by a reframing. Every
records-mode column is some kind of cell (`Record`, `Derived`, `Create` or
`Coordinate`). Conflating the two either hides a cell or offers a
write the type system exists to make unconstructible.
### `CellDisplay::edit_text` — the difference corrupts data
@@ -615,8 +660,8 @@ and an editor's seed is exactly a feedback path.
It is the projection of `App::display_value_at_cursor`, which is what
`EditOrDrill` seeds the TUI's buffer from, so both front ends start from one
string rather than each deriving one. `None` where there is nothing to edit:
the same set of positions `id` is `None` for, plus cells whose value is
absent. An error cell carries none, because its text is a diagnostic and not
positions whose `id` is `None`, plus pivot cells with no key. A records-mode
cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not
a value.
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
@@ -812,9 +857,16 @@ one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to
smuggle a multi-level axis in flattened.
`FrameGroup::collapsed` is on the wire rather than left to the client because
it is a fact about the *view*, which only the server holds. `FrameGroup::cat`
travels so a client can echo it back in `ToggleGroup` without ever working out
which category a label came from — which slash-joined labels made impossible.
it is a fact about the *view*, which only the server holds. There is no
`FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a
client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs`
has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host
list), and neither renderer read the field. A promise about an interaction no
code performs is the stale-sibling defect, so the field went with it. It is
not a loss for `z`. The TUI's `ToggleGroupAtCursor` also places the cursor on
the layout the toggle *will produce*, which a thin client cannot compute. So
composing `z` in a browser needs its own design, and the operand should come
back with that design rather than wait for it.
### `AxisFrame`: four accessors that are **not for a draw loop**
@@ -1321,13 +1373,13 @@ frozen behaviour reads as decided afterwards and nothing says it was only frozen
for the duration of the move.
**The ticket's own proposed fix does not work, and that is worth more than the
fix.** h0iv suggested comparing the `(cat, name)` pair, `FrameGroup` carrying
`cat` precisely so a client need not work out which category a label came from.
fix.** h0iv suggested comparing the `(cat, name)` pair; `FrameGroup` carried
`cat` then (improvise-7xyx has since removed it).
But its own reachability case is *one* category: `Month` grouped into `Q1`,
nested under `Region`, giving `North/Q1` and `South/Q1` adjacent — the two
headers are `(Month, Q1)` and `(Month, Q1)`, **equal as values**, so the pair
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them`
pins that, so the next reader does not re-propose it. What separates them is
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them`
pins that as whole-value equality, so the next reader does not re-propose it. What separates them is
that they are two different `FrameEntry::Group` entries, and `groups()` already
borrows each from `self.entries` — so the comparison is `std::ptr::eq` on the
header, which is the literal statement of "the same header opened both".
+3 -2
View File
@@ -298,8 +298,9 @@ The unit-level form of the headless test, against `CmdRegistry::interactive`
directly: cursor on `Type=Food, Month=Jan`, a decoy `edit` buffer, and an
explicit `5` for `Type/Clothing Month/Feb`. It asserts the built command's key
and value. Rival (a) above — a `contextualize` that overwrites the parsed
fields — fails it; this is the one hole the new registry shape leaves
representable, so it is the one that needs a test.
fields — failed it. improvise-yf7p then removed `contextualize` altogether
(`context/code/cli.md` §8), so rival (a) no longer compiles; the test stays as
the behavioural statement of what the signature now guarantees.
### every_argument_list_the_gate_accepts_the_run_accepts_too
+49
View File
@@ -3054,6 +3054,23 @@ 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.
**Strengthened by improvise-i0v9's second pass.** Every assertion above read the
*final* `#status`, so two failures could still pass it. A watchdog that fired
first and was then replaced by a late notice passed. So did a success claim
shown before the notice. The test now records every status with `logStatus` and
asserts, **before** the page clock is walked, that the log ends in `read-only`
and never contained `did not answer` or the exact `in-page worker · <doc>`. A
reason that arrived after the watchdog would sit in that log behind "did not
answer"; that is the "well before 8s" property, stated without a wall-clock
bound. Measured failing against `gh/main` at `da2e5ab`, whose log was
`["in-page worker · two-tab-….improv", "read-only — …"]`: the premature claim
the ticket measured at 13ms is still there once 1wft's notice exists. The
brief's rival, `onNotice` calling `setShell` instead of `fail` (the watchdog not
disarmed), fails it at the first `toContainText("read-only")`. The Snapshot's
claim replaced the reason at once, which is why the eight-second form of this
rival is now exercised by the invalid-`?doc=` spec instead (see
`context/code/clients.md`, "The watchdog").
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.
@@ -4446,6 +4463,38 @@ registers to agree; a TUI-only blank filter (`Text("")` to `None`) fails it on
`+`, which is why that filter was removed rather than kept. The evidence entries
for the host-side tests are in `command.md`.
### the worker is not called up until its first frame has arrived
improvise-i0v9. The worker transport wrote `in-page worker · <doc>` when it
spawned the worker, before anything had answered. A worker that then failed
therefore showed a success-looking status over a blank page. The spec records
every `#status` with `logStatus` and requires the whole log to be exactly
`["starting the worker · <doc>…", "in-page worker · <doc>"]`. It pins the order
as well as the final text, since a final read cannot see a claim made too early.
Measured failing against `gh/main` at `da2e5ab`. The rival, restoring the
immediate `setShell(\`in-page worker · ${DOC}\`)` in `workerTransport`, fails it
on the log's first entry. The final read is `toHaveText`, not `toContainText`.
The older specs' `toContainText("in-page worker")` could not tell the starting
text from the claim if the one contained the other, and the starting text
deliberately does not.
### only the first frame claims the peer answered, and not before it
improvise-i0v9, the unit half of the spec above. `arm(message, { answer })`
must write nothing until `sawFrame`, then write `answer` once, however many
frames follow. Fails against `gh/main`'s `createShell`, which has no `answer`
and so never writes the claim at all.
### a reason given before the first frame is not replaced by the frame's claim
The order `worker.js` really produces on a locked document: the `ReadOnly`
notice is posted after `start()` and **before** the Snapshot. So `fail` then
`sawFrame` must leave the notice's text as the only thing ever shown. The rival
is the obvious fix, `sawFrame` writing `answer` unconditionally. It fails both
this test and the one above: the first on its exact log, which is then written
twice. This test passes against `gh/main` by construction, since no claim exists
there. It is not evidence of the bug; it guards against the naive fix.
## Numbers across the serialized wire (improvise-6oy4)
No manifest enabled serde_json's `float_roundtrip`, so its default f64 parser
+142 -18
View File
@@ -1978,12 +1978,27 @@ Two measure columns of one row are two cells of one record, and the next row is
different record — the same shape the pivot case has, checked here because records
mode reaches it by a different path.
### a_coordinate_column_is_projected_with_no_identity
### a_coordinate_cell_is_named_rather_than_left_as_no_cell
A coordinate column is not a cell to write into: editing it renames that coordinate
across *every* measure cell in the row (gotcha 1), which is a different intent from
`SetCell`. So it is projected with no id at all, and a client cannot compose a
write against it.
improvise-exdg. This entry replaces `a_coordinate_column_is_projected_with_no_identity`,
a test that **pinned the bug**. It asserted that a records-mode coordinate cell
projects `id: None`. ADR 002 and gotcha 42 define `None` as "no projected cell",
yet the Region column is drawn and holds text. So a client could not tell it
from a padded slot. Absence was being used to encode a permission ("the host's"),
which is the conflation ADR 002's rejected `Option<CellId>` draft was about.
`improvise-poia` removed the same conflation for the creating columns.
The cell now projects `CellId::Coordinate`. That keeps the old test's real
property: it is still not a `SetCell` target, because `typed_cell_command`,
`record_cell_at_cursor` and `render.rs::editable` all match `Record` alone.
The test seats the cursor on the **second** row, so the first-row and
first-item cases cannot coincide. It then enumerates every cell of the records
grid and requires each to carry an id, which is the ticket's claim with no
case picked by hand. Before the fix it failed at `a drawn coordinate projected
as no cell`. Rival injected afterwards: `RecordCol::Category(_) => return None`
restored. This test failed at the `Some(CellId::Coordinate)` assertion, and
`a_creating_column_is_named_rather_than_left_as_no_cell` (whose `None`-count
now expects zero) failed too.
### a_write_moves_the_version_under_the_clients_own_surrogate
@@ -3140,10 +3155,13 @@ Injected fault: hardcode the `category_panel_open` propagator to `true`. Fails.
### toggle_panel_visibility_reads_the_panel_it_names
The behavioural half, and the one that reaches the registry. Its interactive arm
passes `currently_open` straight through, so with exactly one panel open the command
built for each of the three names carries its own panel's flag. Both injections above
fail here too.
The behavioural half, and the one that reaches the registry. With exactly one panel
open, the command built for each of the three names and executed must emit
`SetPanelOpen` for **its own** panel, flipping **its own** flag. Both injections above
fail here too. Since improvise-yf7p the flag is read in `execute` rather than copied
in by a registry contextualizer, so the assertion moved from the built command's
`Debug` to its effects; it also names the panel, so an `execute` that ignores the
parsed panel (`ctx.panel_open(Panel::Formula)`) fails it — injected and run.
### a_focused_open_panel_closes_and_a_closed_one_opens
@@ -3295,11 +3313,12 @@ aggregates, arriving a third time.
The test walks **every** column of a records grid and counts, rather than
asserting the two creating columns alone. That matters in the direction a
weaker test fails: a producer that answered `Create` for everything, or that
made the `Region` coordinate column nameable, would pass a two-position check
and fails this one. The fault injected — returning `None` for both creating
columns, which is the pre-fix code — reports three nameless columns where one
is allowed.
weaker test fails: a producer that answered `Create` for everything would pass
a two-position check and fails this one. The fault injected — returning `None`
for both creating columns, which is the pre-fix code — reported three nameless
columns where one was allowed. Since improvise-exdg the coordinate column is
named too (`CellId::Coordinate`), so the count of nameless columns is now zero.
See `a_coordinate_cell_is_named_rather_than_left_as_no_cell`.
### a_records_coordinate_is_text_however_it_is_spelled
@@ -4728,19 +4747,52 @@ Its mirror, and the one that pins the wrap: from the first item `[` must land on
`SetPanelCursor` carries a panel *and* a cursor and neither was checked. Both
are now named, so moving the right cursor on the wrong panel fails.
### move_panel_cursor_moves_from_the_named_panels_own_cursor
improvise-yf7p. `move-panel-cursor formula 2` through the registry's interactive
arm, with the formula cursor at **1** of five formulas and the category and view
cursors at 0, must land on **3**. Before yf7p the cursor and bound were copied in
by a registry `contextualize`, and review showed one that clamped the parsed delta
to `delta.signum()` left the whole workspace green: every test moved by ±1, where
a clamp is invisible. They are read in `execute` now, and this test is what sees a
bug there. Rival `self.delta.signum()` in `execute` — injected, run, fails (lands
on 2). Reading another panel's cursor fails too, because the fixture seats them at
different values — the index-0 fixture could not separate them.
### delete_formula_at_cursor_with_formulas
Asserts the `RemoveFormula` names `Profit` in `Type` — the formula the cursor is
on — rather than merely that a formula was removed.
Asserts the `RemoveFormula` names the formula the cursor is on, rather than merely
that a formula was removed. PR #148 named the field, but its fixture was **one**
formula with the cursor at **0**, where "the formula under the cursor" and "the
first formula" are the same formula (improvise-y1av). The rival it could not see —
`DeleteFormulaAtCursor` reading index `0` instead of `ctx.formula_cursor` — passed
the **whole workspace**, so `d` in the formula panel deleting the wrong formula had
no guard anywhere. Now two formulas, cursor at **1**: it must remove `Tax` and must
not name `Profit`. Rival `let cursor = 0usize.min(..)` injected, full suite run:
this is the only test that fails.
### switch_view_at_cursor_with_valid_cursor
Names `Default`, the view under the cursor.
Names the view under the cursor. Same fixture defect as above (improvise-y1av):
`two_cat_model` has one view, so the cursor at 0 named `Default` whether or not
the command read it. Now `Default, Other` with the cursor at **1**, asserting
`Other`. Rival `get(cursor).and(first())` — in bounds only, so the out-of-bounds
arm is unchanged and `switch_view_at_cursor_out_of_bounds_returns_empty` cannot be
what catches it — injected and run: fails here, in
`delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor`, and in the two
`wasm-client/tests/lockstep.rs` tests that were previously the only guard.
### delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor
improvise-y1av. `Default, Other, Third` with the cursor at **1**: deletes `Other`
and steps the panel cursor back to 0. The same rival as above fails it.
### delete_view_at_cursor_zero_does_not_adjust_cursor
Names `Default` too. Its existing negative half — no `SetPanelCursor` at
position 0 — is unchanged.
position 0 — is unchanged. Position 0 is this test's subject, so it cannot
separate "the view under the cursor" from "the first view" and does not try to;
the test above does.
### commit_category_add_with_name_produces_add_effect
@@ -5006,6 +5058,31 @@ run alone with the ones before it removed, and each fails: the diff returns
test passes. See
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
### ui::host::tests::a_frame_writing_a_view_its_own_first_command_deleted_is_refused
improvise-bok4. This is the reviewer's first measured case:
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]`. Before the fix
the asker's reply was `[Commands([ForgetView("Scratch")]), Commands([Project…])]`
and then `Accepted`. The second command had done nothing, because
`view_mut_of` found no view and every view-addressed effect swallowed the
`None`. Now `on_view` refuses, the abort becomes
`Rejected(MutationRefused)`, and the assertion that `Scratch` still exists
proves the deletion was undone with the rest of the frame.
Its sibling, `a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused`,
is the reviewer's second case: `[RemoveCategory("Region"), AddItem { category:
"Region" }]`. It was accepted as well, with `AddItem`'s error written only to
the host's `status_msg`. Both tests failed first against the unfixed effects.
`deleting_the_documents_last_view_is_refused_rather_than_accepted` covers the
one-command case, which `DeleteView`'s `let _` swallowed. The fault injected
was to put the `let _` back. The test then failed with a projection batch
where the refusal should have been, and it was reverted.
The control is `a_frame_whose_commands_all_land_is_accepted`, the same two
commands in an order where both land. Without it, a host that refused every
two-command frame would pass all three tests.
### ui::reduce::vocabulary_is_complete::every_effect_is_on_the_wire_or_has_a_verdict
improvise-h3y7, the forward direction of the vocabulary derivation. It is the
@@ -5139,6 +5216,53 @@ the first message does not mention drilling. At `0ed2e41` it failed with the
old text. The rival is restoring that text. Real drill paste, which means
staging a typed `CellValue`, is still open as improvise-48jd.
### ui::host::tests::every_failing_model_command_refuses_its_whole_frame
improvise-9fpl, found by PR #181's review. Eight of bok4's refusal sites had no
test: reverting any one of them to a status-only write failed nothing. They
were `AddItemInGroup`, `AddFormula`'s parse error, and all six
`ImportJsonHeadless` arms. Two improvise-40j4 cases were added to the same
table: `DeleteView` of an absent view, and `AddFormula` into an absent
category.
Each case sends a frame of two commands. The first, `AddItem { Region,
"Landed" }`, lands. The second fails. The test asserts that the asker hears
exactly `[Rejected(MutationRefused)]` and that `Landed` is gone. The fixture
has a second view, `Scratch`, because with one view `DeleteView("Ghost")` is
refused as "the last view" and the absent-name case is never reached. The
first run found exactly that: the case passed before the fix.
Faults injected by a script (`/tmp/wire2_inject2.py`, not committed): each of
the 14 refusal sites was reverted **alone** to a status-only write, after the
merge with #185. Every one failed at least one test. The eight that were
unwitnessed fail this test, `DeleteView`'s last-view arm fails
`deleting_the_documents_last_view_is_refused_rather_than_accepted`, `on_view`
fails `a_frame_writing_a_view_its_own_first_command_deleted_is_refused`, and
`LoadModel` fails both `a_load_that_fails_is_not_a_rebase` and
`a_failed_load_or_import_does_not_run_the_quit_after_it`. The file was
restored and compared.
### ui::host::tests::a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused
improvise-40j4. `[RemoveCategory("Region"), AddFormula { "Total = 1", Region }]`
was accepted, and the document held a formula for a category that did not
exist; `format_md` wrote it and `parse_md` reloaded it. `AddFormula` ran
`let _ = add_item(…)` and then `add_formula` anyway. The test failed first with
the frame accepted. `AddFormula` now refuses when `add_item` errs, before
storing anything.
### ui::app::tests::a_failed_load_or_import_does_not_run_the_quit_after_it
improvise-cz8d. After #185, `App::refuse` does not abort. So on gh/main a failed
`load` refused, and the `force-quit` after it in the same batch still ran:
`load absent.improv . q` quit having loaded nothing. bok4's aborting helper had
prevented that, and the merge would have silently lost it. The test binds
`[load|import-json <absent>, force-quit]` as one sequence and asserts that the
refusal is recorded and the mode is not `Quit`. `import-json` failed first,
before `refuse_and_stop` was applied to its arms. For `load`, the injected fault
is removing its abort (`refuse_and_stop` back to `app.refuse`), and the test
then fails with "`load absent.improv . q` quit having loaded nothing".
### `an_untouched_pivot_edit_of_a_misread_value_writes_nothing`
### `an_untouched_records_edit_of_a_misread_value_writes_nothing`
+55 -5
View File
@@ -557,7 +557,7 @@ the evidence that nothing was relying on the permissive parse.
### render::tests::two_runs_under_two_headers_of_one_name_are_two_runs
### render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them
### render::tests::the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them
improvise-h0iv. `group_runs` coalesced adjacent positions by group **name**, so
two runs under two different headers sharing a name read as one and both front
@@ -571,10 +571,12 @@ and nothing ungrouped between the runs. Before the fix it returns
at the precondition rather than pass with a single run.
The second test exists because the ticket proposed the wrong fix and a reader
will propose it again. h0iv says `FrameGroup` carries `cat` and "comparing the
pair would fix it" — but its own reachability case is a single category nested
under an outer one, so both headers are `(Month, Q1)`, equal as values. The test
asserts that equality directly. What actually separates them is that they are
will propose it again. h0iv said `FrameGroup` carried `cat` and that "comparing
the pair would fix it". But its own reachability case is a single category
nested under an outer one, so both headers are `(Month, Q1)`, equal as values.
The test asserts that equality directly. Since improvise-7xyx removed
`FrameGroup::cat`, it asserts equality of the whole header, which is stronger:
no field-wise comparison can separate two equal values. What actually separates them is that they are
two distinct entries in `self.entries`, which `std::ptr::eq` on the borrowed
header states exactly.
@@ -1050,3 +1052,51 @@ Faults injected, one per half, each compiling only `improvise-protocol`:
A field added to an existing variant is caught too, but not in isolation,
because every constructor of that variant also fails. Reverted with
`git restore`.
### source_scan::tests::a_test_only_item_hides_nothing_after_it
improvise-59q8, found by the PR #172 review. The scanner's line-based
`strip_test_items` looked for `}` at the attribute's indentation, or for an item
line ending in `;`. So three shapes were never bounded, and it dropped
**everything to the end of the file**:
- a test-only `const … = &[\n … \n];`;
- a `#[cfg(test)] let x = foo(\n 1,\n);` statement;
- a one-line `#[cfg(test)] use foo::bar;`, where the attribute's own line is
also the item line.
In `vocabulary_is_complete` that silently hides every effect after such an
item. Each shape is a case here, asserting that the production code after it
(`Hidden1`, `Visible`) survives. Against the line scan all three failed; the
first printed `"impl X {\n"` and nothing else. They now pass because items are
bounded by balanced brackets on the blanked code.
`an_unbounded_test_only_item_is_refused_not_swallowed` pins the other half of
the acceptance criteria: an item that cannot be bounded panics instead of
consuming the rest of the file. Against the line scan it failed, because
nothing panicked.
### source_scan::tests::a_test_only_cfg_is_recognised_whatever_its_spelling
improvise-59q8, the other direction: test code counted as production, which
makes `vocabulary_is_composed` weaker. `cfg(all(test, not(target_arch =
"wasm32")))` was kept because the old rule rejected any line containing
`not(`. A leading `#![cfg(test)]` was kept because only `#[cfg(` was
recognised. An attribute split across lines was not seen at all. All three
failed against the old scanner. The predicate is now parsed and evaluated in
three-valued logic (`context/code/protocol.md` §2). The controls
(`cfg(not(test))`, `cfg(any(test, feature = "serde"))` and `cfg(target_arch =
…)`) must stay production, so a scanner that dropped every `cfg` item could
not pass.
A consequence to know about: `cfg(any(test, feature = "x"))` for an ordinary
feature is now production. Only `feature = "test-support"`, the one test
feature this repo has, counts as a test build.
`test_only_items_and_modules_are_not_production` was updated to that spelling.
### source_scan::tests::a_c_string_literal_is_blanked
improvise-59q8's minor case. In `cr#"x" Command::InRawC(2) "y"#` the `r` has a
word character before it, so the literal was lexed as two ordinary strings,
and the text between the inner quotes leaked. It failed as
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
this test used a C string with no inner quotes and passed against the bug,
because the ordinary lexing happened to blank it too.
@@ -3,6 +3,8 @@ use std::fmt::Debug;
use crate::command::key::KeyCode;
use super::navigation::CursorState;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
@@ -141,13 +143,15 @@ pub trait Cmd: Debug + Send + Sync {
/// Factory that constructs a Cmd from text arguments (headless/script).
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
/// Fills the context-only fields of a command `parse` already built. It never
/// sees the args, so it cannot discard them — `context/code/cli.md` §8.
pub type ContextualizeFn<C> = fn(C, &CmdContext) -> C;
/// Builds a command from context, reached ONLY for args `parse` refused.
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
/// A command whose one context-only field is the cursor it moves from. The
/// registry fills it, and no registration can — `context/code/cli.md` §8.
pub trait AtCursor: Cmd {
fn cursor_mut(&mut self) -> &mut CursorState;
}
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
@@ -188,25 +192,48 @@ impl CmdRegistry {
name
}
/// Register a command whose interactive arm is `parse` then `contextualize`, so
/// args `parse` accepts are honoured whole — `context/code/cli.md` §8.
/// Register a command whose interactive arm is `parse`, reaching `fallback`
/// only where `parse` refused — `context/code/cli.md` §8.
pub fn register<C: Cmd + 'static>(
&mut self,
prototype: &C,
parse: fn(&[String]) -> Result<C, String>,
contextualize: ContextualizeFn<C>,
fallback: Option<FallbackFn<C>>,
) {
self.push(prototype.name(), parse, fallback, |_, _| {});
}
/// As [`register`](Self::register), then the interactive arm moves the
/// command's cursor to the live one. The cursor is the only field it writes.
pub fn register_at_cursor<C: AtCursor + 'static>(
&mut self,
prototype: &C,
parse: fn(&[String]) -> Result<C, String>,
fallback: Option<FallbackFn<C>>,
) {
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
});
}
fn push<C: Cmd + 'static>(
&mut self,
name: &'static str,
parse: fn(&[String]) -> Result<C, String>,
fallback: Option<FallbackFn<C>>,
at_cursor: fn(&mut C, &CmdContext),
) {
self.entries.push(CmdEntry {
name: prototype.name(),
name,
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
interactive: Box::new(move |args, ctx| {
let built = match (parse(args), fallback) {
(Ok(cmd), _) => Ok(contextualize(cmd, ctx)),
(Err(_), Some(fallback)) => fallback(args, ctx),
(Err(err), None) => Err(err),
let mut built = match (parse(args), fallback) {
(Ok(cmd), _) => cmd,
(Err(_), Some(fallback)) => fallback(args, ctx)?,
(Err(err), None) => return Err(err),
};
built.map(|c| Box::new(c) as Box<dyn Cmd>)
at_cursor(&mut built, ctx);
Ok(Box::new(built) as Box<dyn Cmd>)
}),
});
}
@@ -118,6 +118,22 @@ pub(super) mod test_helpers {
wb
}
/// `two_cat_model` with one `_Measure` formula per target, in order.
pub fn with_formulas(targets: &[&str]) -> Workbook {
let mut wb = two_cat_model();
for target in targets {
wb.model.add_formula(crate::formula::ast::Formula {
raw: format!("{target} = 0"),
target: target.to_string(),
target_category: "_Measure".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
}
wb
}
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
format!("{:?}", effects)
}
@@ -192,11 +192,7 @@ mod tests {
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
@@ -208,11 +204,7 @@ mod tests {
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_panel_open = true;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: false,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = format!("{:?}", effects[0]);
@@ -231,11 +223,7 @@ mod tests {
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_panel_open = true;
ctx.category_panel_open = true;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -256,11 +244,7 @@ mod tests {
ctx.formula_panel_open = true;
ctx.category_panel_open = true;
ctx.mode = &AppMode::FormulaPanel;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -281,11 +265,7 @@ mod tests {
ctx.category_panel_open = true;
ctx.view_panel_open = true;
ctx.mode = &AppMode::CategoryPanel;
let cmd = CyclePanelFocus {
formula_open: false,
category_open: true,
view_open: true,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -305,11 +285,7 @@ mod tests {
ctx.formula_panel_open = true;
ctx.view_panel_open = true;
ctx.mode = &AppMode::ViewPanel;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: false,
view_open: true,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -329,11 +305,7 @@ mod tests {
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.category_panel_open = true;
ctx.mode = &AppMode::CategoryPanel;
let cmd = CyclePanelFocus {
formula_open: false,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
@@ -344,15 +316,13 @@ mod tests {
#[test]
fn move_panel_cursor_down_from_zero() {
let m = two_cat_model();
let m = with_formulas(&["A", "B"]);
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: 1,
current: 0,
max: 5,
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
@@ -363,17 +333,35 @@ mod tests {
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn move_panel_cursor_moves_from_the_named_panels_own_cursor() {
let m = with_formulas(&["A", "B", "C", "D", "E"]);
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_cursor = 1;
let args = ["formula".to_string(), "2".to_string()];
let effects = reg
.interactive("move-panel-cursor", &args, &ctx)
.expect("move-panel-cursor resolves")
.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SetPanelCursor { panel: Formula, cursor: 3 }"),
"must move from the formula cursor (1) by the parsed delta (2), got: {dbg}"
);
}
#[test]
fn move_panel_cursor_clamps_at_zero() {
let m = two_cat_model();
let m = with_formulas(&["A", "B"]);
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: -1,
current: 0,
max: 5,
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
@@ -388,46 +376,55 @@ mod tests {
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: 1,
current: 0,
max: 0,
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn delete_formula_at_cursor_with_formulas() {
let mut m = two_cat_model();
m.model.add_formula(crate::formula::ast::Formula {
raw: "Profit = Revenue - Cost".to_string(),
target: "Profit".to_string(),
target_category: "Type".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
for (target, raw) in [("Profit", "Profit = 1"), ("Tax", "Tax = 2")] {
m.model.add_formula(crate::formula::ast::Formula {
raw: raw.to_string(),
target: target.to_string(),
target_category: "Type".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
}
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_cursor = 1;
let effects = DeleteFormulaAtCursor.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("RemoveFormula { target: \"Profit\", target_category: \"Type\" }"),
"must remove the formula AT THE CURSOR, got: {dbg}"
dbg.contains("RemoveFormula { target: \"Tax\", target_category: \"Type\" }"),
"must remove the formula AT THE CURSOR (Tax, index 1), got: {dbg}"
);
assert!(
!dbg.contains("\"Profit\""),
"removed the first formula, not the one under the cursor: {dbg}"
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn switch_view_at_cursor_with_valid_cursor() {
let m = two_cat_model();
let mut m = two_cat_model();
m.create_view("Other");
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_panel_cursor = 1;
let effects = SwitchViewAtCursor.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SwitchView(\"Default\")"),
"must switch to the view UNDER THE CURSOR, got: {dbg}"
dbg.contains("SwitchView(\"Other\")"),
"must switch to the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
);
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
}
@@ -504,6 +501,27 @@ mod tests {
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor() {
let mut m = two_cat_model();
m.create_view("Other");
m.create_view("Third");
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_panel_cursor = 1;
let dbg = effects_debug(&DeleteViewAtCursor.execute(&ctx));
assert!(
dbg.contains("DeleteView(\"Other\")"),
"must delete the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
);
assert!(
dbg.contains("SetPanelCursor { panel: View, cursor: 0 }"),
"a delete off position 0 steps the cursor back, got: {dbg}"
);
}
#[test]
fn delete_view_at_cursor_zero_does_not_adjust_cursor() {
let m = two_cat_model();
@@ -559,16 +577,15 @@ impl Cmd for TogglePanelAndFocus {
#[derive(Debug)]
pub struct TogglePanelVisibility {
pub panel: Panel,
pub currently_open: bool,
}
impl Cmd for TogglePanelVisibility {
fn name(&self) -> &'static str {
"toggle-panel-visibility"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SetPanelOpen {
panel: self.panel,
open: !self.currently_open,
open: !ctx.panel_open(self.panel),
})]
}
}
@@ -576,20 +593,16 @@ impl Cmd for TogglePanelVisibility {
/// Tab through open panels Formula → Category → View, wrapping, starting from the
/// focused one. One open panel self-transitions; none open is a no-op.
#[derive(Debug)]
pub struct CyclePanelFocus {
pub formula_open: bool,
pub category_open: bool,
pub view_open: bool,
}
pub struct CyclePanelFocus;
impl Cmd for CyclePanelFocus {
fn name(&self) -> &'static str {
"cycle-panel-focus"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let order = [
(self.formula_open, AppMode::FormulaPanel),
(self.category_open, AppMode::CategoryPanel),
(self.view_open, AppMode::ViewPanel),
(ctx.formula_panel_open, AppMode::FormulaPanel),
(ctx.category_panel_open, AppMode::CategoryPanel),
(ctx.view_panel_open, AppMode::ViewPanel),
];
// Index of the first candidate: the panel after the focused one,
// or the first panel when invoked from a non-panel mode.
@@ -614,16 +627,17 @@ impl Cmd for CyclePanelFocus {
pub struct MovePanelCursor {
pub panel: Panel,
pub delta: i32,
pub current: usize,
pub max: usize,
}
impl Cmd for MovePanelCursor {
fn name(&self) -> &'static str {
"move-panel-cursor"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let cursor = self.current;
let max = self.max;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let (cursor, max) = match self.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
if max == 0 {
return vec![];
}
@@ -27,19 +27,21 @@ fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
}
/// A `Move` built from args takes the live cursor as its origin.
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
Move {
cursor: CursorState::from_ctx(ctx),
..cmd
impl AtCursor for Move {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
/// A commit built from args still advances from the live cursor.
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
CommitAndAdvance {
cursor: CursorState::from_ctx(ctx),
..cmd
impl AtCursor for EnterAdvance {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
impl AtCursor for CommitAndAdvance {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
@@ -62,11 +64,6 @@ fn commit_at_cursor(
})
}
/// For a command with no context-only fields: `parse`'s result is used as built.
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
cmd
}
/// Build the default command registry with all commands.
/// Registry names MUST match the `Cmd::name()` return value.
pub fn default_registry() -> CmdRegistry {
@@ -91,7 +88,6 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(ClearCellCommand {
key: cursor_key(ctx)?,
@@ -122,7 +118,7 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
@@ -138,13 +134,12 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "move-selection",
})
},
move_from_cursor,
None,
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
$r.register(
$r.register_at_cursor(
&Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
@@ -165,7 +160,6 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: $name,
})
},
move_from_cursor,
None,
);
};
@@ -174,7 +168,7 @@ pub fn default_registry() -> CmdRegistry {
reg_jump!(r, true, true, "jump-last-row");
reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col");
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
@@ -189,10 +183,9 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "scroll-rows",
})
},
move_from_cursor,
None,
);
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Page(0),
cursor: CursorState::default(),
@@ -207,10 +200,9 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "page-scroll",
})
},
move_from_cursor,
None,
);
r.register(
r.register_at_cursor(
&EnterAdvance {
cursor: CursorState::default(),
},
@@ -228,9 +220,6 @@ pub fn default_registry() -> CmdRegistry {
},
})
},
|_, ctx| EnterAdvance {
cursor: CursorState::from_ctx(ctx),
},
None,
);
@@ -247,7 +236,6 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(YankCell {
key: cursor_key(ctx)?,
@@ -266,7 +254,6 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(PasteCell {
key: cursor_key(ctx)?,
@@ -319,7 +306,6 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(DrillIntoCell {
key: cursor_key(ctx)?,
@@ -357,77 +343,30 @@ pub fn default_registry() -> CmdRegistry {
});
Ok(TogglePanelAndFocus { panel, target })
},
as_parsed,
None,
);
r.register(
&TogglePanelVisibility {
panel: Panel::Formula,
currently_open: false,
},
|args| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(TogglePanelVisibility {
panel,
currently_open: false,
})
},
|cmd, ctx| TogglePanelVisibility {
currently_open: ctx.panel_open(cmd.panel),
..cmd
},
None,
);
r.register(
&CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
},
|_| {
Ok(CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
})
},
|_, ctx| CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
Ok(TogglePanelVisibility { panel })
},
None,
);
r.register_nullary(|| Box::new(CyclePanelFocus));
r.register(
&MovePanelCursor {
panel: Panel::Formula,
delta: 0,
current: 0,
max: 0,
},
|args| {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(MovePanelCursor {
panel,
delta,
current: 0,
max: 0,
})
},
|cmd, ctx| {
let (current, max) = match cmd.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
MovePanelCursor {
current,
max,
..cmd
}
Ok(MovePanelCursor { panel, delta })
},
None,
);
@@ -494,7 +433,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Commit ───────────────────────────────────────────────────────────
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
r.register(
r.register_at_cursor(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
@@ -517,10 +456,9 @@ pub fn default_registry() -> CmdRegistry {
opened: None,
})
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
);
r.register(
r.register_at_cursor(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
@@ -530,7 +468,6 @@ pub fn default_registry() -> CmdRegistry {
opened: None,
},
|_| Err("commit-and-advance-right requires context".into()),
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
);
r.register(
@@ -542,7 +479,6 @@ pub fn default_registry() -> CmdRegistry {
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
Ok(CommitBuffer { kind })
},
as_parsed,
Some(|args, ctx| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], ctx.mode)?;
@@ -867,12 +803,16 @@ mod panel_open_tests {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
for asked in Panel::ALL {
let args = [asked.name().to_string()];
let cmd = ctx
let effects = ctx
.registry
.interactive("toggle-panel-visibility", &args, &ctx)
.expect("toggle-panel-visibility resolves");
let dbg = format!("{cmd:?}");
let expected = format!("currently_open: {}", asked == opened);
.expect("toggle-panel-visibility resolves")
.execute(&ctx);
let dbg = format!("{effects:?}");
let expected = format!(
"SetPanelOpen {{ panel: {asked:?}, open: {} }}",
asked != opened
);
assert!(
dbg.contains(&expected),
"with only {opened:?} open, toggling {asked:?} read another \
+37
View File
@@ -2991,6 +2991,43 @@ mod tests {
);
}
/// improvise-cz8d: a refusal need not abort, so a failed document swap must
/// ask for the abort itself. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_failed_load_or_import_does_not_run_the_quit_after_it() {
use improvise_protocol::keymap::Keymap;
for (command, path) in [("load", "absent.improv"), ("import-json", "absent.json")] {
let mut app = App::new(Workbook::new("T"), None);
app.storage = Box::new(crate::storage::MemoryFs::new());
let key = KeyCode::Char('x');
let mods = KeyModifiers::NONE;
let mut km = Keymap::new();
km.bind_seq(
key,
mods,
vec![(command, vec![path.to_string()]), ("force-quit", vec![])],
);
let effects = {
let ctx = app.cmd_context(key, mods);
app.keymap_set.dispatch_transient(&km, &ctx, key, mods)
}
.expect("both steps resolve in the registry");
app.apply_effects(effects);
assert!(
app.last_batch_refused(),
"setup: `{command}` must actually have failed, got {:?}",
app.view_state.status_msg
);
assert_ne!(
app.view_state.mode,
AppMode::Quit,
"`{command} {path} . q` quit having loaded nothing"
);
}
}
/// A prefix is consumed whether or not the next key matches, so a stray key
/// after `g` does nothing rather than acting as a fresh Normal binding.
#[test]
+63 -31
View File
@@ -44,6 +44,31 @@ pub enum ViewTarget {
Named(String),
}
/// Refuse AND stop the batch, for a failed document swap: what follows it in
/// a `:` line assumes it happened, so `load x . q` must not quit (cz8d).
fn refuse_and_stop(app: &mut App, why: impl Into<String>) {
app.refuse(why);
app.abort_effects = true;
}
/// Write the view a geometry effect names, or refuse: a view the document no
/// longer has is a failure to report, not a no-op to swallow (improvise-bok4).
fn on_view(app: &mut App, target: &ViewTarget, write: impl FnOnce(&mut crate::view::View)) {
let missing = match app.view_mut_of(target) {
Some(view) => {
write(view);
None
}
None => Some(match target {
ViewTarget::Named(name) => format!("No view named \"{name}\""),
ViewTarget::Active => "No active view".to_string(),
}),
};
if let Some(why) = missing {
app.refuse(why);
}
}
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
@@ -183,11 +208,13 @@ impl Effect for AddFormula {
// grid; _Measure targets arrive via `measure_item_names()`.
if !crate::model::category::CategoryKind::of_name(&formula.target_category)
.is_measure()
{
let _ = app
&& let Err(e) = app
.model_state
.workbook
.add_item(&formula.target_category, &formula.target);
.add_item(&formula.target_category, &formula.target)
{
// Its category may be gone: store no formula for it (40j4).
return app.refuse(e.to_string());
}
app.model_state.workbook.model.add_formula(formula);
}
@@ -255,9 +282,9 @@ impl Effect for EnterEditAtCursor {
pub struct TogglePruneEmpty(pub ViewTarget);
impl Effect for TogglePruneEmpty {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.0) {
on_view(app, &self.0, |v| {
v.prune_empty = !v.prune_empty;
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -321,7 +348,14 @@ impl Effect for CreateView {
pub struct DeleteView(pub String);
impl Effect for DeleteView {
fn apply(&self, app: &mut App) {
let _ = app.model_state.workbook.delete_view(&self.0);
// `delete_view` answers Ok for a name it lacks, which is a failure to
// report, not a deletion (improvise-40j4).
if !app.model_state.workbook.views.contains_key(&self.0) {
return app.refuse(format!("No view named \"{}\"", self.0));
}
if let Err(e) = app.model_state.workbook.delete_view(&self.0) {
return app.refuse(e.to_string());
}
// This client may have been the one on it. Idempotent for every other
// view; the mirrors owe the same move (`Sessions::adopt_deleted_views`).
app.view_state.active_view = app.view_name().to_string();
@@ -405,9 +439,9 @@ pub struct SetAxis {
}
impl Effect for SetAxis {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.view) {
on_view(app, &self.view, |v| {
v.set_axis(&self.category, self.axis);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -422,9 +456,9 @@ pub struct SetPageSelection {
}
impl Effect for SetPageSelection {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.view) {
on_view(app, &self.view, |v| {
v.set_page_selection(&self.category, &self.item);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -439,9 +473,9 @@ pub struct ToggleGroup {
}
impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.view) {
on_view(app, &self.view, |v| {
v.toggle_group_collapse(&self.category, &self.group);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -456,9 +490,9 @@ pub struct HideItem {
}
impl Effect for HideItem {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.view) {
on_view(app, &self.view, |v| {
v.hide_item(&self.category, &self.item);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -473,9 +507,9 @@ pub struct ShowItem {
}
impl Effect for ShowItem {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.view) {
on_view(app, &self.view, |v| {
v.show_item(&self.category, &self.item);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -486,9 +520,9 @@ impl Effect for ShowItem {
pub struct TransposeAxes(pub ViewTarget);
impl Effect for TransposeAxes {
fn apply(&self, app: &mut App) {
if let Some(v) = app.view_mut_of(&self.0) {
on_view(app, &self.0, |v| {
v.transpose_axes();
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -500,9 +534,9 @@ pub struct CycleAxis(pub ViewTarget, pub String);
impl Effect for CycleAxis {
fn apply(&self, app: &mut App) {
let category = self.1.clone();
if let Some(v) = app.view_mut_of(&self.0) {
on_view(app, &self.0, |v| {
v.cycle_axis(&category);
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -514,9 +548,9 @@ pub struct SetNumberFormat(pub ViewTarget, pub String);
impl Effect for SetNumberFormat {
fn apply(&self, app: &mut App) {
let format = self.1.clone();
if let Some(v) = app.view_mut_of(&self.0) {
on_view(app, &self.0, |v| {
v.number_format = format;
}
});
}
fn kind(&self) -> EffectKind {
EffectKind::Model(ModelChange::LayoutGeometry)
@@ -939,9 +973,7 @@ impl Effect for LoadModel {
app.install_workbook(loaded);
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.refuse(format!("Load error: {e}"));
}
Err(e) => refuse_and_stop(app, format!("Load error: {e}")),
}
}
fn kind(&self) -> EffectKind {
@@ -998,7 +1030,7 @@ impl Effect for ImportJsonHeadless {
let bytes = match app.storage.read(&self.path) {
Ok(b) => b,
Err(e) => {
app.refuse(format!("Cannot read '{}': {e}", self.path.display()));
refuse_and_stop(app, format!("Cannot read '{}': {e}", self.path.display()));
return;
}
};
@@ -1009,7 +1041,7 @@ impl Effect for ImportJsonHeadless {
(recs, raw)
}
Err(e) => {
app.refuse(format!("CSV error: {e}"));
refuse_and_stop(app, format!("CSV error: {e}"));
return;
}
}
@@ -1017,21 +1049,21 @@ impl Effect for ImportJsonHeadless {
let content = match app.storage.read(&self.path) {
Ok(c) => c,
Err(e) => {
app.refuse(format!("Cannot read '{}': {e}", self.path.display()));
refuse_and_stop(app, format!("Cannot read '{}': {e}", self.path.display()));
return;
}
};
let value: serde_json::Value = match serde_json::from_slice(&content) {
Ok(v) => v,
Err(e) => {
app.refuse(format!("JSON parse error: {e}"));
refuse_and_stop(app, format!("JSON parse error: {e}"));
return;
}
};
match json_import_records(&value, self.array_path.as_deref()) {
Ok(recs) => (recs, value),
Err(msg) => {
app.refuse(msg);
refuse_and_stop(app, msg);
return;
}
}
@@ -1070,7 +1102,7 @@ impl Effect for ImportJsonHeadless {
};
}
Err(e) => {
app.refuse(format!("Import error: {e}"));
refuse_and_stop(app, format!("Import error: {e}"));
}
}
}
+289 -7
View File
@@ -166,11 +166,12 @@ impl Host {
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
return self.undo_frame(id, undo, mirrors, reason);
}
// Only a mutation runs `apply_effects`, and only `apply_effects`
// resets the flag — so after anything else it is a STALE answer.
// Only a mutation runs `apply_effects`, which resets both flags — so
// after anything else they are STALE. A refusal need not abort (cz8d).
let mutation = matches!(cmd.command(), Command::Model { .. });
out.extend(self.command(id, cmd));
if mutation && self.app.last_batch_aborted() {
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
if mutation && failed {
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
}
}
@@ -1541,10 +1542,12 @@ mod tests {
.any(|f| matches!(f.msg, ServerFrame::Snapshot(_))),
"a failed load cost every client its cursors: {frames:?}"
);
assert!(
host.app().view_state.status_msg.starts_with("Load error"),
"the fixture did not actually fail the load: {:?}",
host.app().view_state.status_msg
// Since improvise-bok4 a failed load is REFUSED, so the refusal is the
// witness that it failed; the host's status line is rolled back with it.
assert_eq!(
to(&frames, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"the fixture did not actually fail the load"
);
}
@@ -1778,4 +1781,283 @@ mod tests {
let json = serde_json::to_string(&inbound).unwrap();
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
}
/// Two model commands, each caught up against the document as the client
/// saw it, so no basis tier can refuse the frame first (improvise-bok4).
fn two_command_frame(host: &Host, first: ModelCommand, then: ModelCommand) -> ClientFrame {
ClientFrame::Commands(vec![
crate::ui::reduce::caught_up(host.app(), first),
crate::ui::reduce::caught_up(host.app(), then),
])
}
/// improvise-bok4, the view-addressed case: the second command's view is
/// gone. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_writing_a_view_its_own_first_command_deleted_is_refused() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::DeleteView("Scratch".into()),
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.views
.contains_key("Scratch"),
"the refused frame's deletion must be undone"
);
}
/// improvise-bok4, the category-addressed case: it narrated its failure into
/// the HOST's status line and told the client nothing.
#[test]
fn a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.model
.category("Region")
.is_some(),
"the refused frame's removal must be undone"
);
}
/// The one-command case of the same class: deleting the last view was
/// swallowed with `let _`, so the frame was accepted having done nothing.
#[test]
fn deleting_the_documents_last_view_is_refused_rather_than_accepted() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let only = host.app().view_name().to_string();
let answer = host.model(asker, ModelCommand::DeleteView(only.clone()));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a deletion the workbook refused was reported accepted: {answer:?}"
);
assert!(host.app().model_state.workbook.views.contains_key(&only));
}
/// The control: two commands that both land are accepted, so the refusals
/// above are about a failure and not about the frame's shape.
#[test]
fn a_frame_whose_commands_all_land_is_accepted() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert!(
to(&answer, asker).contains(&ServerFrame::Accepted),
"a frame with nothing to refuse was refused: {answer:?}"
);
}
/// improvise-40j4: the formula was stored for a category the frame's own
/// first command removed, and it survived save and reload.
#[test]
fn a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Region".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a formula for a vanished category was accepted: {answer:?}"
);
let model = &host.app().model_state.workbook.model;
assert!(
model.category("Region").is_some(),
"the removal must be undone"
);
assert!(
model.formulas().iter().all(|f| f.target != "Total"),
"and the formula must not be stored"
);
}
/// A host whose storage holds one file per way `ImportJsonHeadless` can
/// fail, plus nothing at the two paths that must fail to read.
fn host_with_import_fixtures() -> Host {
let storage = MemoryFs::new();
let files: [(&str, &[u8]); 4] = [
// Every field numeric, so no category is accepted and the build fails.
("measures.json", br#"[{"x": 1}, {"x": 2}]"#),
("bad.json", b"{not json"),
("nested.json", br#"{"data": [{"Region": "North"}]}"#),
// A record that is not UTF-8 is a CSV read error, not a warning.
("bad.csv", b"Region,Revenue\n\xff\xfe,1\n"),
];
for (name, bytes) in files {
crate::storage::Storage::write(&storage, Path::new(name), bytes)
.expect("fixture writes");
}
// A second view, so deleting an absent one is not refused as "the last".
let mut wb = workbook();
wb.create_view("Scratch");
let mut app = App::new(wb, None);
app.storage = Box::new(storage);
app.rebuild_layout();
Host::new(app)
}
fn import(path: &str, array_path: Option<&str>) -> ModelCommand {
ModelCommand::ImportJsonHeadless {
path: PathBuf::from(path),
model_name: None,
array_path: array_path.map(String::from),
}
}
/// improvise-9fpl: one case per refusal #181 converted that no other test
/// witnesses. Evidence: `context/test-evidence/command.md`.
#[test]
fn every_failing_model_command_refuses_its_whole_frame() {
let cases: Vec<(&str, ModelCommand)> = vec![
(
"AddItemInGroup into an absent category",
ModelCommand::AddItemInGroup {
category: "Ghost".into(),
item: "East".into(),
group: "Coastal".into(),
},
),
(
"AddFormula that does not parse",
ModelCommand::AddFormula {
raw: "Total = (".into(),
target_category: "_Measure".into(),
},
),
(
"ImportJsonHeadless of an absent JSON",
import("absent.json", None),
),
(
"ImportJsonHeadless of an absent CSV",
import("absent.csv", None),
),
(
"ImportJsonHeadless of a CSV that is not text",
import("bad.csv", None),
),
(
"ImportJsonHeadless of JSON that does not parse",
import("bad.json", None),
),
(
"ImportJsonHeadless at a path with no array",
import("nested.json", Some("nope")),
),
(
"ImportJsonHeadless that builds no model",
import("measures.json", None),
),
// improvise-40j4: `delete_view` answers Ok for a name it lacks.
(
"DeleteView of a view the document lacks",
ModelCommand::DeleteView("Ghost".into()),
),
(
"AddFormula into a category the document lacks",
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Ghost".into(),
},
),
];
for (what, failing) in cases {
let mut host = host_with_import_fixtures();
let (asker, _) = host.connect(10, 10);
let landed = ModelCommand::AddItem {
category: "Region".into(),
item: "Landed".into(),
};
let answer = host.handle(asker, two_command_frame(&host, landed, failing));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"{what}: the frame was not refused: {answer:?}"
);
assert!(
!host
.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"Landed")),
"{what}: the half of the frame that landed was not undone"
);
}
}
}
+32 -21
View File
@@ -222,7 +222,6 @@ fn axis_frame(
pending.clear();
}
pending.push(FrameEntry::Group(FrameGroup {
cat: cat_name.clone(),
name: group_name.clone(),
collapsed: view.is_group_collapsed(cat_name, group_name),
}));
@@ -324,7 +323,7 @@ fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<Cel
RecordCol::NewCategory => return Some(CellId::Create(CreateKind::Category)),
// Renaming a coordinate moves every measure cell in the row, which is a
// different intent from `SetCell` and still the host's (gotcha 1).
RecordCol::Category(_) => return None,
RecordCol::Category(_) => return Some(CellId::Coordinate),
};
return Some(CellId::Record(RecordCell {
record: RecordHash::of(record),
@@ -493,7 +492,10 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis {
.flatten()
.filter_map(|cell| match cell.id.as_ref()? {
CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate => None,
})
.collect();
basis::published(app.document_generation(), app.layout_version(), records)
@@ -4853,20 +4855,6 @@ mod cell_identity {
);
}
/// A coordinate column is not a cell to write into (gotcha 1), so it is projected
/// with no id and a client cannot compose a write against it.
#[test]
fn a_coordinate_column_is_projected_with_no_identity() {
let app = records_app();
let region = column_named(&app, "Region");
assert_eq!(
grid_of(&app, WHOLE)
.cell(0, region)
.and_then(|c| c.id.clone()),
None
);
}
/// A document where `Profit` is BOTH an explicit `_Measure` item and a
/// formula target — which the `+` column lets a user build.
fn shadowed_measure_records_app() -> App {
@@ -5138,10 +5126,9 @@ mod cell_identity {
let ids: Vec<Option<CellId>> = (0..app.layout.col_count())
.map(|c| grid.cell(0, c).and_then(|cell| cell.id.clone()))
.collect();
assert_eq!(
ids.iter().filter(|id| id.is_none()).count(),
1,
"only the one Region coordinate column may be nameless: {ids:?}"
assert!(
ids.iter().all(Option::is_some),
"no records column is nameless since improvise-exdg: {ids:?}"
);
assert_eq!(
ids.iter()
@@ -5162,6 +5149,30 @@ mod cell_identity {
);
}
/// improvise-exdg: a coordinate cell is drawn and holds text, so it is not
/// "no cell". `context/test-evidence/command.md`.
#[test]
fn a_coordinate_cell_is_named_rather_than_left_as_no_cell() {
let app = records_app();
let grid = grid_of(&app, WHOLE);
let region = column_named(&app, "Region");
let north = grid.cell(1, region).expect("the second row's coordinate");
assert_eq!(north.text, "North", "setup: a visible coordinate cell");
assert_eq!(
north.id,
Some(CellId::Coordinate),
"a drawn coordinate is named"
);
let nameless: Vec<(usize, usize)> = (0..app.layout.row_count())
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
.filter(|&(r, c)| grid.cell(r, c).is_some_and(|cell| cell.id.is_none()))
.collect();
assert!(
nameless.is_empty(),
"every records cell is something: {nameless:?}"
);
}
/// The version counter is keyed by the surrogate the client holds, so one
/// identity answers ADR 002's question and ADR 001's.
#[test]
+8 -13
View File
@@ -55,6 +55,9 @@ pub enum CellId {
/// A records-mode creating column: typing a **name** into it adds that
/// measure or category and writes no cell at all (gotcha 1, improvise-poia).
Create(CreateKind),
/// A records-mode coordinate. Renaming it moves every measure cell in the
/// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg).
Coordinate,
}
/// What a creating column creates. The name typed into it is the whole
@@ -194,9 +197,6 @@ pub const MAX_ROW_HEADER_W: u16 = 24;
/// because it is a fact about the *view*, which only the server holds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameGroup {
/// The category the group belongs to, echoed back in `ToggleGroup` so a
/// client never works out which category a label came from.
pub cat: String,
pub name: String,
pub collapsed: bool,
}
@@ -1137,7 +1137,6 @@ mod tests {
FrameEntry::Item(vec!["A".into(), "x".into()]),
FrameEntry::Item(vec!["A".into(), "y".into()]),
FrameEntry::Group(FrameGroup {
cat: "Type".into(),
name: "Late".into(),
collapsed: true,
}),
@@ -1150,7 +1149,6 @@ mod tests {
/// ungrouped between them — improvise-h0iv's reachable shape.
fn repeated_group_frame() -> AxisFrame {
let q1 = || FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: false,
};
@@ -1176,7 +1174,6 @@ mod tests {
cats: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: false,
}),
@@ -1214,7 +1211,6 @@ mod tests {
cats: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: true,
}),
@@ -1266,7 +1262,7 @@ mod tests {
/// The pair `FrameGroup` carries cannot separate them — h0iv's own proposed
/// fix. See `context/test-evidence/protocol.md`.
#[test]
fn the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them() {
fn the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them() {
let frame = repeated_group_frame();
let headers: Vec<&FrameGroup> = frame
.entries
@@ -1277,10 +1273,9 @@ mod tests {
})
.collect();
assert_eq!(
(&headers[0].cat, &headers[0].name),
(&headers[1].cat, &headers[1].name),
"both headers name the same category AND the same group, so the \
ticket's (cat, name) comparison would still merge them"
headers[0], headers[1],
"both headers are the same group in the same state, so no \
comparison of their fields can split them"
);
}
@@ -1344,7 +1339,7 @@ mod tests {
assert_eq!(frame.item(2), Some(&vec!["B".to_string(), "z".to_string()]));
assert!(frame.group_of(0).is_none(), "[A,x] is in no group");
let group = frame.group_of(2).expect("[B,z] sits under Late");
assert_eq!((group.cat.as_str(), group.name.as_str()), ("Type", "Late"));
assert_eq!(group.name, "Late");
assert!(group.collapsed, "the collapse state travels with the group");
}
+7 -2
View File
@@ -755,6 +755,7 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
// Both creating columns are records-only, so the guard above takes
// them; declining rather than `None` keeps that a door, not a hole.
CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]),
CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]),
}
}
@@ -966,7 +967,9 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option
fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
match cell_at_cursor(state)?.id.as_ref()? {
CellId::Record(cell) => Some(cell.record),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
None
}
}
}
@@ -1046,7 +1049,9 @@ fn model(state: &ClientState, cmd: ModelCommand) -> Option<Command> {
fn record_cell_at_cursor(state: &ClientState) -> Option<RecordCell> {
match cell_at_cursor(state)?.id.as_ref()? {
CellId::Record(cell) => Some(cell.clone()),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => {
None
}
}
}
+417 -75
View File
@@ -11,28 +11,28 @@ pub fn production_code(roots: &[PathBuf]) -> Vec<(PathBuf, String)> {
for root in roots {
collect_rust(root, &mut files);
}
let code: Vec<(PathBuf, String)> = files
let read: Vec<(PathBuf, Source)> = files
.into_iter()
.map(|file| {
let text = std::fs::read_to_string(&file).expect("a listed source reads");
let code = code_only(&text);
(file, code)
let source = Source::of(&text, &file);
(file, source)
})
.collect();
let test_modules: BTreeSet<PathBuf> = code
let test_modules: BTreeSet<PathBuf> = read
.iter()
.flat_map(|(file, code)| test_module_paths(file, code))
.flat_map(|(file, source)| test_module_paths(file, source))
.collect();
code.into_iter()
read.into_iter()
.filter(|(file, _)| !test_modules.iter().any(|m| is_module_file(m, file)))
.map(|(file, code)| (file, strip_test_items(&code)))
.map(|(file, source)| (file, source.production()))
.collect()
}
/// One file's text as [`production_code`] cleans it. Which *files* are test
/// modules needs their declaring file too, so only `production_code` knows.
pub fn production_text(text: &str) -> String {
strip_test_items(&code_only(text))
Source::of(text, Path::new("<text>")).production()
}
fn collect_rust(dir: &Path, into: &mut Vec<PathBuf>) {
@@ -55,83 +55,319 @@ fn is_module_file(m: &Path, file: &Path) -> bool {
file == m.with_extension("rs") || file.starts_with(m)
}
/// One file, read once. Its `cfg` predicates are read off the TEXT, whose
/// `feature` values are literals; every bracket off the code (improvise-59q8).
struct Source {
code: Vec<char>,
/// Test-only items, as `(attribute start, item start, item end)`.
items: Vec<(usize, usize, usize)>,
/// A leading `#![cfg(test)]`: the whole file is test code.
whole_file: bool,
}
impl Source {
fn of(text: &str, file: &Path) -> Self {
let code: Vec<char> = code_only(text).chars().collect();
let text: Vec<char> = text.chars().collect();
let (items, whole_file) = test_only_items(&text, &code, file);
Self {
code,
items,
whole_file,
}
}
/// The code with every test-only item removed.
fn production(&self) -> String {
if self.whole_file {
return String::new();
}
let mut kept = String::with_capacity(self.code.len());
let mut at = 0;
for &(start, _, end) in &self.items {
kept.extend(&self.code[at..start]);
at = end;
}
kept.extend(&self.code[at..]);
kept
}
fn item_code(&self, start: usize, end: usize) -> String {
self.code[start..end].iter().collect()
}
}
/// The out-of-line modules `file` declares under a test-only `cfg`, as paths
/// without extension (`#[cfg(test)] mod x;` is `<dir>/x`).
fn test_module_paths(file: &Path, code: &str) -> Vec<PathBuf> {
fn test_module_paths(file: &Path, source: &Source) -> Vec<PathBuf> {
let dir = match file.file_stem().and_then(|s| s.to_str()) {
Some("lib" | "main" | "mod") => file.parent().map(Path::to_path_buf),
Some(stem) => file.parent().map(|p| p.join(stem)),
None => None,
}
.expect("a source file has a directory and a stem");
let lines: Vec<&str> = code.lines().map(str::trim).collect();
let mut found = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !is_test_cfg(line) {
continue;
}
let item = lines[i + 1..]
.iter()
.find(|l| !l.starts_with("#[") && !l.is_empty());
if let Some(name) = item.and_then(|l| out_of_line_mod(l)) {
found.push(dir.join(name));
}
}
found
source
.items
.iter()
.filter_map(|&(_, start, end)| out_of_line_mod(source.item_code(start, end).trim()))
.map(|name| dir.join(name))
.collect()
}
/// `mod x;`, `pub mod x;`, `pub(crate) mod x;` → `x`.
fn out_of_line_mod(line: &str) -> Option<&str> {
let rest = line.split_once("mod ")?.1.strip_suffix(';')?;
let prefix = line.split_once("mod ")?.0.trim();
(prefix.is_empty() || prefix.starts_with("pub")).then_some(rest.trim())
fn out_of_line_mod(item: &str) -> Option<String> {
let (prefix, rest) = item.split_once("mod ")?;
let name = rest.strip_suffix(';')?.trim();
let prefix = prefix.trim();
(prefix.is_empty() || prefix.starts_with("pub")).then(|| name.to_string())
}
/// A `cfg` attribute that compiles its item only under `test`. `cfg(not(test))`
/// is production, and `"test-support"` is a literal, already blanked.
fn is_test_cfg(line: &str) -> bool {
let line = line.trim();
line.starts_with("#[cfg(")
&& !line.contains("not(")
&& line
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| word == "test")
/// Every test-only item in a file, bounded by balanced brackets on the code
/// rather than by indentation. An item it cannot bound is a panic, never EOF.
fn test_only_items(
text: &[char],
code: &[char],
file: &Path,
) -> (Vec<(usize, usize, usize)>, bool) {
let mut items = Vec::new();
let mut k = 0;
while k < code.len() {
let Some((inner, end)) = attribute_at(code, k) else {
k += 1;
continue;
};
let predicate: String = text[k..end].iter().collect();
if !is_test_only(&predicate) {
k = end;
continue;
}
if inner {
// An inner attribute heads the file or it heads an inline module;
// only the first is a shape this scan can honour.
let leading = code[..k].iter().all(|c| c.is_whitespace());
assert!(
leading,
"{}: an inner test-only cfg could not be bounded",
file.display()
);
return (Vec::new(), true);
}
let start = past_attributes(code, end);
let item_end = item_end(code, start).unwrap_or_else(|| {
panic!(
"{}: a test-only item at char {k} could not be bounded",
file.display()
)
});
items.push((k, start, item_end));
k = item_end;
}
(items, false)
}
/// Drop every test-only item. rustfmt puts an item's closing brace at the
/// attribute's own indentation, which is what makes this a line scan (§2).
fn strip_test_items(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut kept = String::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
match is_test_cfg(line) {
false => {
kept.push_str(line);
kept.push('\n');
i += 1;
}
true => {
let indent = line.len() - line.trim_start().len();
let close = format!("{}}}", " ".repeat(indent));
i += 1;
while i < lines.len() && lines[i].trim_start().starts_with("#[") {
i += 1;
}
match lines.get(i).is_some_and(|l| l.trim_end().ends_with(';')) {
true => i += 1,
false => {
while i < lines.len() && lines[i].trim_end() != close {
i += 1;
}
i += 1;
}
/// An attribute starting at `k`: whether it is inner (`#!`) and where it ends.
fn attribute_at(code: &[char], k: usize) -> Option<(bool, usize)> {
if code[k] != '#' || (k > 0 && (code[k - 1].is_alphanumeric() || code[k - 1] == '_')) {
return None;
}
let inner = code.get(k + 1) == Some(&'!');
let open = k + 1 + usize::from(inner);
(code.get(open) == Some(&'[')).then(|| matching(code, open).map(|close| (inner, close + 1)))?
}
/// The index of the bracket closing the one at `open`, counting all three kinds.
fn matching(code: &[char], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (k, &c) in code.iter().enumerate().skip(open) {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => {
depth -= 1;
if depth == 0 {
return Some(k);
}
}
_ => {}
}
}
kept
None
}
/// Skip whitespace and any further outer attributes on the same item.
fn past_attributes(code: &[char], mut k: usize) -> usize {
loop {
while code.get(k).is_some_and(|c| c.is_whitespace()) {
k += 1;
}
let next = (k < code.len()).then(|| attribute_at(code, k)).flatten();
match next {
Some((false, end)) => k = end,
_ => return k,
}
}
}
/// Where the item starting at `start` ends: a `;` at depth zero, or the `}`
/// closing its body. `None` at the end of the file, which is not an end.
fn item_end(code: &[char], start: usize) -> Option<usize> {
let mut depth = 0usize;
let mut k = start;
while k < code.len() {
match code[k] {
'(' | '[' | '{' => depth += 1,
';' if depth == 0 => return Some(k + 1),
// The enclosing block closed first: the item was its last expression.
')' | ']' | '}' if depth == 0 => return Some(k),
')' | ']' => depth -= 1,
'}' => {
depth -= 1;
if depth == 0 && !continues(code, k + 1) {
return Some(k + 1);
}
}
_ => {}
}
k += 1;
}
None
}
/// Whether the item goes on after a `}` at depth zero: `S { .. };`, a method
/// call on a block, or an `if` with an `else`.
fn continues(code: &[char], after: usize) -> bool {
let rest: String = code[after..]
.iter()
.skip_while(|c| c.is_whitespace())
.take(5)
.collect();
rest.starts_with([';', '.', '?']) || rest.starts_with("else")
}
/// A `cfg` attribute whose predicate no shipped build satisfies. Evaluated,
/// not substring-matched (improvise-59q8): `context/code/protocol.md` §2.
fn is_test_only(attribute: &str) -> bool {
let tokens = cfg_tokens(attribute);
match tokens.as_slice() {
[Token::Word(cfg), Token::Open, rest @ ..] if cfg == "cfg" => {
let mut at = 0;
predicate(rest, &mut at) == Truth::No
}
_ => false,
}
}
/// What a `cfg` predicate is in a build without `test`. `Unknown` for what a
/// source scan cannot know, such as a target or an ordinary feature.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Truth {
Yes,
No,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Word(String),
Str(String),
Open,
Close,
Comma,
Equals,
}
/// The tokens of an attribute's text, from its first word: `#`, `!` and the
/// brackets around it are dropped.
fn cfg_tokens(attribute: &str) -> Vec<Token> {
let inner = attribute
.trim_start_matches('#')
.trim_start_matches('!')
.trim()
.trim_start_matches('[')
.trim_end()
.trim_end_matches(']');
let chars: Vec<char> = inner.chars().collect();
let mut tokens = Vec::new();
let mut k = 0;
while k < chars.len() {
let c = chars[k];
match c {
'(' => tokens.push(Token::Open),
')' => tokens.push(Token::Close),
',' => tokens.push(Token::Comma),
'=' => tokens.push(Token::Equals),
'"' => {
let end = quoted_end(&chars, k + 1, '"');
tokens.push(Token::Str(chars[k + 1..end - 1].iter().collect()));
k = end;
continue;
}
c if c.is_alphanumeric() || c == '_' => {
let word: String = chars[k..]
.iter()
.take_while(|c| c.is_alphanumeric() || **c == '_')
.collect();
k += word.chars().count();
tokens.push(Token::Word(word));
continue;
}
_ => {}
}
k += 1;
}
tokens
}
/// One predicate, advancing `at` past it. `test` is false; so is the
/// `test-support` feature, which this repo never ships (improvise-26dm).
fn predicate(tokens: &[Token], at: &mut usize) -> Truth {
let Some(Token::Word(word)) = tokens.get(*at) else {
return Truth::Unknown;
};
*at += 1;
match (word.as_str(), tokens.get(*at)) {
(op @ ("all" | "any" | "not"), Some(Token::Open)) => {
*at += 1;
let mut args = Vec::new();
while !matches!(tokens.get(*at), Some(Token::Close) | None) {
args.push(predicate(tokens, at));
if tokens.get(*at) == Some(&Token::Comma) {
*at += 1;
}
}
*at += 1;
combine(op, &args)
}
(key, Some(Token::Equals)) => {
*at += 1;
let value = match tokens.get(*at) {
Some(Token::Str(v)) => v.clone(),
_ => String::new(),
};
*at += 1;
match (key, value.as_str()) {
("feature", "test-support") => Truth::No,
_ => Truth::Unknown,
}
}
("test", _) => Truth::No,
_ => Truth::Unknown,
}
}
/// Kleene's three-valued `all`, `any` and `not`.
fn combine(op: &str, args: &[Truth]) -> Truth {
match op {
"all" if args.contains(&Truth::No) => Truth::No,
"all" if args.iter().all(|a| *a == Truth::Yes) => Truth::Yes,
"any" if args.contains(&Truth::Yes) => Truth::Yes,
"any" if args.iter().all(|a| *a == Truth::No) => Truth::No,
"not" => match args.first() {
Some(Truth::Yes) => Truth::No,
Some(Truth::No) => Truth::Yes,
_ => Truth::Unknown,
},
_ => Truth::Unknown,
}
}
/// `text` with comments, string, byte-string, raw-string and char literals
@@ -166,8 +402,8 @@ fn literal_or_comment_end(c: &[char], i: usize) -> Option<usize> {
(Some('/'), Some('/')) => Some(line_end(c, i)),
(Some('/'), Some('*')) => Some(block_end(c, i)),
(Some('"'), _) => Some(quoted_end(c, i + 1, '"')),
(Some('b'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
(Some('r' | 'b'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
(Some('b' | 'c'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
(Some('r' | 'b' | 'c'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
(Some('\''), _) => char_end(c, i),
_ => None,
}
@@ -211,9 +447,10 @@ fn quoted_end(c: &[char], mut k: usize, quote: char) -> usize {
c.len()
}
/// `r"…"`, `r#"…"#`, `br#"…"#`. `None` when the `r` is only an identifier.
/// `r"…"`, `r#"…"#`, `br#"…"#`, `cr#"…"#`. `None` when the `r` is only an
/// identifier.
fn raw_end(c: &[char], i: usize) -> Option<usize> {
let mut k = i + usize::from(c[i] == 'b');
let mut k = i + usize::from(matches!(c[i], 'b' | 'c'));
if c.get(k) != Some(&'r') {
return None;
}
@@ -283,21 +520,20 @@ mod tests {
"}\n",
"#[cfg(test)]\n",
"mod out_of_line;\n",
"#[cfg(any(test, feature = \"x\"))]\n",
"#[cfg(any(test, feature = \"test-support\"))]\n",
"pub mod gated;\n",
"#[cfg(not(test))]\n",
"fn production_only() {}\n",
"fn also_kept() {}\n",
);
let code = code_only(text);
let stripped = strip_test_items(&code);
let stripped = production_text(text);
for kept in ["fn kept", "fn production_only", "fn also_kept"] {
assert!(stripped.contains(kept), "{kept} was dropped: {stripped}");
}
assert!(!stripped.contains("gone_inline"), "{stripped}");
let lib = Path::new("/c/src/lib.rs");
assert_eq!(
test_module_paths(lib, &code),
test_module_paths(lib, &Source::of(text, lib)),
vec![
PathBuf::from("/c/src/out_of_line"),
PathBuf::from("/c/src/gated")
@@ -305,7 +541,7 @@ mod tests {
);
let nested = Path::new("/c/src/ui/reduce.rs");
assert_eq!(
test_module_paths(nested, &code)[0],
test_module_paths(nested, &Source::of(text, nested))[0],
PathBuf::from("/c/src/ui/reduce/out_of_line")
);
assert!(is_module_file(
@@ -322,6 +558,112 @@ mod tests {
));
}
/// improvise-59q8, the shapes that HID production code: a test-only item
/// whose end the line scan could not find. Evidence: test-evidence/protocol.md.
#[test]
fn a_test_only_item_hides_nothing_after_it() {
let cases = [
(
"a multi-line item ending in `];`",
concat!(
"impl X {\n",
" #[cfg(test)]\n",
" const NAMES: &[&str] = &[\n",
" \"a\",\n",
" ];\n",
" fn prod() { let c = Command::Hidden1(1); }\n",
"}\n",
"fn after() { Command::Visible(2) }\n",
),
),
(
"a multi-line statement ending in `);`",
concat!(
"fn f() {\n",
" #[cfg(test)]\n",
" let x = foo(\n",
" 1,\n",
" );\n",
" let c = Command::Hidden1(1);\n",
"}\n",
"fn after() { Command::Visible(2) }\n",
),
),
(
"a one-line `#[cfg(test)] use`",
concat!(
"#[cfg(test)] use foo::bar;\n",
"fn prod() { Command::Hidden1(1) }\n",
"fn after() { Command::Visible(2) }\n",
),
),
];
for (what, text) in cases {
let kept = production_text(text);
for name in ["Hidden1", "Visible"] {
assert!(kept.contains(name), "{what}: {name} was hidden: {kept:?}");
}
}
}
/// improvise-59q8, the shapes that COUNTED test code as production.
#[test]
fn a_test_only_cfg_is_recognised_whatever_its_spelling() {
let cases = [
(
"`all(test, not(..))`",
"#[cfg(all(test, not(target_arch = \"wasm32\")))]\nfn t() { Command::TestOnly(1) }\n",
),
(
"an inner `#![cfg(test)]` heading the file",
"#![cfg(test)]\nfn t() { Command::TestOnly(1) }\n",
),
(
"an attribute split across lines",
"#[cfg(\n test\n)]\nfn t() { Command::TestOnly(1) }\n",
),
];
for (what, text) in cases {
let kept = production_text(text);
assert!(
!kept.contains("TestOnly"),
"{what} was read as production: {kept:?}"
);
}
// The controls: a predicate a shipped build can satisfy is production.
for text in [
"#[cfg(not(test))]\nfn p() { Command::Shipped(1) }\n",
"#[cfg(any(test, feature = \"serde\"))]\nfn p() { Command::Shipped(1) }\n",
"#[cfg(target_arch = \"wasm32\")]\nfn p() { Command::Shipped(1) }\n",
] {
assert!(
production_text(text).contains("Shipped"),
"{text:?} was dropped"
);
}
}
/// improvise-59q8's minor case: a C string literal leaked its contents.
#[test]
fn a_c_string_literal_is_blanked() {
// The raw one quotes inside itself, so lexing it as ordinary strings
// leaves the text BETWEEN the inner quotes in the open.
let kept = code_only(
"let a = c\"Command::InC(1)\"; let b = cr#\"x\" Command::InRawC(2) \"y\"#;\n",
);
for gone in ["InC", "InRawC"] {
assert!(!kept.contains(gone), "{gone} survived: {kept}");
}
}
/// A test-only item the scan cannot bound is REFUSED, never read to the
/// end of the file, which is what silently hid every shape above.
#[test]
#[should_panic(expected = "could not be bounded")]
fn an_unbounded_test_only_item_is_refused_not_swallowed() {
let _ = production_text("#[cfg(test)]\nfn broken() {\n Command::X(1)\n");
}
#[test]
fn comments_and_literals_are_blanked_and_code_is_kept() {
let text = concat!(
+4 -1
View File
@@ -173,7 +173,10 @@ impl Client {
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
match self.state.grid.cell(row, col)?.id.clone()? {
CellId::Record(cell) => Some(cell),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate => None,
}
}
+22 -6
View File
@@ -144,7 +144,16 @@ impl RenderCell {
text: cell.text.clone(),
numeric: cell.numeric,
error: cell.error,
editable: matches!(cell.id, Some(CellId::Record(_))),
editable: match cell.id {
Some(CellId::Record(_)) => true,
Some(
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate,
)
| None => false,
},
selected,
edit_text: cell.edit_text.clone().unwrap_or_default(),
}
@@ -563,9 +572,8 @@ mod tests {
/// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so
/// one fixture answers every editability question.
fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
fn group(name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
cat: cat.into(),
name: name.into(),
collapsed,
})
@@ -583,11 +591,11 @@ mod tests {
first: 0,
cats: vec!["Month".into()],
entries: vec![
group("Month", "Q1", false),
group("Q1", false),
item("Jan"),
group("Fiscal", "Q1", true),
group("Q1", true),
item("Feb"),
group("Month", "Q2", false),
group("Q2", false),
item("Apr"),
],
};
@@ -628,6 +636,10 @@ mod tests {
}),
),
((1, 0), CellDisplay::text("pending")),
(
(1, 2),
CellDisplay::text("North").with_id(CellId::Coordinate),
),
])),
],
});
@@ -659,6 +671,10 @@ mod tests {
!model.rows[1].cells()[1].editable,
"and neither is a slot fit_to_labels padded"
);
assert!(
!model.rows[1].cells()[2].editable,
"renaming a coordinate moves the whole row, which is not a cell write"
);
}
/// The numeric/error flags are the server's answers, echoed — so the test
+29
View File
@@ -187,6 +187,21 @@ test.describe("the standalone deployment", () => {
await expect(page.locator("#grid td.editable").first()).toBeVisible();
});
// improvise-i0v9: "in-page worker · <doc>" was written at 13ms, before the
// worker had answered anything, and read as success over a blank page.
test("the worker is not called up until its first frame has arrived", async ({ page }) => {
const doc = `first-frame-${Date.now()}.improv`;
await page.addInitScript(logStatus);
await page.goto(`/app/index.html?doc=${doc}`);
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(status(page)).toHaveText(`in-page worker · ${doc}`);
const log = await page.evaluate(() => window.__statusLog);
expect(log, `#status said: ${json(log)}`).toEqual([
`starting the worker · ${doc}…`,
`in-page worker · ${doc}`,
]);
});
// improvise-9cma: a `?doc=` this backend cannot hold used to reach the
// generic watchdog 8s later. The REASON now crosses the port (improvise-1wft).
test("an invalid ?doc= is named on screen, and not blamed on the browser", async ({
@@ -235,6 +250,7 @@ test.describe("the standalone deployment", () => {
const second = await context.newPage();
await second.clock.install();
await second.addInitScript(logStatus);
await second.goto(`/app/index.html?doc=${doc}`);
// It PAINTS. That is the whole point: a locked document is read-only, not
@@ -242,6 +258,15 @@ test.describe("the standalone deployment", () => {
await expect(second.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(second.locator("#status")).toContainText("read-only");
await expect(second.locator("#status")).toContainText("another tab");
// The page's clock has not been walked, so a reason that arrived AFTER the
// watchdog would have been logged behind "did not answer". Nor was success
// ever claimed: the Snapshot that follows the notice must not overwrite it.
const early = await second.evaluate(() => window.__statusLog);
expect(early.at(-1), `#status said: ${json(early)}`).toContain("read-only");
expect(
early.filter((t) => t.includes("did not answer") || t === `in-page worker · ${doc}`),
`#status said: ${json(early)}`,
).toEqual([]);
// And the reason STANDS: it is not replaced 8s later by a watchdog
// blaming the browser for something the code knew precisely.
@@ -252,6 +277,10 @@ test.describe("the standalone deployment", () => {
await second.clock.runFor(HANDSHAKE_TIMEOUT_MS + 2_000);
await expect(second.locator("#status")).toContainText("read-only");
await expect(second.locator("#status")).not.toContainText("did not answer");
const late = await second.evaluate(() => window.__statusLog);
expect(late.some((t) => t.includes("did not answer")), `#status said: ${json(late)}`).toBe(
false,
);
await second.close();
await first.close();
+3 -1
View File
@@ -181,7 +181,8 @@ function workerTransport() {
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
console.error("improvise: worker error", event);
});
shell.setShell(`in-page worker · ${DOC}`);
// Not yet a claim that it works: the first frame makes that (improvise-i0v9).
shell.setShell(`starting the worker · ${DOC}…`);
// Frames posted before the worker has finished `start()` are held by
// worker.js in arrival order, so the handshake can go out immediately.
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
@@ -798,6 +799,7 @@ shell.arm(
WS_URL
? `${WS_URL} never answered — is improvise-ws-server running, and did it send a document?`
: "the in-page worker did not answer (see console)",
{ answer: WS_URL ? null : `in-page worker · ${DOC}` },
);
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
+13 -7
View File
@@ -339,7 +339,12 @@ export function createShell({
clearTimer = clearTimeout,
}) {
let watchdog = null;
let answered = null;
let arms = 0;
const disarm = () => {
clearTimer(watchdog);
watchdog = null;
};
return {
setShell(text) {
@@ -350,24 +355,25 @@ export function createShell({
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
arm(message) {
arm(message, { answer = null } = {}) {
arms += 1;
clearTimer(watchdog);
answered = answer;
disarm();
watchdog = setTimer(() => {
watchdog = null;
this.setShell(message);
}, timeoutMs);
},
/** A frame arrived, so the peer answered: the watchdog has no question
* left to ask. */
/** A frame arrived, so the peer answered. Only the FIRST, and only if no
* failure spoke first, may say so (improvise-i0v9, clients.md). */
sawFrame() {
clearTimer(watchdog);
watchdog = null;
if (watchdog !== null && answered !== null) this.setShell(answered);
disarm();
},
/** A peer that failed *loudly* has already said something more precise, so
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
fail(text) {
this.sawFrame();
disarm();
this.setShell(text);
},
armed: () => watchdog !== null,
+37
View File
@@ -351,6 +351,43 @@ test("a notice DISARMS the watchdog rather than being replaced by it", () => {
);
});
// improvise-i0v9: "in-page worker · <doc>" was written before the worker had
// answered anything, and read as success over a blank page for eight seconds.
test("only the first frame claims the peer answered, and not before it", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.arm("did not answer", { answer: "in-page worker · a.improv" });
assert.deepEqual(seen, [], "the claim was made before any frame arrived");
shell.sawFrame();
shell.sawFrame();
assert.deepEqual(seen, ["in-page worker · a.improv"]);
});
// The order worker.js really produces on a locked document: the ReadOnly
// notice is posted after `start()` and BEFORE the Snapshot answers the handshake.
test("a reason given before the first frame is not replaced by the frame's claim", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onShell: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.arm("did not answer", { answer: "in-page worker · a.improv" });
shell.fail(noticeText({ kind: "ReadOnly", document: "a.improv", detail: "" }));
shell.sawFrame();
clock.fire();
assert.equal(seen.length, 1, `#status said: ${JSON.stringify(seen)}`);
assert.match(seen[0], /read-only/);
});
// The notice's remembered state. The value crosses `localStorage`, which is
// allowed to forget and allowed to throw, so the interesting inputs are the
// ones that are NOT either spelling.