Merge remote-tracking branch 'gh/main' into wire2/lane

This commit is contained in:
Edward Langley
2026-09-22 21:20:09 -07:00
26 changed files with 1393 additions and 382 deletions
+18 -15
View File
File diff suppressed because one or more lines are too long
+10 -3
View File
@@ -126,9 +126,16 @@ Two agents each adding a workspace member will still collide in
reports spurious failures. And **never pipe a gate**: `$?` must be the gate's
own, and `check-comment-runs.py --check-evidence` prints two lines with the
comment cap **first**, so `| tail -1` silently drops half of it.
4. **Do not push, do not merge to `main`.** The orchestrator merges.
5. The commit-message trailers, spelled out.
6. **What to report**: branch and commits, what changed per ticket, *what could
4. **Commit frequently, so the work is safe in git.** A WIP commit after the
first green test, and again after every meaningful step. A worktree is not
durable storage: an agent finished a whole ticket with every gate green and
lost all of it, uncommitted, when its worktree was deleted during a disk
cleanup. Tidying the history before the PR is fine; losing it is not.
5. **Do not merge to `main`.** The orchestrator merges. Pushing the agent's own
branch (`git push gh HEAD:refs/heads/<branch>`) is encouraged once it is
worth keeping.
6. The commit-message trailers, spelled out.
7. **What to report**: branch and commits, what changed per ticket, *what could
not be verified and why*, and proposed repo-map/`kb.pl` text. Ask explicitly
for the thing the agent learned that the ticket did not predict — that is
consistently the most valuable part of a hand-back, and agents omit it unless
+68
View File
@@ -151,7 +151,75 @@ atomicity is preserved by keeping the existing whole-line `parse_line` as a
still parses, and a line that did not still does not, so the only behavioural
change is which cursor the commands see.
**That atomicity claim was false as written** (improvise-2bac), and §8 is what
makes it true. The gate runs the pure arm; the loop runs the interactive arm. A
gate is only a gate if *every argument list the pure arm accepts, the
interactive arm accepts too* — and `commit-cell-edit` broke it:
`add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document
passed the gate, applied the first segment, and then refused the second with
`no cell at cursor`. Half the line landed.
The TUI's `:` line had the same defect and is fixed the same way, with one
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
#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
found the fourth (improvise-2bac): `commit-cell-edit <mode> <value> <coords>`,
whose interactive arm read only `<mode>`, took the value from the `edit` buffer
and the key from the cursor. So a script that wrote 5 to `Region/East`
**deleted the cell under the cursor** — the empty buffer, committed — with exit
0 and nothing on stderr. The fifth was already in the tree:
`toggle-panel-and-focus <panel> false false` ignored both flags and toggled.
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:
- `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.
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.
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
gate — rather than a commit of the edit buffer to the cursor cell.
- 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.
`toggle-panel-and-focus` did not fit `contextualize`, 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
decision depends on context, and `execute` is where context is.
Guards: `every_argument_list_the_gate_accepts_the_run_accepts_too` enumerates
**every registration** against every candidate argument list, in a context whose
cursor sits on no cell — the context in which the old `commit-cell-edit` arm
refused. It lists no commands, so a new registration is covered the day it
lands. It is backed by the process-level tests in `tests/headless_cli.rs`.
+17 -2
View File
@@ -891,8 +891,23 @@ gotcha 47 asks for — closing e24v has to come to that line and delete it.
broadcasts nothing, so it reaches this user and no one else. This argument
arrived as `edit_or_drill`'s, i.e. about `i`/`a` on an aggregate, and for a
while it read as doctrine for `>` while `>` was still silent. Both doors now
refuse, and **they refuse with different sentences on purpose**: "this key is
not built yet" and "this cell is not yours to write" are different facts.
refuse, and **they name the same missing feature in the same words** —
`not_yet("drill")` — because they reach the same one. In the TUI,
`EditOrDrill` hands an aggregate straight to `DrillIntoCell`, so an aggregate
*is* writable there, by drilling; `i` on it is drill by another door. `i`'s
sentence adds only *why* it drilled rather than edited ("an aggregate is
edited by drilling in — …"), and `>` still says nothing about aggregates,
because it is refused whatever the cell is.
This **reverses** what this bullet said until improvise-s8h7: that the two
refused with different sentences on purpose, "this key is not built yet"
against "this cell is not yours to write". The second half was false about
the application — nothing refuses a write to an aggregate; the TUI drills —
and the separate wording hid from a browser user that `i` and `>` were one
missing feature. The help page's `edit-or-drill` caption carried the same
error, calling `i` only "Edit current cell"; it now says it drills into an
aggregate, which is true in both front ends because the browser's refusal
names drill. Delivering drill is `improvise-obtu`'s, not this bullet's.
- **The commit's write is not spelled again here.** It is
`Client::typed_cell`'s, the same one click-to-edit uses, so a browser keyboard
commit and a browser mouse commit cannot disagree about what `""` or `"42"`
+7 -6
View File
@@ -2489,12 +2489,13 @@ line separated by `.`, coordinate pairs written `Category/Item`, and quoted stri
available for the rare command that needs to enumerate all views, such as the view
panel.
`InteractiveFn` constructs a `Cmd` from the interactive context (keymap dispatch).
It receives both the keymap args and the interactive context, so commands can
combine text arguments (a panel name) with runtime state (whether that panel is
currently open). `register_pure` registers a command that does not need interactive
context: called interactively with args it delegates to `parse`, and without args it
errors.
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`.
`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
+59
View File
@@ -83,8 +83,67 @@ argument but the last, so `doctrine(Name,_)` keys on the name and
through, and it overwrites in place — a count that grew would be two clauses
under one name.
**A write may not end up short of HEAD** (improvise-usus). `kb add` wrote four
facts onto a working-tree `kb.pl` that had already lost 276 of its 801, and
reported success: `kb_consistent` asks whether the facts are *consistent*, never
whether they are all *there*, and a knowledge base missing a prefix of a
predicate is perfectly consistent. `kb-merge` printed `529 before` beside a last
known 805 and nothing compared them. The cause was never settled between a
`kb replace` killed mid-write and git being moved under the session, so the
guard had to catch both, and only a comparison against something *outside* the
working tree can: a guard that compares the file against its own pre-write
count passes a file that was truncated before the write began, which is exactly
what happened. So every write runs `kb-merge.pl complete` on the candidate,
before `kb_consistent`, against `git show HEAD:prolog-sessions/kb.pl`, and
refuses unless every clause HEAD has is still present **by key**. It runs first
so that the refusal names the loss rather than whatever consistency error the
loss happens to cause.
It compares keys rather than counts, although the ticket asked for a count,
because a count can be masked: uncommitted adds in one predicate offset a
truncation in another, and the count comes out even. Keys cost the same (0.26s
for a write, most of it trealla start-up) and the message still names both
totals and the number missing.
**How the count legitimately falls.** `add` never lowers it and `replace`
overwrites in place, so neither can trip the guard. `kb rm` is the only
intended removal, and it records the keys it removed in a **ledger** that later
writes read: a clause missing from the working tree is excused only if its key
is in the ledger. The ledger lives in the git dir (`git rev-parse --git-path
kb-removed.pl`), so it is per-worktree and can never be committed; with
`KB_FILE` set it sits beside that file. It prunes itself on every write, keeping
only keys HEAD still has and the candidate does not, so an entry drops once the
removal is committed, and once the key is re-added. That second case matters:
without it, a re-added clause that was lost again would still be excused. There
is deliberately no override flag. The way out of a refusal is to restore
`kb.pl` from HEAD and re-apply, or to commit the removal so HEAD moves.
**The write is a rename.** The candidate used to come from `mktemp` in
`$TMPDIR`, and `mv` from there onto the repository crosses a device wherever
`/tmp` is its own filesystem. That makes `mv` a copy and an unlink, so a kill
partway through leaves a truncated `kb.pl`. The candidate is now a sibling of
`kb.pl` (`kb.pl.cand.XXXXXX`), so publishing it is `rename(2)`. The ledger is
renamed first. A kill between the two renames then leaves a ledger naming keys
the kb still has, and the next write prunes those entries. Until it does, they
would excuse the loss of exactly those clauses, which is a narrower hole than a
kb.pl that is already short.
**Exit codes.** 0 done, 1 refused, 2 usage, 3 HEAD's `kb.pl` could not be read,
127 no `tpl`. Only 1 is a verdict. 3 is separate because "could not compare"
is not "compared and refused" (improvise-b4ks). A pre-existing wart survives:
failing to acquire the lock exits 1 too.
## What no test here reaches
The completeness guard trusts HEAD. A loss that has been **committed** is
invisible to it by construction. That was the right trade for this incident,
where HEAD held all 801, but a bad commit of `kb.pl` becomes the new floor.
A `SIGKILL` leaves its `kb.pl.cand.*` and `kb.pl.lock` behind, because no trap
runs. The lock already had that property. The stray candidate is new: it is
never read, but it shows as untracked in `prolog-sessions/`, where a `git add
-A` would pick it up.
The key rule is looser than `functional/1` for `crate/3`, which keys on the
first argument alone; `kb_consistent` is the backstop for that case rather than
the merge.
+84 -2
View File
@@ -229,12 +229,94 @@ the syntax gate and then cleared whatever the cursor was sitting on — the orig
So improvise-6uva's own lesson had a **third** instance in its own fix: two
construction arms that mean different things demote whichever caller reaches the
weaker one, and here the "weaker" one was the arm that had the context. The four
arms now go through `key_from_args_or_cursor`: explicit coordinates win, the
arms went through `key_from_args_or_cursor`: explicit coordinates win, the
cursor is the fallback, which is also what the keymap path wants since a binding
carries no arguments.
carries no arguments. That helper is gone since improvise-2bac: the cursor is
now the registry's `fallback`, reached only when `parse` refused, so explicit
coordinates win by construction (`context/code/cli.md` §8).
The fixture writes three cells and clears the one the cursor is **not** on, so
the assertion separates "cleared the named cell" from "cleared the cursor cell"
— clearing `North` by name would have passed against the broken code. Restoring
the arg-discarding arm fails it: `the cursor cell was cleared instead of the
named one: ["Region=East = 3", "Region=South = 2"]`.
### commit_cell_edit_writes_the_named_cell_and_spares_the_cursors
improvise-2bac, found by the post-merge adversarial review of PR #151: the
fourth instance of that PR's own lesson. `commit-cell-edit` was registered with
two free arms. The pure arm read `<mode> <value> <coords>`; the interactive arm
read only `<mode>`, took the value from the `edit` buffer and the key from the
cursor. Once #151 made scripts construct through the interactive arm,
`commit-cell-edit editing 5 Region/East` passed the syntax gate and then
committed an **empty** buffer to the cursor cell — deleting it — with exit 0
and nothing on stderr.
Reproduced at `40b8913` before the fix, through the real binary: the document
came back as `["Region=East = 3", "Region=North = 1"]` — `South`, the cell the
cursor had been moved onto, was gone and `East` was unchanged.
The fixture writes three cells and **moves the cursor to `South`** in the same
line before committing to `East`, so the cursor sits on neither the target nor
the origin: a test with the cursor on the target is right by accident, and one
that only asserts the target misses the destruction. It asserts all three.
Injected rivals, run: (a) `contextualize` for `CommitAndAdvance` overwriting
`key` and `value` from the cursor and the buffer — fails this test and
`a_line_whose_gate_passes_runs_whole`; (b) the registry dispatching to the
fallback first whenever one exists (the pre-fix shape) — fails this test,
`a_line_whose_gate_passes_runs_whole`, `an_explicit_coordinate_still_beats_the_cursor`
and both registry unit tests below.
### a_line_whose_gate_passes_runs_whole
improvise-2bac's second symptom, reported on the ticket: `context/code/cli.md`
§7 said the whole-line `parse_line` gate preserved line atomicity. It did not,
wherever the interactive arm refused an argument list the pure arm accepted.
`add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document
printed `Parse error: commit-cell-edit: no cell at cursor` after `Foo` had
already been added and saved: half the line landed.
The assertion is on `Parse error` and on the committed cell, **not** on the exit
code. The line exits 1 both before #151 and after this fix, for a different and
pre-existing reason: the grid has no rows, so `commit-cell-edit`'s advance
emits `AbortChain`, and the CLI maps an aborted batch to exit 1
(`context/code/cli.md` §3). Asserting success would have made this test fail on
the correct behaviour.
### a_malformed_commit_line_is_refused_whole
The other half of atomicity, which must survive the fix: a segment the gate
refuses (`commit-cell-edit editing 5` — a value and no coordinates) refuses the
whole line, so the preceding `add-category Foo` never lands. It passed before
the fix and must keep passing; its job is to catch a "fix" that loosens the
pure arm to make the gate agree.
### commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor
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.
### every_argument_list_the_gate_accepts_the_run_accepts_too
Enumerates **every** registration (`CmdRegistry::names`, no hand-kept list)
against every candidate argument list, and requires the interactive arm to
accept whatever the pure arm accepts, in a context whose cursor sits on **no
cell** — a category with no items. That context is what made the old
`commit-cell-edit` arm refuse, so a context with a cell under the cursor would
have passed the bug. The assertion that the cursor has no cell is checked
first; a plain `Workbook::new` does not satisfy it, which is how the fixture
came to add the empty category. Rival (b) fails it.
### explicit_open_and_focus_arguments_beat_the_toggle
The fifth instance, named on improvise-2bac as plausible and not yet run:
`toggle-panel-and-focus <panel> false false` ignored both flags and toggled, so
on a **closed** panel it opened it. The fixture closes every panel first,
because on an open-and-focused panel the toggle also closes it and the test
would pass against the bug. Rival: `execute` ignoring `target` and always
toggling — fails this test for every panel.
+113 -29
View File
@@ -1389,28 +1389,74 @@ happened* passes against a page that has stopped listening, so it presses `i`
afterwards and requires `#status` to change. Without that, this file would be
folklore with a green tick.
### the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons
### an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key
The Rust half of the interim, and the assertion that carries the whole weight.
Two keys on the same aggregate cell must both refuse and must **not** refuse
with the same sentence: `>` says drill is unavailable, `i` says drilling into an
aggregate needs the host. Collapsing them into one message would be the
tempting tidy-up and would lose the distinction between "this key does nothing
here yet" and "this cell is not one you may write".
improvise-s8h7. Until it, this test was
`the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons` and asserted
the opposite: that `i` and `>` on an aggregate must refuse with *different*
sentences, "this key does nothing here yet" against "this cell is not one you
may write". The second half was false about the application. `EditOrDrill`
hands an aggregated cell to `DrillIntoCell`, so in the TUI `i` on an aggregate
**is** drill — nothing refuses the write, it drills — and the browser's
separate wording ("drilling into an aggregate needs the host") hid that `i` and
`>` are one missing feature. The reasoning is `context/code/clients.md`.
**The injection, and why it is not the obvious one.** Replacing `>`'s sentence
with the *aggregate* sentence looks like it should fail on the first assertion —
and does not: that sentence contains both "drill" and "browser", so it clears
the substring check. What fires is the anti-aggregate assertion lower down. The
claim that the second assertion is the load-bearing one was doubted on review,
tested, and confirmed; record it here because the first assertion reads like the
discriminating one and is not.
So the test now asserts four things on one aggregate fixture: `>` names drill
and the browser; `>` does **not** mention aggregates (it is refused whatever the
cell is); `i`'s sentence **contains** `>`'s sentence verbatim; and `i`'s
sentence mentions "aggregate", so it still says why it drilled rather than
edited. The fixture is `client_on(CellId::Aggregate(..))` rather than the old
empty `in_mode` state, because `i` needs a cell under the cursor to reach the
`Aggregate` arm at all — on an empty grid it answers nothing and only `>` was
ever exercised.
The harness sibling (`drill.spec.js`) asserts the same distinction in a browser,
but its `not.toContainText("aggregate")` is **decoration under the only
injection that exists** — Playwright throws at the positive assertion above it
first, so that line is never reached. The Rust assertion is the guard; the
harness one is end-to-end confirmation that the string survives the wire.
**Three injections, each failing at a different assertion.** Run, not reasoned:
- `i` says only `not_yet("drill")` — the "why" dropped. Fails at the last
assertion, "and `i` must still say why". This is the tempting over-tidy fix.
- `>`'s `declined` subject becomes "drilling into an aggregate". Fails at the
anti-aggregate assertion on `>`. Note that `i` still contains `>`'s *old*
sentence here, because `edit_or_drill` calls `not_yet("drill")` itself rather
than going through `declined` — so the containment assertion alone would not
have caught a `>` that started blaming the cell.
- `i` paraphrases ("… drill needs the host"). Fails at the containment
assertion: the same feature named in different words is exactly what the
ticket reported.
The harness sibling (`drill.spec.js`) asserts the same wording in a browser.
Its `not.toContainText("aggregate")` on `>` is still **decoration under the
only injection that exists** — Playwright throws at the positive assertion
above it first — so the Rust assertion is the guard and the harness is
end-to-end confirmation that the string survives the wire.
### the_edit_key_row_says_it_drills_into_an_aggregate
improvise-s8h7, the help-page half. The `edit-or-drill` row on the Editing page
read "Edit current cell", while `i`/`a` on an aggregate drill in the TUI and
name drill in the browser. The row is found by its **keys** — the labels
`keys_for(Normal, "edit-or-drill", ["editing"])` returns — not by its caption,
and exactly one row must carry them, so a caption fix that also moved or
duplicated the row fails on the `let [..]` pattern rather than passing on
whichever copy was found first. Failed at HEAD on the caption; the injection
"Edit current cell, or drill" (drill named, aggregate not) fails it too, since
`>` is also drill and the row must say *when* `i` drills.
### only_the_edit_key_is_captioned_as_editing_the_cell
Found while fixing improvise-s8h7, not predicted by it. The row below the edit
key read `Enter Edit current cell (same as i)`, but Normal-mode Enter is bound
to `enter-advance` alone — not a `Binding::Sequence` — and `EnterAdvance` only
moves the cursor down, wrapping to the next column. The test takes every help
row captioned "Edit current cell" and requires its keys to be a subset of
`edit-or-drill`'s; at HEAD it failed naming `["Enter"]`. The fix moved the row
to the Navigation page's "Cursor movement" section with a caption that says
what it does.
Its reach is stated rather than implied: it checks the one phrase that was
false. A caption that misdescribes Enter in *other* words passes it: a caption
is prose, and nothing checks it against the command it documents. That is a
neighbour of improvise-wk18, not the same gap — wk18 is about `literal()` rows
whose *keys* go unchecked; here the keys were right and the words were wrong.
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
@@ -4041,17 +4087,18 @@ Demonstrated at `e7de19d`: the first test below reports `Some("")`.
### `an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank`
The fallback is `improvise_protocol::edit_seed`, which is the **existing** home
of "what an editor opened here starts with" — `seed_at` at the cursor, the
function `enter-advance` already seeds from. A second reconciliation of the
scroll offset inside `render.rs` would have been gotcha 42's own trap, so the
one-line fallback cost one `pub` rather than a copy.
**Superseded in part by improvise-ktlk** (entry at the end of this file). As
first landed, the fallback lived in `render.rs`: `editing` fell back to
`edit_seed` while `commit` and `append-char` still read the absent buffer as
`""`, so the overlay showed `1200` and Enter cleared it. The fallback now lives
in the fold (`ClientState::settle_editor`), which seeds the buffer itself, and
`render.rs` reads the same buffer `commit` does. The test's first assertion
changed with it: it used to check that the buffer was still absent after the
fold, and now checks that the fold seeded it.
The test asserts three things in order, and the middle one is why it is not
vacuous: that the buffer really is absent, that the cell really holds `1200`,
and only then what `editing` says. Without the first two a fixture that happened
to carry an `edit` buffer, or a grid whose cell was blank anyway, would pass
against the bug.
The test asserts three things in order: what the fold put in the buffer, that
the cell really holds `1200`, and only then what `editing` says. Without the
second, a grid whose cell was blank anyway would pass against the bug.
**Fault injected:** `unwrap_or_default()`, the original expression. Fails with
`Some("")`.
@@ -4177,3 +4224,40 @@ and converged.
**Fault injected:** the same `Binding::Prefix` → `Upstream` reversion. Fails on
the second press.
## improvise-ktlk — the overlay and the commit read one buffer
PR #162 (improvise-xg1z) changed what the overlay **draws** for an `Editing`
mode that arrived without its `edit` buffer, and not what Enter **commits**.
`render.rs` painted `edit_seed(state)`, while `resolve.rs::commit` and
`append-char` read `read_buffer(&state.view, "edit")`, which is `""` for an
absent key. So the overlay showed the value, Enter composed `ClearCell`, and one
typed character replaced the shown text rather than extending it. #162's tests
only looked at the drawing, and none of them pressed a key.
The fix gives the fact one home. The fold seeds the missing buffer
(`ClientState::settle_editor`, called after every fold, bootstrap and refusal,
beside `settle_mode`), and `render.rs` reads the buffer through `read_buffer`,
as `commit` does. `edit_seed` is `pub(crate)` again and no renderer calls it.
Seeding happens only when there is a cell under the cursor that has
`edit_text`. With no such cell the buffer stays absent, which reads as `""`
on both paths, and `commit` declines anyway for want of a cell.
### `an_editor_entered_without_its_buffer_commits_and_extends_the_cell`
The fixture's display text (`1,200.50`) and edit text (`1200.5`) **differ**.
With `CellDisplay::number("1200")`, as #162 used, a fallback that seeds from the
formatted `text` passes too (gotcha 8). The test folds `ChangeMode(editing)`
with no `SetBuffer`, then presses the keys: Enter must compose
`SetCell(Number(1200.5))`, and after a typed `5`, Enter must compose
`SetCell(Number(1200.55))`.
**Faults injected, each run:**
- The tree before this fix (render-only fallback). The buffer assertion fails
with `""`. With that assertion removed, the Enter assertion fails on its own
with `None`, because the first command is `ClearCell`.
- `edit_seed` returning the drawn `text` instead of `edit_text`. Fails with
`"1,200.50"`.
- The over-broad seed, "absent **or empty**, refill". This test passes, and
`an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell` fails,
as it did against the same rival in `render.rs`.
+33 -3
View File
@@ -2382,6 +2382,31 @@ keystroke to land in.
Injected fault: dropping `self.view_state = snapshot.view_state` from
`App::restore` fails this test and only this test.
**Addendum (improvise-gho5).** Improvise-gho5 found that this
test and `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back` passed
against a host that **never ran the load**. The review's fault, reproduced
here, was placed at the top of `Host::commands`: refuse any multi-command frame
that contains a `LoadModel` with `Rejected(DocumentReplaced)`, before
`CheckedBatch` runs. Both tests stayed green. A frame that never got that far
leaves the generation, the view, the status line and every mirror untouched,
which is exactly what the tests asserted.
Both tests now assert that the asker's whole reply is
`the_load_ran_then_the_write_broke()`, which is `Rejected(ChainBroken(North))`.
They also assert, before the frame, that `North` resolves. Together these make
the rejection a witness that the load ran. `ChainBroken` comes only from
`reduce::broken_chain`, which runs inside `mutating_frame` just before each
mutation. It names `North` only once the document no longer holds that
record, and only an applied `LoadModel` in this frame can have removed it.
With the same fault re-injected, both tests fail. The left side is
`[Rejected(DocumentReplaced)]` and the right is
`[Rejected(ChainBroken(<North>))]`. The fault was reverted.
The residual: a rogue up-front check that forged `ChainBroken` specifically
would still pass. That is the same forgery a hand-rolled client could commit
against any basis check, and a test cannot guard against it.
### a_refused_mutation_rolls_back_its_predecessor_and_the_abort_flag
The `Rejection::MutationRefused` arm — the one the first hand-back wrongly called
@@ -4885,9 +4910,14 @@ Three things make it non-vacuous:
`LoadModel` silently failed, `Applied::Rebased` would never run and every
assertion here would pass over nothing. The control asserts the generation
moves and that *both* clients are owed a `Snapshot`.
- **The rollback is asserted before anything is concluded from it** — the
`document_generation` equality is checked first, so "the frame was rejected"
cannot be confused with "the frame never got that far".
- **The load is witnessed to have RUN before anything is concluded from the
rollback** (improvise-gho5). The asker's whole reply must be
`Rejected(ChainBroken(North))`. `North` is asserted to resolve before the
frame, so only `broken_chain` can give that answer, and only after the
`LoadModel` has replaced the document. `CheckedBatch` never produces
`ChainBroken`. This bullet used to say the `document_generation` equality
did the job, and that was false: generation equality is exactly what holds
when the frame never got that far.
- **A bystander is seated where a wrong relay could move it.** A single-client
fixture cannot tell "the mirrors were restored" from "there was one mirror and
it happened to be the asker's".
+116 -3
View File
@@ -391,6 +391,8 @@ green suite over a vacuous assertion.
**What is not covered.** The script asks `cargo metadata` for members, so a root
whose metadata succeeds but whose member list is wrong is not detectable here.
*Narrowed by improvise-90ea, below:* a list that **omits** a package in the tree
is now refused; one that names a package it should not is still not detected.
And `harness/opfs-contract`'s own `cargo fmt -p opfs-contract` step in the
`harness-lint` job is left as it is: it resolves correctly because that step
sets `working-directory`, and the ticket's own note confirms CI as written was
@@ -415,9 +417,11 @@ getting wrong. The line the split actually needs is **"did the gate run"**:
- exit 1 — rustfmt did not certify a root clean. A diff or a refusal to parse;
either way **a file is named** and the tree is what is wrong.
- exit 2 — the gate did not run. Usage, no cargo, no rustfmt, `cargo metadata`
failed, no `[workspace]` root. **No file is named.** Every such cause is
checked *above* the loop, which is what makes the narrowing true rather than
hopeful.
failed, no `[workspace]` root. **No source file is named.** Every such cause
is checked *above* the loop, which is what makes the narrowing true rather
than hopeful. (Since improvise-90ea, a package no root reaches is also exit 2,
and it names that package's `Cargo.toml`, which is the reason no verdict
exists. The line is "no verdict", not "no output"; improvise-ximf.)
Both directions are now pinned, and two further rivals were injected:
@@ -438,3 +442,112 @@ same way: the suite passed *under a rival*. The generalisable rule is that a
stub must fail in the **shape** of the real fault, not merely fail — a stub that
breaks more than the real thing breaks satisfies the test through the wrong arm,
and reads exactly like coverage.
#### The root package, and why the fourth fix is structural — `scripts/fmt-workspaces-test.sh`
A post-merge review of PR #159 found a **fourth** fixture vacuity
(improvise-90ea). The fixture's outer root is a package (`root-pkg`) as well as
a workspace, which is right, but no case ever dirtied **the root package's own
`src/lib.rs`**. Only the member `a/` and `nested/` were dirtied. So a script
that formats every member *except* the root package passed 17/17. In this repo
the root package is `improvise`, the whole TUI. Reproduced at `40b8913` with
the review's rival in `members_of` (drop the package whose manifest sits at
`workspace_root`): the suite passed 17/17 and `--list` left `improvise` out.
Adding one more hand-picked case would have fixed the fourth and not the fifth.
Four vacuities in one checker all have the same cause: **the cases were chosen
by hand, one per hazard somebody had thought of.** So the fix is structural,
and it has two halves because it has to cover two sets of packages:
- **Every package the fixture has.** The two hand-picked fault cases are
replaced by a loop over every `[package]` manifest in the fixture. Each one
gets its own misformatted `src/lib.rs`, must exit 1, and must be named
**exactly once**, so the `--all` boundary check now covers every package
rather than one. The enumeration is a `grep` for `[package]`, deliberately
*not* the script's `[workspace]` + `cargo metadata` discovery, since that
discovery is what is under test. A count assertion (three packages) stops
the loop going vacuous if the fixture loses a package. A package added to
the fixture gets its case automatically.
- **Every package the tree has.** The loop proves only that the script handles
the fixture's *shapes*. A real tree can hold a package no root lists (an
`exclude`d crate, say), and nothing in the fixture would say so. So the gate
itself now checks an invariant **before it formats anything**: every
`[package]` manifest the find reaches must be a member of some root, or it
exits **2** and names the package. That is exit 2 and not 1 because no
verdict was produced for that package, which is what exit 2 means here.
Paths are compared through `realpath`, because on macOS `mktemp` lives under
`/var` while cargo reports `/private/var`.
**Rivals injected, and where each failed** (the suite went from 17 to 21 cases):
- **The review's skip-the-root rival.** It now fails the fixture at `a fault in
src/lib.rs is reported` (exit 0). With the invariant in place it fails
**six** cases (exit 2, "no workspace root reaches"). It also fails the
**real repo** with no fixture involved: `fmt-workspaces.sh` exits 2 naming
`Cargo.toml`. That last one is the property worth having. The gate refuses
its own blindness in CI, not only in its test suite.
- **Disabling the invariant** (`if false`). It fails `a package no workspace
root reaches is exit 2, not a pass` with **exit 0**, even though that
package's `src/lib.rs` is misformatted. That exit 0 *is* the hazard: an
unreached package is silently clean.
- **The two earlier rivals, re-run against the loop.** `--all` for `-p` fails
both `a/src/lib.rs` and `src/lib.rs` "named exactly once". The second of
those was not predicted: the nested root's `--all` also reaches the root
package. The bare manifest-path form fails `a fault in a/src/lib.rs is
reported`.
The real-repo control from the ticket still holds: a misformatted fn appended
to `src/ui/text.rs` is reported (`Diff in …/src/ui/text.rs:72`).
**What is still not covered.** A root whose member list *includes* something it
should not is not detected, and a package in a directory the find excludes
(`target/`, `.claude/`, `node_modules/`) is outside the invariant by
construction.
### The kb tool's completeness guard and atomic write — `scripts/kb-test.sh` cases 18–22
improvise-usus: `scripts/kb add` wrote onto a working-tree `kb.pl` that had lost
276 of its 801 clauses, and `kb_consistent` passed. The argument for the fix
(compare with HEAD by key, record `kb rm` removals in a ledger, publish by
rename) is in `context/code/kb-tool.md`. This entry covers what each case was
watched to catch.
**Reproduced at `40b8913` before any fix.** A copy of HEAD's `kb.pl` with 34 of
its 35 `location/3` facts removed still passes `kb_consistent`, and
`kb add` onto it printed `775 before, 1 in delta, 776 after` and exited 0.
Against the unfixed tool, the new cases gave 33 passed, 5 failed: 18, 19, 20b,
21 and 22 failed. Case 20 passed there because an rm followed by an add has no
guard to trip.
**The fixture has to fail in the incident's shape.** The ticket's "truncate to
half" fixture also breaks `kb_consistent`, because the rules live in the second
half. So the unfixed tool refused it too, by the wrong guard. Removing a whole
predicate is an existence error, and removing cited clauses fails the citation
check. Case 18 keeps one `location/3` fact and asserts that the fixture passes
`kb_consistent` before using it. Case 19 keeps the ticket's half-truncation, and
`said()` is what makes it evidence: the completeness guard runs first and must
be the one that answers.
**Rivals injected into `scripts/kb`, one at a time. Each failed only its own
case:**
- baseline taken from the file's own pre-write state rather than HEAD: 18, 19
and 20b failed (35/38 passed). This is the guard that would have passed the
real incident.
- ledger never consulted: 20 and 20b failed. A legitimate rm then blocks every
later write.
- guard stands down whenever the ledger is non-empty: 20b failed.
- candidate built by plain `mktemp` in `$TMPDIR`: 22 failed on the directory
check.
- candidate copied over `kb.pl` before the publish point: 22 failed as
modified.
- unreadable baseline exiting 1 rather than 3: 21 failed.
**A rival can fail wider than its fault.** The first form of the "stands down"
rival skipped `merge complete` altogether. That also skipped writing
`$cand.ledger`, so the later `mv` crashed under `set -e` and five unrelated
cases failed. It proved nothing until it was rewritten to carry the ledger
forward.
**Checked by hand, not in the suite:** the ledger prunes an entry once the key
is re-added and once HEAD stops having it (a committed removal).
@@ -139,9 +139,12 @@ 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>;
/// Factory constructing a Cmd from the interactive context. It takes both the keymap
/// args and the context, so a command can combine text args with runtime state.
pub type InteractiveFn = fn(&[String], &CmdContext) -> 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>;
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
@@ -183,13 +186,26 @@ impl CmdRegistry {
name
}
/// Register a command with both a text parser and an interactive constructor.
/// The name is derived from a prototype command instance.
pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) {
/// Register a command whose interactive arm is `parse` then `contextualize`, so
/// args `parse` accepts are honoured whole — `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.entries.push(CmdEntry {
name: prototype.name(),
parse: Box::new(parse),
interactive: Box::new(interactive),
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),
};
built.map(|c| Box::new(c) as Box<dyn Cmd>)
}),
});
}
@@ -20,8 +20,7 @@ mod tests {
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: true,
focused: true,
target: Some((true, true)),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
@@ -40,8 +39,7 @@ mod tests {
let ctx = make_ctx(&m, &layout, &reg);
let cmd = TogglePanelAndFocus {
panel: effect::Panel::Formula,
open: false,
focused: false,
target: Some((false, false)),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
@@ -531,20 +529,24 @@ mod tests {
#[derive(Debug)]
pub struct TogglePanelAndFocus {
pub panel: Panel,
pub open: bool,
pub focused: bool,
/// Explicit `(open, focused)`; `None` toggles: close if open and focused, else open and focus.
pub target: Option<(bool, bool)>,
}
impl Cmd for TogglePanelAndFocus {
fn name(&self) -> &'static str {
"toggle-panel-and-focus"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let (open, focused) = self.target.unwrap_or_else(|| {
let shown = ctx.panel_open(self.panel) && ctx.mode.panel() == Some(self.panel);
(!shown, !shown)
});
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
effects.push(Box::new(effect::SetPanelOpen {
panel: self.panel,
open: self.open,
open,
}));
if self.focused {
if focused {
effects.push(effect::change_mode(self.panel.mode()));
} else {
effects.push(effect::change_mode(AppMode::Normal));
@@ -22,15 +22,50 @@ use super::text_buffer::*;
use super::tile::*;
use super::wizard::*;
/// Explicit coordinates win; the cursor is the fallback — `context/code/cli.md`.
fn key_from_args_or_cursor(args: &[String], ctx: &CmdContext) -> Result<CellKey, String> {
if args.is_empty() {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
} else {
Ok(parse_cell_key_from_args(args))
/// The cell under the cursor: what a coordinate command falls back to when given none.
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
}
}
/// 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
}
}
/// `<mode>` alone: commit the edit buffer to the cell under the cursor.
fn commit_at_cursor(
args: &[String],
ctx: &CmdContext,
advance: AdvanceDir,
) -> Result<CommitAndAdvance, String> {
let [mode] = args else {
return Err("takes a mode alone, or a mode, a value and coordinates".into());
};
Ok(CommitAndAdvance {
edit_mode: parse_mode_name(mode)?,
value: read_buffer(ctx, "edit"),
key: cursor_key(ctx)?,
advance,
cursor: CursorState::from_ctx(ctx),
})
}
/// 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 {
@@ -51,14 +86,16 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("clear-cell requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(ClearCellCommand {
Ok(ClearCellCommand {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(ClearCellCommand { key }))
})
},
as_parsed,
Some(|_, ctx| {
Ok(ClearCellCommand {
key: cursor_key(ctx)?,
})
}),
);
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
r.register_pure(&FitMeasure::prototype(), FitMeasure::parse);
@@ -80,16 +117,8 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
r.register(
&HelpPageNextCmd(vec![]),
HelpPageNextCmd::parse,
|_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))),
);
r.register(
&HelpPagePrevCmd(vec![]),
HelpPagePrevCmd::parse,
|_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))),
);
r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse);
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
@@ -102,22 +131,14 @@ pub fn default_registry() -> CmdRegistry {
require_args(args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::default(),
cmd_name: "move-selection",
}))
},
|args, ctx| {
require_args(args, 2)?;
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(dr, dc),
cursor: CursorState::from_ctx(ctx),
cmd_name: "move-selection",
}))
})
},
move_from_cursor,
None,
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
@@ -133,7 +154,7 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: $name,
},
|_| {
Ok(Box::new(Move {
Ok(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
@@ -141,19 +162,10 @@ pub fn default_registry() -> CmdRegistry {
},
cursor: CursorState::default(),
cmd_name: $name,
}))
},
|_, ctx| {
Ok(Box::new(Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
} else {
MoveKind::ToStart($is_row)
},
cursor: CursorState::from_ctx(ctx),
cmd_name: $name,
}))
})
},
move_from_cursor,
None,
);
};
}
@@ -170,21 +182,14 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::default(),
cmd_name: "scroll-rows",
}))
},
|args, ctx| {
require_args(args, 1)?;
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Relative(n, 0),
cursor: CursorState::from_ctx(ctx),
cmd_name: "scroll-rows",
}))
})
},
move_from_cursor,
None,
);
r.register(
&Move {
@@ -195,28 +200,21 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
Ok(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::default(),
cmd_name: "page-scroll",
}))
},
|args, ctx| {
require_args(args, 1)?;
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(Move {
kind: MoveKind::Page(dir),
cursor: CursorState::from_ctx(ctx),
cmd_name: "page-scroll",
}))
})
},
move_from_cursor,
None,
);
r.register(
&EnterAdvance {
cursor: CursorState::default(),
},
|_| {
Ok(Box::new(EnterAdvance {
Ok(EnterAdvance {
cursor: CursorState {
row: 0,
col: 0,
@@ -227,13 +225,12 @@ pub fn default_registry() -> CmdRegistry {
visible_rows: 20,
visible_cols: 8,
},
}))
})
},
|_, ctx| {
Ok(Box::new(EnterAdvance {
cursor: CursorState::from_ctx(ctx),
}))
|_, ctx| EnterAdvance {
cursor: CursorState::from_ctx(ctx),
},
None,
);
// ── Cell operations ──────────────────────────────────────────────────
@@ -245,14 +242,16 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("yank requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(YankCell {
Ok(YankCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(YankCell { key }))
})
},
as_parsed,
Some(|_, ctx| {
Ok(YankCell {
key: cursor_key(ctx)?,
})
}),
);
r.register(
&PasteCell {
@@ -262,14 +261,16 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into());
}
Ok(Box::new(PasteCell {
Ok(PasteCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(PasteCell { key }))
})
},
as_parsed,
Some(|_, ctx| {
Ok(PasteCell {
key: cursor_key(ctx)?,
})
}),
);
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
@@ -313,21 +314,19 @@ pub fn default_registry() -> CmdRegistry {
if args.is_empty() {
return Err("drill-into-cell requires Cat/Item coordinates".into());
}
Ok(Box::new(DrillIntoCell {
Ok(DrillIntoCell {
key: parse_cell_key_from_args(args),
}))
},
|args, ctx| {
let key = key_from_args_or_cursor(args, ctx)?;
Ok(Box::new(DrillIntoCell { key }))
})
},
as_parsed,
Some(|_, ctx| {
Ok(DrillIntoCell {
key: cursor_key(ctx)?,
})
}),
);
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
r.register(
&ViewNavigate { forward: true },
|_| Ok(Box::new(ViewNavigate { forward: true })),
|_, _| Ok(Box::new(ViewNavigate { forward: true })),
);
r.register_nullary(|| Box::new(ViewNavigate { forward: true }));
r.register_pure(&EnterMode(AppMode::Normal), |args| {
require_args(args, 1)?;
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
@@ -345,38 +344,20 @@ pub fn default_registry() -> CmdRegistry {
r.register(
&TogglePanelAndFocus {
panel: Panel::Formula,
open: true,
focused: true,
target: None,
},
|args| {
// Parse: toggle-panel-and-focus <panel> [open] [focused]
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
},
|args, ctx| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
// Default interactive: if already open+focused -> close, else open+focus
let currently_open = ctx.panel_open(panel);
let currently_focused = ctx.mode.panel() == Some(panel);
let (open, focused) = if currently_open && currently_focused {
(false, false) // close
} else {
(true, true) // open + focus
};
Ok(Box::new(TogglePanelAndFocus {
panel,
open,
focused,
}))
let target = args.get(1).map(|s| {
let open = s == "true";
(open, args.get(2).map(|s| s == "true").unwrap_or(open))
});
Ok(TogglePanelAndFocus { panel, target })
},
as_parsed,
None,
);
r.register(
&TogglePanelVisibility {
@@ -386,20 +367,16 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(Box::new(TogglePanelVisibility {
Ok(TogglePanelVisibility {
panel,
currently_open: false,
}))
})
},
|args, ctx| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
let currently_open = ctx.panel_open(panel);
Ok(Box::new(TogglePanelVisibility {
panel,
currently_open,
}))
|cmd, ctx| TogglePanelVisibility {
currently_open: ctx.panel_open(cmd.panel),
..cmd
},
None,
);
r.register(
&CyclePanelFocus {
@@ -408,19 +385,18 @@ pub fn default_registry() -> CmdRegistry {
view_open: false,
},
|_| {
Ok(Box::new(CyclePanelFocus {
Ok(CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
}))
})
},
|_, ctx| {
Ok(Box::new(CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
}))
|_, ctx| CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
},
None,
);
r.register(
&MovePanelCursor {
@@ -433,29 +409,26 @@ pub fn default_registry() -> CmdRegistry {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(Box::new(MovePanelCursor {
Ok(MovePanelCursor {
panel,
delta,
current: 0,
max: 0,
}))
})
},
|args, ctx| {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
let (current, max) = match panel {
|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()),
};
Ok(Box::new(MovePanelCursor {
panel,
delta,
MovePanelCursor {
current,
max,
}))
..cmd
}
},
None,
);
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
r.register_nullary(|| Box::new(AddRecordRow));
@@ -490,11 +463,7 @@ pub fn default_registry() -> CmdRegistry {
// ── Grid operations ──────────────────────────────────────────────────
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
r.register(
&ToggleGroupAtCursor { is_row: false },
|_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
);
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: false }));
r.register_nullary(|| Box::new(HideSelectedRowItem));
// ── Text buffer ──────────────────────────────────────────────────────
@@ -537,27 +506,16 @@ pub fn default_registry() -> CmdRegistry {
return Err("commit-cell-edit requires a mode, value, and coords".into());
}
let edit_mode = parse_mode_name(&args[0])?;
Ok(Box::new(CommitAndAdvance {
Ok(CommitAndAdvance {
key: parse_cell_key_from_args(&args[2..]),
value: args[1].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode,
}))
},
|args, ctx| {
require_args(args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Down,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
})
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
);
r.register(
&CommitAndAdvance {
@@ -568,19 +526,8 @@ pub fn default_registry() -> CmdRegistry {
edit_mode: AppMode::editing(),
},
|_| Err("commit-and-advance-right requires context".into()),
|args, ctx| {
require_args(args, 1)?;
let edit_mode = parse_mode_name(&args[0])?;
let value = read_buffer(ctx, "edit");
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitAndAdvance {
key,
value,
advance: AdvanceDir::Right,
cursor: CursorState::from_ctx(ctx),
edit_mode,
}))
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
);
r.register(
&CommitBuffer {
@@ -589,13 +536,14 @@ pub fn default_registry() -> CmdRegistry {
|args| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
Ok(Box::new(CommitBuffer { kind }))
Ok(CommitBuffer { kind })
},
|args, ctx| {
as_parsed,
Some(|args, ctx| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], ctx.mode)?;
Ok(Box::new(CommitBuffer { kind }))
},
Ok(CommitBuffer { kind })
}),
);
r.register_nullary(|| Box::new(CommitExport));
r.register_nullary(|| Box::new(CommitSave));
@@ -641,6 +589,9 @@ mod tests {
vec!["1", "1"],
vec!["formula", "1"],
vec!["editing", "5", "Region", "East"],
vec!["editing", "5", "Region/East"],
vec!["formula", "false", "false"],
vec!["Region/East"],
]
.into_iter()
.map(|v| v.into_iter().map(String::from).collect())
@@ -802,6 +753,67 @@ mod tests {
"only {checked} ex-command examples were found; the scan has stopped seeing them"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor() {
let mut app = crate::ui::app::App::new(two_cat_model(), None);
app.view_state.buffers.insert("edit".into(), "decoy".into());
let ctx = app.cmd_context(
crate::command::key::KeyCode::Null,
crate::command::key::KeyModifiers::NONE,
);
let target = CellKey::new(vec![
("Month".into(), "Feb".into()),
("Type".into(), "Clothing".into()),
]);
assert_ne!(
ctx.cell_key().as_ref(),
Some(&target),
"the cursor is elsewhere"
);
let args: Vec<String> = ["editing", "5", "Type/Clothing", "Month/Feb"]
.map(String::from)
.into();
let dbg = format!(
"{:?}",
ctx.registry
.interactive("commit-cell-edit", &args, &ctx)
.expect("commit-cell-edit resolves")
);
assert!(
dbg.contains(&format!("key: {target:?}")),
"wrong cell: {dbg}"
);
assert!(dbg.contains("value: \"5\""), "wrong value: {dbg}");
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn every_argument_list_the_gate_accepts_the_run_accepts_too() {
let r = default_registry();
let mut wb = crate::workbook::Workbook::new("Empty");
wb.add_category("Foo").expect("a category with no items");
let app = crate::ui::app::App::new(wb, None);
let ctx = app.cmd_context(
crate::command::key::KeyCode::Null,
crate::command::key::KeyModifiers::NONE,
);
assert!(ctx.cell_key().is_none(), "the cursor must sit on no cell");
let mut accepted = 0;
for name in r.names().collect::<Vec<_>>() {
for args in candidate_args() {
if r.parse(name, &args).is_err() {
continue;
}
accepted += 1;
if let Err(e) = r.interactive(name, &args, &ctx) {
panic!("the gate accepts `{name} {args:?}` but the run refuses it: {e}");
}
}
}
assert!(accepted > 50, "only {accepted} accepted pairs were tried");
}
}
#[cfg(test)]
@@ -880,6 +892,7 @@ mod panel_open_tests {
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: false"),
@@ -897,6 +910,7 @@ mod panel_open_tests {
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: true"),
@@ -905,4 +919,29 @@ mod panel_open_tests {
);
}
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn explicit_open_and_focus_arguments_beat_the_toggle() {
for panel in Panel::ALL {
let mut app = App::new(two_cat_model(), None);
app.view_state.formula_panel_open = false;
app.view_state.category_panel_open = false;
app.view_state.view_panel_open = false;
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let args = [panel.name().to_string(), "false".into(), "false".into()];
let dbg = format!(
"{:?}",
ctx.registry
.interactive("toggle-panel-and-focus", &args, &ctx)
.expect("toggle-panel-and-focus resolves")
.execute(&ctx)
);
assert!(
dbg.contains("open: false") && !dbg.contains("open: true"),
"`{} false false` on a closed panel toggled it open: {dbg}",
panel.name()
);
}
}
}
+27 -5
View File
@@ -1049,6 +1049,12 @@ mod tests {
let was_view = host.app().view_state.active_view.clone();
let was_status = host.app().view_state.status_msg.clone();
let basis = frame_basis(&host, &["North"]);
assert!(
host.app()
.resolve_record(addressed("North").record)
.is_some(),
"North must resolve BEFORE the load, or a refusal naming it proves nothing"
);
// The load lands, then the write names a record the incoming document
// has never had — so the frame fails at its second command.
@@ -1069,11 +1075,10 @@ mod tests {
]),
);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
"the fixture must actually refuse the frame: {answer:?}"
assert_eq!(
to(&answer, asker),
vec![the_load_ran_then_the_write_broke()],
"only a load that RAN can refuse the write this way: {answer:?}"
);
assert_eq!(
host.app().view_state.active_view,
@@ -1097,6 +1102,12 @@ mod tests {
);
}
/// `North` resolves in the outgoing document, so only `broken_chain` AFTER the
/// load applied can answer this. Evidence: `context/test-evidence/command.md`.
fn the_load_ran_then_the_write_broke() -> ServerFrame {
ServerFrame::Rejected(Rejection::ChainBroken(addressed("North").record))
}
/// Everything one client was told, flattened to the projections it carries.
fn projections(frames: &[Addressed], id: SessionId) -> Vec<Projection> {
to(frames, id)
@@ -1147,6 +1158,12 @@ mod tests {
let (bystander, _) = host.connect(10, 10);
let was_generation = host.app().document_generation();
let basis = frame_basis(&host, &["North"]);
assert!(
host.app()
.resolve_record(addressed("North").record)
.is_some(),
"North must resolve BEFORE the load, or a refusal naming it proves nothing"
);
// The load lands and rebases every mirror; the write then names a
// record the incoming document has never had, so the frame fails.
@@ -1167,6 +1184,11 @@ mod tests {
]),
);
assert_eq!(
to(&answer, asker),
vec![the_load_ran_then_the_write_broke()],
"only a load that RAN can refuse the write this way: {answer:?}"
);
assert_eq!(
host.app().document_generation(),
was_generation,
+18
View File
@@ -74,6 +74,7 @@ impl ClientState {
self.apply_projection(projection);
}
self.settle_mode();
self.settle_editor();
self.revision += 1;
}
@@ -85,6 +86,7 @@ impl ClientState {
other => reduce_view(&mut self.view, other),
}
self.settle_mode();
self.settle_editor();
self.revision += 1;
}
@@ -124,6 +126,7 @@ impl ClientState {
}
self.last_rejection = Some(rejection);
self.settle_mode();
self.settle_editor();
self.revision += 1;
}
@@ -158,6 +161,21 @@ impl ClientState {
self.view.mode = settled_mode(&self.view.mode, self.grid.records);
}
/// An editor that arrived without its buffer is seeded here, so what is
/// drawn and what Enter commits read one buffer (improvise-ktlk).
fn settle_editor(&mut self) {
let absent = self
.view
.mode
.minibuffer()
.filter(|_| self.view.mode.is_editing())
.map(|mb| mb.buffer_key.clone())
.filter(|key| !self.view.buffers.contains_key(key));
if let (Some(key), Some(seed)) = (absent, crate::resolve::edit_seed(self)) {
self.view.buffers.insert(key, seed);
}
}
/// The widget dispatch: a projection names the widget it is addressed to.
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
fn apply_projection(&mut self, projection: &Projection) {
+64 -2
View File
@@ -439,6 +439,12 @@ fn navigation() -> Vec<HelpLine> {
Some(HelpLine::Heading("Cursor movement")),
Some(HelpLine::Blank),
keys(n, "move-selection", None, "Move one cell"),
keys(
n,
"enter-advance",
None,
"Move down, wrapping to the next column",
),
Some(literal("gg", "Jump to first row")),
keys(n, "jump-last-row", None, "Jump to last row"),
keys(n, "jump-first-col", None, "Jump to first column"),
@@ -513,8 +519,12 @@ fn editing() -> Vec<HelpLine> {
Some(HelpLine::Blank),
Some(HelpLine::Heading("Entering edit mode")),
Some(HelpLine::Blank),
keys(n, "edit-or-drill", Some(&["editing"]), "Edit current cell"),
keys(n, "enter-advance", None, "Edit current cell (same as i)"),
keys(
n,
"edit-or-drill",
Some(&["editing"]),
"Edit current cell (drill into an aggregate)",
),
keys(
e,
"clear-buffer",
@@ -950,6 +960,58 @@ mod tests {
"an undescribed name falls back to itself rather than to silence"
);
}
fn rows() -> Vec<(Vec<String>, &'static str)> {
pages()
.into_iter()
.flat_map(|page| page.lines)
.filter_map(|line| match line {
HelpLine::Keys { keys, description } => Some((keys, description)),
_ => None,
})
.collect()
}
fn labels_of(command: &str, args: Option<&[String]>) -> Vec<String> {
keys_for(&ModeKey::Normal, command, args)
.iter()
.map(|(c, m)| key_label(c, *m))
.collect()
}
/// improvise-s8h7: `i` on an aggregate drills in the TUI, so the row
/// that documents it must say so. See `context/test-evidence/clients.md`.
#[test]
fn the_edit_key_row_says_it_drills_into_an_aggregate() {
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
let found: Vec<_> = rows().into_iter().filter(|(k, _)| *k == edit).collect();
let [(_, description)] = &found[..] else {
panic!("expected one help row keyed {edit:?}, got {found:?}");
};
assert!(
description.contains("drill") && description.contains("aggregate"),
"{edit:?} drills into an aggregate and the row must say so: {description}"
);
}
/// improvise-s8h7: Normal's Enter is `enter-advance`, which only moves.
/// See `context/test-evidence/clients.md`.
#[test]
fn only_the_edit_key_is_captioned_as_editing_the_cell() {
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
let claims: Vec<_> = rows()
.into_iter()
.filter(|(_, d)| d.contains("Edit current cell"))
.collect();
assert!(!claims.is_empty(), "the check needs a row to check");
for (keys, description) in claims {
assert!(
keys.iter().all(|k| edit.contains(k)),
"{keys:?} is captioned {description:?} but only {edit:?} edit"
);
}
}
/// Not an assertion: a printout for a human to read once. `cargo test -p
/// improvise-protocol eyeball -- --nocapture --ignored`.
#[test]
+1 -1
View File
@@ -36,7 +36,7 @@ pub use render::{
WizardProposal, WizardStepKind, header_level_widths,
};
pub use resolve::{
Click, KeyOutcome, Request, click, edit_seed, read_buffer, resolve, typed, typed_cell_command,
Click, KeyOutcome, Request, click, read_buffer, resolve, typed, typed_cell_command,
};
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
// `improvise-core`'s, because the index that resolves one lives in
+84 -25
View File
@@ -748,9 +748,10 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option<Vec<Command>> {
},
Command::ChangeMode(mode),
]),
CellId::Aggregate(_) => Some(vec![Command::SetStatus(
"drilling into an aggregate needs the host — not in the browser yet".to_string(),
)]),
CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!(
"an aggregate is edited by drilling in — {}",
not_yet("drill")
))]),
CellId::Derived { target } => Some(vec![Command::SetStatus(format!(
"{target} is computed by a formula — edit the formula, not the cell"
))]),
@@ -954,11 +955,10 @@ fn record_at_cursor(state: &ClientState) -> Option<crate::RecordHash> {
}
}
/// What an editor opened on the cursor starts with. The one home, so a
/// renderer does not reconcile the scroll offset again (gotcha 42).
pub fn edit_seed(state: &ClientState) -> String {
let (row, col) = state.view.cursor().selected;
seed_at(state, row, col)
/// What an editor opened on the cursor starts with, or `None` with no cell
/// there to seed from. Read by the fold, never a renderer (improvise-ktlk).
pub(crate) fn edit_seed(state: &ClientState) -> Option<String> {
cell_at_cursor(state)?.edit_text.clone()
}
/// The display text at an absolute grid position, as the cache holds it.
@@ -2435,28 +2435,38 @@ mod tests {
);
}
/// The advertised key must say what is actually true. Two refusals, two
/// subjects — see `context/test-evidence/clients.md`.
/// `i` on an aggregate IS drill in the TUI, so both doors name one missing
/// feature; only `i` says why. See `context/test-evidence/clients.md`.
#[test]
fn the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons() {
let mut state = in_mode(AppMode::Normal);
state.grid.extent = GridExtent { rows: 2, cols: 2 };
state.window = (2, 2);
let KeyOutcome::Local(drill) = resolve(&state, ev(KeyCode::Char('>'))) else {
panic!("`>` is advertised in the hint line; it must answer");
};
let [Command::SetStatus(said)] = &drill[..] else {
panic!("expected one status, got {drill:?}");
fn an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key() {
let aggregate = CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
);
let state = client_on(aggregate);
let said = |key| match resolve(&state, ev(key)) {
KeyOutcome::Local(commands) => match &commands[..] {
[Command::SetStatus(said)] => said.clone(),
other => panic!("expected one status, got {other:?}"),
},
other => panic!("{key:?} is advertised; it must answer, got {other:?}"),
};
let (drill, edit) = (said(KeyCode::Char('>')), said(KeyCode::Char('i')));
assert!(
said.contains("drill") && said.contains("browser"),
"the status must name drill and say it is the browser that lacks it: {said}"
drill.contains("drill") && drill.contains("browser"),
"the status must name drill and say it is the browser that lacks it: {drill}"
);
assert!(
!said.contains("aggregate"),
"a sentence about aggregates is TRUE-SOUNDING about the wrong thing — \
`>` is refused whatever the cell is: {said}"
!drill.contains("aggregate"),
"`>` is refused whatever the cell is, so it must not blame the cell: {drill}"
);
assert!(
edit.contains(&drill),
"`i` reached the same missing feature and must name it the same way: \
{edit:?} does not contain {drill:?}"
);
assert!(
edit.contains("aggregate"),
"and `i` must still say why it drilled rather than edited: {edit}"
);
}
@@ -3017,6 +3027,55 @@ mod tests {
));
}
/// improvise-ktlk: what an editor shows and what Enter commits are one
/// fact. See `context/test-evidence/clients.md`.
#[test]
fn an_editor_entered_without_its_buffer_commits_and_extends_the_cell() {
let mut client = client_on(record_cell());
let mut cell = CellDisplay::number("1,200.50");
cell.edit_text = Some("1200.5".into());
cell.id = Some(record_cell());
client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells(
vec![((0, 0), cell)],
))));
client.fold(&Command::ChangeMode(AppMode::editing()));
assert_eq!(
read_buffer(&client.view, "edit"),
"1200.5",
"the buffer is what the overlay paints and what a commit reads"
);
let committed = |client: &ClientState| {
let KeyOutcome::Local(commands) = resolve(client, ev(KeyCode::Enter)) else {
panic!("Enter in Editing must resolve here");
};
match &commands[0] {
Command::Model {
cmd: ModelCommand::SetCell { value, .. },
..
} => Some(value.clone()),
_ => None,
}
};
assert_eq!(
committed(&client),
Some(CellValue::Number(1200.5)),
"Enter on an editor showing the cell must not clear it"
);
let KeyOutcome::Local(typed) = resolve(&client, ev(KeyCode::Char('5'))) else {
panic!("a character in Editing is append-char");
};
for command in &typed {
client.fold(command);
}
assert_eq!(
committed(&client),
Some(CellValue::Number(1200.55)),
"a typed character extends what the editor showed, not replaces it"
);
}
/// At the bottom-right corner `EnterAdvance` returns `AbortChain`, so the
/// commit leaves the user in normal mode. See the evidence file.
#[test]
+12 -14
View File
@@ -294,16 +294,13 @@ pub fn render_model(state: &ClientState) -> RenderModel {
help: help_rows(&state.view),
help_page: state.view.help_page,
help_titles: help_titles(&state.view),
// An ABSENT buffer is not an empty one: blanking the overlay over a
// real value is one Enter from clearing it (improvise-xg1z).
editing: state.view.mode.is_editing().then(|| {
state
.view
.buffers
.get("edit")
.cloned()
.unwrap_or_else(|| improvise_protocol::edit_seed(state))
}),
// The buffer Enter commits, never a second derivation of it: the fold
// seeds an absent one (improvise-xg1z, improvise-ktlk).
editing: state
.view
.mode
.is_editing()
.then(|| improvise_protocol::read_buffer(&state.view, "edit")),
minibuffer: state.view.mode.minibuffer().map(|mb| RenderMinibuffer {
prompt: mb.prompt.clone(),
value: improvise_protocol::read_buffer(&state.view, &mb.buffer_key),
@@ -912,10 +909,11 @@ mod tests {
client
.state_mut()
.fold(&Command::ChangeMode(improvise_protocol::AppMode::editing()));
assert!(
!client.state().view.buffers.contains_key("edit"),
"the fixture must really be missing the buffer, or this proves \
nothing about the fallback"
assert_eq!(
client.state().view.buffers.get("edit").map(String::as_str),
Some("1200"),
"the fold seeds the missing buffer, so the overlay and a commit \
read one thing (improvise-ktlk)"
);
assert_eq!(
client.model().rows[0].cells()[0].text,
+9 -8
View File
@@ -36,12 +36,14 @@ async function anAggregateGrid(page) {
test.describe("drill is refused in the browser, not broken", () => {
// The honest half: `i`/`a` on an aggregate declines OUT LOUD, because
// `edit_or_drill` answers the `CellId::Aggregate` arm with a status.
test("editing an aggregate says drill needs the host", async ({ page }) => {
test("editing an aggregate says it is edited by drilling, and drill is missing", async ({
page,
}) => {
await anAggregateGrid(page);
await page.keyboard.press("i");
await expect(page.locator("#toast")).toContainText(
"drilling into an aggregate needs the host",
"an aggregate is edited by drilling in — drill is not available in the browser yet",
);
// And it really is a refusal: no editor opened, no mode change.
await expect(page.locator("#mode")).toHaveText("NORMAL");
@@ -63,16 +65,15 @@ test.describe("drill is refused in the browser, not broken", () => {
);
await expect(page.locator("#mode")).toHaveText("NORMAL");
// The two refusals have two SUBJECTS, and conflating them would be a
// true-sounding sentence about the wrong thing — worse than silence,
// because it stops the reader looking. `>` must not talk about aggregates.
// `>` is refused whatever the cell is, so it must not blame the cell.
await expect(page.locator("#toast")).not.toContainText("aggregate");
// And `i` still gives the OTHER refusal, which is what proves the two are
// distinct rather than one message reached by two keys.
// `i` reaches the SAME missing feature (in the TUI it drills), so it names
// it the same way and adds only why — improvise-s8h7, clients.md.
await page.keyboard.press("i");
await expect(page.locator("#toast")).toContainText("an aggregate is edited by drilling in");
await expect(page.locator("#toast")).toContainText(
"drilling into an aggregate needs the host",
"drill is not available in the browser yet",
);
});
});
+54 -27
View File
@@ -76,36 +76,37 @@ run_case() {
scaffold
run_case "both workspaces clean is a pass" 0 "$work/repo"
# The load-bearing case. `cargo fmt --all` at the outer root cannot reach this
# file, and `cargo fmt -p inner-pkg` from the outer root exits 1 saying `not a
# member of the workspace` -- the usage error this whole script exists to split
# from a real verdict.
# One fault case PER PACKAGE, enumerated from the fixture rather than
# hand-picked: three hand-picked shapes each missed a rival, and the fourth
# missed the root package itself (improvise-90ea). Reasoning:
# context/test-evidence/tooling.md. The enumeration is a find over
# `[package]` manifests, deliberately NOT the script's own `[workspace]` +
# `cargo metadata` discovery, which is the thing under test.
scaffold
printf '%s' "$DIRTY" > "$work/repo/nested/src/lib.rs"
if run_case "a fault in the NESTED workspace is reported" 1 "$work/repo"; then
case "$OUT" in
*nested/src/lib.rs*) ok " and the nested file is named" ;;
*) bad " and the nested file is named" "output did not name it: ${OUT:0:300}" ;;
esac
pkg_manifests=()
while IFS= read -r m; do pkg_manifests+=("$m"); done < <(
cd "$work/repo" && grep -rl --include=Cargo.toml '^\[package\]' . | sort)
# An empty or shrunken enumeration would make the loop below vacuous.
if [ "${#pkg_manifests[@]}" = 3 ]; then
ok "the fixture enumerates all three packages (root, member, nested)"
else
bad "the fixture enumerates all three packages" "found: ${pkg_manifests[*]:-none}"
fi
scaffold
printf '%s' "$DIRTY" > "$work/repo/a/src/lib.rs"
if run_case "a fault in the OUTER workspace is reported" 1 "$work/repo"; then
case "$OUT" in
*a/src/lib.rs*) ok " and the outer file is named" ;;
*) bad " and the outer file is named" "output did not name it: ${OUT:0:300}" ;;
esac
# The rival: spelling the per-root check `--all` instead of `-p <members>`
# walks PAST the nested workspace boundary (measured), so the OUTER file is
# reported a second time under the inner root. Counting is what sees that.
n="$(printf '%s\n' "$OUT" | grep -c 'a/src/lib.rs')"
if [ "$n" = 1 ]; then
ok " and it is named ONCE -- no root reaches past its own boundary"
else
bad " and it is named ONCE" "named $n times; a root is walking past its boundary"
for m in "${pkg_manifests[@]}"; do
rel="$(dirname "${m#./}")/src/lib.rs"; rel="${rel#./}"
scaffold
printf '%s' "$DIRTY" > "$work/repo/$rel"
if run_case "a fault in $rel is reported" 1 "$work/repo"; then
# Counting, not matching: `--all` for `-p` walks PAST the nested boundary
# (measured) and names an outer file a second time under the inner root.
n="$(printf '%s\n' "$OUT" | grep -cF "$work/repo/$rel")"
if [ "$n" = 1 ]; then
ok " and $rel is named exactly once"
else
bad " and $rel is named exactly once" "named $n times; output: ${OUT:0:300}"
fi
fi
fi
done
# A usage error must NOT wear a formatting failure's exit code. This is the
# whole ticket: `cargo fmt -p opfs-contract` from the repo root exits 1.
@@ -121,6 +122,32 @@ run_case "a tree with no workspace root is a usage error, not a pass" 2 "$work/e
run_case "--help is a usage error, so a typo cannot pass" 2 "$work/repo" --help
# The loop above covers every package the FIXTURE has; this covers every
# package the TREE has. A package no root lists is checked by nothing, so the
# gate must refuse a verdict -- and name the package -- rather than pass.
scaffold
mkdir -p "$work/repo/orphan/src"
printf '[package]\nname = "orphan-pkg"\nversion = "0.1.0"\nedition = "2021"\n' \
> "$work/repo/orphan/Cargo.toml"
printf '%s' "$DIRTY" > "$work/repo/orphan/src/lib.rs"
cat > "$work/repo/Cargo.toml" <<'EOF'
[workspace]
members = ["a"]
exclude = ["orphan"]
resolver = "2"
[package]
name = "root-pkg"
version = "0.1.0"
edition = "2021"
EOF
if run_case "a package no workspace root reaches is exit 2, not a pass" 2 "$work/repo"; then
case "$OUT" in
*"no workspace root reaches"*orphan/Cargo.toml*) ok " and the unreached package is named" ;;
*) bad " and the unreached package is named" "output: ${OUT:0:300}" ;;
esac
fi
# The narrowed contract, pinned so it is not merely prose (ChatGPT Codex on
# PR #159). rustfmt exits 1 for a source it cannot parse, exactly as for a
# diff -- so exit 1 is "rustfmt did not certify this root clean", and what
+43 -17
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Format-checks every Cargo workspace root in the repo, from any directory.
# `cargo fmt --all` reaches only ONE of them. Reasoning: context/code/tooling.md
# section 4 -- including why `-p` scoped by `--manifest-path` is the only
# `cargo fmt --all` reaches only ONE of them. Reasoning: context/test-evidence/
# tooling.md, "The two-workspace fmt gate" -- including why `-p` scoped by `--manifest-path` is the only
# spelling that is neither blind nor boundary-crossing (improvise-b4ks).
set -uo pipefail
@@ -18,8 +18,9 @@ exit 1 rustfmt did not certify a root clean: a file differs from rustfmt's
output, or rustfmt refused to parse one. Either way a FILE IS NAMED
and the tree is what is wrong.
exit 2 the gate did not run: usage error, no cargo, no rustfmt, cargo
metadata failed, or no [workspace] root found. NO verdict was
produced and NO file is named.
metadata failed, no [workspace] root found, or a [package] that no
root has as a member. NO verdict was produced. No SOURCE file is
named; the unreached-package case names its Cargo.toml.
The split is "did the gate run", not "is every exit-1 cause a diff". That is
narrower than it sounds and it is deliberate -- see the note on rc=1 below.
@@ -38,8 +39,8 @@ EOF
# The find is RELATIVE to the tree being scanned: an absolute `-path` filter
# matches the ancestors too, and this repo is itself checked out under
# `.claude/worktrees/` in every agent worktree, where that excluded everything.
workspace_roots() {
local root="$1"
manifests_with() {
local root="$1" section="$2"
( cd "$root" && find . -name Cargo.toml \
-not -path './target/*' -not -path '*/target/*' \
-not -path './.git/*' \
@@ -47,11 +48,12 @@ workspace_roots() {
-not -path './.claude/*' ) \
| sort \
| while IFS= read -r rel; do
if grep -q '^\[workspace\]' "$root/${rel#./}"; then
if grep -q "^\\[$section\\]" "$root/${rel#./}"; then
printf '%s\n' "$root/${rel#./}"
fi
done
}
workspace_roots() { manifests_with "$1" workspace; }
# The members of ONE workspace, named by cargo rather than parsed out of the
# manifest: a `members = [...]` list may carry globs and path dependencies.
@@ -61,10 +63,10 @@ members_of() {
printf '%s' "$meta" | python3 -c '
import json, sys
doc = json.load(sys.stdin)
names = [p["name"] for p in doc["packages"]]
if not names:
rows = [p["name"] + "\t" + p["manifest_path"] for p in doc["packages"]]
if not rows:
sys.exit(1)
print("\n".join(names))
print("\n".join(rows))
' || return 1
}
@@ -106,15 +108,39 @@ main() {
return 2
fi
local rc=0 manifest member
for manifest in "${roots[@]}"; do
local -a pkgs=()
while IFS= read -r member; do [ -n "$member" ] && pkgs+=("$member"); done \
< <(members_of "$manifest")
if [ ${#pkgs[@]} -eq 0 ]; then
printf 'fmt-workspaces: cargo metadata failed for %s\n' "$manifest" >&2
# Pass 1: every root's members, before anything is formatted, so that a
# package NO root reaches is exit 2 rather than silently never checked.
local rc=0 manifest member i m
local -a members=() covered=()
for i in "${!roots[@]}"; do
local rows
rows="$(members_of "${roots[$i]}")" || rows=""
if [ -z "$rows" ]; then
printf 'fmt-workspaces: cargo metadata failed for %s\n' "${roots[$i]}" >&2
return 2
fi
members[$i]="$(printf '%s\n' "$rows" | cut -f1 | tr '\n' ' ')"
while IFS= read -r m; do covered+=("$m"); done < <(printf '%s\n' "$rows" | cut -f2)
done
local orphans
orphans="$(manifests_with "$root" package | python3 -c '
import os, sys
covered = {os.path.realpath(p) for p in sys.argv[1:]}
for line in sys.stdin:
m = line.rstrip("\n")
if m and os.path.realpath(m) not in covered:
print(m)
' "${covered[@]}")"
if [ -n "$orphans" ]; then
printf 'fmt-workspaces: no workspace root reaches these packages, so nothing checks them:\n%s\n' \
"$orphans" >&2
return 2
fi
for i in "${!roots[@]}"; do
manifest="${roots[$i]}"
local -a pkgs=()
read -r -a pkgs <<< "${members[$i]}"
if [ "$list" = 1 ]; then
printf '%s: %s\n' "$manifest" "${pkgs[*]}"
+60 -10
View File
@@ -9,6 +9,8 @@ KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}"
# Test seam: kb-test.sh injects a pre-fix kb-merge.pl to prove the arithmetic
# check catches a merge that drops a clause.
MERGE="${KB_MERGE:-$here/kb-merge.pl}"
# Exit codes: 0 done, 1 REFUSED (a verdict on the write), 2 usage, 3 the
# completeness baseline could not be read, 127 no tpl. Only 1 is a verdict.
# trealla comes from the dev shell, which direnv puts on PATH. Reaching it
# through `nix shell` per invocation cost more than the work it did: this
@@ -62,7 +64,39 @@ acquire_lock() {
fi
sleep 0.1
done
trap 'rmdir "$KB.lock" 2>/dev/null || true' EXIT
trap 'rm -f "${scratch[@]}"; rmdir "$KB.lock" 2>/dev/null || true' EXIT
}
# Every temporary a writing command makes. The candidate lives BESIDE the kb, so
# publishing it is a rename(2): a mv from $TMPDIR may cross a device and copy.
scratch=()
new_cand() {
cand="$(mktemp "$KB.cand.XXXXXX")"
scratch+=("$cand" "$cand.delta" "$cand.removed" "$cand.ledger" "$cand.base")
}
# What `kb rm` removed that HEAD still has: the record that tells an intended
# removal from a lost clause. A fixture's sits beside it; the real one in .git.
ledger_path() {
if [ -n "${KB_FILE:-}" ]; then echo "$KB.removed"
else git -C "$repo" rev-parse --path-format=absolute --git-path kb-removed.pl
fi
}
# The committed knowledge base, which no write may end up short of.
# KB_BASELINE is the test seam, naming a file.
baseline_to() {
if [ -n "${KB_BASELINE:-}" ]; then cp -f "$KB_BASELINE" "$1"
else git -C "$repo" show HEAD:prolog-sessions/kb.pl > "$1" 2>/dev/null
fi
}
# Test seam: SIGKILL once the candidate is complete and checked, before it is
# published -- where a killed write dies. Records the candidate's path first.
kill_hook() {
[ -n "${KB_TEST_KILL:-}" ] || return 0
echo "$1" > "$KB_TEST_KILL"
kill -9 $$
}
# Test seam: simulate another writer landing between the read and the commit.
@@ -83,14 +117,17 @@ usage: kb <command>
replace <delta.pl> add clauses, overwriting existing keys
rm <keys.pl> remove clauses by key; refuses if any key is absent
Every writing command verifies the result reads back as the clauses intended
and passes kb_consistent before it replaces the knowledge base.
Every writing command verifies the result reads back as the clauses intended,
still holds every clause HEAD has that no `kb rm` removed, and passes
kb_consistent before it replaces the knowledge base.
Exit: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable, 127 no tpl.
EOF
exit 2
}
commit() { # $1 = candidate file, $2 = stamp of the kb before it was read
local cand="$1" was="${2:-}"
commit() { # $1 = candidate, $2 = stamp of the kb as read, $3 = keys a rm removes
local cand="$1" was="${2:-}" removing="${3:-}" ledger
race_hook
if [ -n "$was" ] && [ "$(stamp_of "$KB")" != "$was" ]; then
rm -f "$cand"
@@ -98,11 +135,24 @@ commit() { # $1 = candidate file, $2 = stamp of the kb before it was read
echo "kb: another writer won; re-run and your delta applies on top." >&2
exit 1
fi
ledger="$(ledger_path)"
if ! baseline_to "$cand.base"; then
echo "kb: cannot read the committed kb.pl to compare against; $KB is unchanged" >&2
exit 3
fi
{ if [ -f "$ledger" ]; then cat "$ledger"; fi
if [ -n "$removing" ]; then cat "$removing"; fi; } > "$cand.removed"
if ! merge complete "$cand.base" "$cand" "$cand.removed" "$cand.ledger"; then
echo "kb: REFUSED -- clauses HEAD has are gone, with no kb rm recorded; $KB is unchanged." >&2
echo "kb: restore it from HEAD and re-apply your deltas, or commit the removal." >&2
exit 1
fi
if ! check_file "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2
exit 1
fi
kill_hook "$cand"
mv -f "$cand.ledger" "$ledger"
mv -f "$cand" "$KB"
}
@@ -138,7 +188,7 @@ case "$cmd" in
fmt)
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
new_cand
: > "${cand}.delta"
if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then
rm -f "$cand" "${cand}.delta"
@@ -153,20 +203,20 @@ case "$cmd" in
[ $# -eq 1 ] || usage
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
new_cand
if ! merge remove "$KB" "$1" "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
commit "$cand" "$was"
commit "$cand" "$was" "$1"
echo "kb: $KB updated"
;;
add|replace)
[ $# -eq 1 ] || usage
acquire_lock
was="$(stamp_of "$KB")"
cand="$(mktemp)"
new_cand
if ! merge "$cmd" "$KB" "$1" "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- $KB is unchanged" >&2
+36 -1
View File
@@ -10,7 +10,9 @@ main :-
-> run(Mode, KbFile, DeltaFile, OutFile)
; Argv = [CountStr, KbFile], CountStr == "count"
-> count_only(KbFile)
; die('usage: kb-merge.pl <add|replace> <kb> <delta> <out> | count <kb>')
; Argv = [CS, BaseFile, CandFile, ExcFile, LedgerOut], CS == "complete"
-> complete(BaseFile, CandFile, ExcFile, LedgerOut)
; die('usage: kb-merge.pl <add|replace|remove> <kb> <delta> <out> | count <kb> | complete <baseline> <candidate> <removed> <ledger-out>')
).
% argv arrives as strings; every later comparison is on an atom.
@@ -321,5 +323,38 @@ count_only(KbFile) :-
length(Terms, Total),
format("total=~w~n", [Total]).
% Consistency is not completeness: a kb that lost 276 clauses still passed
% kb_consistent. Why by key and against HEAD: context/code/kb-tool.md.
complete(BaseFile, CandFile, ExcFile, LedgerOut) :-
terms_of(BaseFile, Base),
terms_of(CandFile, Cand),
terms_of(ExcFile, Exc),
ground_keys(Cand, CandKeys),
ground_keys(Exc, ExcKeys),
findall(T, ( member(T, Base), ground_key(T, K),
\+ memberchk(K, CandKeys), \+ memberchk(K, ExcKeys) ), Missing),
length(Base, NB), length(Cand, NC),
( Missing == []
-> ground_keys(Base, BaseKeys),
findall(E, ( member(E, Exc), ground_key(E, K),
memberchk(K, BaseKeys), \+ memberchk(K, CandKeys) ), Kept),
write_terms(LedgerOut, Kept),
length(Kept, NK),
format("kb-merge: complete -- baseline ~w, result ~w, ~w removal(s) recorded~n",
[NB, NC, NK])
; length(Missing, NM),
format("kb-merge: REFUSED -- the baseline has ~w clauses and the result ~w; ~w of the baseline's are missing and no kb rm recorded their removal:~n",
[NB, NC, NM]),
forall(( nth1(I, Missing, T), I =< 10 ), format(" ~q~n", [T])),
( NM > 10 -> Rest is NM - 10, format(" ... and ~w more~n", [Rest]) ; true ),
halt(2)
).
ground_key(T, C) :- clause_key([], T, K), canon_key(K, C).
ground_keys(Terms, Sorted) :-
findall(C, ( member(T, Terms), ground_key(T, C) ), Cs),
sort(Cs, Sorted).
die(Msg) :- format("kb-merge: ~w~n", [Msg]), halt(2).
die_file(F) :- format("kb-merge: cannot read ~w~n", [F]), halt(2).
+113 -2
View File
@@ -5,9 +5,15 @@ set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
KB="$repo/prolog-sessions/kb.pl"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# Fixtures are HEAD's kb.pl, never the working tree's: the tool now measures a
# write against HEAD, and the working tree is the thing that can be damaged.
KB="$work/head.pl"
if ! git -C "$repo" show HEAD:prolog-sessions/kb.pl > "$KB"; then
echo "kb-test: cannot read HEAD:prolog-sessions/kb.pl" >&2
exit 2
fi
pass=0
fail=0
@@ -15,7 +21,8 @@ ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s\n' "$1"; fail=$((fail+1)); }
# Each case runs against its own copy, so a bug cannot reach the real file.
fresh() { cp "$KB" "$work/kb.pl"; }
# Also clears the ledger, the lock and any candidate a killed case left behind.
fresh() { rm -rf "$work"/kb.pl.*; cp "$KB" "$work/kb.pl"; }
kb() { KB_FILE="$work/kb.pl" "$here/kb" "$@"; }
unchanged() { diff -q "$KB" "$work/kb.pl" >/dev/null; }
# A refusal is only evidence when it is the refusal you NAMED: two of these
@@ -500,5 +507,109 @@ else
bad "add refused a key holding a variable: the fold matched by unification"
fi
echo "kb-test: completeness against HEAD (improvise-usus)"
# 18. The incident's shape: clauses gone from the working tree and the rest
# still CONSISTENT -- 276 of 801 were lost and kb_consistent passed, so the
# add landed. The fixture must keep kb_consistent green, or a different
# guard refuses it and the case proves nothing: dropping a whole predicate
# is an existence error, and dropping cited clauses fails the citation
# check. So it keeps one location/3 fact and drops the other 34. No
# KB_BASELINE: this case runs the real `git show HEAD` path, which is also
# what a guard counting the file against ITSELF would pass.
fresh
awk '/^location\(/ { if (seen++) next } { print }' "$KB" > "$work/kb.pl"
cp "$work/kb.pl" "$work/lost.pl"
if ! KB_FILE="$work/lost.pl" "$here/kb" check >/dev/null 2>&1; then
bad "the lossy fixture fails kb_consistent -- case 18 proves nothing"
else
kb add "$work/new.pl" >"$work/out" 2>&1; rc=$?
if [ "$rc" -eq 0 ]; then
bad "add wrote onto a kb missing 34 of HEAD's clauses"
elif ! cmp -s "$work/kb.pl" "$work/lost.pl"; then
bad "the loss was refused but the kb was MODIFIED"
elif [ "$rc" -ne 1 ]; then
bad "the loss was refused with exit $rc, not the verdict code 1"
elif said "$work/out" "34 of the baseline's are missing"; then
ok "add refuses a kb that lost clauses HEAD has, and names how many"
else
bad "the loss was refused, but not BY THE COMPLETENESS GUARD"
fi
fi
# 19. The ticket's own fixture: a copy truncated to half its clauses. This one
# ALSO breaks kb_consistent, so said() is what makes it evidence -- the
# completeness guard runs first and must be the one that answers.
fresh
head -n "$(( $(wc -l < "$KB") / 2 ))" "$KB" > "$work/kb.pl"
cp "$work/kb.pl" "$work/half.pl"
if kb add "$work/new.pl" >"$work/out" 2>&1; then
bad "add wrote onto a kb truncated to half"
elif ! cmp -s "$work/kb.pl" "$work/half.pl"; then
bad "the truncation was refused but the kb was MODIFIED"
elif said "$work/out" "no kb rm recorded their removal"; then
ok "a kb truncated to half is refused by name, byte-identical"
else
bad "the truncation was refused, but not by the completeness guard"
fi
# 20. rm is how the count legitimately falls, and the ledger is how a LATER
# write knows it: rm a clause HEAD has, then add -- the add must land. A
# ledger that is never written, or is ignored, refuses here.
fresh
printf "location(cli_args,'src/main.rs',_).\n" > "$work/drop-head.pl"
if ! kb rm "$work/drop-head.pl" >"$work/out" 2>&1; then
bad "rm of a clause HEAD has was refused: $(tail -1 "$work/out")"
elif kb add "$work/new.pl" >"$work/out" 2>&1; then
ok "an add after a recorded rm is accepted"
else
bad "an add after a legitimate rm was refused: the removal was not recorded"
fi
# 20b. ...and the record excuses exactly what it names. A second, unrecorded
# loss on top of a recorded rm must still refuse; a guard that stands down
# whenever a ledger exists passes 20 and fails here.
sed -i.bak '/^location(add_command,/d' "$work/kb.pl"; rm -f "$work/kb.pl.bak"
cp "$work/kb.pl" "$work/twice.pl"
printf "doctrine(another_probe_after_the_rm,'x').\n" > "$work/new2.pl"
if kb add "$work/new2.pl" >"$work/out" 2>&1; then
bad "a recorded rm excused an unrecorded loss beside it"
elif cmp -s "$work/kb.pl" "$work/twice.pl" && said "$work/out" "1 of the baseline's are missing"; then
ok "a recorded rm excuses only the clause it removed"
else
bad "the unrecorded loss was refused, but not by name, or the kb changed"
fi
# 21. A usage error, an unreadable baseline and a verdict are three exit codes.
# Sharing one is improvise-b4ks: "could not check" reading as "refused".
fresh
KB_BASELINE="$work/no-such-file.pl" kb add "$work/new.pl" >/dev/null 2>&1; rc3=$?
kb add >/dev/null 2>&1; rc2=$?
if [ "$rc3" -eq 3 ] && [ "$rc2" -eq 2 ] && unchanged; then
ok "an unreadable baseline exits 3 and usage exits 2, neither a verdict"
else
bad "exit codes: unreadable baseline $rc3 (want 3), usage $rc2 (want 2)"
fi
# 22. A killed write leaves the old kb. The tool SIGKILLs itself once the
# candidate is complete and checked, just before publishing it. Two rivals
# this separates: writing into the kb in place (the kb changes), and
# building the candidate in $TMPDIR, where mv may cross a device and copy
# -- so the candidate must be a sibling of the kb, making mv a rename(2).
fresh
KB_TEST_KILL="$work/killed" kb add "$work/new.pl" >/dev/null 2>&1; rc=$?
cand="$(cat "$work/killed" 2>/dev/null)"
if [ "$rc" -ne 137 ] || [ -z "$cand" ]; then
bad "the kill seam did not fire (exit $rc) -- case 22 proves nothing"
elif ! unchanged; then
bad "a write killed before publishing MODIFIED the kb"
elif [ "$(dirname "$cand")" != "$work" ]; then
bad "the candidate was built in $(dirname "$cand"), not beside the kb: mv may copy"
elif ! grep -q '^doctrine(a_name_that_is_certainly_not_taken,' "$cand"; then
bad "the candidate was not complete when the write died"
else
ok "a write killed before its rename leaves the old kb byte-identical"
fi
printf '\nkb-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+83
View File
@@ -357,3 +357,86 @@ fn an_explicit_coordinate_still_beats_the_cursor() {
"the named cell survived: {left:?}"
);
}
/// North=1, South=2, East=3 on the row axis; the cursor starts on North.
fn three_cells(scratch: &Scratch, name: &str) -> PathBuf {
let doc = three_rows(scratch, name);
run_cmd(
&[
"set-cell 1 Region/North",
"set-cell 2 Region/South",
"set-cell 3 Region/East",
],
&doc,
);
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
doc
}
fn value_of(doc: &Path, item: &str) -> Option<String> {
cell_lines(doc)
.into_iter()
.find(|l| l.contains(&format!("Region={item}")))
.and_then(|l| l.rsplit(" = ").next().map(str::to_string))
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() {
let scratch = Scratch::new("commit-explicit");
let doc = three_cells(&scratch, "commit.improv");
let out = run_cmd(
&["move-selection 1 0 . commit-cell-edit editing 5 Region/East"],
&doc,
);
assert!(out.status.success(), "the line failed: {}", stderr_of(&out));
let cells = cell_lines(&doc);
assert_eq!(value_of(&doc, "East").as_deref(), Some("5"), "{cells:?}");
assert_eq!(
value_of(&doc, "South").as_deref(),
Some("2"),
"the cell under the cursor was touched: {cells:?}"
);
assert_eq!(
value_of(&doc, "North").as_deref(),
Some("1"),
"the origin cell was touched: {cells:?}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_line_whose_gate_passes_runs_whole() {
let scratch = Scratch::new("commit-atomic");
let doc = scratch.path("atomic.improv");
let out = run_cmd(
&["add-category Foo . commit-cell-edit editing 5 Foo/x"],
&doc,
);
assert!(
!stderr_of(&out).contains("Parse error"),
"a line the gate accepted was refused part-way: {}",
stderr_of(&out)
);
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
text.lines()
.any(|l| l.contains("Foo=x") && l.ends_with("= 5")),
"the commit did not land: {text}"
);
}
/// Evidence: `context/test-evidence/cli.md`.
#[test]
fn a_malformed_commit_line_is_refused_whole() {
let scratch = Scratch::new("commit-malformed");
let doc = seeded(&scratch, "malformed.improv");
let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5"], &doc);
assert!(!out.status.success(), "a malformed line exited 0");
let text = std::fs::read_to_string(&doc).expect("the document");
assert!(
!text.contains("Foo"),
"the first half of a refused line landed: {text}"
);
}