310 Commits

Author SHA1 Message Date
Ed Langley 433c2d79e0 Merge pull request #287 from fiddlerwoaroof/ci/nixos-runners
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / Nix build (crate2nix) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Ci/nixos runners
2026-09-23 22:06:11 -07:00
Ed L 1790764f76 ci: run the browser harness on Chromium and Firefox only
nixpkgs' WebKit (WPEWebProcess) aborts on its first page inside the
self-hosted runner VMs, though not on the host with the same sandbox;
its OPFS specs skip on a runtime probe anyway. Re-add --project=webkit
once the abort is understood.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:58:51 -07:00
Ed L 91fee245b8 ci: build with Nix (crane) instead of cargo in the dev shell
Every CI build is now a derivation in nix/ci.nix, so the self-hosted
runners substitute it from titan's harmonia cache instead of rebuilding:

- checks.clippy, checks.test (with the --check-evidence gate, which
  needs the compiled tests), checks.wasm-check (per crate, no feature
  unification), checks.harness-lint (opfs-contract fmt + clippy)
- packages.web: harness/build.sh unchanged -> the three wasm bundles
  and harness/serve; packages.site re-assembles with the commit as the
  cache-bust token
- packages.ci-deps: the crane dependency builds, for titan's cache
  timer to keep rooted

Dependencies are separate buildDepsOnly derivations keyed on the lock,
so a PR that leaves Cargo.lock alone rebuilds only the workspace. The
script-only checks (node, kb, checkers, comment/CI-graph gates, fmt)
stay dev-shell steps: they compile nothing, and their enforced
`#!/usr/bin/env bash` cannot run in the build sandbox.

ci.yml's build, test, wasm and harness-lint jobs now run `nix build` on
these; rust-cache and the PATH workaround for it are gone. Job names
and the deploy graph are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 21:44:36 -07:00
Ed L 171f982de3 chore: fix wasm builds 2026-09-23 21:25:11 -07:00
Ed L af44653f92 chore: setup nixos runners 2026-09-23 20:27:52 -07:00
Ed L c5e8d7d922 chore: update Cargo.nix 2026-09-23 20:05:56 -07:00
Ed Langley 202e7eac63 Merge pull request #219 from fiddlerwoaroof/fix/vndi-edit-generation
improvise-vndi: an inline edit opened before a document swap is refused
2026-09-23 10:50:14 -07:00
Ed Langley 0ff17c0f0e Merge pull request #240 from fiddlerwoaroof/fix/ugj4-jibp-mouse-editor
Mouse editor: seed from the target, report the first edit (improvise-ugj4, improvise-jibp)
2026-09-23 10:49:45 -07:00
Ed Langley 799a81aed6 Merge pull request #235 from fiddlerwoaroof/collapse-fu/b114
improvise-b114/xur5: no coordinate of a wire write may name a group
2026-09-23 10:49:18 -07:00
Ed Langley b8a4849622 Merge pull request #233 from fiddlerwoaroof/fix/cq3d-status-expiry
A TUI status message lasts until the next key, then the hint returns (cq3d)
2026-09-23 10:48:51 -07:00
Ed Langley d3f03f8c01 Merge pull request #241 from fiddlerwoaroof/fix/djcn-canonical-debug
Make Model's Debug exhaustive and the Debug fingerprint canonical across instances (improvise-djcn)
2026-09-23 10:48:23 -07:00
Ed Langley c26f016968 Merge pull request #243 from fiddlerwoaroof/fix/c0aj-walk-tracked-documents
every_shipped_document_loads walks the index, not a .gitignore reading (improvise-c0aj)
2026-09-23 10:47:56 -07:00
Ed Langley 4502ed2b11 Merge pull request #214 from fiddlerwoaroof/wave/km71-edit-formula
improvise-km71: edit a formula from the formula panel, in place
2026-09-23 09:00:07 -07:00
Edward Langley 228f130e61 evidence: an entry for the shape test that cites it (improvise-c0aj) 2026-09-23 08:58:41 -07:00
Ed Langley a634b3f356 Merge pull request #230 from fiddlerwoaroof/fix/wdmp-asker-cursor
improvise-wdmp: a reframing browser frame keeps the asker's own placement
2026-09-23 08:58:28 -07:00
Edward Langley 6dceaad1c7 every_shipped_document_loads walks the index, not a .gitignore reading (improvise-c0aj) 2026-09-23 08:56:26 -07:00
Edward Langley 4e52d06838 WIP red: four ways of shipping a document the .gitignore walk misses (improvise-c0aj) 2026-09-23 08:54:15 -07:00
Edward Langley 35013b6bda Evidence, docs and the bundle surface for the mouse editor's seed and first edit (improvise-ugj4, improvise-jibp)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:52:26 -07:00
Edward Langley 463bef149d Merge commit '5932ad749f0df01d4321cc961f57a488ab637dde' into wave/km71-edit-formula
# Conflicts:
#	crates/improvise-protocol/src/lib.rs
2026-09-23 08:51:45 -07:00
Edward Langley 6d5b4e328a improvise-km71: serde back in improvise-formula for FormulaId alone
tt22 removed the formula crate's serde dependency with the legacy JSON loader,
keeping serde only where a type crosses the wire. FormulaId crosses the wire, so
it is the third such type. No derive goes back on the formula AST.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:50:53 -07:00
Edward Langley fa3735b068 Merge gh/main (5932ad74) into fix/wdmp-asker-cursor
Resolves context/test-evidence/command.md: both sides appended at EOF;
kept gh/main's entries, then this branch's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:49:29 -07:00
Edward Langley 3a4b0eac69 Keep the fixture's doc to two lines (improvise-djcn)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:49:20 -07:00
Edward Langley f14a7f5f7a Merge gh/main ca2d1b19 into fix/vndi-edit-generation
socket.rs: EditTarget takes the union (document_generation, cell, version,
opened); typed_target_frame keeps #205's untouched early return, then claims
as read at the captured generation. Evidence appended after gh/main's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:48:46 -07:00
Ed Langley 5932ad749f Merge pull request #231 from fiddlerwoaroof/fix/oplp-tile-cursor
Tile select opens on the first regular category, not _Index (oplp)
2026-09-23 08:48:09 -07:00
Edward Langley 47bab86a50 Document the canonical Debug fingerprint and its evidence (improvise-djcn)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:47:21 -07:00
Ed Langley b4779edb0b Merge pull request #236 from fiddlerwoaroof/fix/i3o5-set-status-refusals
The collapsed-group and three paste declines refuse, so they exit 1 and reject a frame (improvise-i3o5)
2026-09-23 08:47:18 -07:00
Edward Langley fa0af07988 Merge remote-tracking branch 'gh/main' into fix/ugj4-jibp-mouse-editor 2026-09-23 08:45:28 -07:00
Ed Langley ca2d1b19ef Merge pull request #226 from fiddlerwoaroof/tooling/52eq-audit
tooling: the dead-ID audit compares positively and is a checked script (52eq)
2026-09-23 08:45:15 -07:00
Edward Langley 6096c03caa Make Model's Debug exhaustive and the Debug fingerprint canonical across instances (improvise-djcn)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:45:14 -07:00
Edward Langley 37a64445e0 Merge commit 'c6de77dbde8d05ba287cb69d92c230089098916f' into wave/km71-edit-formula
# Conflicts:
#	context/code/command.md
2026-09-23 08:42:50 -07:00
Ed Langley c6de77dbde Merge pull request #234 from fiddlerwoaroof/fix/qlrh-walk-harness-fixtures
every_shipped_document_loads walks what .gitignore un-ignores, harness/fixtures included (improvise-qlrh)
2026-09-23 08:42:02 -07:00
Ed Langley aa96cd6404 Merge pull request #224 from fiddlerwoaroof/wave/tt22-no-legacy-json
.improv is the only native format: remove the legacy JSON document loader (improvise-tt22)
2026-09-23 08:41:29 -07:00
Edward Langley 6c181a9c11 Merge gh/main (e406edc8) into fix/wdmp-asker-cursor
Resolves context/test-evidence/command.md: both sides appended at EOF;
kept gh/main's entries, then this branch's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:40:49 -07:00
Edward Langley 5c19fdd4be Merge gh/main (bb5dfe5c) into collapse-fu/b114
#217 has landed; this PR now carries only b114 and xur5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:36:55 -07:00
Edward Langley 7b045567c6 Merge remote-tracking branch 'gh/main' into fix/i3o5-set-status-refusals 2026-09-23 08:36:45 -07:00
Edward Langley b060d4cc65 improvise-xur5: pin the fold record written with an undrawn measure
The reviewer's probe_fold_hidden_measure, over its three rows (hidden,
bogus, no measure). The b114 group rule refuses all three; removing it
fails this test and the b114 test and nothing else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:36:37 -07:00
Edward Langley 961b0c01f8 context: i3o5's four conversions, and what the refuse sweeps cannot see 2026-09-23 08:36:35 -07:00
Edward Langley 5960fe8f0a Merge gh/main (e406edc8) into fix/oplp-tile-cursor
Clean merge, no conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:35:24 -07:00
Ed Langley bb5dfe5c04 Merge pull request #232 from fiddlerwoaroof/collapse-fu/d7aj
improvise-d7aj: pin the fold memo to the whole key, and the diamond (stacked on #203)
2026-09-23 08:35:17 -07:00
Ed Langley cc9bc41092 Merge pull request #217 from fiddlerwoaroof/collapse-fu/ciw8
improvise-ciw8: a wire write reaches only a cell the host projects as a Record
2026-09-23 08:34:49 -07:00
Edward Langley 2f7d9c8c5a WIP: the collapsed-group and three paste declines refuse (improvise-i3o5) 2026-09-23 08:33:54 -07:00
Edward Langley 8747074e52 Seed the mouse editor from its target, and mark the target on the first edit (improvise-ugj4, improvise-jibp)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:33:40 -07:00
Edward Langley bd49de6b48 improvise-4vw8: click_formula opens the editor, as Enter does
The doc comment still said a formula-row click was navigation only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:32:06 -07:00
Edward Langley e5b18d3499 Merge remote-tracking branch 'gh/main' into wave/km71-edit-formula
# Conflicts:
#	context/test-evidence/command.md
#	crates/improvise-protocol/src/reduce.rs
2026-09-23 08:32:01 -07:00
Edward Langley c4877456a4 WIP red: mouse editor stale seed and touched (improvise-ugj4, improvise-jibp) 2026-09-23 08:30:51 -07:00
Edward Langley d24dc8ec61 Merge gh/main (968f65af) into tooling/52eq-audit
Resolves context/test-evidence/tooling.md: both sides appended at EOF;
kept gh/main's entries, then this branch's. AGENTS.md and ci.yml merged
without conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:30:40 -07:00
Edward Langley 872f9bc07b improvise-b114: no coordinate of a wire write may name a group
App::group_refusal asks the document, not the layout, whether any
coordinate names a group rather than an item, and runs first in
unwritable: a measure-half fold hides from both the stored arm and the
projection. Rival (rule removed) fails the new test and nothing else.
command.md and the ciw8 fold evidence now say the aggregate arm guards
only the None-axis case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:29:28 -07:00
Edward Langley 91c80cb7e6 Merge remote-tracking branch 'gh/main' into fix/qlrh-walk-harness-fixtures 2026-09-23 08:28:38 -07:00
Ed Langley e406edc8e8 Merge pull request #203 from fiddlerwoaroof/collapse-fu/eccl
improvise-eccl/f2oo: a fold evaluates formulas over itself and covers only drawn members
2026-09-23 08:28:26 -07:00
Edward Langley 5cd00770cf Merge #217 head (52fa3976) into collapse-fu/b114
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:28:16 -07:00
Edward Langley 4d2252373e every_shipped_document_loads walks what .gitignore un-ignores, harness/fixtures included (improvise-qlrh)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:26:48 -07:00
Edward Langley 0ec808f7a5 Merge gh/main (968f65af) into fix/vndi-edit-generation
Resolves context/test-evidence/clients.md: both sides appended at EOF;
kept gh/main's entries, then this branch's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:25:13 -07:00
Ed Langley 194d8f529c Merge pull request #205 from fiddlerwoaroof/fix/ixxx-untouched-commit
An untouched cell edit writes nothing (improvise-ixxx)
2026-09-23 08:24:48 -07:00
Edward Langley 52fa3976bc Merge gh/main (705c92d9) into collapse-fu/ciw8
#197 changed project::cell_id to take &Model; aggregate_refusal and the two
sjdb tests now pass the host model. The text merge was clean there and did
not compile (E0308) until they did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:24:24 -07:00
Edward Langley 66359a2e04 Merge remote-tracking branch 'gh/main' into fix/oplp-tile-cursor
# Conflicts:
#	context/test-evidence/clients.md
2026-09-23 08:23:17 -07:00
Edward Langley d9ea594604 A TUI status message lasts until the next key, then the hint returns
improvise-cq3d: nothing in production cleared status_msg, and draw_status
prints it instead of the derived hint, so after the first message of a
session the TUI never drew the key hint again, in any mode.

run_tui now sends each key through draw.rs::press, which applies
SetStatus("") and then dispatches: a message a key produces is drawn after
that key and expires at the next one, vim-style, so an error is never
wiped before it is drawn. The expiry lives in the TUI front end rather than
App::dispatch_key, which the lockstep harness shares with the browser. The
browser already shows messages transiently (a TOAST_MS toast) beside an
always-drawn #hint. Reasoning: context/code/tui.md.

The test drives press: a stale message must give way to TileSelect's hint,
a message must survive its own key, and give way at the next. Red at base
at the first step; clearing after dispatch fails the second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:21:59 -07:00
Edward Langley 0051e57341 Pin the touched reset on the editor an advance reopens (improvise-465t)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:20:36 -07:00
Edward Langley 8c797720ba Merge gh/main (705c92d9) into collapse-fu/eccl
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:20:12 -07:00
Edward Langley 1c78e71322 wip-b114 fix 2026-09-23 08:19:35 -07:00
Edward Langley 5eaafbae1e improvise-b114: red — a wire write naming a measure group is stored
The record {Month=Feb} is stored, so the stored arm accepts it and
Month=Feb, _Measure=Money = 7 lands: a non-item coordinate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:18:55 -07:00
Edward Langley 38ec60616c Merge remote-tracking branch 'gh/main' into wave/tt22-no-legacy-json
# Conflicts:
#	context/test-evidence/command.md
2026-09-23 08:18:38 -07:00
Ed Langley 705c92d9ff Merge pull request #197 from fiddlerwoaroof/collapse-fu/gw18
improvise-gw18/yk2s/lx7w/54g1: the cursor's cell has three doors
2026-09-23 08:18:10 -07:00
Edward Langley 21e239dca5 Model's Debug renders its HashMaps in key order, so the effect fingerprints cannot flake (improvise-pc3b) 2026-09-23 08:15:57 -07:00
Ed Langley 968f65afe0 Merge pull request #211 from fiddlerwoaroof/docs/28ri-oga4-diho
docs: one home for the review rules; pin gh/main; audit prose fixes (28ri, oga4, diho)
2026-09-23 08:14:35 -07:00
Ed Langley 0a4f906b1c Merge pull request #218 from fiddlerwoaroof/tooling/7epn-lnsh
tooling: pr-reviews polls past poll 2; stale harness lock names its fix (7epn, lnsh)
2026-09-23 08:14:05 -07:00
Edward Langley c6c2de96b2 Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
2026-09-23 08:13:40 -07:00
Ed Langley cb60166a84 Merge pull request #225 from fiddlerwoaroof/fix/rtud-command-md
command.md: say where each refuse_and_stop refusal is held (improvise-rtud)
2026-09-23 08:13:37 -07:00
Edward Langley f85fddbb2f harness/opfs-contract: refresh Cargo.lock for the dropped serde features (improvise-tt22) 2026-09-23 08:13:25 -07:00
Ed Langley c7e48546c3 Merge pull request #220 from fiddlerwoaroof/fix/17ak-help-footer
The TUI help footer's keys come off the keymap (17ak)
2026-09-23 08:13:10 -07:00
Edward Langley 993418f143 improvise-d7aj: pin the fold memo to the whole key, and the diamond
A formula met at two fold keys in one evaluation (an IF branch naming an
item of another category) must be evaluated at each. A memo keyed by the
formula alone read circular there and passed the whole suite; this test
fails under it and nothing else does. The diamond A1 = B2 + C3 over D is
pinned too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:12:48 -07:00
Edward Langley 6026768ceb rustfmt the sjdb tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:10:36 -07:00
Edward Langley 2383c37f2f Merge remote-tracking branch 'gh/main' into fix/oplp-tile-cursor
# Conflicts:
#	context/test-evidence/clients.md
2026-09-23 08:10:09 -07:00
Edward Langley 27cbe2c1dd Tile select opens on the first regular category, not _Index
improvise-oplp: EnterTileSelect and the browser's resolver both opened the
tile cursor at 0, which is _Index, so `T Enter` or `T r` put _Index alone
on Row and emptied a pivot grid; the browser worker then persisted it.

The rule is improvise_protocol::opening_tile, over &[TileEntry], called by
both ends. TileEntry gains `regular`, filled host-side from CategoryKind in
project::tiles_of, because a thin client cannot tell _Index from a user
category without sniffing names. _Index/_Dim stay tileable (#192).

A lockstep case presses `T Enter` on both ends and pins which category
moved; it was red at base (_Index on Row), and fails for a TUI-only revert
and for a `regular` filled true everywhere. contract.spec.js counted three
ArrowRights from tile 0 and now asserts `T` alone selects Product.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:08:54 -07:00
Edward Langley c312431154 improvise-sjdb: judge an unstored record at its own cell, not its first column
RecordHash drops _Measure, so every column of a row carries the same record,
and with a collapsed _Measure group the first is the fold. aggregate_refusal
now finds the position by the whole key the write names. Rival for the
stored-record test: stored records through cell_id fails it alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:08:45 -07:00
Edward Langley 6e021699d4 improvise-sjdb: red — an empty cell beside a collapsed measure group
The host projects Feb x Units as a Record, and #217 refuses the write:
aggregate_refusal judges the record at its first column, the Money fold.
Also pins that a stored record stays writable whatever the host view does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:07:38 -07:00
Edward Langley 93c455db19 Merge commit '33cf49692781f1bd0f4b7654bce6ac9f7a30cc4f' into fix/wdmp-asker-cursor 2026-09-23 08:05:47 -07:00
Edward Langley ab1c981829 improvise-wdmp: drop an unused mut clippy flagged
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:05:15 -07:00
Edward Langley a1a9be33f0 Merge gh/main (33cf4969) into collapse-fu/gw18
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:03:56 -07:00
Edward Langley f3fb83ee45 improvise-kb1d: document the refusal; v4hg has closed the add/edit asymmetry
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:02:14 -07:00
Edward Langley 77a3825576 improvise-wdmp: the asker's own placement survives the reset its reframe earned
A browser frame that reframes and then places the cursor was folded whole by
the client before the relayed ResetCursor arrived, so the reset undid the
placement while the mirror (reset, then placed, in frame order) kept it.
Host::mutating_frame now relays to the asker every later cursor-placing
command of the frame after its reset, so the client ends where its mirror
and the TUI do. Excluding the asker from the reset was rejected: a frame
that places nothing would reset the mirror silently (gotcha 24).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:01:41 -07:00
Edward Langley 1f541e0fb2 core.md §14: a stored total is fold-invariant only under Sum/Count
Under Avg/Min/Max the strip sums each row's aggregate, so a Max
measure's total moves on z for the same reason a formula column's does
(improvise-wm20). Docs only, from the #203 review.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:01:15 -07:00
Edward Langley 5bcea582f2 Merge gh/main (33cf4969) into collapse-fu/eccl
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:00:49 -07:00
Edward Langley 168a28d2c5 tooling: the dead-ID audit compares positively and is a checked script
improvise-52eq: the AGENTS.md recipe searched `bd show` output for "not
found" and printed "all resolve" when there was none, so a bd that was
missing, failing or never run passed the audit. scripts/bd-audit-ids
keeps the file scan and exclusions, then `comm -23`s the cited ids
against `bd list --all --limit 0 --json`. A dead id is exit 1 and is
named. A bd that does not answer is exit 3, never a pass.

scripts/bd-audit-ids-test.sh has eight cases on a throwaway repo with a
fake bd. The old recipe as a rival fails 7 of the 8. Disabling the
comparison fails the dead-id case, and an empty exclusion list fails the
pass case. CI runs it with the other named suites. The "One environment
note" now reports which forms the guard refused and allowed, as observed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:00:05 -07:00
Edward Langley 7da905cdbb command.md: say where each refuse_and_stop refusal is held (improvise-rtud)
#213 merged before this correction was pushed; the push re-created a
branch main never saw. This lands the final wording on gh/main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:59:08 -07:00
Ed Langley 33cf496927 Merge pull request #215 from fiddlerwoaroof/fix/5xbq-1c5m
One page rule for both ends; the help overlay shows how to page (5xbq, 1c5m)
2026-09-23 07:58:59 -07:00
Edward Langley 82bbc74c13 Merge remote-tracking branch 'gh/main' into wave/km71-edit-formula 2026-09-23 07:57:04 -07:00
Edward Langley 531cf4954c improvise-kb1d: refuse renaming a formula outside _Measure
A regular category's formula target is a real item, and replace_formula added
the new target without removing the old, stranding it as an empty item and
appending the new one. Until formulas have a stable identity (improvise-sboz),
a target change outside _Measure is refused and the editor stays open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:57:04 -07:00
Edward Langley ae281a58a8 Merge gh/main (bf2de499) into collapse-fu/gw18
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:56:53 -07:00
Ed Langley bf2de4995b Merge pull request #208 from fiddlerwoaroof/fix/b9f2-width-guard
Determinism law: the width guard measures the drills it compared (improvise-b9f2)
2026-09-23 07:56:04 -07:00
Ed Langley 345a35895e Merge pull request #213 from fiddlerwoaroof/wave/refuse-fu
Pin every refusal site, and keep a bad formula in its editor (improvise-v4hg, 514o, 0v8l)
2026-09-23 07:55:36 -07:00
Ed Langley cd55944e98 Merge pull request #216 from fiddlerwoaroof/fix/y5eo-records-startup
improvise-y5eo: a document opening on a records view opens the host in RecordsNormal (after #207)
2026-09-23 07:55:09 -07:00
Edward Langley e62a289cc3 Merge remote-tracking branch 'gh/main' into wave/tt22-no-legacy-json 2026-09-23 07:53:45 -07:00
Edward Langley 56f8d37e95 fmt: leading blank line left by the removed serde import 2026-09-23 07:53:20 -07:00
Edward Langley 41aa4073f1 docs: -d skip reason measured; the ADR exception at both merge rules
improvise-28ri, from the #211 review at 6ccad4b: the recipe's grep has no
-r, so the skills' symlinks arrive as directory operands and are never
read. -d skip only silences "Is a directory", and sort -u would absorb a
duplicate anyway. Measured with BSD grep 2.6.0 and GNU grep 3.12: 3092
matches either way, 462 distinct ids. GNU grep -r follows those
symlinks (3107), so -r must not be added.

improvise-oga4: "Shipping a wave"'s opening and "Repository policy over
the Beads blocks" now point at the ADR exception. The wave SKILL.md
pointer is unwrapped, so a grep for "ADR pull requests" finds it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:53:01 -07:00
Edward Langley 5e37973fc0 The TUI help footer's keys come off the keymap
improvise-17ak: render_footer hand-wrote l/Tab, h/S-Tab and q/Esc, a key
table no check could read, and already short of the keymap (n, → and ? were
bound and not advertised). It now draws the page indicator followed by
hint_line(Help), truncated in cells.

Derived rather than deleted: the status row carries the same hint, but
draw_status prints status_msg instead of it whenever a message is standing,
and nothing clears the message on entering Help, so deleting the footer's
keys would leave a user who saved and then pressed ? with none.

The guard renders the popup at 140 columns on every page and requires the
footer row to contain hint_line(Help). Red at base; and a literal copy of
today's string fails it as soon as the keymap drops a binding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:51:50 -07:00
Edward Langley ec548862ac Cargo.lock: improvise-formula no longer depends on serde 2026-09-23 07:50:30 -07:00
Edward Langley 7fb6960cf8 WIP: drop the serde derives only the legacy loader used; docs (improvise-tt22) 2026-09-23 07:50:26 -07:00
Edward Langley ebf3dd2ba2 tooling: pr-reviews polls past the second poll; stale harness lock names its fix
improvise-7epn: two cases on the one-tick-per-read clock. One resolves on
poll 3, and one times out after polling up to the deadline. Replacing the
post-poll deadline check with `break` now fails exactly those two
(23 passed, 2 failed), where before it left the suite 23/23.

improvise-lnsh: when `cargo metadata --locked` refuses the opfs-contract
lock, build.sh now says not to remove --locked and names
`cargo update -w --manifest-path harness/opfs-contract/Cargo.toml`.
Checked by hand against a lock with pest pinned to 2.9.1. Evidence is in
context/test-evidence/tooling.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:49:40 -07:00
Edward Langley 5690903d60 improvise-vndi: an inline edit opened before a document swap is refused
EditTarget captures document_generation when the editor opens, and
BasisCache::claim_as_read claims it rather than the current one. The
layout tier stays current, as clients.md argues for 2wu3. A red test
through a real Host over MemoryFs: A opens North/Revenue, B loads a
same-shaped document holding 5555, A commits "42" -> DocumentReplaced,
5555 survives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:48:15 -07:00
Ed Langley 12c81dd3d6 Merge pull request #207 from fiddlerwoaroof/fix/v8ab-fresh-slice
improvise-v8ab: a fresh session's slice is the host's startup slice (after #206)
2026-09-23 07:44:07 -07:00
Edward Langley f176c2b8ca WIP: a legacy JSON workbook is refused by name (improvise-tt22) 2026-09-23 07:43:13 -07:00
Edward Langley bc060f6760 Merge remote-tracking branch 'gh/main' into collapse-fu/ciw8 2026-09-23 07:41:45 -07:00
Edward Langley 46e75c415f improvise-ciw8: pin the unresolved-write answer; document the rule and why vec![] stays
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:41:43 -07:00
Edward Langley a8737f3fa5 One page rule for both ends; the help overlay shows how to page
improvise-5xbq: resolve::page_rows is now the one home of the page-scroll
rule; the TUI's Move calls it instead of repeating visible_rows * 3 / 4.
A lockstep case pages (PgDn/PgUp, Ctrl-d/Ctrl-u) on a non-square window,
8 rows by 3 columns, and pins the TUI's landing rows as well as comparing
host and client, since one home moves both ends together. Both faults
the ticket injected (page_rows = window_rows / 2; paging by window_cols)
now fail at the first PgDn, before and after the move to one home.

improvise-1c5m: the help overlay draws the mode's own hint (hint_line,
the string #hint carries) in a sticky footer, so a narrow window whose
app bar wraps under #help still says how to page. Playwright case at
600px, red at base, and red again with the footer made non-sticky.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:41:03 -07:00
Edward Langley 08b87edd49 improvise-y5eo: a document opening on a records view opens the host in RecordsNormal
App::with_storage now ends with settle_normal_mode, the rule every batch
ends with, so the host's startup mode matches the one Sessions::connect
hands a client. #207's startup-slice test gains a records-opening case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:40:45 -07:00
Edward Langley 6ccad4b80d docs: only the user's explicit approval accepts or merges an ADR
The user's ruling: "An ADR isn't approved until I say it is." CI, the
reviews and the adversarial review stay required as gates, but none of
them is approval. SKILL.md's "not on a review" pointer is reworded to
match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:40:13 -07:00
Edward Langley 7aff3e7717 Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit
# Conflicts:
#	context/test-evidence/command.md
2026-09-23 07:40:02 -07:00
Ed Langley 52c6a05369 Merge pull request #212 from fiddlerwoaroof/tooling/mkbp-qnpi
pr-reviews re-polls after a slow tick; the harness lock fails loudly (qnpi, mkbp)
2026-09-23 07:39:35 -07:00
Edward Langley 104c10f570 improvise-ciw8: a wire write to a cell the host projects as no Record is refused
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:39:23 -07:00
Edward Langley 3929196096 clippy: needless borrow in the limit fixture 2026-09-23 07:38:43 -07:00
Edward Langley 092a4020a3 WIP improvise-ciw8: red tests, wire writes to a non-record are accepted
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:44:48 -07:00
Edward Langley 77f059c852 Merge remote-tracking branch 'gh/main' into wave/km71-edit-formula
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
2026-09-22 23:43:39 -07:00
Edward Langley a91db1d857 improvise-km71: edit the formula under the cursor, in place
Enter or e in the formula panel opens FormulaEdit on the formula under the
cursor, seeded with its text. The editor names the formula by a FormulaId
captured when it opens, and commits through a new ReplaceFormula effect and
ModelCommand. The edit keeps the formula's position, renames it in place when
the target changes, and refuses out loud if the formula was removed or
rewritten meanwhile, if the new target belongs to another formula, or if the
text does not parse. A refusal leaves the editor open over the user's text.

FormulaCache now carries each formula's id, so the browser composes the same
edit, and a formula-row click opens the editor on the clicked row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:42:33 -07:00
Edward Langley f3a0ef45e6 Merge commit 'ea119bd9d17deed96bcbe6ce8a27079eee70a135' into fix/v8ab-fresh-slice
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 23:42:13 -07:00
Edward Langley 6f04819d40 Mouse evidence rewritten for EditTarget.opened; file improvise-ugj4
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:41:32 -07:00
Edward Langley 74dda4c5c7 Merge remote-tracking branch 'gh/main' into wave/refuse-fu
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 23:41:03 -07:00
Edward Langley 0e846b8e41 WIP: evidence and design notes for v4hg, 514o, 0v8l 2026-09-22 23:40:19 -07:00
Edward Langley e311d4174c Merge gh/main (#188): the seed joins EditTarget and the page passes none
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:39:42 -07:00
Ed Langley ea119bd9d1 Merge pull request #209 from fiddlerwoaroof/fix/phg1-record-rewrite-tests
Give RecordRewrite's three untested branches a test that can fail (improvise-phg1)
2026-09-22 23:38:51 -07:00
Edward Langley b3379d1a17 Merge remote-tracking branch 'gh/main' into collapse-fu/eccl 2026-09-22 23:38:33 -07:00
Edward Langley a00fffc94e rustfmt the width guard (improvise-btyw) 2026-09-22 23:36:49 -07:00
Edward Langley 087aa854b5 Merge remote-tracking branch 'gh/main' into collapse-fu/gw18
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 23:36:41 -07:00
Edward Langley 1550d390cb pr-reviews re-polls after a slow tick; the harness lock fails loudly
improvise-qnpi: `pr-reviews --wait` checked its deadline before the first
re-poll, so a second boundary between two reads of the clock skipped the
only poll (exit 1 with polls=1, reproduced under CPU load). The deadline
is now checked after each poll. A new case drives it with a PATH shim
whose `date` ticks one second per read, so it fails deterministically on
the old loop rather than under load.

improvise-mkbp: harness/opfs-contract/Cargo.lock pinned pest 2.9.1 while
its path deps require 2.9.2, so every build rewrote it. The resolved lock
is committed and build.sh starts with `cargo metadata --locked`; the
wasm-pack `-- --locked` route was tried and does not work (wasm-pack
re-resolves first). opfs.spec.js now names the fix when harness/serve/opfs
is missing, which is what running web/assemble.sh alone leaves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:36:00 -07:00
Ed Langley 7767cee9b6 Merge pull request #200 from fiddlerwoaroof/collapse-fu/b5zh
improvise-b5zh: an outer group's header is pruned with its rows
2026-09-22 23:34:50 -07:00
Edward Langley 1499296586 Merge remote-tracking branch 'gh/main' into fix/b9f2-width-guard 2026-09-22 23:34:47 -07:00
Edward Langley 83c3c7d392 Docs: the width guard reads the representation the law compared (improvise-btyw) 2026-09-22 23:34:45 -07:00
Ed Langley de7037365e Merge pull request #188 from fiddlerwoaroof/wire2/2wu3
The inline editor commits by record identity, not viewport position (2wu3)
2026-09-22 23:34:23 -07:00
Edward Langley 7e1e8afba2 Pin RecordRewrite's clear arm at the renamed record (improvise-q2mq)
A blank edit on a measure a renamed drill row lacks must clear at the renamed
key. The phg1 sparse-row test covered only the write arm, so aiming the clear
at the unrenamed key left the workspace green. The reviewer's probe fails under
that rival and nothing else does.

Also corrects the phg1(c) evidence. The swap test cannot see the narrow rival
(eager clears, deferred writes). It does catch the wider one (eager clears and
writes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:34:08 -07:00
Edward Langley e2b132f594 The width guard counts only drills whose ordered records the law compared (improvise-btyw) 2026-09-22 23:34:05 -07:00
Ed Langley 2a528884a7 Merge pull request #206 from fiddlerwoaroof/fix/lxg0-w1bu
improvise-lxg0/w1bu: a fresh session has no history; records cell kinds checked per column
2026-09-22 23:33:55 -07:00
Edward Langley 72f0c63fff docs: one home for the review rules; pin gh/main; audit prose fixes
improvise-28ri: -d skip only silences "Is a directory" on the skills'
symlinks, which git ls-files lists as files. grep -r never follows them,
so nothing was ever read twice. The "spells no id" sentence now says what
the recipe actually cites.

improvise-oga4: the review-reply and head-SHA rules live only in
agent-rules "GitHub" (plus the after-merge clause moved there); AGENTS.md
points to it, and the code-review skill names it. An ADR's "Approved" is
an extra gate on top of CI, every review answered and the adversarial
review.

improvise-diho: agent-rules rule 7, pin gh/main to a SHA at task start.
Also: build a test-name list in a fresh CARGO_TARGET_DIR, because
git archive's commit mtimes make a reused one return a stale list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:33:53 -07:00
Edward Langley fb20d1c73a Merge remote-tracking branch 'gh/main' into collapse-fu/eccl
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/core.md
#	crates/improvise-core/src/view/layout.rs
2026-09-22 23:33:52 -07:00
Edward Langley e131ae99ff improvise-o1y0/lnkb: §14 states the memo, the IS ruling and the totals limit; evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:33:08 -07:00
Edward Langley 1114cce532 Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit 2026-09-22 23:33:06 -07:00
Edward Langley 1dbf6de936 Evidence and context for improvise-2054 and improvise-wv3h; file improvise-jibp
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:32:56 -07:00
Edward Langley 57fea26aa6 Projection-level: grid_of and a CSV export agree on a fold row
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:31:40 -07:00
Edward Langley 3b8b186f67 Pin that append and pop mark the editor, and only the editor's buffer does (improvise-2054)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:31:33 -07:00
Edward Langley 0f3d06afa2 WIP improvise-km71: edit a formula from the panel
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:31:24 -07:00
Ed Langley ef743e926d Merge pull request #201 from fiddlerwoaroof/docs/agents-md-wave
AGENTS.md agrees with the wave skill; bd block superseded; audit scans .agents; gates from ci.yml
2026-09-22 23:29:37 -07:00
Edward Langley 94e8f0f08e improvise-lnkb: the folded category reads as absent to IS and WHERE, as on None
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:25:54 -07:00
Edward Langley 4e451199af Merge gh/main into fix/phg1-record-rewrite-tests
Resolved context/test-evidence/command.md by keeping both appended sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:23:53 -07:00
Edward Langley 0ecb1d89a9 Merge gh/main (4b88084) into collapse-fu/b5zh
Resolved context/test-evidence/core.md: gh/main's version, with #191's
two improvise-t0ma entries, plus this branch's improvise-b5zh entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:22:44 -07:00
Edward Langley 141be9fca6 Review replies go on the PR thread; ADR PRs merge on the user's approval
- agent-rules "GitHub": authors reply on the PR to every finding they
  address (commit, or why declined); reviewers read existing reviews first
  with scripts/pr-reviews, answer every Codex review on its thread, and
  state the head SHA they reviewed. SKILL.md points reviewers there.
- AGENTS.md "ADR pull requests": numbered open questions, the user answers
  on the PR, "What the user decided", merge only on "Approved" (PR #122).
  SKILL.md "Shepherding" points to it.
- Fix a pointer to the renamed policy section that wrapped across a line
  break and so escaped the rename.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:16:48 -07:00
Edward Langley 689c7665da improvise-o1y0: a fold evaluates each formula once, memoised per evaluation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:15:26 -07:00
Edward Langley fa8c8b61a2 An edited editor always writes, and the stale-seed guards are pinned (improvise-2054, improvise-wv3h)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:14:50 -07:00
Edward Langley 8ba64c8ef9 Merge gh/main (4b88084) into wire2/2wu3
Resolved context/test-evidence/clients.md: gh/main's version, whose
improvise-w7qd/5kdr paragraphs continue the entry above the conflict,
followed by this branch's two improvise-0j0f/4img entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:14:23 -07:00
Edward Langley 7407abbca6 Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
#	crates/improvise-command/src/command/cmd/commit.rs
2026-09-22 23:11:06 -07:00
Edward Langley 7716d823d2 Merge remote-tracking branch 'gh/main' into collapse-fu/gw18
# Conflicts:
#	context/code/command.md
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
2026-09-22 23:09:55 -07:00
Edward Langley 32d5d8f7f3 Merge gh/main (4b88084) into fix/lxg0-w1bu
Resolved context/test-evidence/command.md: gh/main's version plus this
branch's three entries, kept ahead of gh/main's new JSON-import section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:09:02 -07:00
Edward Langley 775f02b4e4 improvise-q9eg: correct the false sentence in command.md; evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:08:27 -07:00
Edward Langley 70c33bd3f3 Work is done when its PR has merged; one statement of it, pointed to
- User ruling: work is not done until it is in a PR and that PR has
  merged. Stated once, in AGENTS.md's repo-owned section outside bd's
  markers (renamed "Repository policy over the Beads blocks"); agent-rules
  "Do not merge" and SKILL.md "Shepherding" point to it.
- "Who owns what" no longer says kb facts are batched two sentences
  before saying they are not: repo-map lines are batched, kb facts are
  written as they arrive.
- improvise-8haj: the ID audit takes its file list from git ls-files
  (tracked + untracked, minus ignored), so build output such as
  harness/serve and other agents' worktrees are not scanned; grep -d skip
  passes over the .claude/skills symlinks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:07:57 -07:00
Edward Langley 42bb8a26a7 The fold sweep also holds the data section and the hidden set still
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:07:06 -07:00
Ed Langley 4b880846b9 Merge pull request #202 from fiddlerwoaroof/fix/6yvg-dz8c-json-float
Every JSON parser reads numbers exactly; derive the float_roundtrip check (improvise-6yvg, improvise-dz8c)
2026-09-22 23:06:31 -07:00
Edward Langley 345bb8cdb6 improvise-q9eg: a key the cursor's door resolved is not re-vetted by its shape
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:06:11 -07:00
Ed Langley 4e5299f6d7 Merge pull request #198 from fiddlerwoaroof/fix/w7qd-ssoe-help-guards
Grid-move and help-row guards that the #180 re-review found blind (w7qd, ssoe)
2026-09-22 23:05:01 -07:00
Ed Langley 8c3b013c48 Merge pull request #191 from fiddlerwoaroof/collapse-fu/t0ma
improvise-t0ma: pin records_steps and drill_steps as steps
2026-09-22 23:04:35 -07:00
Edward Langley daa09e1f4f Merge remote-tracking branch 'gh/main' into docs/agents-md-wave 2026-09-22 23:04:21 -07:00
Edward Langley 7dfa90f138 rustfmt the width guard (improvise-b9f2) 2026-09-22 23:04:13 -07:00
Edward Langley ccb517c610 Merge gh/main into fix/phg1-record-rewrite-tests
Resolved context/test-evidence/command.md by keeping both appended sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:04:12 -07:00
Ed Langley 966637cdb6 Merge pull request #204 from fiddlerwoaroof/fix/uo2p-drill-apply-order
Compose a drill's staged edits instead of replaying them in hash order (improvise-uo2p)
2026-09-22 23:03:06 -07:00
Edward Langley ab17674811 Merge remote-tracking branch 'gh/main' into fix/b9f2-width-guard 2026-09-22 23:01:35 -07:00
Edward Langley 13bccf772e Docs: the width guard measures the drills it compared (improvise-b9f2) 2026-09-22 23:01:26 -07:00
Edward Langley 5d56196f56 WIP: one scratch App for the width guard (improvise-b9f2) 2026-09-22 23:00:59 -07:00
Edward Langley e829b66487 Give RecordRewrite's three untested branches a test that can fail (improvise-phg1)
Three RecordRewrite branches could be broken with the whole workspace still
green:
- the lone measure-less cell of an `o`-seeded row;
- a measure a sparse drill row has no cell for, which must be written at the
  renamed record;
- every row reading the model before any row clears it.

Each test is red against the rival the review injected and fails alone. No
production code changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:00:08 -07:00
Edward Langley 13b998d7f6 improvise-v8ab: a fresh session's slice is the host's startup slice
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 23:00:07 -07:00
Edward Langley aadf5ddd35 Drop --locked from the float_roundtrip check: --no-deps never reads the lockfile
Review of #202 showed `cargo metadata --no-deps --locked` exits 0 against a
stale lockfile, so the flag promised a check it does not make.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:57:58 -07:00
Edward Langley 6b3b9f03de Merge remote-tracking branch 'gh/main' into fix/6yvg-dz8c-json-float
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
2026-09-22 22:57:58 -07:00
Edward Langley afb3e471c9 Merge remote-tracking branch 'gh/main' into fix/lxg0-w1bu
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:56:55 -07:00
Edward Langley 74d329d15f improvise-hoxy: names_its_column no longer accepts Derived on a formula-free fixture
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:53:58 -07:00
Edward Langley 9b9b1798a8 fmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:53:18 -07:00
Ed Langley b4f58e9c35 Merge pull request #187 from fiddlerwoaroof/fix/hzjo-determinism-law
hzjo: the determinism law, over every command and independent contexts
2026-09-22 22:52:22 -07:00
Edward Langley bcbb5591f9 Merge remote-tracking branch 'gh/main' into fix/lxg0-w1bu
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:52:14 -07:00
Edward Langley 3cad9c2936 Merge commit '98d10ca5' into wire2/2wu3
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 22:51:46 -07:00
Edward Langley 5c50bf8780 Merge gh/main into fix/uo2p-drill-apply-order
Resolved context/test-evidence/command.md by keeping both appended sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:51:43 -07:00
Edward Langley 5be1306281 improvise-4img: test the clear variant; it is RecordMoved, not RecordGone
B clears the cell while A's editor is open, then A commits. The commit is
refused as RecordMoved. The store no longer holds the record, but
App::resolve_record falls back to the layout and still resolves it, and its
counter has moved. Under the claim-at-commit rival, the same commit is accepted
and re-creates the cell as 42. Corrects the evidence that reasoned RecordGone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:51:05 -07:00
Edward Langley 288db9b269 WIP: width guard counts the records the compared drills froze (improvise-b9f2) 2026-09-22 22:50:50 -07:00
Edward Langley 691c6c69d1 Pin edit_target in the page's client surface (improvise-au6c)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:46:30 -07:00
Edward Langley 170d94807b Merge remote-tracking branch 'gh/main' into wire2/2wu3 2026-09-22 22:44:47 -07:00
Ed Langley 98d10ca591 Merge pull request #180 from fiddlerwoaroof/fix/help-keys-checks
Help and advertised-key checks that can see what they claim (wk18, 3t5x, 3ij0, jhdf)
2026-09-22 22:44:46 -07:00
Edward Langley 8775267865 WIP: pin the refusal sites nothing else pinned (improvise-0v8l) 2026-09-22 22:44:27 -07:00
Edward Langley cfcc9bff97 Correct the determinism law's width-guard and analyzer claims (improvise-b9f2) 2026-09-22 22:43:14 -07:00
Edward Langley de49716a41 Merge remote-tracking branch 'gh/main' into fix/hzjo-determinism-law
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:42:42 -07:00
Edward Langley b22f6e5389 improvise-lxg0/w1bu: evidence, and command.md describes the fixed state
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:42:34 -07:00
Edward Langley 09a5cdfa03 WIP: a formula that does not parse keeps its editor and text (improvise-v4hg) 2026-09-22 22:41:54 -07:00
Ed Langley fdc8e91383 Merge pull request #192 from fiddlerwoaroof/fix/jgzw-tile-virtuals
jgzw: keep _Index/_Dim tileable, and pin the offered set by name
2026-09-22 22:41:32 -07:00
Edward Langley f75b58af91 The drawn keys rows must equal what the builders asked for, in order (aszt)
A count let a bypass that also drops a real row pass; content does not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:41:06 -07:00
Edward Langley 31fd2f5480 Merge remote-tracking branch 'gh/main' into collapse-fu/t0ma 2026-09-22 22:41:05 -07:00
Edward Langley 9cb7741c4b WIP: pin every refuse_and_stop arm's abort (improvise-514o) 2026-09-22 22:39:53 -07:00
Edward Langley 561cff479e Merge remote-tracking branch 'gh/main' into collapse-fu/b5zh 2026-09-22 22:39:51 -07:00
Edward Langley 9512a2db1f The grid-move walk stands far from every edge, so it sees magnitude (5kdr)
21x21 grid, 8x8 window, start (10,10): moves land at 10+d, page-scroll at
10+page_rows(8)*d, jumps at 0/20. grid_move's page rule is page_rows(),
which the test reads rather than restating. Doubled move-selection, a
one-row scroll-rows and a page-size-blind page-scroll each fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:39:36 -07:00
Edward Langley a9a5b3075a Merge remote-tracking branch 'gh/main' into fix/lxg0-w1bu 2026-09-22 22:39:18 -07:00
Edward Langley 5df8ed2ce5 Name improvise-fy9o in the ixxx evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:39:10 -07:00
Edward Langley 2a4f35d76f Merge remote-tracking branch 'gh/main' into collapse-fu/eccl 2026-09-22 22:38:42 -07:00
Edward Langley 097533e50c Merge remote-tracking branch 'gh/main' into collapse-fu/gw18
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 22:38:14 -07:00
Ed Langley a2f3c8d3e5 Merge pull request #193 from fiddlerwoaroof/fix/view-history
improvise-lrrx/8nd9: view history does not survive a document swap, and a refused step leaves no frame
2026-09-22 22:37:55 -07:00
Edward Langley e5721b9d94 improvise-lxg0/w1bu: a fresh session has no history; records kinds checked per column
fresh_view points the new slice at the document's view instead of folding
a SwitchView from ViewState::default(), which recorded a visit to
"Default" that never happened. The records-grid enumeration now checks
each cell's CellId against its column's RecordCol rather than only that
it is Some.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:37:25 -07:00
Edward Langley 66398f163f Merge branch 'fix/help-keys-checks' into fix/w7qd-ssoe-help-guards 2026-09-22 22:37:19 -07:00
Edward Langley 30187208d7 improvise-eccl: pool each distinct filtered context once (Codex review on #203)
An inline WHERE on the folded category rewrote every member context to the same item, so SUM(Revenue WHERE Month = Jan) on the Q1 fold counted Jan twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:35:12 -07:00
Edward Langley 3390ab0394 Merge gh/main into fix/jgzw-tile-virtuals
Resolves the append-at-EOF conflict in context/test-evidence/command.md by
keeping both sides: gh/main's version plus this branch's tile.rs section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:32:54 -07:00
Edward Langley 7628770d79 Merge remote-tracking branch 'gh/main' into fix/ixxx-untouched-commit
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:31:19 -07:00
Edward Langley 92eefeec4b Merge gh/main into fix/uo2p-drill-apply-order
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:31:15 -07:00
Edward Langley bc08a13038 Compose a drill's staged edits instead of replaying them in hash order (improvise-uo2p)
ApplyAndClearDrill walked pending_edits, a HashMap, and applied each edit on
its own. A rename moves the row's cells, so a measure edit replayed after it
wrote the old key. That left a ghost record there and a stale value in the moved
record. A second rename on the same row always found nothing and was dropped.
Across rows, a swap lost one record's data in either order.

RecordRewrite::of builds each row's final record once. It reads unedited values
off the model before anything is written, and applies every rename to every
cell. RecordRewrite::merged puts every clear before every write, in snapshot row
order. The plain records path calls the same function with its one edit, so
rename_record_coordinate and the drill's own copy are gone (gotcha 19).

Reasoning: context/code/command.md, RecordRewrite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:30:55 -07:00
Edward Langley 856122604a rustfmt (improvise-ixxx)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:30:26 -07:00
Edward Langley 95bd593dbe Merge remote-tracking branch 'gh/main' into fix/help-keys-checks
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/protocol.md
2026-09-22 22:30:19 -07:00
Edward Langley c1932094d0 Merge gh/main into fix/view-history; point the client-stack claim at improvise-lxg0
The evidence file is gh/main plus this branch's five entries, inserted at
their original anchor. command.md no longer claims a client's history
cannot hold a dead name: fresh_view seeds a phantom Default frame
(improvise-lxg0, not fixed here).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:30:18 -07:00
Edward Langley 0825f9984d rustfmt the determinism law's width guard (improvise-e1sm) 2026-09-22 22:29:09 -07:00
Edward Langley 20390cf65f Merge remote-tracking branch 'gh/main' into collapse-fu/gw18
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/command.md
#	crates/improvise-wasm-client/tests/lockstep.rs
2026-09-22 22:28:37 -07:00
Ed Langley d1c4884399 Merge pull request #196 from fiddlerwoaroof/fix/projection-identities
improvise-exdg/7xyx: a coordinate cell is CellId::Coordinate; FrameGroup::cat deleted
2026-09-22 22:28:36 -07:00
Ed Langley b59af2dfe0 Merge pull request #199 from fiddlerwoaroof/fix/i0v9-second-tab-lock
The worker's status claims success only on its first frame (improvise-i0v9)
2026-09-22 22:28:12 -07:00
Edward Langley 9d3fe197df WIP: evidence and context for improvise-ixxx
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:27:50 -07:00
Edward Langley 14acf46d77 clippy: slice from a reference
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:26:04 -07:00
Edward Langley f3932c1ca5 Merge remote-tracking branch 'gh/main' into fix/hzjo-determinism-law
# Conflicts:
#	context/code/command.md
#	context/test-evidence/command.md
2026-09-22 22:24:38 -07:00
Edward Langley 79175bbb58 improvise-eccl/f2oo: correct §14's premise and state the fold's coverage; evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:24:21 -07:00
Edward Langley 4c45166b8a WIP: the mouse editor hands wasm its captured seed (improvise-ixxx)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:24:17 -07:00
Edward Langley f24944b437 WIP: determinism law docs — measured catch rate, execute-not-apply scope (improvise-e1sm) 2026-09-22 22:23:26 -07:00
Edward Langley c70c428c47 WIP: determinism law drills every item of the document (improvise-e1sm) 2026-09-22 22:22:29 -07:00
Edward Langley e3aa2ccb4b WIP improvise-eccl/f2oo: a fold evaluates its formula over the fold, and covers only drawn members
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:21:02 -07:00
Edward Langley c8db3ee2dd WIP: an untouched cell edit writes nothing (improvise-ixxx)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:20:51 -07:00
Edward Langley feefe17d53 Merge remote-tracking branch 'gh/main' into fix/6yvg-dz8c-json-float
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/tooling.md
2026-09-22 22:19:30 -07:00
Edward Langley 95a35e8cce Every JSON parser reads numbers exactly, and the check derives its crates (improvise-6yvg, improvise-dz8c)
improvise-6yvg reproduced. A JSON import in improvise-command and a legacy
JSON .improv load in improvise-io stored 0.24815714285714285 as
0.24815714285714283 in each crate's own test build. The root binary's
`improvise import` used the same lossy parser. Now float_roundtrip is on every
normal serde_json dependency in the workspace: root, improvise-command,
improvise-io, and the optional test-support dependency of improvise-protocol.

improvise-dz8c: the hand-listed cargo-tree check over three frame-parsing
crates is replaced by tests/json_numbers.rs. It reads
`cargo metadata --no-deps` and names every package, including target-specific
entries, whose normal serde_json dependency lacks the feature. The rule and why
it beats "parses a frame", the build graph, and enabling the feature once in
the protocol crate are in context/code/protocol.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:19:05 -07:00
Edward Langley b535245a21 Refactor: GridLayout::evaluate is the one evaluation of a pivot key
The grid, its totals, pruning, search and yank all read a key through the layout, so what a fold covers can have one home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:17:35 -07:00
Edward Langley a5fbd84f73 Merge remote-tracking branch 'gh/main' into wire2/2wu3 2026-09-22 22:17:34 -07:00
Edward Langley 31b5acdb6e AGENTS.md and the wave rules agree on how work ships; gates derived from ci.yml
- improvise-rzux: AGENTS.md now states the per-agent PR workflow the wave
  skill uses (adversarial review at the head SHA, merge on GitHub, local main
  fast-forwards to gh/main) and points to SKILL.md "Shepherding". The
  obsolete one-PR-per-wave and local-integration text is cut.
- improvise-x1uk: the Beads integration block is regenerated whole by bd
  (bd init, bd setup factory|opencode|mux). It is replaced with bd 1.3.0's
  own minimal render, which no longer says git merge origin/main, and the
  repository's policy (no team-maintainer opt-in, the TODO exception that
  had been hand-edited inside the markers) moves to a section outside them.
- improvise-anmm: the ID audit scans .agents and the tracked .claude files,
  excludes the kb skill's example crate name, and anchors its matcher so a
  ticket body saying "not found" is not reported as a dead id.
- improvise-xolb: agent-rules' gate list is the test/js/kb CI jobs step for
  step, with the conditional jobs (wasm incl. improvise-gp, harness-lint,
  build+browser incl. the bundle node tests) and the excluded one named.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:15:51 -07:00
Ed Langley 608ba6a6c8 Merge pull request #195 from fiddlerwoaroof/wire2/59q8
Source scanner: bound test-only items by brackets, evaluate cfg predicates (59q8)
2026-09-22 22:15:24 -07:00
Edward Langley 59e67df3bb The inline editor claims the version it opened on, not the one at commit (improvise-0j0f)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:14:56 -07:00
Ed Langley bd95044a6e Merge pull request #182 from fiddlerwoaroof/fix/yf7p-cursor-is-the-only-context
yf7p: no registration can overwrite a parsed field
2026-09-22 22:14:51 -07:00
Ed Langley 810077a0d0 Merge pull request #181 from fiddlerwoaroof/wire2/bok4
A model effect that cannot do what it names refuses its frame (bok4)
2026-09-22 22:13:43 -07:00
Edward Langley 4ada5211a2 improvise-b5zh: an outer group's header is pruned with its rows
The 'saw no item, so collapsed' arm was not dead after 6qet: an outer header followed directly by an inner one reached it, and kept an orphan header over rows that had all pruned. Pending headers are now a stack closed per category.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:13:11 -07:00
Edward Langley aeb88e3eac Merge gh/main into fix/jgzw-tile-virtuals
Resolves the append-at-EOF conflict in context/test-evidence/command.md by
keeping both sides: gh/main's version plus this branch's tile.rs section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:11:35 -07:00
Edward Langley d5854bc11c Merge remote-tracking branch 'gh/main' into fix/i0v9-second-tab-lock
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 22:11:29 -07:00
Edward Langley a1b86001db Merge remote-tracking branch 'gh/main' into wire2/2wu3
# Conflicts:
#	context/test-evidence/clients.md
#	crates/improvise-wasm-client/tests/browser_round_trip.rs
2026-09-22 22:11:28 -07:00
Edward Langley c960713f8c The worker's status claims success only on its first frame (improvise-i0v9)
The worker transport wrote "in-page worker · <doc>" when it spawned the
worker, before anything had answered, so a tab whose worker could not start
showed a success-looking status over a blank page. It now writes "starting
the worker · <doc>…", and createShell's arm() takes an `answer` that sawFrame
writes on the first frame, but only while the watchdog is still armed. A
ReadOnly or SeedMissing notice lands BEFORE the Snapshot, and fail() has
already disarmed, so the claim cannot overwrite the precise reason.

The two-tab spec now logs every #status and denies both the premature claim
and "did not answer" before the page clock is walked. A new spec pins the
exact status sequence on the writable path. Reasoning:
context/code/clients.md, "The watchdog".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:10:29 -07:00
Ed Langley a164726fbd Merge pull request #183 from fiddlerwoaroof/fix/y1av-cursor-off-zero
y1av: seat the at-cursor panel tests off index 0
2026-09-22 22:09:55 -07:00
Edward Langley 8772db2e7b A keys row is drawn only if the builders asked for it (ssoe)
KeyRow::looked_up is private to key_row, whose keys() is the lookup
pages() hands the builders; the reviewer's direct call is E0624. A new
test asserts the drawn keys rows equal help_bindings(), which catches
the remaining self::keys bypass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:08:42 -07:00
Edward Langley 1c4a27243d Every grid move Normal binds must land where it says (w7qd)
scroll-rows and page-scroll derive their landing from their argument as
move-selection does; any bound command that moves the cursor with no
stated landing fails; the numeric floor is gone. Reversed Ctrl-d/u and
PgUp/PgDn, and a dropped jump table with $ sent to column 0, both fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:07:02 -07:00
Edward Langley b76d8d6ef8 improvise-gw18: document the cursor's three doors and the evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:06:43 -07:00
Ed Langley 1a6b877482 Merge pull request #194 from fiddlerwoaroof/fix/umi4-edit-seed
improvise-umi4: an editor opens on the value, not on its rounded Display
2026-09-22 22:06:30 -07:00
Edward Langley 2619d1710d Merge gh/main (#190) into wire2/bok4
Only conflict was context/test-evidence/command.md: both sides inserted a
section before the same heading, and both kept in full (gh/main's first).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:05:18 -07:00
Ed Langley 5bcc3cacdd Merge pull request #186 from fiddlerwoaroof/fix/edpl-pr-watch-unknown
pr-watch: wait out UNKNOWN and the needs: gap, pass only an allowlisted state (improvise-edpl, improvise-zlj2)
2026-09-22 22:05:10 -07:00
Edward Langley 8fb5f6b14a fmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:05:08 -07:00
Edward Langley aa959343a2 WIP: commit refuses a fold and a formula's result; lockstep for x, p and folds
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:04:35 -07:00
Ed Langley 1158c23d7b Merge pull request #190 from fiddlerwoaroof/fix/6oy4-4w09-wire-numbers
Numbers survive the JSON wire; a NaN cell quiesces (improvise-6oy4, improvise-4w09)
2026-09-22 22:03:33 -07:00
Edward Langley f8f4dd5fa0 Merge remote-tracking branch 'gh/main' into fix/view-history
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:02:49 -07:00
Edward Langley 54ef80d960 Merge remote-tracking branch 'gh/main' into wire2/bok4
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 22:01:23 -07:00
Edward Langley 90b93d4874 WIP improvise-lx7w/54g1/l4mp: clear and paste land where the browser's CellId says
CmdContext::value_cell_key answers the cursor through project::cell_id, the
question the browser is given: only a Record is written, a records position
resolves to its real record, and a fold, an aggregate, a formula-owned cell,
a coordinate or creating column and a drill are each declined out loud.
clear-cell and paste take that door; commit and drill take the fold-only one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:59:10 -07:00
Edward Langley 6b3dd8ca3e improvise-exdg: correct the paste claim against #184, and name improvise-kkcy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:58:43 -07:00
Edward Langley f24882766e Merge remote-tracking branch 'gh/main' into fix/help-keys-checks
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 21:57:17 -07:00
Edward Langley 23a3c4d269 Merge remote-tracking branch 'gh/main' into fix/projection-identities 2026-09-22 21:57:04 -07:00
Edward Langley 48f52e7da1 Merge remote-tracking branch 'gh/main' into wire2/2wu3
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 21:56:55 -07:00
Edward Langley 25a06d52ff improvise-7xyx: delete FrameGroup::cat and the echo it promised
The field's doc comment said a client echoes it back in ToggleGroup, but
resolve.rs has no toggle-group arm and neither renderer read it. Deleting
it is the minimal honest answer: composing `z` in a browser needs its own
design (the TUI places the cursor on the layout the toggle will produce),
and the operand should come back with that consumer. The h0iv test now
asserts whole-header equality, which is stronger than the (cat, name)
pair it compared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:56:53 -07:00
Edward Langley 7935b006eb Drop the Welcome page's key tip rather than deriving it
As a derived keys row it gave Welcome a row with a description, and
harness/tests/help.spec.js holds Welcome to prose; CI failed on it at
544744c. Help mode's status hint already derives next-page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:56:38 -07:00
Edward Langley 04230a7548 Merge remote-tracking branch 'gh/main' into fix/edpl-pr-watch-unknown 2026-09-22 21:56:21 -07:00
Edward Langley a4fa3ae174 pr-watch: wait on green-but-BLOCKED, the needs: gap before a required check registers (improvise-zlj2, improvise-h3ni)
The required Browser harness check comes from a job that needs: build, so for
a moment per push every registered check is green and GitHub says
MERGEABLE/BLOCKED. #186 at 128a2a0 refused that and exited 1 on healthy PRs.
BLOCKED with no failed check is now waited on (exit 3 if it never clears).
Adds the gap sequence from PR #189's real rollup, and a head moving during
the UNKNOWN wait. Corrects tooling.md's claim that nothing else produces
BLOCKED, the evidence entry's rival-2 claims, and the fixture wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:56:15 -07:00
Edward Langley 43ba5e6151 Bound test-only items by brackets and evaluate cfg predicates in the source scanner (improvise-59q8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:55:32 -07:00
Edward Langley 44a183aa26 Merge remote-tracking branch 'gh/main' into fix/6oy4-float-roundtrip
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 21:54:12 -07:00
Edward Langley bb1a05674c improvise-exdg: a records coordinate cell projects CellId::Coordinate
A drawn coordinate cell projected id: None, which ADR 002 and gotcha 42
define as "no projected cell". Absence was encoding a permission (the
host's). It is now its own kind, carrying no operand because no client
composes a coordinate rename. render.rs::editable is an exhaustive match
so the next variant must be classified. Paste, clear and edit are
unchanged: every write path still matches Record alone. The test that
pinned the bug (a_coordinate_column_is_projected_with_no_identity) is
replaced by one that enumerates the whole records grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:54:11 -07:00
Edward Langley 3bcd8774cb WIP pr-watch: failing cases for the needs-gap BLOCKED and a head moving during UNKNOWN (improvise-zlj2, improvise-h3ni)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:53:01 -07:00
Edward Langley 0680f65da8 Merge remote-tracking branch 'gh/main' into fix/umi4-edit-seed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:52:26 -07:00
Edward Langley 9907a20a62 Merge remote-tracking branch 'gh/main' into collapse-fu/gw18 2026-09-22 21:52:19 -07:00
Edward Langley a9346a0be2 WIP improvise-gw18/yk2s: writers refuse a collapsed group's fold
CmdContext::writable_cell_key refuses a fold, and the registry's cursor
fallback for clear-cell, commit, paste and drill-into-cell goes through it;
yank alone reads. H refuses on a fold row via GridLayout::row_folds_a_group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:52:13 -07:00
Edward Langley 54d9cc85de WIP: 59q8 red tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:52:01 -07:00
Edward Langley 141e41ec4b improvise-umi4: an editor opens on the value, not on its rounded Display
CellValue's Display writes a non-whole number {:.4}, and every editor seed
was that string, so i then Enter on an untouched 1.23456789 stored 1.2346,
in the TUI (pivot and records) and in the browser, which seeds from the
projected edit_text. CellValue::edit_text is now the one home of the seed:
the shortest decimal that reads back as the same f64 (non-finite values
take the core.md §16 token). display_value_in's two arms and
cell_display's records arm all go through it; the now-orphaned
GridLayout::resolve_display is removed.

Text that reads as a number (007) is exact in the seed but still
reclassified by typed(); filed as improvise-ixxx.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:49:20 -07:00
Edward Langley 0a6e940dc5 Merge remote-tracking branch 'gh/main' into fix/yf7p-cursor-is-the-only-context 2026-09-22 21:48:14 -07:00
Edward Langley 300bf613fc Merge remote-tracking branch 'gh/main' into fix/view-history 2026-09-22 21:47:46 -07:00
Edward Langley 12171fb335 Merge remote-tracking branch 'gh/main' into wire2/bok4
# Conflicts:
#	crates/improvise-command/src/ui/effect.rs
2026-09-22 21:47:23 -07:00
Edward Langley f44767c9a8 Merge remote-tracking branch 'gh/main' into fix/jgzw-tile-virtuals 2026-09-22 21:47:01 -07:00
Edward Langley ffd3ec80be jgzw: keep _Index/_Dim tileable, and pin the offered set by name
The tile bar offering _Index and _Dim is a decision the user already
made (improvise-kos), and it is the only way out of a records layout
that was saved into a real view: R there emits ViewBack on an empty back
stack and claims Pivot mode (filed as improvise-gj5p). A new test names
the whole offered list with _Measure on a column, so a filter dropping
_Measure, or one dropping _Index/_Dim, fails it.

Reasoning: context/code/command.md, cmd/tile.rs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:46:55 -07:00
Edward Langley 99046f7c63 improvise-8nd9: a history step observes acceptance, not movement
A frame naming the view you are on is still a place (view + mode), and
the client's reduce_view::stepped steps onto it unconditionally. Observing
"active_view changed" dropped that frame on the host and so split host
from client; colon_view_back_respects_whether_history_exists caught it.
App::switch_to_view now says whether the document accepted the view, and
stepped_to asks that rather than re-deriving the condition.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:45:36 -07:00
Edward Langley 77893d830c improvise-t0ma: pin records_steps and drill_steps as steps, not as effects
records_steps was guarded by one incidental persistence test. Two tests now
assert the ViewStep lists directly: the records spine plus every non-index/dim
category on None, and a drill's Filter-fixed coordinates plus its freed
aggregated categories. The is_measure() rival fails the first; a Page-for-Filter
rival fails the second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:44:27 -07:00
Edward Langley 544744cfd2 context: the help rows' premise is a type
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:51 -07:00
Edward Langley c246dc1df6 improvise-umi4: WIP failing tests — an untouched edit must store the identical value
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:38 -07:00
Edward Langley 327fa176ad Evidence for 6562 and v29m
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:35 -07:00
Edward Langley f8345ea8c6 A keys row can only be looked up, and an ex row can name no key (v29m)
HelpLine::Keys carries a KeyRow whose fields are private to a module
whose only constructor looks the keys up; a builder that types one is a
compile error. ex! asserts at compile time that every word after the
command name is a placeholder, so ':q  or press Q' does not build.
':add-items <cat> a b c ...' is now ':add-items <cat> <item> ...'.
Renderers read the row through accessors (src/ui/help.rs, render.rs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:18 -07:00
Edward Langley ea1f2d313b improvise-lrrx/8nd9: view history does not survive a document swap
install_workbook clears view_back_stack and view_forward_stack beside
cursors, so `<` after LoadModel or AcceptWizardModel no longer walks
the outgoing document's views. ViewBack and ViewForward now observe the
switch as SwitchView does (improvise-swzk): a frame the document refuses
is consumed, leaves no frame behind, and restores no mode. Each fix holds
without the other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:10 -07:00
Edward Langley 07d9732b7a Refuse DeleteView of an absent view and AddFormula into an absent category (improvise-40j4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:02 -07:00
Edward Langley 8fe55e0b4a Witness every bok4 refusal with a frame test (improvise-9fpl)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:42:05 -07:00
Edward Langley 3c6001ac7b Merge remote-tracking branch 'gh/main' into fix/6oy4-float-roundtrip
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/protocol.md
2026-09-22 21:41:49 -07:00
Edward Langley e6d7d13e90 The advertised-key guard judges by what the fold moved, off every edge (6562)
heard() compared command types, so a clamped move counted as acting. It
now folds the outcome and compares the view slice (status and disarm
aside); a Model command counts as sent. The fixture is a 3x3 grid with
the cursor at (1,1), three tiles with the cursor at 1, help page 1.
Direction is out of reach for any did-it-move check, so every grid key
is also pressed from the centre and must land where its binding says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:40:17 -07:00
Edward Langley 128a2a093e Merge remote-tracking branch 'gh/main' into fix/edpl-pr-watch-unknown 2026-09-22 21:40:14 -07:00
Edward Langley ddf463e366 fmt 2026-09-22 21:38:58 -07:00
Edward Langley fc0b13c465 Merge remote-tracking branch 'gh/main' into fix/hzjo-determinism-law 2026-09-22 21:38:03 -07:00
Edward Langley 384e04acbc The inline editor commits by record identity, not viewport position (improvise-2wu3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:37:56 -07:00
Edward Langley 60dbe970b0 hzjo: the determinism law, over every command and independent contexts
Every registered command, under every argument list that builds it, must
give equal effects in four contexts built independently from the shipped
demo. Separately built Apps give each HashMap its own RandomState, so a
command that follows hash order disagrees with itself; the old law ran
execute twice on one context and could not fail. A companion test keeps
that property honest, and the exemption list is checked both ways.

Reasoning: context/code/command.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:37:55 -07:00
Edward Langley c5e72e889f Merge remote-tracking branch 'gh/main' into fix/help-keys-checks
# Conflicts:
#	context/test-evidence/protocol.md
2026-09-22 21:37:17 -07:00
Edward Langley da8c67109a CellDisplay equality is the wire's, so a NaN cell quiesces (improvise-4w09); docs and evidence for both 2026-09-22 21:37:12 -07:00
Edward Langley 2db69d927a pr-watch skill: exit 0 now means mergeable; UNKNOWN is the fourth false green (improvise-edpl)
Only the .agents copy is edited: .claude/skills/pr-watch becomes a symlink to
it in the improvise-5fzx PR, and editing both here would make that PR's merge
a modify/delete conflict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:34:45 -07:00
Edward Langley d1a9ef9add pr-watch: wait out UNKNOWN mergeability; pass only an allowlisted state (improvise-edpl)
GitHub reports UNKNOWN for a while after the base moves, and PRs #172 and
#179 both exited 0 there. UNKNOWN is now waited on (exit 3 on timeout), a
failed check or CONFLICTING ends the wait, and the pass condition is
MERGEABLE with CLEAN, UNSTABLE or HAS_HOOKS. BLOCKED, DIRTY, BEHIND, DRAFT
and any unshipped state fail closed. The suite enumerates the whole table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:34:07 -07:00
Edward Langley 473ac9622e Merge remote-tracking branch 'gh/main' into wire2/2wu3 2026-09-22 21:33:08 -07:00
Edward Langley e0550fc51f WIP pr-watch: failing cases for UNKNOWN mergeability (improvise-edpl)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:32:18 -07:00
Edward Langley c831e5ea4a WIP: float_roundtrip on frame parsers; failing NaN quiescence test (improvise-6oy4, improvise-4w09) 2026-09-22 21:32:00 -07:00
Edward Langley 34ef2802d2 Merge remote-tracking branch 'gh/main' into wire2/bok4
# Conflicts:
#	context/test-evidence/command.md
2026-09-22 21:31:45 -07:00
Edward Langley a4910eb4c1 Merge remote-tracking branch 'gh/main' into fix/y1av-cursor-off-zero 2026-09-22 21:29:13 -07:00
Edward Langley 4277cf6f84 y1av: seat the at-cursor tests off index 0
delete-formula-at-cursor, switch-view-at-cursor and delete-view-at-cursor
were asserted with the cursor at 0 of a one-element list, where the item
under the cursor and the first item are one item. A DeleteFormulaAtCursor
hard-wired to index 0 passed the whole workspace. Now each cursor sits at
index 1 of a longer list; the index-0 rivals fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:28:58 -07:00
Edward Langley 1dd9bba452 WIP: 2wu3 red test and by-identity commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:27:16 -07:00
Edward Langley a6e36511b5 WIP: failing tests, numbers across the serialized wire (improvise-6oy4) 2026-09-22 21:26:58 -07:00
Edward Langley 7e1bf86260 Merge remote-tracking branch 'gh/main' into fix/yf7p-cursor-is-the-only-context 2026-09-22 21:23:54 -07:00
Edward Langley d4b60d7979 yf7p: no registration can overwrite a parsed field
CmdRegistry::register loses its contextualize parameter. Every
contextualizer in the tree was one of two shapes: the command's
CursorState, now filled by the registry itself through AtCursor (a slot
typed &mut CursorState, so it cannot point at a parsed field); or a
read of context copied into the command only to be read back
(toggle-panel-visibility, cycle-panel-focus, move-panel-cursor), now
read in execute. The five overwrites review injected are unwritable.

Reasoning: context/code/cli.md section 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:23:43 -07:00
Edward Langley ddc0bf4ce2 Merge remote-tracking branch 'gh/main' into wire2/bok4 2026-09-22 21:22:16 -07:00
Edward Langley 949810cd30 Merge remote-tracking branch 'gh/main' into fix/help-keys-checks 2026-09-22 21:19:50 -07:00
Edward Langley 3d03d9ff8f fmt, and the comment cap
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:19:44 -07:00
Edward Langley 4f6e033a35 WIP yf7p: the cursor is the only context a registration fills
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:19:39 -07:00
Edward Langley 26abc3b9a1 WIP: bok4 docs, evidence and last-view refusal
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:18:18 -07:00
Edward Langley 6e3e51b0ac WIP: a model effect that cannot do what it names refuses its frame (improvise-bok4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:17:11 -07:00
Edward Langley bdb01b8763 context: the prefix rows are derived; the pages expose what they ask for
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:15:41 -07:00
Edward Langley 3cf3718764 Evidence for wk18, 3t5x and jhdf
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:15:17 -07:00
Edward Langley 299ade52d4 The edit-caption test pins Enter's row by command, not by phrase (jhdf)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:14:35 -07:00
Edward Langley 740511e0f9 Help rows name no key by hand, and the guard hears what advertised keys do
wk18: gg/yy/ZZ and the page tip are keymap lookups (prefixed sequences
included); ex-command rows can only be built by ex!, whose colon is its
own. help_bindings() records every row the pages ask for, from the same
builders.

3t5x: every_advertised_command_answers_in_its_own_mode walks the hint
line AND the help pages, judges each key by what moved (a lone status is
Declines, a disarm alone is Silent), and names every key that does not do
what it says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:13:40 -07:00
Edward Langley 97d17b60f2 Merge remote-tracking branch 'gh/main' into fix/help-keys-checks 2026-09-22 21:05:21 -07:00
Edward Langley 6bdd3ad7cf WIP wk18: failing test and prefixed key lookup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:05:20 -07:00
Edward Langley a06f50560d The forward-step test pins its key through resolve, not by name
composed() dispatches on the command name and ignores the event, so the
',' it was given pinned nothing: rebinding view-forward to '~' left the
test green (improvise-3ij0). resolve() looks the key up in the shared
keymap first, so the same rival now fails at that assertion.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:04:03 -07:00
111 changed files with 13752 additions and 1795 deletions
+1
View File
@@ -28,6 +28,7 @@ them here would let the copy drift:
| `context/repo-map.md` | The "where", plus **numbered gotchas** — read the ones your diff touches |
| `prolog-sessions/kb.pl` | Executable knowledge: `invariant/2`, `doctrine/2`, `forbidden/2`, `decision/3`, and prior `work_done/3` |
| `AGENTS.md` | Working rules: bug-first tests, `Option`/`Result`/`Box` at the edges, merges over rebases |
| `.agents/skills/wave/agent-rules.md`, "GitHub" | The reviewer's rule: what to read before your first comment, how to answer each existing review, and what every review you post must state. Read it before reviewing a PR |
`kb.pl` is the highest-value and least-obvious of these. Grep it before
reviewing: `grep -n "invariant(\|doctrine(\|decision(" prolog-sessions/kb.pl`.
+18 -4
View File
@@ -10,26 +10,40 @@ scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec
```
```
exit 0 every check COMPLETED and none failed
exit 1 a check failed, or the PR is CONFLICTING
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
CLEAN, UNSTABLE or HAS_HOOKS
exit 1 a check failed, or the PR is not mergeable (CONFLICTING, BLOCKED,
DIRTY, BEHIND, DRAFT, or a state it does not know)
exit 2 the head moved during the wait, or is not --expect
exit 3 timed out with checks still running
exit 3 timed out with checks still running, mergeability still UNKNOWN,
or still BLOCKED with every registered check green
exit 4 usage or environment error
```
**Exit 0 now means mergeable, not only green.** GitHub computes mergeability
lazily and reports `UNKNOWN` for a while after the base moves. It also reports
`BLOCKED` for the moment before a required check that `needs:` another job has
registered. The watch waits out both, so you do not need to re-check
`mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required
check that never ran. Why each
state counts the way it does: `context/code/tooling.md`, "pr-watch and
mergeability" (improvise-edpl).
**Pass `--expect <sha>` whenever you know which commit you are asking about.**
It is the difference between "this PR is green" and "the thing I pushed is
green", and a PR here was once merged at a head that predated its own fix.
## Do not write the loop yourself
Three signals look like "finished" and are not:
Four signals look like "finished" and are not:
- **"nothing pending"** — also true when no check has registered yet. Require a
**non-zero total** as well.
- **`conclusion == null`** — `conclusion` is a *string*, empty while running.
Test `status == "COMPLETED"`.
- **a verdict from before the head moved** — re-check the head, or `--expect`.
- **`mergeable: UNKNOWN`** — GitHub has not computed it yet. It is not a
pass, and PRs #172 and #179 both went green on it.
`gh` is shell-aliased to a different tool in these shells; `pr-watch` locates a
real one itself. Do not reintroduce a bare `gh` call in a script here.
+11 -4
View File
@@ -37,7 +37,8 @@ it must cross. Two agents adding a workspace member will still collide in
**Point the agent at [`agent-rules.md`](agent-rules.md) and tell it to read it in
full first.** That file is the agent's standing rules: base and push mechanics,
the gate list, the claim, the vacuity shapes, evidence, the files it must not
write, GitHub conventions, the hand-back and disk cleanup. Do not restate them
write, GitHub conventions (including how authors and reviewers answer reviews
on the PR thread), the hand-back and disk cleanup. Do not restate them
in the brief. A restated rule drifts from its source, and the brief is the copy
nobody corrects. The brief adds only what varies per wave:
@@ -73,14 +74,20 @@ defect is a test that passes.
## Shepherding
- Wait on checks with `scripts/pr-watch <pr> --expect <sha>`. Never hand-roll a
poll; see the `pr-watch` skill for the three ways they report green while wrong.
poll; see the `pr-watch` skill for the four ways they report green while wrong.
- Review before merging. Copilot's quota may be exhausted and its request is a
**silent no-op**; an absent review and an unrequested one both read as
"pending". Dispatch an adversarial reviewer and give it the head SHA, the base,
and a test-merge, so it reviews the tree that will exist.
and a test-merge, so it reviews the tree that will exist. Point it at
`agent-rules.md`, "GitHub", for what a reviewer reads and answers first.
- Only the user's explicit approval accepts an ADR, and lets its PR merge. The
review gates still apply, and none of them is approval:
`AGENTS.md`, "ADR pull requests".
- A re-review is of the **delta**, at a named SHA. A review of a superseded
commit reads as a pass.
- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`.
- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`. Close
the bead only once the merge has landed. That is when the work is done:
`AGENTS.md`, "Repository policy over the Beads blocks".
- Anything posted to GitHub goes out under the owner's account: open every
comment with a line naming the model and saying an agent wrote it. That
wording is agent-neutral because this one file serves Claude and Codex
+67 -15
View File
@@ -29,12 +29,20 @@ not in a second copy.
5. **Before pushing a fix to an existing PR, check `gh pr view <n> --json
state`.** GitHub deletes a merged PR's branch. A push to it then prints
`* [new branch]` and lands nowhere. Why: `AGENTS.md`, "Shipping a wave".
6. **The "Session Completion" block near the end of `AGENTS.md` does not apply
to you.** It says `git merge origin/main` and then a bare `git push`. On a
wave branch the first is the #145 trap and the second is refused (rule 4).
Rules 1–5 replace it. The block sits between bd's generated
`BEGIN/END BEADS INTEGRATION` markers, so it is not edited by hand
(improvise-x1uk).
6. **The Beads "Session Completion" block near the end of `AGENTS.md` does not
set your git policy.** This repository does not opt in to the block's
"team-maintainer" profile. On a wave branch that profile's pull and bare
`git push` would be refused (rule 4), or worse. Rules 1–5 and 7 are your
git policy. The block is bd's and bd regenerates it whole, so it is not
edited by hand: `AGENTS.md`, "Repository policy over the Beads blocks" (improvise-x1uk).
7. **Pin `gh/main` to a literal SHA when the task starts.** Run
`git rev-parse gh/main` once. Use that SHA, not the ref name, for every
reset, merge-base and diff. Remote refs are shared by every worktree of the
repository, so `gh/main` moves whenever another session fetches. An agent
ran `git reset --soft gh/main` mid-task and staged a reverse diff of #196.
It noticed only because it read `git status` (improvise-diho). Re-resolve
only on purpose, for example when you mean to merge a newer main. Rule 3's
check before a PR is such a time.
## Commit early: a worktree is not durable storage
@@ -47,14 +55,33 @@ deleted worktree. Tidying history before the PR is fine; losing it is not.
## Gates
```
direnv exec . cargo test --workspace --no-fail-fast
direnv exec . cargo clippy --workspace --all-targets -- -D warnings
direnv exec . ./scripts/fmt-workspaces.sh # NOT cargo fmt --all; exit 2 = the gate did not run
direnv exec . python3 scripts/check-comment-runs.py --check-evidence
direnv exec . ./scripts/checkers.sh
direnv exec . cargo clippy --workspace --all-targets -- -D warnings
direnv exec . cargo test --workspace --no-fail-fast
direnv exec . python3 scripts/check-comment-runs.py --self-test
direnv exec . python3 scripts/check-comment-runs.py --check-evidence # also answers the comment cap
direnv exec . python3 scripts/check-ci-graph.py --self-test
direnv exec . python3 scripts/check-ci-graph.py
direnv exec . ./scripts/checkers.sh # every suite in scripts/, its own self-test included
direnv exec . tpl prolog-sessions/kb.pl -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
```
This list is the `test`, `js` and `kb` jobs of `.github/workflows/ci.yml`,
step for step, and this file is the one place it is written down. Change it in
the same PR that changes CI. The other CI jobs are conditional, below, or left
out on purpose:
- **`wasm`**: when you touch a crate it checks (next bullet).
- **`harness-lint`**: when you touch `harness/opfs-contract`, run
`cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings`
from that directory. Its format half is already `fmt-workspaces.sh`.
- **`build` and `browser`**: when you touch `web/`, `harness/`, or the surface
the page calls in `improvise-wasm-client` or `improvise-worker-server`
(third bullet).
- **`pages-deploy`** is left out. It deploys, and it runs only on a push to
`main`. What guards it locally is `check-ci-graph.py`, which is in the list.
- **Never `cargo fmt --all`.** It reaches only **one** of this repo's two
workspace roots, because `harness/opfs-contract` declares its own
`[workspace]`. The obvious repair is worse: `cargo fmt -p opfs-contract` from
@@ -70,10 +97,17 @@ direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
- **`--no-fail-fast`**, because plain `cargo test --workspace` stops at the
first failing binary. Use it for any claim about blast radius.
- If you touch a browser-bundle crate (core, formula, io, protocol, command,
wasm-client, worker-server), also run
wasm-client, worker-server) or `improvise-gp`, also run
`cargo check --target wasm32-unknown-unknown -p <crate>` **for each crate**.
CI checks each one separately, so feature unification cannot hide a leak.
- If you touch `web/` or `harness/`, run the Playwright suite in `harness/`.
- If you touch `web/`, `harness/` or the page's wasm surface, run
`./harness/build.sh`, then
`./scripts/node-test.sh 'web/tests/bundle/*.test.js'`, then `npm ci` and
`npx playwright test` in `harness/`. The bundle tests sit outside the
`web/tests/*.test.js` glob and read the built `web/pkg`, so the standard list
never runs them. They failed only in CI once. A fresh worktree has no
`harness/node_modules`, and Playwright then reports a missing
`@playwright/test`, which reads like a broken checkout.
- **If you change the grid's row or column count, also grep
`harness/fixtures/*.improv` and `harness/tests/*.spec.js`.** On PR #176 a
Playwright spec pinned the old collapsed-group row count. `cargo test` cannot
@@ -87,6 +121,12 @@ direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
check that no test *disappeared*. Diff test **names**, not counts: a
textually clean merge can delete a test module with no conflict marker, and
the other side's new tests mask the loss in a count.
- **Build each side's test list in a fresh `CARGO_TARGET_DIR`, every time.** If
a parent's tree comes from `git archive`, a reused target dir gives a wrong
answer, not just a slow one. Archive stamps every file with its commit time,
so cargo judges the sources older than the last build and lists the previous
tree's tests. On #188 that made three of the branch's own tests look
merge-only.
- **The shell guard refusing a compound command means NEITHER part ran.** Split
the command. After a refusal, re-check the state it was meant to produce.
- **Re-run the full suite after reverting an injected fault.** A revert applied
@@ -165,9 +205,21 @@ text for any of these in your hand-back.
They name the agent and the session, so this shared file cannot hold them.
- Read reviews with `./scripts/pr-reviews <n>`, and do not pipe it. How to tell
a ChatGPT Codex review from ours, and why Copilot is not coming: `AGENTS.md`,
"Shipping a wave". Answer each Codex review on its thread.
- **Do not merge, and do not close beads.** Avoid stacked PRs. If you must
stack, never let the base branch be deleted.
"Shipping a wave".
- **As a PR's author, reply on the PR to every review finding you address.**
Name the commit that fixes it, or say why you declined it. Putting it only in
your hand-back to the orchestrator is not enough: the PR thread is where the
next reader looks.
- **As a reviewer, read the existing reviews first**, with
`./scripts/pr-reviews <n>`, unpiped. Reply on its thread to every Codex
review: fixed (name the commit), declined (say why), or already one of our
tickets (name it). Every review you post states the head SHA it reviewed.
- **Answer a review even if the PR has already merged.** Reviews arrive late,
and the thread is still where the next reader looks.
- **Do not merge, and do not close beads.** Your part ends at an open PR plus
your hand-back. What "done" means is in `AGENTS.md`, "Repository policy over
the Beads blocks". Avoid stacked PRs. If you must stack, never let the base
branch be deleted.
## Hand-back, per ticket
+6
View File
@@ -0,0 +1,6 @@
# Labels the self-hosted NixOS microVM runners register with.
self-hosted-runner:
labels:
- nixos
- microvm
- audited
+123 -169
View File
@@ -12,6 +12,15 @@ on:
env:
CARGO_TERM_COLOR: always
# Every job runs on the self-hosted NixOS microVM runners, and every `run:`
# step inside the flake's `ci` dev shell. Generic-Linux downloads (rustup,
# setup-node's node, Playwright's browsers) cannot run on NixOS, so the
# toolchain comes from flake.nix, the same one `nix develop` gives locally.
# Each step re-enters the shell; after the first, the eval cache makes it cheap.
defaults:
run:
shell: nix develop .#ci --command bash --noprofile --norc -eo pipefail {0}
jobs:
# ---------------------------------------------------------------------------
# The one place wasm-pack runs (improvise CI dedup).
@@ -35,7 +44,7 @@ jobs:
# would serialise a 109s job behind a 126s one for no artifact at all.
build:
name: Build all bundles
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
# First, because it needs nothing and takes a second: the gate that stops
@@ -44,61 +53,52 @@ jobs:
# below, so it fails under this job's name rather than another's.
- name: The stamp gate refuses what it claims to
run: ./scripts/checkers.sh assemble-stamp
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
# Two workspaces, because `harness/opfs-contract` carries an empty
# `[workspace]` table and so has its own target dir, which the default
# single-workspace configuration never cached (it recompiled its whole
# dependency tree every run).
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
harness/opfs-contract -> target
# One home for the two pins, so the release capture
# (scripts/build-web-release.sh, improvise-oujb) provisions the same
# versions rather than a second copy free to go stale. The grep keeps a
# comment or a blank line out of $GITHUB_ENV, which takes NAME=VALUE only.
- name: Pinned web toolchain versions
run: grep -E '^[A-Z_]+=' scripts/web-toolchain-versions.env >> "$GITHUB_ENV"
# Pinned release binary rather than `cargo install` (a three-minute
# source build) or a third-party action. wasm-pack runs its own bundled
# `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names
# `--enable-bulk-memory`: without it the optimizer rejects the
# `memory.copy` LLVM emits regardless.
# v0.15.0 is what `nix develop` supplies, and the bundle was built and
# measured against it locally (346 KB, 120 KB gzipped). Pinning the same
# version is what makes that local run evidence about this one.
- name: Install wasm-pack
# scripts/web-toolchain-versions.env stays the one home for the pins, so
# the release capture (scripts/build-web-release.sh, improvise-oujb)
# provisions the same versions. CI no longer installs them — the dev
# shell supplies wasm-pack and node — so this checks the shell against
# the pins instead: a nixpkgs bump that moves either fails here, by name.
# wasm-pack runs its own bundled `wasm-opt`, which is why
# improvise-wasm-client's Cargo.toml names `--enable-bulk-memory`.
- name: The dev shell supplies the pinned web toolchain
run: |
set -eu
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
. scripts/web-toolchain-versions.env
test "$(wasm-pack --version)" = "wasm-pack ${WASM_PACK_VERSION#v}"
test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION"
wasm-pack --version
node --version
# wasm-pack uses the shell's wasm-bindgen only at Cargo.lock's exact
# version; otherwise it cargo-installs one (71s) — so a lock bump
# must move flake.nix's wasmBindgenCli too, and this says so.
lock="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, ""); print}' Cargo.lock)"
test "$(wasm-bindgen --version)" = "wasm-bindgen $lock" || {
echo "flake.nix wasmBindgenCli is $(wasm-bindgen --version); Cargo.lock wants $lock" >&2
exit 1
}
# Both assemblies below generate the prose site with node. The runner
# image ships one, but pinning it is what makes a local run evidence.
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# The same script a developer runs locally, so there is one answer to
# "how is the bundle built" rather than two that can drift. It builds all
# three wasm bundles (client, worker, opfs-contract) and assembles
# `harness/serve` — the deployed arrangement plus /opfs/.
- name: Build the bundles and the harness tree
run: ./harness/build.sh
# Re-assembles from the pkg dirs the step above already produced — no
# wasm-pack runs here. A second assembly rather than a copy of
# `harness/serve` because the cache-bust token differs: the harness tree
# is stamped `harness` and the published site the commit SHA, which is
# what makes a returning visitor pick up a fresh bundle (improvise-9x6).
- name: Assemble the site
run: ./web/assemble.sh site "$GITHUB_SHA"
# Built by Nix (packages.web and packages.site in nix/ci.nix), which runs
# the same harness/build.sh and web/assemble.sh a developer runs, so there
# is still one answer to "how is the bundle built". `web` is the three
# wasm bundles plus `harness/serve`; `site` re-assembles from web's pkg
# dirs with the commit SHA as the cache-bust token (improvise-9x6), where
# the harness tree is stamped `harness`. Dependencies are their own
# derivations, substituted from titan's cache when Cargo.lock is unchanged.
- name: Build the bundles, the harness tree and the site
shell: bash
run: |
nix build --print-build-logs --out-link result-web .#web
nix build --print-build-logs --out-link result-site .#site
# The upload steps below read these paths. Copied rather than linked:
# an artifact of a symlink into /nix/store is a dangling link elsewhere.
- name: Lay the outputs out where the uploads read them
shell: bash
run: |
rm -rf harness/serve web/pkg web/worker-pkg site
cp -rL result-web/harness-serve harness/serve
cp -rL result-web/pkg web/pkg
cp -rL result-web/worker-pkg web/worker-pkg
cp -rL result-site site
chmod -R u+w harness/serve web/pkg web/worker-pkg site
- run: find site -type f | sort
- run: du -sh site
@@ -133,27 +133,29 @@ jobs:
test:
name: Test and lint (host)
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
# NOT `cargo fmt --all`, which reaches only ONE of the two workspace
# roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks).
- name: Every workspace root is formatted
run: ./scripts/fmt-workspaces.sh
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
# checks.clippy and checks.test in nix/ci.nix: the same cargo commands,
# on the dev/test profiles, with dependencies as a cached derivation.
- name: cargo clippy --workspace --all-targets -- -D warnings
shell: bash
run: nix build --print-build-logs .#checks.x86_64-linux.clippy
# Also runs the test-evidence gate (check-comment-runs.py
# --check-evidence), which lists the tests this derivation compiled.
- name: cargo test --workspace, and test-evidence headings name real tests
shell: bash
run: nix build --print-build-logs .#checks.x86_64-linux.test
# The comment cap and the test-evidence keys (improvise-9c8c); without
# this step the rule is advisory. See context/design-principles.md §6.
- name: The comment checker catches what it has been fixed for
run: python3 scripts/check-comment-runs.py --self-test
- name: Comment runs capped at two lines
run: python3 scripts/check-comment-runs.py
- name: Test-evidence headings name real tests
run: python3 scripts/check-comment-runs.py --check-evidence
# `pages-deploy` needs every other job, and saying so was not enough: a
# job added during review of PR #83 was ungated and nothing noticed.
- name: The deploy gate refuses what it claims to
@@ -161,83 +163,47 @@ jobs:
- name: Every check job gates the deploy
run: python3 scripts/check-ci-graph.py
# Too slow on GitHub Actions
#
# nix-test:
# name: Nix Test
# runs-on: ubuntu-latest
# steps:
# - name: Free up disk space
# run: |
# sudo rm -rf /usr/share/dotnet \
# /usr/local/lib/android
# sudo df -h
# - uses: actions/checkout@v7
# - name: Install Nix
# uses: DeterminateSystems/nix-installer-action@v14
# - name: Magic Nix Cache
# uses: DeterminateSystems/magic-nix-cache-action@v8
# - name: Check Nix health
# run: nix doctor
# - run: nix build
# ---------------------------------------------------------------------------
# The crate2nix build (Cargo.nix), which `cargo` never exercises. It was off
# because GitHub-hosted runners start with an empty store and rebuilt every
# dependency crate each run. The NixOS runners substitute from titan's
# harmonia first, and titan builds gh/main's `#improvise` hourly, so a PR that
# leaves Cargo.lock alone rebuilds only the workspace crates it changed.
nix-build:
name: Nix build (crate2nix)
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
# Plain bash, not the dev shell: this step needs nix and nothing else.
- name: nix build .#improvise
shell: bash
run: nix build --print-build-logs .#improvise
wasm:
name: wasm32 check (browser bundle crates)
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
# Only the crates that go into the browser bundle. The root `improvise`
# crate is the terminal binary and depends on crossterm, which does not
# build for wasm — that is correct, not a gap (improvise-ywd).
- name: improvise-core
run: cargo check -p improvise-core --target wasm32-unknown-unknown
- name: improvise-formula
run: cargo check -p improvise-formula --target wasm32-unknown-unknown
- name: improvise-protocol
run: cargo check -p improvise-protocol --target wasm32-unknown-unknown
- name: improvise-io
run: cargo check -p improvise-io --target wasm32-unknown-unknown
- name: improvise-command
run: cargo check -p improvise-command --target wasm32-unknown-unknown
# The browser client itself. This is the one crate for which a wasm
# build is the *point* rather than a property worth keeping, so a
# failure here is not a portability regression but a broken deliverable
# (improvise-gsw).
- name: improvise-wasm-client
run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown
# The worker half of the standalone deployment (improvise-9x6). Same
# terms as the client: a wasm build is the point, so a failure here is a
# broken deliverable rather than a portability regression.
- name: improvise-worker-server
run: cargo check -p improvise-worker-server --target wasm32-unknown-unknown
# Not in the browser bundle yet — nothing depends on improvise-gp. Gated
# anyway because it is a candidate (fitting a tree in the browser) and
# staying wasm-clean is free now, expensive to retrofit (improvise-z5eg).
- name: improvise-gp
run: cargo check -p improvise-gp --target wasm32-unknown-unknown
# checks.wasm-check in nix/ci.nix: `cargo check --target wasm32` for each
# crate in the browser bundle, one at a time, so features are not unified
# across them. The root `improvise` crate is the terminal binary and
# depends on crossterm, which does not build for wasm — that is correct,
# not a gap (improvise-ywd). The log names each crate as it is checked.
- name: cargo check --target wasm32-unknown-unknown, per bundle crate
shell: bash
run: nix build --print-build-logs .#checks.x86_64-linux.wasm-check
# ---------------------------------------------------------------------------
# The browser JavaScript's unit tests. Its own job, deliberately: a failure
# here is a decision `web/shell.js` or `web/serial.js` got wrong, and
# reporting that as "Browser harness" would put it behind an 80 MB browser
# download and two wasm-pack builds. It needs neither — `node --test` is in
# the runtime, and this job installs nothing at all.
# the runtime, which the dev shell supplies; this job installs nothing.
js:
name: Browser JS unit tests (node --test)
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: 22
# The glob is the SCRIPT's, not the shell's and not node's. Under bash an
# unmatched glob survives as a literal, node globs it, matches nothing and
# exits 0 having run no test -- so this step gated nothing the moment
@@ -252,23 +218,23 @@ jobs:
kb:
name: Knowledge base consistency
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
# kb.pl is executable knowledge, so it gets checked like code: rules
# must reject what they should, domains must close, and functional keys
# must not carry conflicting values. See kb_report/0 in the file.
# trealla comes from the dev shell, as it does locally.
- name: kb_consistent
run: |
nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \
tpl prolog-sessions/kb.pl \
-g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
# scripts/kb is the only thing that writes kb.pl, so its refusals are as
# load-bearing as kb_consistent itself. Without this the replacement
# writer could regress green -- the review that found the directive
# bypass also found that nothing ran this suite.
- name: The kb tool refuses what it claims to
run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb
run: ./scripts/checkers.sh kb
# Every suite goes through scripts/checkers.sh, which pins a bash >= 5
# and scrubs the environment, so the shell and the variables a suite
# passes under here are the ones it passes under locally (improvise-f9qf).
@@ -280,8 +246,10 @@ jobs:
# was blind to inline comments for as long as it existed (improvise-xgra).
# fmt-workspaces is here because its own failure mode is the bug it
# exists for: a usage error wearing a formatting failure's exit code.
# bd-audit-ids is the dead-ID audit, whose recipe once read a bd that
# never ran as a clean audit (improvise-52eq).
- name: The remaining checkers refuse what they claim to
run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces
run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids
# ---------------------------------------------------------------------------
# Lints the one crate the `test` job cannot reach.
@@ -299,20 +267,14 @@ jobs:
# failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file.
harness-lint:
name: Lint the harness crate
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: harness/opfs-contract -> target
- working-directory: harness/opfs-contract
run: |
cargo fmt -p opfs-contract -- --check
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
# checks.harness-lint in nix/ci.nix, run from inside harness/opfs-contract:
# `cargo fmt -p opfs-contract -- --check` and clippy for wasm32.
- name: cargo fmt -p opfs-contract and clippy --target wasm32
shell: bash
run: nix build --print-build-logs .#checks.x86_64-linux.harness-lint
# ---------------------------------------------------------------------------
# The browser harness (improvise-vvl3, absorbing improvise-xf1).
@@ -333,37 +295,25 @@ jobs:
browser:
name: Browser harness (OPFS contract + shell)
needs: build
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install the harness
working-directory: harness
run: npm ci
# Keyed on the resolved @playwright/test version, so a version bump is a
# miss and anything else is a hit. The download was 50s on run
# 35535225637 and is pure I/O — nothing about it depends on this commit.
- name: Playwright version
id: pw
# No browser install step: the dev shell points PLAYWRIGHT_BROWSERS_PATH
# at nixpkgs' Chromium, Firefox and WebKit, since Playwright's own
# downloads cannot run on NixOS. All three, because improvise-xf1's
# acceptance criterion names Chrome, Firefox and Safari and
# `createSyncAccessHandle` is implemented separately by each engine.
# WebKit's OPFS is absent today; the spec skips on a runtime probe.
# @playwright/test must equal nixpkgs' playwright-driver version, or it
# looks for browser builds that are not there; this says which.
- name: Playwright matches the dev shell's browsers
working-directory: harness
run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }}
# All three, because improvise-xf1's acceptance criterion names Chrome,
# Firefox and Safari and `createSyncAccessHandle` is implemented
# separately by each engine. WebKit is installed even though its OPFS is
# absent today: the spec skips on a runtime probe, so the day that build
# gains OPFS the check starts running without a CI change.
# `--with-deps` installs apt packages OUTSIDE the cached directory, so it
# runs on a cache hit too; only the browser download is skipped.
- name: Install browsers
working-directory: harness
run: npx playwright install --with-deps chromium firefox webkit
run: |
npx playwright --version
ls "$PLAYWRIGHT_BROWSERS_PATH"
# The tree `build` assembled, not one this job rebuilds. Same bytes as
# the Pages artifact, modulo the cache-bust token `web/assemble.sh`
# stamps — the harness tree is stamped `harness` and the site the commit
@@ -387,9 +337,13 @@ jobs:
# renamed export is cheaper to read here than as a blank page.
- name: The page's calls match the bundle it calls
run: ./scripts/node-test.sh 'web/tests/bundle/*.test.js'
# Chromium and Firefox only, for now. nixpkgs' WebKit (WPEWebProcess)
# aborts on its first page inside the runner VMs, though not on the
# host; its four specs skip on the OPFS probe anyway, so nothing gated
# is lost. Put `--project=webkit` back once that abort is understood.
- name: Run the harness
working-directory: harness
run: npx playwright test
run: npx playwright test --project=chromium --project=firefox
- if: failure()
uses: actions/upload-artifact@v7
with:
@@ -430,9 +384,9 @@ jobs:
# exists to stop.
pages-deploy:
name: Deploy to GitHub Pages
needs: [test, wasm, js, browser, harness-lint, kb, build]
needs: [test, wasm, js, browser, harness-lint, kb, build, nix-build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
runs-on: [self-hosted, nixos, microvm]
permissions:
contents: read
pages: write
+154 -65
View File
@@ -57,9 +57,9 @@ cp -rf source dest # NOT: cp -r source dest
## The Wave Workflow
Most work here is done by **waves**: an orchestrator chunks the open backlog,
fans several agents out into their own git worktrees, merges what comes back,
reviews it, and pushes the wave as one PR. This section is what that costs to
get right. Everything in it was learned by getting it wrong first.
fans several agents out into their own git worktrees, and shepherds each
agent's pull request through review to a merge on GitHub. This section is what
that costs to get right. Everything in it was learned by getting it wrong first.
### Who owns what
@@ -75,11 +75,13 @@ badly:
Wave agents **read** all three freely (`bd show`, `bd ready`, grep `kb.pl`) and
**write** none of them. They report proposed repo-map lines and `kb.pl` facts as
*text* in their hand-back, and the orchestrator applies them — batched, at the
end of the wave, after the review. **`kb.pl` is written by `scripts/kb add|replace|rm` and by nothing else** — not
*text* in their hand-back, and the orchestrator applies them. Repo-map lines
are batched at the end of the wave, after the review, because they conflict.
`kb.pl` facts are not batched: the orchestrator writes each one when it arrives.
**`kb.pl` is written by `scripts/kb add|replace|rm` and by nothing else** — not
the Prolog MCP, not an editor. Every write is gated on `kb_consistent` against
the candidate, so a bad delta leaves the file untouched. Facts do not need
batching: write one when you learn it. There is a `kb` skill; read it before
the candidate, so a bad delta leaves the file untouched, which is why a fact
can be written the moment it is learned. There is a `kb` skill; read it before
your first write. Reading is unaffected — `loadSession` at session start is
still how a session gets the model in hand.
@@ -127,18 +129,22 @@ and watch the check fail.
### Shipping a wave
**Do not push `main` to `gh/main`.** Work reaches GitHub as a branch and a pull
request, and lands on `main` by being merged there. The orchestrator still
merges agent branches into its *local* `main` to integrate and run the gates —
that is how conflicts get found early — but the push is a branch push, not a
`main` push. `origin` and `gitea` are mirrors and take `main` directly.
**Do not push `main` to `gh/main`.** Each agent's work reaches GitHub as its own
branch and its own pull request, and lands on `main` by being merged there, once
CI is green and an adversarial review agent has passed the PR's head SHA. An ADR
PR also needs the user's explicit approval, and nothing else stands in for it:
"ADR pull requests" below. The orchestrator does not integrate agent branches
locally: its `main` only fast-forwards to `gh/main` after a merge. The procedure is
`.agents/skills/wave/SKILL.md`, "Shepherding". `origin` and `gitea` are mirrors
and take `main` directly.
**So `origin/main` runs ahead of `gh/main`, and a branch destined for a GitHub
PR must merge `gh/main` — never `origin/main`.** This is a trap in this repo's
own topology rather than a slip: the two remotes are both spelled `main`, and
nothing about `git merge origin/main` looks wrong. The session-completion
protocol below literally says `git merge origin/main`, which is right for
`main` itself and wrong for a PR branch. It cost a review round on PR #145,
**Because the mirrors take `main` directly, `origin/main` can run ahead of
`gh/main`, and a branch destined for a GitHub PR must merge `gh/main` — never
`origin/main`.** This is a trap in this repo's own topology rather than a slip:
the two remotes are both spelled `main`, and nothing about
`git merge origin/main` looks wrong. bd's generated Session Completion block
once prescribed exactly that command (see "Repository policy over the Beads blocks" below).
It cost a review round on PR #145,
where four unmerged commits — including another PR's whole diff — were pulled
into a branch based on `gh/main` and had to be rebuilt by cherry-picking.
ChatGPT Codex caught it; nothing in CI could, because the result compiles and
@@ -153,15 +159,13 @@ finishing. It happened in this repo: 27 commits across six agent branches —
including a new crate, a new protocol type, and a type moving between crates —
went straight to `main` unreviewed, while `AGENTS.md` already said not to. If
you notice a batch has bypassed the loop, the cheap repair is a **review-only
PR**: base a branch at the last PR merge, open a PR with `main` as the head, let
Copilot review the diff, and land the findings as an ordinary PR afterwards. It
PR**: base a branch at the last PR merge, open a PR with `main` as the head,
have an adversarial review agent review the diff, and land the findings as an
ordinary PR afterwards. It
cannot be merged — the commits are already in — so close it once its findings
are filed or fixed. Rewinding the default branch to fake a mergeable PR is not
worth it on a repo with other collaborators.
One PR per wave, built by a **dedicated PR agent in its own worktree**, so waves
pipeline against it: the next wave runs while the PR absorbs review fixes.
**Read the PR's reviews before merging it.** This is separate from CI and from
the adversarial agent, and it has been missed: two ChatGPT Codex reviews landed
on merged PRs and were not read until afterwards, one of them a live P2
@@ -181,8 +185,8 @@ earlier head, and the finding may already be fixed — verify rather than assume
in either direction.
Reviews arrive **as the owner's quota allows**, so absence is not evidence.
Wait where a review is plausible, and when one is found, answer it on the
thread even if the PR has already merged: that is where the next reader looks.
Wait where a review is plausible. How a review is answered once found, by the
author and by a reviewer, is `.agents/skills/wave/agent-rules.md`, "GitHub".
**Copilot is not reviewing this repository, and the request is a silent no-op.**
Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61,
@@ -228,8 +232,8 @@ normally.)
Across four PRs Copilot has found genuine bugs and no false positives, so its
findings are worth the loop. **But judge each one against the repo's doctrine
rather than applying it reflexively**, and when you decline, reply on the thread
with the reason. The distinction that has mattered: a finding can be *correct*
rather than applying it reflexively**. How a declined finding is answered is
agent-rules, "GitHub". The distinction that has mattered: a finding can be *correct*
and still be wrong to fix in that PR. On #12, a non-ASCII `truncate` panic was
applied (byte-identical on ASCII, so it cost no evidence) while a hint-line
padding bug in the same file was declined — fixing it would have rewritten the
@@ -248,11 +252,31 @@ is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the
stray re-creation. Check `gh pr view <n> --json state` *before* pushing a fix to
a PR you have not looked at in a few minutes, rather than after.
Merge only once a review has passed — an adversarial agent's while Copilot is
absent, and it must name the head SHA it reviewed. Prefer a **merge commit** —
`AGENTS.md` says merges over rebases, and a wave's commits are individually
Merge only once a review of the PR's current head has passed — an adversarial
agent's while Copilot is absent. What a review must state is agent-rules,
"GitHub". Prefer a **merge commit** —
`AGENTS.md` says merges over rebases, and a PR's commits are individually
meaningful.
### ADR pull requests
**Only the user's explicit approval accepts an ADR (`context/adr/`), and only
it lets an ADR PR merge.** "An ADR isn't approved until I say it is" (the
user). Green CI, every review answered and a passing adversarial review
("Shipping a wave") are still required. They are gates, never approval: an ADR
PR that passes all of them is still unapproved and does not merge. The steps,
modelled on PR #122 (ADR 004):
1. The PR proposes the ADR with its **open questions numbered**.
2. The user answers them on the PR.
3. The answers are folded into the ADR, in a section headed **"What the user
decided"**.
4. The PR merges only after the user comments **"Approved"**, and only while
the gates above still hold. Nobody else's comment or review stands in for
it. "Read the PR's reviews before merging it" still
applies after the approval. On #122 the user's own review landed as the PR
merged.
### Never hand-edit `.beads/issues.jsonl`
bd manipulates that file and auto-exports to it. Editing it — or even
@@ -269,15 +293,42 @@ session before an agent tried one and found nothing. Read the ID back after
filing, and before pushing docs run the audit:
```bash
grep -rohE 'improvise-[a-z0-9]{3,8}\b' \
context README.md AGENTS.md CLAUDE.md crates src tests examples web harness docs \
| sort -u \
| grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|side|web)$' \
> /tmp/bd-audit-ids.txt
bd show $(cat /tmp/bd-audit-ids.txt) 2>&1 \
| grep -iE 'not found|no such issue' || echo "all resolve"
./scripts/bd-audit-ids
```
It exits 0, printing `all N cited ids resolve`, only when every cited id is in
what `bd list --all` returns. A dead id is exit 1, and each one is named. A bd
that is missing, fails, or lists no issues is exit 3. The comparison is
positive because the recipe the script replaced was not: it searched `bd show`
for "not found" lines and printed "all resolve" when there were none, so a bd
that never ran read as a clean audit (improvise-52eq). Its suite,
`scripts/bd-audit-ids-test.sh`, runs in `checkers.sh` and checks both halves:
a dead id, and a bd that does not answer.
In the script, `git ls-files` picks the files, and grep only reads them. The file list is the
tracked files plus the untracked ones, minus anything git ignores. That leaves
out two things a `grep -r` over the same roots reads:
- **Build output.** After `./harness/build.sh`, `harness/serve/` holds
rendered copies of `context/`, and `grep -r` reported three dead ids from them
that no tracked file contains (improvise-8haj).
- **Other agents' worktrees.** In the orchestrator's checkout
`.claude/worktrees/` holds every agent's worktree, dozens of copies of the
repository with their unpushed work.
`-d skip` is there for the skills' symlinks, and it changes no answer. The
`.claude/skills/*` entries are symlinks to directories under `.agents/skills/`,
and `git ls-files` lists each one, so each reaches grep as a command-line
operand. The grep has no `-r`, so it never reads a directory operand. It prints
`grep: <path>: Is a directory` on stderr instead, and `-d skip` only silences
those lines. The output goes through `sort -u`, so a duplicate could not change
the answer anyway. Measured with BSD grep 2.6.0 and GNU grep 3.12: 3092 matches
with or without the flag, 462 distinct ids. Do not add `-r`: GNU grep then
follows those symlinks and reads the skills a second time (3107 matches).
Plain `git grep` is not a substitute: on macOS its `-E` silently ignores `\b`
and finds a fraction of the ids.
Three things the earlier version of this recipe got wrong, each of which let a
real defect through:
@@ -291,9 +342,10 @@ real defect through:
to match. An ID-shaped non-ID is worse than a dead ID, because the
diagnosis is identical and the repair is not.
- **The exclusion list named only `core` and `wasm`**, so every other crate
name would have been reported missing. The exclusions above also cover the
name would have been reported missing. The script's exclusions also cover the
non-ticket tokens this repo really contains: a package name, a temp file
name, and the prose word "improvise-side". **They are anchored `^…$` for the
name, the prose word "improvise-side", and the example crate name in the
`kb` skill's `crate(...)` fact. **They are anchored `^…$` for the
same reason the recipe is read as code**: unanchored, the `gp` alternative is
a *prefix* match, so a future real ticket whose id happens to begin `gp` is
silently excluded and the audit reports nothing while skipping it. An
@@ -303,20 +355,23 @@ real defect through:
- **A release artifact's name is ID-shaped, and `improvise-installer.sh`
escaped only by being ten characters long.** `improvise-web.tar.gz`
(improvise-oujb) is three, so it matches `{3,8}` and resolves to nothing;
`web` is therefore excluded above. The general point is that the length
`web` is therefore excluded in the script. The general point is that the length
bound, not any rule, is what has been keeping `dist`'s artifact names out of
this audit — so **a new artifact whose name is a short lowercase word must be
added to the exclusion list in the same commit**, or it arrives as a dead id
that no amount of `bd` archaeology explains.
Note the recipe's own prose deliberately spells no live or dead id: a document
that lists ids as examples makes itself a source of audit noise.
The recipe's own prose spells no id as an *example*, because a document that
lists example ids makes itself a source of audit noise. The ids it does cite,
improvise-8haj and improvise-oujb, are the tickets behind two of the rules
above. They resolve, and the audit checks them like any other.
One environment note: in a worktree-isolated agent the shell guard refuses a
loop that passes a runtime-computed argument to `bd`, so the recipe above
materialises the id list into a file and hands `bd show` the whole list in one
call rather than looping. If `bd show` in your build does not take several ids,
run the ids through `xargs -n1 -I{} bd show {}` from a plain shell instead.
One environment note: run the audit by its name. Which `bd` command forms a
worktree-isolated agent's shell guard allows has varied between sessions. In
one session it refused the old recipe's `bd show $(cat …)`, and also
`xargs bd show < file` and `sh file`. In another, both `xargs` forms ran.
`./scripts/bd-audit-ids` passes `bd` no argument computed at runtime, and it
ran in the session that refused the other three.
A dead ID is worse than no ID: it reads as a promise that the follow-up exists.
@@ -327,8 +382,33 @@ in progress. Two duplicates were filed in this repo within minutes of the
hand-backs that prompted them. `bd search` the core concept *and* scan
in-progress issues first.
## Repository policy over the Beads blocks
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
Everything between a `BEGIN BEADS …` marker and its `END` is generated by bd,
and bd replaces it whole. `bd init` and `bd setup factory|opencode|mux` rewrite
the integration block, and the last three call it "stale" and invite the
rewrite whenever bd's template has moved on. `bd setup codex` rewrites the
Codex block. So a hand edit inside the markers is silently reverted by the next
regeneration. Repository policy lives here, outside them:
- **Work is not done until it is in a pull request and that pull request has
merged.** A pushed branch is not done, and neither is a green one. In a wave,
the merge is the orchestrator's, after an adversarial review passes the head
SHA ("Shipping a wave"). An ADR PR merges only on the user's explicit
approval as well ("ADR pull requests"). So a wave agent's part ends at an
open PR plus its hand-back, and the ticket is done, and its bead closed, only
when the merge lands.
- **This repository does not opt in to the Beads "team-maintainer" profile.**
So the pull (`git pull`, or `git pull --rebase` in bd's "full" profile) and
the bare `git push` that the block lists for that profile are not
instructions here. Work reaches `main` as "Shipping a wave" describes,
and a wave agent pushes with the explicit refspec in
`.agents/skills/wave/agent-rules.md`, "Base, branch, push".
- **The no-markdown-TODO and no-`MEMORY.md` rules have one exception:**
`context/design-principles.md` and `context/repo-map.md` are kept by hand
(the repo map by the orchestrator only; see "Who owns what").
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:46cd31e7 -->
## Beads Issue Tracker
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
@@ -344,35 +424,44 @@ bd close <id> # Complete work
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/core-concepts/sync-concepts.md for details and anti-patterns.
## Agent Context Profiles
The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions.
- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands.
- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise.
- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins.
## Session Completion
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
1. **File issues for remaining work** - Create beads for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
4. **Handle git/sync by active profile**:
```bash
git merge origin/main
# Conservative/minimal/default: report status and proposed commands; wait for approval.
git status
# Team-maintainer opt-in only, unless current instructions forbid it:
git pull
bd dolt push
git push
git status # MUST show "up to date with origin"
git status
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
**Critical rules:**
- Explicit user or orchestrator instructions override this Beads block.
- Do not commit or push without clear authority from the active profile or the current user request.
- If a required sync or push is blocked, stop and report the exact command and error.
<!-- END BEADS INTEGRATION -->
<!-- BEGIN BEADS CODEX SETUP: generated by bd setup codex -->
Generated
-2
View File
@@ -997,8 +997,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
+97 -91
View File
@@ -2640,7 +2640,6 @@ rec {
{
name = "indexmap";
packageId = "indexmap";
features = [ "serde" ];
}
{
name = "ratatui";
@@ -2654,6 +2653,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "thiserror";
@@ -2673,6 +2673,10 @@ rec {
name = "improvise-wasm-client";
packageId = "improvise-wasm-client";
}
{
name = "libc";
packageId = "libc";
}
{
name = "pest";
packageId = "pest";
@@ -2735,6 +2739,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "unicode-width";
@@ -2776,7 +2781,6 @@ rec {
{
name = "indexmap";
packageId = "indexmap";
features = [ "serde" ];
}
{
name = "serde";
@@ -2898,7 +2902,6 @@ rec {
{
name = "indexmap";
packageId = "indexmap";
features = [ "serde" ];
}
{
name = "js-sys";
@@ -2921,6 +2924,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "wasm-bindgen";
@@ -2975,6 +2979,7 @@ rec {
name = "serde_json";
packageId = "serde_json";
optional = true;
features = [ "float_roundtrip" ];
}
{
name = "unicode-width";
@@ -3011,6 +3016,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "wasm-bindgen";
@@ -3068,6 +3074,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "wasm-bindgen";
@@ -3135,6 +3142,7 @@ rec {
{
name = "serde_json";
packageId = "serde_json";
features = [ "float_roundtrip" ];
}
{
name = "tokio";
@@ -3171,27 +3179,6 @@ rec {
packageId = "hashbrown 0.17.1";
usesDefaultFeatures = false;
}
{
name = "serde";
packageId = "serde";
optional = true;
usesDefaultFeatures = false;
target = { target, features }: false;
}
{
name = "serde_core";
packageId = "serde_core";
optional = true;
usesDefaultFeatures = false;
}
];
devDependencies = [
{
name = "serde";
packageId = "serde";
usesDefaultFeatures = false;
features = [ "derive" ];
}
];
features = {
"arbitrary" = [ "dep:arbitrary" ];
@@ -3202,7 +3189,7 @@ rec {
"serde" = [ "dep:serde_core" "dep:serde" ];
"sval" = [ "dep:sval" ];
};
resolvedDefaultFeatures = [ "default" "serde" "std" ];
resolvedDefaultFeatures = [ "default" "std" ];
};
"indoc" = rec {
crateName = "indoc";
@@ -5655,7 +5642,7 @@ rec {
"preserve_order" = [ "indexmap" "std" ];
"std" = [ "memchr/std" "serde_core/std" ];
};
resolvedDefaultFeatures = [ "default" "std" ];
resolvedDefaultFeatures = [ "default" "float_roundtrip" "std" ];
};
"sha1" = rec {
crateName = "sha1";
@@ -8871,7 +8858,15 @@ rec {
)
crateConfigs;
target = makeTarget pkgs.stdenv.hostPlatform;
build = mkBuiltByPackageIdByPkgs pkgs.buildPackages;
# Build-time dependency graph (for proc-macros and build
# dependencies). When not cross-compiling it equals the host
# graph, so reuse `self`; otherwise build it for
# `pkgs.buildPackages`.
build =
if pkgs.stdenv.buildPlatform.config == pkgs.stdenv.hostPlatform.config then
self
else
mkBuiltByPackageIdByPkgs pkgs.buildPackages;
};
in
self;
@@ -8887,38 +8882,35 @@ rec {
devDependencies = lib.optionals (runTests && packageId == rootPackageId) (
crateConfig'.devDependencies or [ ]
);
dependencies = dependencyDerivations {
# Enabled (platform- and feature-filtered) dependency lists, reused
# for both derivation wiring and the crate renames below.
enabledDependencies = filterEnabledDependencies {
inherit features;
inherit (self) target;
buildByPackageId =
depPackageId:
# proc_macro crates must be compiled for the build architecture
if crateConfigs.${depPackageId}.procMacro or false then
self.build.crates.${depPackageId}
else
self.crates.${depPackageId};
dependencies = (crateConfig.dependencies or [ ]) ++ devDependencies;
};
buildDependencies = dependencyDerivations {
enabledBuildDependencies = filterEnabledDependencies {
inherit features;
inherit (self.build) target;
buildByPackageId = depPackageId: self.build.crates.${depPackageId};
dependencies = crateConfig.buildDependencies or [ ];
};
dependencies = map
(
dependency:
# proc_macro crates must be compiled for the build architecture
if crateConfigs.${dependency.packageId}.procMacro or false then
self.build.crates.${dependency.packageId}
else
self.crates.${dependency.packageId}
)
enabledDependencies;
buildDependencies = map
(dependency: self.build.crates.${dependency.packageId})
enabledBuildDependencies;
# Order (build dependencies, then normal dependencies) feeds the
# crateRenames grouping below.
dependenciesWithRenames =
let
buildDeps = filterEnabledDependencies {
inherit features;
inherit (self) target;
dependencies = crateConfig.dependencies or [ ] ++ devDependencies;
};
hostDeps = filterEnabledDependencies {
inherit features;
inherit (self.build) target;
dependencies = crateConfig.buildDependencies or [ ];
};
in
lib.filter (d: d ? "rename") (hostDeps ++ buildDeps);
lib.filter (d: d ? "rename") (enabledBuildDependencies ++ enabledDependencies);
# Crate renames have the form:
#
# {
@@ -9090,6 +9082,17 @@ rec {
corresponding feature sets are merged. Features in rust are additive.
*/
mergePackageFeatures =
args: builtins.mapAttrs (_packageId: builtins.attrNames) (mergePackageFeaturesImpl args);
/*
Core of the feature-resolution fixpoint. The cache (`featuresByPackageId`)
maps each packageId to a feature *set* (an attrset `feature -> 1`) rather
than a sorted list, so the fold merges with `//` and detects convergence
with attrset equality instead of re-concatenating and re-sorting the
accumulated feature list on every step. `mergePackageFeatures` projects the
result back to canonical sorted lists.
*/
mergePackageFeaturesImpl =
{ crateConfigs ? crates
, packageId
, rootPackageId ? packageId
@@ -9138,14 +9141,15 @@ rec {
cache:
{ packageId, features }:
let
cacheFeatures = cache.${packageId} or [ ];
combinedFeatures = sortedUnique (cacheFeatures ++ features);
cacheFeatures = cache.${packageId} or { };
# `features` is the (small) incoming list; merge it into the set.
combinedFeatures = cacheFeatures // listToSet features;
in
if cache ? ${packageId} && cache.${packageId} == combinedFeatures then
if cache ? ${packageId} && cacheFeatures == combinedFeatures then
cache
else
mergePackageFeatures {
features = combinedFeatures;
mergePackageFeaturesImpl {
features = builtins.attrNames combinedFeatures;
featuresByPackageId = cache;
inherit
crateConfigs
@@ -9158,8 +9162,8 @@ rec {
);
cacheWithSelf =
let
cacheFeatures = featuresByPackageId.${packageId} or [ ];
combinedFeatures = sortedUnique (cacheFeatures ++ enabledFeatures);
cacheFeatures = featuresByPackageId.${packageId} or { };
combinedFeatures = cacheFeatures // listToSet enabledFeatures;
in
featuresByPackageId
// {
@@ -9186,27 +9190,31 @@ rec {
assert (builtins.isList features);
assert (builtins.isAttrs target);
let
# Identical for every dep in this call; build the predicate arg once.
targetArgs = { inherit features target; };
in
lib.filter
(
dep:
let
targetFunc = dep.target or (features: true);
in
targetFunc { inherit features target; }
(dep.target or (features: true)) targetArgs
&& (!(dep.optional or false) || builtins.any (doesFeatureEnableDependency dep) features)
)
dependencies;
# Returns whether the given feature should enable the given dependency.
doesFeatureEnableDependency =
dependency: feature:
dependency:
# Callers partially apply this once per dependency, then test every feature,
# so hoist the dep-invariant strings out of the per-feature comparison.
let
name = dependency.rename or dependency.name;
depName = "dep:" + name;
prefix = "${name}/";
len = builtins.stringLength prefix;
startsWithPrefix = builtins.substring 0 len feature == prefix;
in
feature == name || feature == "dep:" + name || startsWithPrefix;
feature:
feature == name || feature == depName || builtins.substring 0 len feature == prefix;
/*
Returns the expanded features for the given inputFeatures by applying the
@@ -9220,29 +9228,24 @@ rec {
assert (builtins.isAttrs featureMap);
assert (builtins.isList inputFeatures);
let
# Transitive closure of `inputFeatures` under `featureMap`. `seen`
# tracks already-expanded features so each is visited at most once;
# this also breaks feature cycles (issue #209).
expandFeaturesNoCycle =
oldSeen: inputFeatures:
if inputFeatures != [ ] then
let
# The feature we're currently expanding.
feature = builtins.head inputFeatures;
# All the features we've seen/expanded so far, including the one
# we're currently processing.
seen = oldSeen // {
${feature} = 1;
};
# Expand the feature but be careful to not re-introduce a feature
# that we've already seen: this can easily cause a cycle, see issue
# #209.
enables = builtins.filter (f: !(seen ? "${f}")) (featureMap."${feature}" or [ ]);
in
[ feature ] ++ (expandFeaturesNoCycle seen (builtins.tail inputFeatures ++ enables))
# No more features left, nothing to expand to.
else
[ ];
outFeatures = expandFeaturesNoCycle { } inputFeatures;
seen: features:
builtins.foldl'
(
acc: feature:
if acc ? ${feature} then
acc
else
expandFeaturesNoCycle (acc // { ${feature} = 1; }) (featureMap.${feature} or [ ])
)
seen
features;
seen = expandFeaturesNoCycle { } inputFeatures;
in
sortedUnique outFeatures;
sortedUnique (builtins.attrNames seen);
/*
This function adds optional dependencies as features if they are enabled
@@ -9301,16 +9304,19 @@ rec {
in
defaultOrNil ++ explicitFeatures ++ additionalDependencyFeatures;
# Builds a feature set (attrset `feature -> 1`) from a list of feature names.
listToSet =
features:
builtins.listToAttrs (map (feature: { name = feature; value = 1; }) features);
# Sorts and removes duplicates from a list of strings.
sortedUnique =
features:
assert (builtins.isList features);
assert (builtins.all builtins.isString features);
let
outFeaturesSet = lib.foldl (set: feature: set // { "${feature}" = 1; }) { } features;
outFeaturesUnique = builtins.attrNames outFeaturesSet;
in
builtins.sort (a: b: a < b) outFeaturesUnique;
# attrNames returns keys in ascending string order, so the result is
# sorted and deduplicated. The feature-merge fixpoint relies on this
# canonical order to detect convergence.
builtins.attrNames (listToSet features);
deprecationWarning =
message: value:
+4 -2
View File
@@ -40,10 +40,12 @@ improvise-command = { path = "crates/improvise-command" }
ratatui = "0.30"
crossterm = "0.29"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses JSON a user's numbers cross, so it must read back the f64 written: per crate,
# because each build stands alone (improvise-6yvg, context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
anyhow = "1"
thiserror = "2"
indexmap = { version = "2", features = ["serde"] }
indexmap = "2"
chrono = { version = "0.4", features = ["serde"] }
flate2 = "1"
unicode-width = "^0.2"
+3 -2
View File
@@ -169,8 +169,9 @@ Measure: column
format: ,.2f
```
Gzip-compressed `.improv.gz` is also supported. Legacy JSON is auto-detected
for backward compatibility.
Gzip-compressed `.improv.gz` is also supported. `.improv` text is the only
native document format: the old JSON document format is no longer read, and
opening one fails with an error that says so. JSON *import* is unaffected.
## Import and scripting
+6
View File
@@ -226,3 +226,9 @@ learns no coordinate space, and an invalid operation is unconstructible rather
than rejected. What the extension adds is that the enum is now **four** answers
to "what may be done here", so `None` finally means only what this ADR always
said it meant — no projected cell at all.
**A fifth kind (2026-09-22, improvise-exdg).** The records-mode coordinate cell
was the last `None` that named a visible cell. It is now `CellId::Coordinate`.
The variant carries no operand, because no client composes a coordinate rename.
The previous paragraph's "four answers" is therefore five. `None` now means
only a padded or blanked slot. Reasoning: `context/code/protocol.md` §3.
+61 -23
View File
@@ -98,7 +98,8 @@ The message states the **shape** rather than the arity, because
## 6 — the help's ex-commands were the unguarded surface
`help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` and
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` (an
ex-command only, since improvise-wk18: built by `ex!`) and
`HelpLine::Accent` rows are hand-written prose and had nothing checking them —
which is how all three documented spellings of `:formula` came to be ones the
parser refuses.
@@ -161,7 +162,7 @@ limitation that is not new: `ExecuteCommand` returns one effect batch, so the
segments of `:a . b` all see the pre-batch cursor, exactly as a
`Binding::Sequence` does (gotchas 21 and 23).
## 8 — the interactive arm is `parse`, then context; it is never a second parser
## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser
#151 fixed three instances of *a registry with two construction arms demotes
whichever caller reaches the weaker one*, one patch each. A post-merge review
@@ -175,31 +176,59 @@ and the key from the cursor. So a script that wrote 5 to `Region/East`
The shape rather than the instances is what changed. `CmdRegistry::register`
used to take two free functions, `parse(args)` and `interactive(args, ctx)`,
and nothing related them; each command re-derived its arguments twice and every
disagreement was silent. It now takes three pieces, and the interactive arm is
**built from `parse`**, not beside it:
disagreement was silent. The interactive arm is now **built from `parse`**, not
beside it:
- `parse(args) -> C` — the only function that reads arguments a caller supplied.
- `contextualize(C, &CmdContext) -> C` — fills the fields that are context and
never argument (a `Move`'s origin cursor, a `MovePanelCursor`'s bound). It is
handed the built command and **not the args**, so it cannot discard them.
- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused.
This is where "no coordinates means the cursor" lives: `clear-cell` bare, and
`commit-cell-edit <mode>` bare, which is what the keymap binds.
- For `register_at_cursor` only, the registry itself then sets the command's
**cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the
live one. That is the whole of what the interactive arm adds to a parsed
command, and no registration supplies it.
Two consequences hold by construction rather than by test. **Whatever `parse`
accepts, the interactive arm accepts** — `contextualize` is infallible — so the
§7 gate cannot pass a segment the loop then refuses. And **an accepted argument
list cannot be overridden by the cursor**, because the only function that sees
both args and context never runs on an accepted list. `register_pure` and
`register_nullary` already had one arm; they are the `contextualize = identity,
fallback = None` case.
Three consequences hold by construction rather than by test. **Whatever `parse`
accepts, the interactive arm accepts** — nothing after `parse` can fail — so the
§7 gate cannot pass a segment the loop then refuses. **An accepted argument list
cannot be overridden by the cursor**, because the only function that sees both
args and context never runs on an accepted list. And **no field `parse` set can
be overwritten from context**, because nothing a registration writes can name
one: the cursor slot is typed `&mut CursorState`, and no command parses a
`CursorState` out of its arguments. `register_pure` and `register_nullary`
already had one arm; they are `register` with no fallback.
That last one is improvise-yf7p, and it is why the registry lost a parameter.
#171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the
built command and not the args, and stated honestly that it could still
**overwrite** a parsed field — naming the field was "a visible line in review".
Review then injected five such overwrites on commands other than the one with a
test (yank, paste and drill-into-cell taking their key from the cursor over the
parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor
clamping its parsed delta to ±1) and the whole workspace stayed green. A hole
guarded by review discipline plus one command's test is not guarded. Every
contextualizer in the tree fell into one of two shapes, and each had an answer
that removes the function rather than checking it:
- **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one
`CursorState` field, always `CursorState::from_ctx`. So the registry does it,
through a slot whose *type* is the guard: an `AtCursor` impl pointing at a
`CellKey` or a delta is a compile error, which was tried.
- **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s
`currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s
cursor and bound). These copied context into the command only to read it back;
`execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus`
got below. Those three are `register`/`register_nullary` now, so their
interactive arm is exactly `parse`.
What `execute` does with a parsed field is still the command's own logic and is
tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and
`move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or
the delta is ignored there. The difference is that the registry is no longer a
second place such a bug can be written.
What is still representable, stated rather than hidden:
- `contextualize` can overwrite a field `parse` set. It would have to name the
field, which is a visible line in review, where the old `|_args, ctx|` was an
invisible omission. `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`
fails if `commit-cell-edit`'s does.
- A `fallback` sees the args `parse` refused and could ignore some of them.
`commit-cell-edit`'s refuses anything but a lone mode, so the malformed
`commit-cell-edit editing 5` is an error on the `:` line — which has no pure
@@ -207,9 +236,12 @@ What is still representable, stated rather than hidden:
- The gate is **stricter** than the loop wherever a fallback exists: a bare
`clear-cell` is refused in a script. That is the safe direction — the whole
line is refused — and it is unchanged from before #151.
- A new command could declare a *second* `CursorState` field and parse it from
args; `cursor_mut` could then point at the wrong one. No command has one, and
a parsed cursor would be a new idea deserving its own argument.
`toggle-panel-and-focus` did not fit `contextualize`, because its pure arm
defaulted the omitted flags to `true` and a contextualizer cannot tell an
`toggle-panel-and-focus` did not fit #171's `contextualize` either, because its
pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an
omitted flag from an explicit one. So the omission is now in the type —
`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is
resolved in `execute` against the same context. That is the honest home: the
@@ -281,9 +313,15 @@ still possible later, and would want the severity *on the message*; that is a
separate change with a separate staleness problem.
**The classification is per writer, and it is not total.** Every refusal in
`ui/effect.rs` and in `cmd/{commit,fit,grid,mode,search,text_buffer}.rs` goes
through `refuse`. Those still written with `set_status` exit 0 as before, and
are listed in improvise-i3o5. `q` on a dirty document is deliberately left a
`ui/effect.rs` and in `cmd/{cell,commit,fit,grid,mode,search,text_buffer}.rs`
goes through `refuse`. `EditOrDrill`'s collapsed-group arm and the three
spelled-paste declines in `cell.rs` (aggregate, coordinate column, inside a
drill) joined them with improvise-i3o5. Those still written with `set_status`
exit 0 as before, and are listed on that bead: three in `cmd/panel.rs`, and
`command/keymap.rs::dispatch`'s arg-error arm. The sweeps that pin refusals
revert `refuse(` calls, so they **cannot see a refusal written as
`set_status`**. Finding one takes a read of every `set_status` on a decline
path, and that is how these were found. `q` on a dirty document is deliberately left a
report: headless, its text is false — the CLI saves anyway (improvise-ep5o).
The two rivals udt2 named both stay green: a successful `:w` and a successful
`fit-measure` report through `set_status` and exit 0.
+146 -23
View File
@@ -950,7 +950,8 @@ gotcha 47 asks for — closing e24v has to come to that line and delete it.
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
`typed_cell_command`'s, the same one click-to-edit reaches through
`typed_target_frame`, so a browser keyboard
commit and a browser mouse commit cannot disagree about what `""` or `"42"`
means.
- **`cell_at_cursor` returns `None` when the selection is scrolled out of the
@@ -995,20 +996,39 @@ folded when the resolver runs — the trap that reads as "the cursor lands on
`Plan` and the document switches to `Default`", silent and wrong in the half a
user does not watch.
The second clause of rule 1 is what makes the formula panel look inconsistent
and is in fact the rule holding. `ModeKey::ViewPanel` binds `Enter` to
`switch-view-at-cursor`; `ModeKey::FormulaPanel` binds `a`/`n`/`o` and
`d`/`Delete` and **no `Enter` at all**. So a view-row click switches and a
formula-row click only navigates. Opening the editor on a formula click would
be an activation *invented for the pointer that the keyboard does not have* —
a second derivation of what a formula row means, which is the disease §5 names.
**The guard for this must be aimed at the keymap, not at the click**: asserting
that the click emits no `FormulaEdit` only restates the author's reading, and a
reviewer demonstrated the gap by adding `Enter -> enter-mode formula-edit` to
`FormulaPanel` and watching the whole workspace stay green while the click and
the keyboard disagreed. `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`
asserts the binding's absence and names `click_formula_row` as what must change
if it ever appears.
The second clause of rule 1 is why a formula-row click opens the editor. Until
improvise-km71, `ModeKey::FormulaPanel` bound `a`/`n`/`o` and `d`/`Delete` and
**no `Enter` at all**, so a formula-row click only navigated, and opening the
editor on a click would have been an activation *invented for the pointer that
the keyboard did not have*. km71 bound `Enter` (and `e`) to
`edit-formula-at-cursor`, so the rule now yields the editor on the clicked row,
and `click_formula_row` ends with `edit_formula_at(index)` exactly as
`click_view_row` ends with `switch_view_at(index)`. **The guard is aimed at what
`Enter` resolves to, not at the keymap and not at the click**:
`a_formula_row_click_ends_with_what_enter_does_on_that_row` resolves `Enter` at
cursor 1 and asserts the click on row 1, made with the cursor at 0, *ends with*
those commands. The earlier guard asserted only that `Enter` was unbound, which
tied the two by a comment; this one fails if either side changes without the
other, and it separates "reads the clicked index" from "reads the cursor",
because the two point at different rows. The superseded test was
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
Editing needs the client to name *which* formula, and `FormulaCache` used to
carry only the raw text, from which neither the target nor its category is
recoverable without reimplementing `parse_formula` on the client — the gap
`decision(improvise-e24v)` recorded for `delete-formula-at-cursor`. km71 closed
it the gotcha 47 way, by **enriching the projection**: `FormulaCache::formulas`
is `Vec<FormulaEntry { id: FormulaId, raw }>`. The id is opaque on purpose
(`context/code/core.md` §14). `delete-formula-at-cursor` is still declined in
the browser; it now needs only its resolver.
One divergence is pinned rather than closed: a rename. The host's
`ReplaceFormula` rewrites the status to say what was renamed and which formulas
still name the old target; the client cannot know the old target (the id is
opaque) and folds the optimistic "Formula updated".
`renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host` asserts
both sides of that, as `a_formula_the_client_cannot_parse_diverges_on_the_status_line`
does for a parse error.
**The browser assertion that PR #60 dropped stays dropped, and improvise-qf57
is the decision rather than the oversight.** `#grid input` `toHaveCount(0)` was
@@ -1025,6 +1045,9 @@ running the spec: `#grid input` `toHaveCount(0)` **passes**, and the run fails
on the `#mode` assertion below it. Restoring it would add an assertion that
cannot fail — the same defect this file already records one line down, where an
explanatory `assert!` was removed for never being able to be the failing one.
Since improvise-km71 the activation is real rather than invented, so the spec's
`#mode` assertion reads `"FORMULA"` and it also asserts that `#minibuffer` holds
the clicked formula's text; the argument about `#grid input` is unchanged.
**Rule 2 — a click is all of its intents or none of them** (improvise-hkr8).
Each `click_*` returns `Option<Vec<Command>>` and `click` does the single
@@ -1411,12 +1434,81 @@ than a check; or this client has not been told the document's basis and so has
nothing truthful to say about what it composed against (`BasisCache::claim`
withholds rather than invents).
A **fifth** `None` is not a failure: the text is the seed the editor captured
when it opened (improvise-ixxx). The seed is part of `EditTarget`, captured by
`edit_target_at` from the cache's `edit_text` beside the record and its
version, so the page passes no seed and cannot pass a wrong one.
`typed_target_frame` asks `resolve::untouched`, the one home beside `typed()`,
before anything else. The argument is in `protocol.md` §3, "An untouched editor
writes nothing".
**Nothing is folded there.** The new value comes back as a `GridUpdate::Cells`
from the host, which is the side that formats it; guessing it locally is how the
two ends drift apart.
**The page commits by identity, never by position (improvise-2wu3).** While an
inline editor is open, `repaint` returns early, so the screen is frozen. But
`on_frame` goes on folding, so the cache moves while the screen does not. The
page used to keep the editor's viewport `(row, col)` and resolve it against the
**current** cache at commit. If another client had hidden a row above, that
position now held a different record. `basis.claim` was satisfied too, because
the fresh `Projection::Basis` had been folded along with the rows. So the write
landed on a row the user never looked at, and it was reported accepted. The
basis was current *for the client*; the stale thing was the screen, and
nothing measures that.
So the JS surface is now two calls. `edit_target(row, col)` returns an
`EditTarget` as an opaque string, and the page takes it when the editor opens.
`typed_cell(target, text)` composes the write against it
(`typed_target_frame`). The position-taking composer is gone from the page's
surface, so the confusion cannot be written in the page. `typed_cell_frame(row,
col, …)` survives as Rust-only sugar over `edit_target_at` and
`typed_target_frame`, for tests that compose at the moment they read.
**The target carries the version the user saw, not only the identity
(improvise-0j0f).** The first version captured the `RecordCell` at open but
claimed the basis at commit. The fold keeps running while the editor is open,
so another client's write to *that same record* bumped the counter in the
client's `BasisCache`. The commit then claimed the fresh counter and was
accepted, and the other write was silently overwritten. If the other client
cleared the cell instead, the commit re-created it. An `EditTarget` is
`{ document_generation, cell, version, opened }`: the document and the record's
counter as the client had them when the editor opened, and the seed it opened
on (improvise-ixxx, above). `BasisCache::claim_as_read`
states both beside the *current* `layout_version`.
**The two document tiers are not alike, and only the layout stays current.**
The rows renumbering under the editor, which was 2wu3's case, moves
`layout_version`. Claiming that at open would refuse every such commit as
`LayoutMoved`, although the identity already makes the commit safe. The
generation is the opposite case (improvise-vndi). A `RecordHash` is derived
from coordinates, so after another client's `LoadModel` the same record can
resolve in the incoming document, and a fresh `DataStore`'s counter can equal
the one the editor read. Claiming the current generation then got the commit
**accepted**: the loaded document's value, which this user never saw, was
overwritten. Nothing composed against the outgoing document names anything in
the incoming one, which is `Rejection::DocumentReplaced`'s own contract, so the
generation is captured at open and the swap refuses the commit. So the result is:
- the document was replaced: `DocumentReplaced`;
- the record moved: `RecordMoved`;
- the record is gone: `RecordGone`;
- only the rows moved: the commit is accepted.
Clearing the cell under an open editor is `RecordMoved`, **not** `RecordGone`
(improvise-4img). The store no longer holds the record, but
`App::resolve_record` falls back to the layout, and the grid still draws the
row. So the record resolves, and its counter has moved. `RecordGone` needs a
record that neither the store nor the layout resolves.
**`EditTarget` is the one object an editor captures at open, and it is a
struct so it can grow.** Two fields have joined `cell` and `version` since:
the seed as `opened` (improvise-ixxx: a commit whose buffer equals the seed
writes nothing) and the generation (improvise-vndi). The page did not change
for either, because the target is opaque to it. `typed_target_frame` asks the
two in that order: an untouched editor writes nothing, so it is never refused.
`typed_cell_command` is the one composition shared by the mouse path
(`typed_cell_frame`) and the keyboard path (`keys::commit`). Writing it twice is
(`typed_target_frame`) and the keyboard path (`keys::commit`). Writing it twice is
how a browser commit by Enter and a browser commit by click would come to
disagree about what an empty string means.
@@ -1514,8 +1606,10 @@ Four smaller decisions that would be re-derived wrongly:
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
**above** it, so the prompt is neither covered nor displaced.
`RenderCell::edit_text` is the stored value, not the formatted one: seeding an
editor from `text` and committing turns `1200` into the string `"1,200"`. The
An editor is seeded from `edit_seed(target)`, the stored value the target
captured, never from `text`: seeding from the formatted text and committing
turns `1200` into the string `"1,200"`. `RenderCell` carries no `edit_text`
since improvise-ugj4, because a paint can be stale while an editor is open. The
`numeric`/`error` flags are the server's answers, echoed — the client must not
re-derive them from the text, which is how the two ends drift.
@@ -1558,14 +1652,17 @@ The browser half of improvise-cr3, kept as thin as it can be made, now talking
to either of two peers (improvise-djm). **The boundary is JSON strings in and
out, so this file holds no protocol knowledge at all**: it never inspects a
`Command`, never builds a `CellId`, never decides whether a cell is editable or
whether a typed string is a number. Five calls are the whole harness:
whether a typed string is a number. These calls are the whole harness:
| call | what it is |
|---|---|
| `client.set_viewport(rows, cols)` | the handshake frame, and every resize; also records the window so keys resolve here |
| `client.on_frame(json)` | fold one frame, in arrival order |
| `client.render_model()` | everything to paint, already decided |
| `client.typed_cell(r, c, s)` | the frame for "the user typed s here" |
| `client.edit_target(r, c)` | what an editor opened here edits (the record, the version seen, and the seed it opened on): an opaque string the page holds, or `null` |
| `client.edit_seed(target)` | the text the target's editor opens on: the seed it captured, never the paint (improvise-ugj4) |
| `client.edited_target(target)` | the same target marked edited, so retyping the seed still writes (improvise-jibp) |
| `client.typed_cell(target, s)` | the frame for "the user typed s into that cell", by identity (improvise-2wu3), or none if `s` is still the target's seed (improvise-ixxx) |
| `client.on_key_event(k,c,a,s,m)` | the frame for a keypress, folded locally first where this client may answer it |
**The two rules this file must not break are ordering and framing**: frames are
@@ -1700,6 +1797,31 @@ watchdog must be DISARMED and not merely pre-empted**: it fires later and would
overwrite the better message with "did not answer" (PR #13 review). The watchdog
is for silence only, and `setShellFailure` clears the timer.
**Success is claimed by the first frame, and only if nothing failed first**
(improvise-i0v9). The worker transport used to write `in-page worker · <doc>`
the moment it spawned the worker — 13ms after navigation, before anything had
answered — so a worker that could not start showed a success-looking status
over a blank page. It now writes `starting the worker · <doc>…`, and
`createShell().arm(message, { answer })` holds the claim until `sawFrame`. The
rule the claim obeys is **the watchdog's own state**: it is written only if the
watchdog is still armed when the first frame lands. That is not a convenience.
`worker.js` posts a `ReadOnly` (or `SeedMissing`) notice straight after
`start()` and **before** the Snapshot that answers the handshake, so an
unconditional "first frame ⇒ say we are up" would overwrite the precise reason
the notice just wrote. `fail` already disarms the watchdog, so "armed" answers
"has anything been said since arming" without a second flag to keep in step.
`fail` disarms **directly** rather than through `sawFrame`, or it would
briefly flash the success text on its way to the failure. The starting text
deliberately does not contain `in-page worker`, so the older specs asserting
that substring now need the first frame to pass. The socket path passes no
`answer`: `open` is already a real fact, and its status was never premature.
One consequence changes what the watchdog guards. In the read-only case a
Snapshot always follows the notice, and `sawFrame` disarms the watchdog either
way, so a notice that forgot to disarm would **not** be overwritten eight
seconds later. It is overwritten **at once**, by the claim. The eight-second
overwrite survives only where no frame follows: `BootFailed`.
**Did this socket ever open?** The page has to record that itself, because
nothing on the close event carries it: `readyState` is already CLOSED by the time
a close listener runs, whichever happened, and a refused connection and a
@@ -1747,9 +1869,10 @@ Neither arm leaves one armed.
- **`editable` is the server's answer relayed through Rust**: a stored cell. An
aggregate is a drill target and drill is not on the wire yet, so it is simply
not offered.
- The inline editor seeds from `RenderCell::edit_text` — the STORED value, not
the drawn one: `td.textContent` has been through the number format, so a stored
1200 reads `"1,200"` and committing that stores the *string* `"1,200"`. Enter
- The inline editor seeds from `client.edit_seed(target)` — the STORED value the
target captured, not the drawn one: `td.textContent` has been through the number
format, so a stored 1200 reads `"1,200"` and committing that stores the *string*
`"1,200"`. Enter
removes the focused `<input>`, which fires `blur`, which calls `close` again:
harmless, but only by accident.
- **The title bar is three fields and one joining.** `doc_name`, `doc_dirty` and
+549 -23
View File
@@ -553,6 +553,106 @@ status line.
one, and the limit is checked in exactly one place — `Model::add_category` — so
nothing in the effect restates it.
### A model effect that cannot do what it names refuses: `App::refuse`, `on_view` and `refuse_and_stop` (improvise-bok4, cz8d)
`AddCategory` was the only wire-reachable model effect that aborted when it
failed. The others did one of two things:
- **Narrated.** `AddItem`, `AddItemInGroup`, `AddFormula`, `LoadModel` and
`ImportJsonHeadless` wrote a sentence to the *host's* `status_msg` and
carried on.
- **Swallowed.** Every view-addressed effect (`SetAxis`, `SetPageSelection`,
`ToggleGroup`, `HideItem`, `ShowItem`, `TransposeAxes`, `CycleAxis`,
`SetNumberFormat`, `TogglePruneEmpty`) did nothing when `view_mut_of` found
no such view. `DeleteView` discarded "cannot delete the last view" with
`let _`, and answered `Ok` for a view the document did not have
(improvise-40j4). `AddFormula` stored a formula for a category that was gone,
through `let _ = add_item(…)` (improvise-40j4).
Either way the batch ran to its end, and `Host::mutating_frame` reported the
frame **accepted**. That is clause 4's partial-and-silent shape, one layer below
the record tier that `broken_chain` guards. The reviewer measured two cases.
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]` was accepted with
half of it a no-op. `[RemoveCategory("Region"), AddItem { category: "Region" }]`
was accepted too, and its reason went only to the host's own status line, so
the client was told nothing at all.
**One writer, and the abort is separate (improvise-cz8d).** improvise-c5ip
(PR #185) had meanwhile added `App::refuse`, which writes the reason, sets
`batch_refused` and does **not** abort. The combination is now:
- **`App::refuse` is the only writer** of a refusal. bok4's own `refused()`
helper, which aborted, is deleted.
- **`on_view(app, target, write)`** writes the named view, or calls
`App::refuse` naming the missing one.
- **`Host::mutating_frame` rejects on `last_batch_refused() ||
last_batch_aborted()`.** That is sound because each `ModelCommand` maps to a
one-effect batch, so either flag belongs to the mutation that raised it.
- **Aborting is an explicit extra step**, taken only where a later effect in a
key-path batch assumes the failed one happened. `refuse_and_stop` does it for
`LoadModel` and every `ImportJsonHeadless` arm, so `load x . q` and
`import-json x . q` do not quit having loaded nothing. `AddCategory` keeps its
own inline abort (gotcha 21). The item and view effects, and `AddFormula`'s
missing-category arm, only refuse: the frame is rolled back by the refusal
alone. On the key path, a failed `AddItem` or view edit no longer stops the
rest of its batch, which is what it did before bok4.
- **`AddFormula`'s parse-error arm also stops (improvise-v4hg).** It is the one
item-or-formula failure a key-path batch was found to depend on. Enter in
`FormulaEdit` is `[SetStatus, AddFormula, ChangeMode(FormulaPanel),
ClearBuffer("formula")]`, and the last two assume the commit worked. Without
the stop, `Total = (` was reported and then the editor closed and the typed
text was cleared. With it, the user stays in `FormulaEdit` with the text, and
the error is drawn beside the prompt. `fit-measure` emits `AddFormula` last, so
there is nothing after it to stop. The missing-category arm is reachable only
from a wire frame or a `:` line naming another category, and nothing in either
depends on it, so it does not stop. PR #181's review searched for an
`AddItem` case the same way and found none, so `AddItem` does not stop either.
An abort is added where a later effect depends on the failed one, never for
symmetry.
In a client's frame, any refusal becomes `Rejected(MutationRefused)`. The frame
is undone whole, and the client is told, which is the channel the narrated
reason never had.
**This takes the ticket's first option, scoped to the effects the wire can
reach.** Failure is *reported* by the effect that knows it failed. The second
option, growing `broken_chain` to ask whether every view, category and item
target still resolves, would have been a second description of which targets
each effect needs. That second copy would be free to disagree with the effect
itself, which is gotcha 24's reason for observing rather than predicting.
Consequences:
- **A failed load is refused**, where it used to be accepted with a host status
line. So `a_load_that_fails_is_not_a_rebase` now witnesses the failure by the
refusal, not by `status_msg`. The frame's rollback also rolls the host's
status line back.
- **Every refusal site has a test that fails when it is reverted** to a
status-only write (improvise-9fpl, improvise-0v8l). Two tables carry most of
them. `every_failing_model_command_refuses_its_whole_frame` covers the wire.
`every_refusal_site_refuses_its_failure_and_only_its_failure` covers the key
path and the `:` line, which is what the headless exit code reads. The wire
table cannot see the refusal half of a `refuse_and_stop` site, because
`Host::mutating_frame` rejects on `refused || aborted`, and the abort alone
suffices. So each `refuse_and_stop` site needs a key-path assertion for its
refusal as well as one for its abort. The abort is
`a_failed_load_or_import_does_not_run_the_quit_after_it` (improvise-514o).
Three sites are not in the key-path table (improvise-rtud). The quit test's
"setup:" assertion holds the refusal of `load` and `import-json absent.json`.
`a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text`
holds `AddFormula`'s parse arm, which has no case in the quit test. The other
five import arms are in
`every_refusal_site_refuses_its_failure_and_only_its_failure`.
Each case in that table has a succeeding twin, so a site that refused
unconditionally would fail too. That matters because these are exactly the
lines a merge conflict could silently drop.
- **Not covered:** a no-op *inside* a view that does resolve. Examples are
`SetAxis` on a category the view does not have, `HideItem` of an absent
item, and `RemoveItem`/`RemoveCategory`/`RemoveFormula` of something already
gone. The removals are idempotent by intent. The others are the remaining
instances of this class, and each needs its own `View` method to say whether
it changed anything.
### `AddFormula` and non-`_Measure` targets
For non-`_Measure` targets the item is added to the category so it appears in the
@@ -721,12 +821,45 @@ is gone rather than joined by a second one. A guard spelled here as
copy of `switch_to_view`'s own condition, free to disagree with it the day that
condition grows a clause.
Not fixed, and reachable only by construction: `ViewBack` and `ViewForward` call
`switch_to_view` just as unguardedly, pushing the current view onto the opposite
stack before the switch they cannot be sure of. They are safe **because
`ViewState::forget_view` purges both stacks when a view is deleted**, so a popped
frame always names a live view — a guard living one level away from the hazard,
which is the shape that keeps returning here.
`ViewBack` and `ViewForward` have the same shape now (improvise-8nd9). They used to
push the current view onto the opposite stack **before** a switch they could not be
sure of, and were safe only because `ViewState::forget_view` purges both stacks when
a view is deleted — a guard living one level away from the hazard. improvise-lrrx
proved the guard incomplete: `App::install_workbook` (`LoadModel`,
`AcceptWizardModel`) cleared `cursors` and not the history, so `<` after a load
walked the **outgoing** document's frames, landing on a view of the same name in the
incoming one that this client had never visited, or on a refused switch that still
left a phantom frame behind. Both halves are fixed, so neither relies on the other:
`install_workbook` clears both stacks beside `cursors`, for the same reason, and the
two steps go through `stepped_to`, which switches, observes whether the document
**accepted** the view (`App::switch_to_view` now returns that), and only then
restores the frame's mode and hands back the frame just left. A frame the document
refuses is consumed and leaves nothing.
The observation is *accepted*, not *moved*, and that is the one place the sibling
shapes differ. `SwitchView` asks "did `active_view` change" because asking for the
view you are on is not a move. A history frame is a **place**, meaning a view and a
mode, so a live frame naming the view you are on still restores its mode. Such
frames exist: `forget_view` collapses `[A, B, A]` to `[A, A]`. The client's
`reduce_view::stepped` also steps onto them unconditionally. A "moved" test
would therefore drop a frame the client keeps, and the host and the client
would disagree. The first version of this fix made exactly that mistake.
`colon_view_back_respects_whether_history_exists` caught it, because its
fixture is such a frame. `reduce_view`'s `stepped` is unchanged, because the
client holds no `Workbook` to observe against. It is safe only while no client
stack holds a frame the document would refuse, which rests on three paths:
`ForgetView` purges a deleted view from every mirror, a load arrives as a fresh
`Snapshot`, and that snapshot's history starts **empty**. The third was false
until improvise-lxg0. `project.rs::fresh_view` used to start from
`ViewState::default()` and fold `SwitchView(active)`, which recorded a visit to
the literal `Default`. So every connected or rebased client carried a phantom
`{Default, Normal}` back-frame, whether or not the document had such a view.
It now builds the slice pointed at the document's active view and folds
nothing, as `App::with_storage` does. The guard is still one level away from
the hazard. A new path that seeds or replaces a client's history without
emptying it reopens the divergence, and nothing but
`a_fresh_session_has_no_history_to_step_back_through` and its rebase twin
would notice.
### `ReturnToNormalUnlessMoved` — observation, not prediction (improvise-toc)
@@ -835,6 +968,52 @@ There is deliberately **no `dirty = true`** in it: it declares
of `MarkDirty` — harmless, and a second home for the one thing that commit set
out to give exactly one.
### `RecordRewrite` — a row's edits composed, not replayed (improvise-uo2p)
A staged drill edit is a statement about the record **as the snapshot showed
it**. It is not a statement about the model as the previous edit left it. The
edits in one row do not commute when they are replayed one at a time. A rename
moves every cell in the row (gotcha 1). A measure edit replayed after it writes
the old key, which leaves a ghost record there and a stale value in the moved
record. A second rename replayed after the first finds nothing at the old keys,
so it is dropped. `ApplyAndClearDrill` replayed them in `HashMap` order, so the
outcome was different from one App to the next. That is gotcha 18's class of
bug, found in an effect rather than an index.
**Sorting is not the fix.** "Measures before renames" repairs the ticket's own
case and still drops a second rename every time (rival 1 in the evidence). Any
fixed order makes the corruption reproducible instead of removing it. So the
rule is **build each row's final record once, then write it**:
- `RecordRewrite::of(model, row, edits)` collects the row's renames and measure
edits into maps keyed by column, and reads each unedited value off the model
**before anything is written**. It then states the rewrite: clear every old
key, and write every cell at the key with *all* renames applied. A measure
edit that is staged replaces the carried value. The result does not depend on
the order of the edits. A drill stages at most one edit per `(row, column)`,
so no two edits in a row name the same column. A blank rename is refused, and
the row's other edits still land at the unrenamed coordinates, as before.
- `RecordRewrite::merged` concatenates the rows' rewrites **by phase**: every
refusal, then every item added, then every clear, then every write. The clears
come first so that a swap is a swap. Rows are taken in snapshot row order
(`BTreeMap`), so where two final records land on one cell, **the later row
wins**. That is the answer plain records mode gives for the same renames made
top to bottom.
The collision rule is a **choice**, and it still loses the earlier row's values
silently. Plain records mode loses them the same way when a rename lands on an
existing record. Refusing the colliding rows instead would be a change to both
paths.
**One copy.** The plain records path (`commit.rs::commit_plain_records_edit`)
calls the same `RecordRewrite::of` with its one edit, so `rename_record_coordinate`
and the drill's `apply_coordinate_rename`/`apply_measure_edit` are gone. This is
the consolidation improvise-fdk1 asked for, "regardless" of its wire half. For a
single edit the output is the effects the plain path used to emit, reordered from
`clear, set` per cell to all clears then all sets, which touches distinct keys.
One behaviour moved on the drill side: it used to discard `add_item`'s error, and
now goes through `effect::AddItem`, which refuses when the category has gone.
### `Save` / `SaveAs` abort the batch (improvise-8tk / improvise-lae)
A failed save aborts the rest of the batch, so the `Quit` that `wq`/`ZZ` chains
@@ -1112,7 +1291,9 @@ emit `RebuildLayout`) first (gotcha 23).
`display_value_of` is the value an editor opened on `key` starts with —
unformatted, and so **not** `CellDisplay::text` (design-principles §4: display
rounding is view-only and must not feed back into the model). Crate-visible so
rounding is view-only and must not feed back into the model). It is also **not**
`CellValue`'s `Display`, which rounds to four places: both arms spell the value
with `CellValue::edit_text` (improvise-umi4, `core.md` §17). Crate-visible so
`project::cell_display` can put it on the wire as `CellDisplay::edit_text`; a
browser that un-formatted the drawn text instead would be a second derivation, and
a lossy one.
@@ -2099,9 +2280,31 @@ would be sent and every client would stay stale *permanently* rather than until
next scroll: a partial fix strictly worse than none. Broadcasting only would leave
the server projecting each client a window it is no longer looking at.
The audience is `Broadcast::ToAll`, and the asker is not an exception. Its mirror
was reset here like everyone else's, and it folded nothing optimistically, because
a `Command::Model` is not something a client can apply to itself.
The audience is every session on a reframed view, each addressed with
`Broadcast::To` (improvise-nynk), and the asker is not an exception: a transpose
invalidates its own position too. What an earlier version of this paragraph got
wrong is the claim that the asker "folded nothing optimistically". A
`Command::Model` is not something a client can apply to itself, but the **rest of
its frame** is, and a browser frame routinely places the cursor *after* the
mutation. The `+cat` commit is `[ChangeMode, AddCategory, SetSelected]`. The
mirror folds that in order: reset, then placed, which is exactly the order the TUI
applies the same batch in. The client folded all of it **before** hearing the
reset, so the relayed `ResetCursor` arrived last and threw the placement away.
The client sat at the origin while its mirror, and every projection built from
it, sat at the placement (improvise-wdmp).
So the host answers the asker in the order the mirror saw. Once the asker has been
told `ResetCursor`, `Host::mutating_frame` relays back to it every **later**
cursor-placing command of the same frame (`SetSelected`, `SetRowOffset`,
`SetColOffset`), ahead of that command's projections. All three are absolute, so
folding them twice is idempotent. Excluding the asker from the reset, which the
ticket proposed, was rejected. For a frame that places nothing after the reframe
it is gotcha 24's own "strictly worse than nothing": the mirror is reset and the
client never hears it. `a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`
runs both shapes. One residue is stated rather than handled: a frame that
reframes and then *switches views* before placing would reset the view the
client ends on rather than the one it reframed. No resolver composes that frame
today.
What is *not* broadcast is a position: another client's transpose must not move
where I am looking — that is UI and it is mine (improvise-g0f). It may only declare
@@ -2492,12 +2695,12 @@ available for the rare command that needs to enumerate all views, such as the vi
panel.
The interactive arm (keymap dispatch, scripts, the `:` line) is built **from**
`parse`: `register` takes `parse`, a `contextualize` that fills context-only
fields of the command `parse` built without seeing the args, and an optional
`fallback` reached only for argument lists `parse` refused. So an accepted
argument list is never overridden by context (improvise-2bac; the argument is
`context/code/cli.md` §8). `register_pure` is the case with nothing to
contextualize and no fallback: both arms are `parse`.
`parse`: `register` takes `parse` and an optional `fallback` reached only for
argument lists `parse` refused, and `register_at_cursor` additionally has the
registry set the command's `CursorState` to the live cursor. No registration
writes any other field, so an accepted argument list is never overridden by
context (improvise-2bac, improvise-yf7p; the argument is `context/code/cli.md`
§8). `register_pure` is the case with no fallback: both arms are `parse`.
`write_buffer` is the effect that writes a named buffer — the mirror of
`read_buffer`, and the only place that knows "search" is not an entry in the buffers
@@ -2527,8 +2730,10 @@ At bottom-right `EnterAdvance` has no place to go, so it emits a single `AbortCh
stages in drill pending edits while in drill mode, or applies directly in plain
records mode; for real keys it writes to the model.
`rename_record_coordinate` renames one coordinate of a records row. In wide format a
row is a group of cells — one per measure — so the rename has to move **every** one
`commit_plain_records_edit` hands a coordinate or measure edit to
`effect::RecordRewrite::of`, the one home it shares with the drill (improvise-uo2p;
see `RecordRewrite` above). It replaced `rename_record_coordinate`. In wide format a
row is a group of cells — one per measure — so a rename has to move **every** one
of them, not just the cell under the cursor (improvise-c5v, gotcha 1). Every cell in
the row moves, and a row seeded by `add-record-row` has no measure cells yet: its
coordinates live on a lone measure-less cell, which must move too or the row loses
@@ -2772,6 +2977,43 @@ flag: tidy-up goes where an abort cannot reach it.
stays in `TileSelect` mode so the user can adjust several tiles. `axis: None` cycles;
`axis: Some(a)` sets.
**The tile bar offers `_Index` and `_Dim`, and that is deliberate (improvise-jgzw).**
Every tile call site passes `Model::category_names()`, virtual categories included,
so tile cursor 0 is `_Index`. The ticket asked whether that was a leak. It is not,
for three reasons, each checked rather than argued:
- **It is a decision the user already made.** improvise-kos: "`_Dim` and `_Index`
should work as pivot axes outside Records/Drill view". A category that works as a
pivot axis is one the axis picker offers.
- **The tile bar is the only way out of a records layout that was saved.**
improvise-jgzw's "check what it does" found that hand-assigning `_Index` → Row and
`_Dim` → Column in a real view (tile bar or `:set-axis`) is **written** to the
`.improv`, because a real view persists every axis, and it reopens as records mode.
(`VIRTUAL_VIEW_NAMES`' "never-persisted `_Index`/`_Dim` on axes" is true only of
`_Records`/`_Drill`.) In that document, `R` (`toggle-records-mode`) emits
`ViewBack` on an empty back stack, so it leaves the layout as it was and sets the
status to "Pivot mode". That is filed as improvise-gj5p. Moving the two axes back through
the tile bar is what gets out. In the browser, which declines
`toggle-records-mode`, the tile bar is also the only way in, and improvise-bd9o
already made that route safe instead of closing it
(`the_tile_bar_route_into_records_mode_does_not_strand_the_user`).
- **What filtering would cost was measured.** With `View::tileable_cats` filtering
on `CategoryKind::is_index_or_dim`, that bd9o test and the TUI's
`renders_each_tile_with_its_axis_label` fail, as well as the two characterization
tests here.
What the default cursor does cost, stated: one cycle key on tile 0 puts `_Index`
on Row alone, and in a pivot view that empties the grid (0 rows on
`web/demo.improv`). The status line says `_Index → Row`, and three more presses
undo it. If that turns out to matter, the fix is the order the tile bar lists
categories in, not taking entries out of it.
**`_Measure` must stay tileable.** It is on an axis in every shipped document, so
`regular_category_names()`, the advice for *prompts*, is the wrong filter here.
`the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included`
names the whole offered list with `_Measure` on a column, and fails if either
decision changes.
### `cmd/effect_cmds.rs`
The parseable model-mutation commands are thin `Cmd` wrappers around effects,
@@ -3048,8 +3290,8 @@ the cursor**. The result was silent corruption of an unrelated record: appending
a row changes `view_frame`'s hash, the frame observation inside `apply_effects`
invalidates the cursor to the origin *mid-batch* (gotcha 24), and
`EnterEditAtCursor` then pre-fills the editor with row 0's category coordinate.
Enter commits through `rename_record_coordinate`, which by gotcha 1 moves every
measure cell of that row.
Enter commits through `rename_record_coordinate` (since improvise-uo2p,
`RecordRewrite::of`), which by gotcha 1 moves every measure cell of that row.
Two things are worth carrying past this fix.
@@ -3120,6 +3362,290 @@ so `paste Region=West _Measure=Revenue Product=Gadget` from a script still
writes where the current view happens to aggregate `Product` away: a key that
names every coordinate is a record whatever the view shows. For the cursor's
key the two questions agree, because a cursor key never carries a None-axis
category. The **collapsed-group fold** half of improvise-l4mp is not covered:
its key carries the group's name as an ordinary coordinate, so no question about
the key alone can see it — it needs `GridLayout::folds_a_group`.
category. The **collapsed-group fold** half of improvise-l4mp cannot be seen by
any question about the key alone, because the key carries the group's name as an
ordinary coordinate. It is answered at the cursor instead, by the next section.
**The first version of that section wrote a false sentence here**: that the
checks in `paste_into` were reached "only by a key a script spells out". The
cursor path handed `PasteCell` the resolved record key, and `paste_into` ran
`aggregates` over it **again**. That is a second derivation beside `cell_id`, and
it reads a record lacking a later category (every record, once `+cat` adds one)
as an aggregate, so `p` refused where gh/main had pasted and the browser still
wrote (improvise-q9eg). The distinction is now a type: `PasteAt::Resolved`
carries a key the door answered and is written as it stands, and
`PasteAt::Spelled` carries a key a script typed, which only its shape can vet.
Nothing is asked twice.
## The cursor's cell has three doors (improvise-gw18, lx7w, 54g1, yk2s)
**The lesson of improvise-gw18 is that guarding one command did not close the
path.** PR #176 taught `EditOrDrill` to refuse a collapsed group's fold, and `>`
still drilled into it. The drill was empty, and `o` inside it wrote
`Month=Q1, _Measure=Revenue`, a coordinate that is not an item, into `## Data`.
The same shape turned up three more times, each in a command nobody had
listed. `p` wrote onto the fold (the fold half of improvise-l4mp). `x` in records
mode cleared the synthetic `(_Index, _Dim)` key, a silent no-op, while the
browser cleared the real record (improvise-lx7w). `p` onto a formula's cell
stored a value under the formula that survived in `## Data`, while the browser
declined (improvise-54g1). Each fix was one more guard in one more command. So
the answer is the **accessor**, not the commands: a command gets the cursor's
cell through a door that matches what it does with it.
Every command that takes the cursor's cell, and its door:
| command | door | on a fold | on a formula's cell | in records mode |
|---|---|---|---|---|
| `yank` | `cell_key` (reads) | yanks the fold's value | yanks the result | `YankCell` resolves the synthetic key |
| `paste`, `clear-cell` | `value_cell_key` | refused | refused | resolved to the real record; a coordinate or creating column and a drill are refused |
| `commit-cell-edit`, `commit-and-advance-right` | `committable_cell_key` | refused | refused | the synthetic key, which `commit_cell_value` resolves itself (gotcha 19) |
| `drill-into-cell` | `unfolded_cell_key` | refused | drills | unchanged |
| `edit-or-drill` | its own check, `folds_a_group` then `owning_formula` | refused | refused | unchanged |
| `hide-selected-row-item` (`H`) | `GridLayout::row_folds_a_group` | refused | n/a (hides a row item) | n/a |
| `add-record-row` (`o`) | none, it takes no cell | n/a | n/a | closed by the drill door: no drill of a fold exists to add a row in |
**`value_cell_key` asks the browser's own question.** It calls
`project::cell_id`, the function that fills `CellDisplay::id` for the browser
(ADR 002), and writes only where the answer is `CellId::Record`. The browser's
`record_cell_at_cursor` is that same match on the projected id. The two ends
therefore agree because they read one answer, not because two derivations
happen to match. `tests/lockstep.rs` holds them together for all three tickets.
`cell_id` took `&App` only to reach the model, so it now takes `&Model`. That is
what let `CmdContext` ask it.
**Commit has a door of its own**, and not `value_cell_key`, because commit is
the one writer that must see the synthetic key: renaming a coordinate column,
creating a column and staging a drill edit all resolve the records position
inside `commit_cell_value`. Its door refuses the two arms `i` refuses, an
aggregate (a fold included) and a formula's result. It needed one because
`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opens it at the
new cursor with no guard. `i` declines to edit `Profit`, yet `Tab` from `Cost`
landed the editor on it, and the next commit stored a value under the formula.
That is a fourth door nobody had listed. **The entry half is still open**
(improvise-enz6): the editor still opens on a fold or a formula's cell. Only the
write is refused, out loud, at commit. Closing the entry belongs in `ui/effect.rs`.
**Drill keeps the narrowest door** because drilling into an aggregate is its
purpose. Only a fold is refused, since no record carries a group's name. The
ticket's alternative, expanding the fold into its members' records, would be the
more useful drill and is not built.
**A text-path key is not covered.** `paste Month=Q1 _Measure=Revenue` typed as
coordinates still reaches `PasteCell` with a group name. The doors are about the
**cursor**, and a key a user spells out is a claim about the document that no
layout checked. The same is true of `set-cell`.
## A command is a function of its context — the determinism law (improvise-hzjo)
`cmd/determinism.rs` holds the law the old `law_toggle_group_involution` claimed
and could not state. For **every** registered command and every argument list
that builds it, the effects `execute` returns must be equal, by `Debug` text,
across contexts built **independently from equal data**: four `App`s, each
parsed from `web/demo.improv` and given the same buffers, expanded categories,
yank, panels and cursor.
**Why the instances must be distinct.** The old test called `execute` twice
against one `CmdContext`. Every `HashMap` it reached was the same instance with
the same `RandomState`, so it iterated the same way both times: `f(x) == f(x)`,
which nothing can fail. Separately built `App`s give every map its own seed. So a
command whose effects follow hash iteration order disagrees with itself. Hash
order is the one kind of nondeterminism this repo has actually shipped (gotcha
18). This law can see it where a command's `execute` builds it, as the drill
snapshot's record order was. It cannot see the other shipped instance, the
analyzer's `HashSet`: `analyze_records` is reached only from inside
`Effect::apply`, which is outside this law (see below). The companion test
`only_distinct_instances_can_see_a_command_that_follows_hash_order` keeps that
property checked: a test-only command that emits `ctx.buffers` in iteration
order must agree with itself on one context and disagree across two.
**Four instances, not two.** A rival that takes *the first* entry of a map, not
all of them, agrees across two seeds whenever both happen to put the same entry
first. With 21 buffers that is roughly one chance in 21, and three comparisons
make it negligible. The fixture's values are also chosen to stay distinct under
the edits commands make. A first version used `v0`…`v15`, which `pop()` collapses
to `v`, and so it let `command-mode-backspace` reading an arbitrary buffer pass.
**The store needs several records to order, so the drills come from the
document (improvise-e1sm).** The registry's `candidate_args` are extended with
names the demo really has, because coordinates that match nothing never reach
the records a drill or a fit would order. A hand-picked name is not enough. The
first version's only real drill was `Product/Latte, Quarter/Q2`: one record, two
cells. With the store's sort removed, two contexts put those two cells in the
same order about half the time, and with three comparisons the law passed about
one run in eight (measured: 46 of 400). So the arguments now include **every
item of every regular category as a one-coordinate drill**, read off the parsed
document. A one-coordinate drill spans every other category whole, so it matches
the whole slice of records under that item, not one record picked by name. In
the demo that is four records and eight cells for each of the eight drills. With
the store's sort removed, the law fails 400 of 400 runs.
**The width guard measures what the comparison compared (improvise-b9f2,
improvise-btyw).** The test asserts that some `drill-into-cell` the comparison
actually built and compared froze at least four records. For each one it applies
those very effects to one scratch `App` and counts the records in its
`drill_state`. It counts them only when their `Debug` text, which carries their
order, appears in the text the comparison compared. The comparison reads `Debug`
while the scratch `App` reads the applied state, and those are two
representations. The review showed they can disagree: give `StartDrill` a
`Debug` that prints only a row count, and the frozen records are still four
while the compared text no longer holds their order. With the store's sort
removed the law then passed 300 of 300 runs, with the guard green. Requiring
the ordered records in the compared text ties the guard to what the comparison
could actually see. A first
version measured what the *argument lists* match in the store instead. That was
a parallel derivation, and the review beat it with a `drill-into-cell` parser
that refuses fewer than two arguments. Every one-coordinate drill then went to
the one-record cursor fallback, the argument lists still matched four records,
and with the store's sort removed the law passed 38 of 300 runs.
Counting frozen records is not enough by itself. A drill over no coordinates
(any candidate with no `Cat/Item` argument, such as `["1", "1"]`) freezes
**every** record, and it reads them through `iter_cells` in insertion order,
never through the narrowing index where gotcha 18 lives. Under the parser rival
that whole-store drill still froze 16 records, and a guard counting it stayed
silent. So only a drill that **narrowed** the store counts: one that froze fewer
records than the store holds. With the parser rival in place, the guard fires on
every run ("froze 1 records"), and it fires on every run under the `Debug` rival
too ("froze 0 records").
What the guard still cannot see is a narrowing drill whose records are frozen in
an order that does not come from the store. It checks that the law had a wide
drill to compare, not why the drill's order would differ.
**What it cannot see.** Only commands and arguments the fixture reaches. A
command whose nondeterminism needs some other state, such as records mode, a
wizard or a drill in progress, is outside this fixture. And `Debug` equality is
text equality: two effects that differ only in something their `Debug` omits are
equal here.
**Its scope is what `execute` returns, not what `Effect::apply` does.** The law
compares the effects a command hands back. Many effects are unit structs that
decide all their work inside `apply`: `ApplyAndClearDrill`,
`SortData`, `CleanEmptyRecords`, `Save`, `RebuildLayout`, `AcceptWizardModel`.
Hash-order iteration inside an `apply` is therefore invisible here, however
often the law runs. improvise-uo2p is the worked example.
`ApplyAndClearDrill::apply` walks `drill.pending_edits`, a `HashMap`, in hash
order. A measure edit and a category rename staged on one row can then write a
ghost record. The command that emits it passes this law, because the effect's
`Debug` is just its name. So "the one kind of nondeterminism this repo has
shipped" is guarded here only on the `execute` side. The `apply` side needs its
own test per effect, or a law over `App` state after `apply_effects`.
**The exemption list.** `OUTSIDE_THE_CONTEXT` names commands that legitimately
depend on something outside the context, such as a clock or an entropy source,
each with its reason. It is empty: `fit-measure`'s seed is an argument with a
fixed default, not a clock read. The list is checked in both directions. An
unlisted command that diverges fails the test, and so does a listed one that
does not diverge. A stale entry therefore cannot linger, and a new registration
is covered the day it lands.
## Editing a formula: the capture is on the mode, read in `execute` (improvise-km71)
`edit-formula-at-cursor` (Enter or `e` in the formula panel) opens
`AppMode::FormulaEdit` with `slot: FormulaSlot::Existing { id, was }` and seeds
the `formula` buffer with the formula's `raw`. That slot is the capture: which
formula, and what it read when the editor opened. Nothing at commit time looks
at the cursor again.
**Where `commit-buffer formula` reads the slot matters, and the registry is the
wrong place.** The obvious move was to resolve it in `parse_commit_kind`, the
way `Item` takes its category from `AppMode::ItemAdd`. That does not work, and
it fails silently: the interactive arm is built **from `parse`**
(`context/code/cli.md` §8), and `parse` accepts `formula` under
`AppMode::Normal`, so the mode-reading fallback is never reached. `Item` only
works that way because `parse` *refuses* `item`. The first build of km71 did
exactly this, and every edit became an add. `add_formula` upserts by target, so
an edit that kept its target still landed in place, and only the rename tests
and the concurrency tests showed it. Making the interactive arm read the mode
first would break improvise-yf7p's guarantee that an accepted argument list is
never overridden by context. So `CommitBuffer::execute` asks
`CommitKind::in_mode(ctx.mode)`. That is a read of context in `execute`, which
is the answer yf7p gave to `toggle-panel-visibility`. A script line stays an
add, and cannot name an edit, because the slot does not exist outside the
editor.
**`effect::ReplaceFormula` refuses with `refuse_and_stop`, not `refuse`.** The
Enter binding is `[commit-buffer formula, clear-buffer formula]`, and
`CommitBuffer` puts the change back to `FormulaPanel` *after* the mutation. A
refusal that stops the batch therefore leaves the editor open with the user's
text and the reason in the minibuffer line (`draw_bottom_bar` draws the status
beside the minibuffer). That covers all four refusals: the formula is gone, it
was rewritten since the editor opened (`was`), the new text does not parse, or
the new target belongs to another formula, or the rename is of a formula
outside `_Measure` (improvise-kb1d, below). `AddFormula`'s parse error stops the
batch the same way since improvise-v4hg (#213), so a bad *new* formula also
leaves the editor open with its text; adding and editing now agree.
**A target change outside `_Measure` is refused (improvise-kb1d).** A regular
category's formula target is a real item of that category, not a derived one.
`replace_formula` added the new target item and left the old one, so renaming
`Total = East + West [Region]` to `Sum` left `Total` as an empty item and
appended `Sum` at the end. Renaming the item in place was the alternative and
was not taken: an item's name is its identity in cells, views, hidden lists and
groups, so that rename is the whole of improvise-sboz, not a small fix. Editing
such a formula without changing its target still works.
On success the command's optimistic "Formula updated" stands, except on a
rename, where the effect rewrites the status to name the old and new targets
and any formula that still mentions the old one (`context/code/core.md` §18).
## A wire write reaches only a cell the host projects as a Record (improvise-ciw8)
`App::resolve_record` answers "which record does this surrogate name". Its
fallback, `record_in_layout`, accepts any key the current layout names, and that
includes a collapsed group's fold and a key the None axis aggregates over. It
has to stay that permissive: it is also the basis check's question, and a record
nobody has stored yet (the empty cell a client is about to fill) is found only
there. But resolving is not the same as being writable. A hand-rolled frame
`SetCell { record: RecordHash::of({Month=Q2}), .. }` was answered `Accepted` and
stored a cell at `Month=Q2`, while the host's own projection called that
position `CellId::Aggregate`. A write under a formula (`Derived`) was accepted
the same way.
So `model_effects` asks `App::unwritable` before a wire `SetCell` or `ClearCell`
builds its effect, and refuses through `effect::refuse`. That is the #181 path:
the refusal marks the batch, `Host::mutating_frame` undoes the frame, the asker
gets `Rejected(MutationRefused)`, and the reason is on the host's status line.
The question it asks is the projection's own, never a second derivation of it:
- A coordinate that names a **group** rather than an item is refused, in any
view (improvise-b114). A group has no cell of its own, so writing one stores a
non-item coordinate. This rule asks the document, not the layout, and runs
first, because a fold in the measure half hides from both rules below. Take
`{Month=Feb, _Measure=Money}`, where `Money` groups Revenue and Cost. Its
record `{Month=Feb}` is stored, so the second rule below accepts it and never
asks the projection. And with `Money` expanded the host draws no such cell at
all, so the third rule has no position to ask. A fold on the row axis is
refused here too, so the `Aggregate` arm below now guards only the None-axis
aggregate. It also closes improvise-xur5: a fold's record `{Month=Q2}` written
with a measure the host draws nowhere (hidden, unknown or absent) has no
position for the third rule to ask, and was accepted.
- A cell a formula owns is refused. That is `cell_id`'s own `Derived` rule,
`formula_for(&key)`. It is asked directly because a stored record may sit on
no position in the host's current view.
- A record found **in the store** is a record, whatever view anyone is on.
`RecordHash` is document-scoped (gotcha 44).
- A record found **only through the layout** is asked of `project::cell_id` at
the position of **its cell**: the one whose key is the whole key the write
names, record and measure together. `Aggregate`, a fold or a sum over a
None-axis category, is refused. It must be the whole key, not the first
position carrying the record (improvise-sjdb). `RecordHash` drops `_Measure`,
so every column of a row carries the same record, and with a collapsed
`_Measure` group the first of them can be the fold. That refused the empty
`Units` cell beside a `Money` fold, which the host itself projects as a
`Record`. A write whose whole key the host's grid does not draw is not
refused here, as it was not before this rule existed.
**Why the `vec![]` arm stays.** A reviewer asked whether a `SetCell` whose cell
does not resolve at all, which `model_effects` turns into `vec![]` and so
applies nothing, should also be a `Rejection`. Through a `Host`, it already is,
before that arm is reached. A lone write naming a record that never existed gets
`RecordGone` up front, whether or not the basis claims it, because the create
exemption covers only a *later* command in a frame
(`a_wire_write_to_a_record_that_never_existed_is_rejected`). A later write whose
record nothing in the frame made resolvable gets `ChainBroken` from
`broken_chain`
(`a_frame_whose_write_would_land_nowhere_is_refused_rather_than_dropped`). So
the arm is unreachable from the wire. Making it a refusal would add a second
answer to "does this resolve" beside `broken_chain`, and that answer could only
ever fire after the first had already refused. It is left as the mutation
path's own behaviour for callers that do not go through the session door.
+160 -14
View File
@@ -869,11 +869,14 @@ an involution without anything being taught about involution, and neither
### The cell
`Model::evaluate_aggregated` expands, at its head, any coordinate that names a
group rather than an item into one key per member (a cross product where several
coordinates do). Doing it in the **model** is what makes `display_text`, prune,
column totals, `grid_of`, search, yank and CSV export all follow with no change.
Three decisions:
`Model::evaluate_covering` expands, at its head, any coordinate that names a group
rather than an item into one key per covered member (a cross product where
several coordinates do). The grid reads every pivot key through
`GridLayout::evaluate`, which passes the members it draws. Since
improvise-eccl/f2oo, display text, pruning, column totals, `grid_of`, search,
yank and CSV export all go through that one evaluation, rather than each calling
`Model::evaluate_aggregated`. That remains the view-less answer, and it covers
every member. Five decisions:
- **Membership is read off each item's own `group` field, never off
`Category::groups`.** `add_item_in_group` registers no `Group`; that list is
@@ -882,14 +885,67 @@ Three decisions:
- **An item with the same name as the group wins.** `items.contains_key`
short-circuits the expansion, so an existing document with an item `Q1` reads
exactly as before.
- **Stored values fold once over every record the group covers; a derived value
folds its members' results.** Folding the members' *aggregates* is right for
`Sum` and wrong for `Avg`/`Count` under `Axis::None` — `avg(avg(2,4), 9) = 6`
where the group's average is `5`. A formula target has no stored records to
pool, so its members' evaluated results are folded with the measure's
aggregation; for a ratio formula that is the usual pivot-table limitation, not
a new one. Hidden items are included in the fold, as on `Axis::None`: hiding
decides what is drawn, not what an aggregate covers.
- **Stored values fold once over every record the group covers.** Folding the
members' *aggregates* is right for `Sum` and wrong for `Avg`/`Count` under
`Axis::None`: `avg(avg(2,4), 9) = 6` where the group's average is `5`.
- **A formula is evaluated over the fold, never folded itself** (improvise-eccl).
This bullet used to say a formula target "has no stored records to pool", so
its members' results were folded with the measure's aggregation, and it called
that "the usual pivot-table limitation". **The premise was wrong.** A formula's
*inputs* are stored and can be pooled, and the model already evaluated a
formula over pooled inputs whenever a category sat on `Axis::None`:
`recompute_formulas` strips the None categories from the stems. Folding the
members' results summed ratios instead, so `Margin = (Revenue - Cost) / Revenue`
at 0.5 and 0.9 showed **1.4** on the Q1 row where Month-on-None showed **0.8**.
Spreadsheet calculated fields do not have that limitation either. The formula
is now evaluated at the group key, with each reference read through the fold
(`Reach::Fold` in `model/types.rs`):
- a raw measure reads the stored fold;
- another formula target is evaluated over the fold in turn;
- an aggregate inside the formula (`SUM`, `MAX`, …) pools every covered
member's records. `MAX(Revenue)` had been *summing* the members' maxima.
The collapsed row now agrees with the None-axis answer. **Test with a ratio**:
a sum agrees under both rules, which is how this was missed.
- **The folded category reads as absent** (improvise-lnkb, the orchestrator's
ruling). `Month IS Q1` is false on the Q1 fold, and a formula's own
`WHERE Month = "Q1"` does not apply there. Both answers are what the None axis
gives, and what an inline `SUM(… WHERE Month = "Q1")` already gave. This
PR's first version said `IS` saw the group's name and called that "stated
rather than hidden". That made the fold row the one place a formula could
observe collapse state, breaking this section's own "fold = None over the
drawn members" rule. `Model::read_coordinate` is the one reading of a
coordinate by name at a fold.
- A formula with its own `WHERE Month = "Jan"` shows **empty** on the Q1 fold,
because the fold is not Jan. The None axis gives the same.
- **A fold evaluates each formula once** (improvise-o1y0). A reference to
another formula target is evaluated over the fold in turn, so without memory
a doubling chain re-evaluates exponentially. The first version did exactly
that: a chain of eight took 511 formula evaluations for one cell, and the
reviewer measured 9.83 s on an 18k-cell grid. `evaluate_covering` now carries
a memo for one evaluation, and a key met while still in progress is reported
`circular`. Measured on an Apple M4 Max in release, on 100 products × 12
months folded to quarters with the F0…F8 chain (4,000 cells): 0.94–1.04 s
without the memo, about 60 ms with it, and about 10 ms on gh/main. gh/main
was cheap because it read the members out of the formula cache, which is
what gave the wrong ratios. The remaining ~6× is each column's cell
re-deriving the chain below it (45 evaluations per row where 9 would do). A
memo scoped to the whole grid would remove it; it is not built.
- **A fold covers the members the grid draws** (improvise-f2oo). Hidden members
are not in it. So a fold is exactly the rows it replaces, the total of a
**stored** measure under Sum or Count does not move on `z`, and a group whose
every member is hidden has no row. Under Avg, Min or Max a stored measure's
total does move, and so does a **formula** column's (PR #203's review measured
Margin 1.90 expanded, 1.30 collapsed). The strip sums the rows' values rather
than folding the column the way a fold does, so it sums a Max row's maximum
and a formula row's result. That is improvise-wm20, and it is not this rule. That last
point was already true, and it contradicted the old rule, under which a hidden
value appeared only while a sibling was visible. This deliberately **differs
from `Axis::None`**, where hidden items still count, because the two replace
different things. A fold replaces *rows the user sees*. A None axis replaces a
*dimension*, and hiding an item on it hides nothing that was drawn. The view's
hidden set reaches the model as the `covers` predicate `GridLayout::evaluate`
passes, since the model knows no view.
### Settability and drill
@@ -900,7 +956,11 @@ regular on None projected the fold as `CellId::Record`, offered it for editing,
and a `SetCell` would have written the coordinate `Month=Q1`, which is not an
item. `render.rs::editable` is untouched (ADR 002: the variant decides).
`EditOrDrill` asks the same predicate and declines out loud: drilling would filter
on `Month=Q1`, which no record carries, and show an empty list.
on `Month=Q1`, which no record carries, and show an empty list. Guarding that one
command left `>`, `p`, `H` and an `Enter` advanced onto the fold all still writing
or drilling there (improvise-gw18, yk2s). Every command that takes the cursor's
cell now goes through a door that asks this predicate, or asks `cell_id`, which
does: `context/code/command.md`, "The cursor's cell has three doors".
### The cursor, and why the order is the fix
@@ -1200,3 +1260,89 @@ off the `## Data` line and off `serde_json` and requires them equal. A second
home that happens to agree is harmless; one that drifts fails there, and
re-copying the tokens into `format.rs` with one letter changed was injected to
prove it does.
## 17. An editor opens on the value, not on its `Display` (improvise-umi4)
`CellValue`'s `Display` is a **screen** spelling: a non-whole number is written
`{:.4}`, so `1.23456789` reads `1.2346`. Every editor seed used to be that
string (`display_value_in`'s pivot arm through `get_cell().to_string()`, its
records arm through `records_display`, and `project::cell_display`'s records
arm spelling it again), so pressing `i` and then `Enter` with nothing typed
stored `1.2346`. The screen looked the same before and after, which is what
made it silent data loss rather than a visible bug. The browser seeds from the
projected `CellDisplay::edit_text`, which came from the same call, so it had
the same bug.
`CellValue::edit_text` is the one home of the seed. A number is written as the
**shortest decimal that parses back to the same `f64`**, which is what Rust's
`f64` `Display` already guarantees (no precision argument, no exponent). The
brief pointed at improvise-gp's `number_text` as the implementation to reuse.
It is not a second implementation of this one: it takes an `f32` and searches
fixed precisions for the shortest *f64* expansion of it, because a threshold
must route the same under f32 and f64 evaluation. For an `f64` the standard
library's spelling is the same answer with no search. A non-finite number takes
§16's token (`inf`, `-inf`, `nan`), which `typed()` also reads back, so the
editor and the `.improv` format spell it one way.
Text and errors keep `Display`. Text is written unchanged, and an error cell has
no edit text on the wire anyway. Display itself is untouched: the grid still
rounds (gotcha 8), only the seed changed.
What this does **not** fix: a text cell whose text reads as a number. `"007"`
seeds `"007"`, and `typed()` classifies that as `Number(7.0)`. The seed is
exact, but the classifier has no way to know the cell was text. That is
improvise-ixxx.
## 18. Which formula an edit means (improvise-km71)
A `Formula` has no stored id. It lives in `Model::formulas: Vec<Formula>` and is
identified, in practice and by `add_formula`'s upsert, by `(target,
target_category)`. **`FormulaId` is that pair behind one opaque type**: its
fields are private, `Formula::id()` is its only constructor, and nothing
outside `improvise-formula` can read a name out of it. That is deliberate. The
stable-identity work (improvise-sboz, ADR 005) will make a formula's identity
something other than its names, and every signature that already says
`FormulaId` — `Model::formula`, `Model::replace_formula`,
`ModelCommand::ReplaceFormula`, `FormulaSlot::Existing`, `FormulaEntry` — then
changes by changing this one struct. A name pair spread across those signatures
would have made the swap a sweep.
**The index is not a candidate.** A position is what `formula_cursor` is, and
the editor must not recompute its target from the cursor at commit
(improvise-ixxx/2wu3's lesson): another client deleting an earlier formula
shifts every index after it, and the commit would land on a neighbour. It would
also put a position on the wire, which `every_model_command_field_is_position_free`
makes a compile error.
**An edit is its own operation, never remove-then-add and never
`add_formula`.** `add_formula` upserts on `(target, target_category)`: fed an
edit that changes the target, it appends a second formula and leaves the old
one; fed an edit onto another formula's target, it silently replaces *that*
formula. `Model::replace_formula(id, formula)` writes into the slot `id` names,
so the position (and with it `_Measure`'s item order, since
`measure_item_names` appends formula targets in formula order) is kept, and it
**refuses** when the new target belongs to a different formula.
**A target change is a rename of the same formula.** The id travels with the
edit, so `Profit = …` edited to `Gross = …` is the formula that was `Profit`,
in the same place. That is the least surprising reading of "edit this row", and
it is what ADR 005 proposes. What a rename cannot carry under name identity is
everything else that names the old target: other formulas' references, a
view's hidden list, a `measure_agg` entry. Those cannot follow without rewriting
`raw` text the parser does not print back, so the edit says so out loud:
`ReplaceFormula` reports which formulas still mention the old name
(`Formula::mentions`, over the expression and its `WHERE`), and their cells
show the `ref` error rather than a silent wrong number. Under a stable id those
references are what sboz makes follow. **This holds for `_Measure` targets only.**
Outside `_Measure` a target is a real item, and a rename is refused until sboz
(improvise-kb1d, `context/code/command.md`). A rename also leaves stored cells under
the old `_Measure` name in `## Data`, invisible because the formula shadowed them.
**Concurrency is checked twice.** `FormulaSlot::Existing { id, was }` is
captured when the editor opens: the id must still resolve, and the formula must
still read `was`. Either failing means another client removed, renamed or
rewrote it meanwhile, and the edit is refused with the batch stopped, so the
editor stays open over the user's text (gotcha 21). `was` is not redundant
with a stable id: a concurrent *replace* keeps the id and changes the text,
which only `was` can see.
+68 -2
View File
@@ -471,8 +471,74 @@ document that decompressed perfectly well. Decoding once, afterwards, also
leaves **one** answer to "is this UTF-8" rather than one per branch.
The `.gz` suffix selects gzip regardless of backend: that is a property of the
document name, not of the filesystem (improvise-6mq). Legacy JSON is detected by
a leading `{` and is never written.
document name, not of the filesystem (improvise-6mq).
**`.improv` text is the only native format (improvise-tt22).** A legacy JSON
workbook used to be detected by a leading `{` and loaded read-only through
`Workbook`'s serde derives. That path is gone, on the user's direction on PR #210.
A document that still starts with `{` is refused with an error that names the
format and says it is no longer supported. The test is
`a_legacy_json_workbook_is_refused_by_name`. The error is decided rather than
defaulted, because otherwise `parse_md` would report a grammar error at line 1,
which diagnoses the wrong thing: the file is not malformed `.improv`. The check
is on the decompressed text, so `.improv.gz` is covered too.
The serde derives that existed only for that path went with it: `Workbook`,
`Model`, `View`, `Category`, `Item`, `Group`, `CategoryKind`, `CellKey`,
`DataStore`'s manual impls and the formula AST. So did indexmap's `serde` feature
in `improvise-core`, `improvise-io` and the root crate, and `improvise-formula`'s
serde dependency. Each was removed and the whole workspace rebuilt, so the
compiler named every other consumer. Two remained, and both are the wire:
`Axis` (whose `rename_all = "lowercase"` is its wire spelling) and `PageAxis`.
A third arrived with improvise-km71 by the same rule: `FormulaId`, which crosses
the wire in `ModelCommand::ReplaceFormula`, `FormulaSlot` and `FormulaEntry`.
So `improvise-formula` depends on serde again, for that one type. No derive went
back on the formula AST.
`CellValue` was never a candidate, since it is carried on the wire too. JSON
*import* (`StartImportWizard`, `ImportJsonHeadless`) is a separate feature and is
untouched.
One test consumer had to move: the effect-classification tests fingerprinted the
model and the views through `serde_json`. They now use `Debug`. `format_md` was
rejected as the replacement, because it skips the virtual views and sorts the
data, so it would have been a weaker fingerprint.
The first version of this paragraph said `Debug` was "no weaker" because it
renders every field, including the derived `formula_cache` the JSON skipped.
**That was wrong (improvise-pc3b).** A derived `Debug` prints a `HashMap` in hash
order, and `recompute_formulas` clears and refills the cache. So a correct
`ViewOnly` effect such as `RebuildLayout` changed the fingerprint in 90 of 200
runs. The suite passed only because the fixture had no formulas, so the cache
was empty. `Model` now has a hand-written `Debug` that renders `formula_cache`
and `measure_agg` in key order. The fingerprint keeps every field, including the
derived one, and a refill in a different hash order is no longer read as a
change. `recomputing_formulas_leaves_the_model_fingerprint_alone` pins this.
`view_only_effects_leave_shared_state_alone` now runs over a formula-bearing
fixture, for the same reason.
**That fix was canonical within one instance, not across two (improvise-djcn).**
It fixed `Model`'s own maps but not the maps inside its fields. `DataStore` has
`index`, `records` and the symbol table, and `View` has `page_selections`,
`hidden_items` and `collapsed_groups`. All still printed through a derived
`Debug`, in hash order. Each `HashMap` gets its own `RandomState`, so two equal
documents built separately printed differently in 32 of 32 runs.
- The effect-classification tests compare one instance with itself before and
after, so they were unaffected.
- `a_model_command_lands_where_its_keypress_lands` compares **two** `App`s. It
passed only because its fixture had no cells and no multi-entry view maps.
Now:
- `DataStore`, `SymbolTable` and `View` have hand-written `Debug` impls. Each
sorts wherever its storage is a hash map.
- `DataStore` resolves symbols to their strings. A `Symbol`'s number is interning
order, not content, so printing it would make two equal stores differ again.
- `SymbolTable` prints its interned strings, sorted.
- `cells` keeps its `IndexMap` order, because that order is part of the document
(gotcha 18).
- Every hand-written `Debug`, `Model`'s included, **destructures** `self`. A field
added later is then a compile error there, where a `debug_struct` that names
fields one by one would skip it silently. The pc3b version of `Model`'s impl
had exactly that hole.
`parse_number_cell` returns `CellValue::Error` naming the offending text rather
than a fabricated `0.0`. No token the current grammar admits actually fails
+313 -34
View File
@@ -84,6 +84,20 @@ naming it would produce a mode with nothing to add to. `Quit` and
`start-import-wizard` — which do work beyond changing the mode, so
`enter-mode` would be a way to reach half of each.
### `AppMode::FormulaEdit` carries what it will commit to (improvise-km71)
`FormulaEdit { minibuf, slot }` is one mode for two editors. `slot` is
`FormulaSlot::New` for `a`/`n`/`o` (`enter-mode formula-edit`, which
`from_name` still builds) or `FormulaSlot::Existing { id, was }` for
`edit-formula-at-cursor`. It is one variant and not a second `AppMode` because
the keys, the buffer and the panel are identical: a second variant would add a
`ModeKey`, a keymap and a hint for no difference in what the keys do. The slot
is `ItemAdd`'s `category` again, a capture made when the mode is entered. Like
`ItemAdd` it is not nameable from `from_name`, since a script line has no
formula to open. It must be portable and snapshot-safe (§1 above). `FormulaId`
is plain serde data, and a fresh client that inherits an open edit inherits
exactly the claim the editor made. The commit re-checks that claim.
### `Panel`
`Panel::ALL`/`name()`/`key()` drive the toggle keys for Normal *and* all
@@ -164,9 +178,12 @@ attempt in a fourth place is the disease.
- The **model half** is `ModelCommand`: every `EffectKind::Model(..)` effect,
one variant apiece, carrying that effect's own fields (improvise-kmgs).
The count is 29 model effects, minus three: `SwitchView`, which `Command`
already names and routes, and `ApplyAndClearDrill`/`AcceptWizardModel`, which
are **not addressable** (improvise-3x3u). That leaves **26**. A variant with
The count is 27 model effects, minus two: `ApplyAndClearDrill` and
`AcceptWizardModel`, which are **not addressable** (improvise-3x3u). That
leaves **25**. `SwitchView`, `ViewBack` and `ViewForward` are no longer model
effects at all (improvise-jytm), so they are not subtracted. This paragraph
said "29 minus three, 26" until improvise-km71 added `ReplaceFormula`, and
that figure was already stale: the enum had 24. A variant with
no data mirrors a nullary effect whose operand is document state the
authoritative `App` already holds.
@@ -230,11 +247,33 @@ guard below (improvise-c7mu). It removes three things a scan must not see.
composers, so no verdict was wrong yet. It also had a mirror: a comment
explaining that a `NOT_COMPOSED` variant is held back would have raised a
false "stale entry" alarm.
- **Test items inside a file.** A line scan that relies on rustfmt putting an
item's closing brace at the attribute's own indentation. It treats
`cfg(any(test, …))` as test code and `cfg(not(test))` as production.
Over-stripping **fails** the test rather than passing it, which is the safe
direction.
- **Test items inside a file.** Each test-only attribute's item is bounded by
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
follows. The first version was a line scan that relied on rustfmt putting an
item's closing brace at the attribute's own indentation. The #172 review
found three shapes it never bounded:
- an item ending in `];` or `);`;
- a `#[cfg(test)]` statement spanning lines;
- a one-line `#[cfg(test)] use x;`.
In each case the scan read on to the end of the file and **hid every line of
production code after it**, which is the silent direction for the forward
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
**Which `cfg` is test-only is evaluated, not substring-matched.** The
predicate is read off the file's *text*, because a `feature` value is a
literal and blanked in the code. It is evaluated in three-valued logic for a
build without `test`: `test` is false, and so is `feature = "test-support"`,
which this repo never ships (improvise-26dm). Everything else a scan cannot
know, such as a target or an ordinary feature, is unknown. An item is
test-only when its predicate is **false**. So
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
heading a file makes the whole file test code. One appearing anywhere else
is a panic, because this scan cannot bound an inline module's inner
attribute.
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
`x/` test code, even though nothing inside the file says so. Before
improvise-c7mu, this checker's own file and `improvise-io`'s
@@ -592,14 +631,37 @@ broader than its reason — improvise-gcvb, which also records the two things
that make it more than a one-liner: an inserted `+cat` column means the TUI
does **not** advance, and `AddCategory` can abort.
### `CellId::Coordinate` — a drawn coordinate is not "no cell" (improvise-exdg)
A records-mode coordinate cell (the `Region` column holding `North`) used to
project `None`. That was a **third** meaning of `None`, next to the padded slot
and the blanked position: a visible cell that holds text and whose write
belongs to the host. So absence was encoding a permission, the same conflation
`Create` removed for the creating columns (improvise-poia). The pinning test,
`a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is
gone.
`Coordinate` carries **nothing**. Editing a coordinate renames it across every
measure cell in the row (gotcha 1), and no client composes that operation: it
needs `record_col`, and the browser declines every records-mode edit before
reaching a cell. Giving the variant a `RecordHash` would hand a client a
surrogate with nothing to spend it on. The operand belongs with the command
that consumes it, whenever that command exists. Nothing a client can do has
changed. `render.rs::editable` is now an exhaustive `match` rather than a
`matches!`, so the next variant has to be classified. `typed_cell_command`,
`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it.
So paste onto a coordinate is still a silent `Upstream` in the browser. The
TUI has declined it out loud since PR #184. That divergence is
improvise-kkcy, and a named kind is what lets the browser decline deliberately
now.
### `CellDisplay::id` — `None` is not "aggregate"
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
of the enum. `None` means the slot holds **no projected cell**: a `GridCache`
position padded by `fit_to_labels`, blanked by a reframing, or a
records-mode column that is not a cell to write into at all (a category column
renames a coordinate across the whole row, gotcha 1, which is a different
intent from `SetCell`). Conflating the two either hides a cell or offers a
position padded by `fit_to_labels`, or one blanked by a reframing. Every
records-mode column is some kind of cell (`Record`, `Derived`, `Create` or
`Coordinate`). Conflating the two either hides a cell or offers a
write the type system exists to make unconstructible.
### `CellDisplay::edit_text` — the difference corrupts data
@@ -615,13 +677,104 @@ and an editor's seed is exactly a feedback path.
It is the projection of `App::display_value_at_cursor`, which is what
`EditOrDrill` seeds the TUI's buffer from, so both front ends start from one
string rather than each deriving one. `None` where there is nothing to edit:
the same set of positions `id` is `None` for, plus cells whose value is
absent. An error cell carries none, because its text is a diagnostic and not
positions whose `id` is `None`, plus pivot cells with no key. A records-mode
cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not
a value.
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
client over the same grid, and they disagreed by a comma.
**It is the value's exact spelling, not its `Display`** (improvise-umi4).
`CellValue`'s `Display` writes a non-whole number `{:.4}`, so this field
carried `1.2346` for a stored `1.23456789`, and `i` then `Enter` with nothing
typed stored the rounded number in both front ends. Every arm that seeds an
editor now goes through `CellValue::edit_text` (`core.md` §17). That is the
shortest decimal that parses back to the same `f64`.
### An untouched editor writes nothing (improvise-ixxx)
An exact seed is not enough, because the commit reclassifies it. `typed()`
reads text, not the cell it came from, so `i` then `Enter` on `Text("007")`
stored `Number(7)`. A raw `CellValue::Error` has the edit text `ERR:msg`, and
the same keys stored it as `Text("ERR:msg")`. After umi4 the seed differs from
the stored value only when `typed(edit_text(v)) != v`. In every such case the
right answer is to keep `v`, which is what an untouched commit means.
So **a commit whose text is the seed the editor captured as it opened writes
nothing**. `resolve::untouched(Option<&OpenedEditor>, &str)` is the one home
for that, beside `typed()`. Every commit path reaches it:
- **TUI, pivot and records.** `CommitAndAdvance` carries `opened`, which
`commit_at_cursor` takes from `CmdContext::opened_editor`. It skips only
`commit_cell_value`: the mode change, the cursor restore, the advance and the
re-entry all still run, so records Tab still advances. The script form
(`commit-cell-edit <mode> <value> <coords>`) carries `None` and always
writes, since no editor was opened. The drill's staging goes through the same
`commit_cell_value`, so it is covered too.
- **Browser keyboard.** `resolve::commit` → `written`, which returns an empty
write for an untouched buffer and leaves `ChangeMode` and `advanced` alone.
- **Browser mouse.** The seed is a field of #188's `EditTarget` (`opened`),
captured by `edit_target_at` from the cache's `edit_text` beside the record
and its version. `typed_target_frame` starts with the `untouched` check, and
the page passes no seed (`typed_cell(target, text)`). The `<input>` shows
`edit_seed(target)`, so what the page shows and what it compares against are
one capture (improvise-ugj4).
**The seed is captured at open, never recomputed at commit.** Where the editor
opened is where `OpenedEditor` is written. For the TUI that is `EditOrDrill`
and `EnterEditAtCursor`. For the browser it is `resolve::opened_on`, which
`edit_or_drill` and `advanced` share, and `ClientState::settle_editor` for an
editor that arrived without its buffer. For the mouse it is `edit_target_at`.
The reason is concurrency. If another client writes the cell while this editor
is open, a seed recomputed at commit has moved to the new value. The untouched buffer then differs from it, so the
stale text gets written over the other client's value. The browser's basis
claim would not stop that write, because it is taken when the commit is
composed. Comparing against the captured seed avoids that lost update as well.
`OpenedEditor` lives on `ViewState` (`opened_editor`) and travels as
`Command::SetOpenedEditor`, with the `ViewOnly` effect of the same name. It is
a struct on purpose. What else an open editor must capture, namely which record
it edits (improvise-2wu3) and at what version (improvise-0j0f), belongs in the
same object. `EnterMode` into an editing mode writes `None`, because an editor
opened that way has no seed and must not inherit an earlier one.
**Untouched means not edited, not merely equal** (improvise-2054). Comparing
content alone dropped a deliberate write. Open on `7`, let another client write
`5`, then press Backspace and `7`: the buffer spelled the seed again, so the
commit wrote nothing and the user was not told. `OpenedEditor` therefore
carries `touched`, and `untouched` requires `!touched && text == seed`. The
edited write is then composed, and if the cell moved, the basis check
(improvise-0j0f) refuses it loudly rather than dropping it silently.
`resolve::edited(opened, buffer)` is the one rule for what marks an editor. It
is an edit of **the editor's own buffer**, `edit`, by `append-char` or
`pop-char`. `AppendChar`/`PopChar` in the TUI and the browser's two arms each
call it and emit `SetOpenedEditor` once, on the first edit. Two things
deliberately do **not** mark it:
- **A key into another buffer.** Typing `:enter-mode editing` goes into
`command`. If that marked the stale editor, it would hide the stale-seed case
the `EnterMode` guard exists for, and no test could see the guard.
- **`clear-buffer`.** Esc and the post-commit clear are not the user's edits.
If they marked the editor, the guard above would again be redundant and
unpinnable.
The comparison stays exact: whitespace is content, and nothing trims.
**The mouse editor reports its first edit.** Its edits happen in a DOM
`<input>` that Rust never sees, so the page's `input` listener, registered
`{ once: true }`, swaps its target for `edited_target(target)`, which is
`EditTarget::edited`: the same capture with `touched` set (improvise-jibp).
Before that, the mouse path compared content alone, so the 2054 case survived
there. The `<input>` used to be seeded from the **painted** `editText` while
`EditTarget` captured the **cache**. `repaint` returns early while an editor is
open, so a paint that went stale then showed old text against a newer seed and
version (improvise-ugj4). The page now seeds from `edit_seed(target)`, and
`RenderCell` no longer carries `edit_text`, so the paint cannot be asked.
This does **not** change classification. `007` typed fresh is still
`Number(7)`, and `a_touched_browser_commit_still_writes` pins that.
`numeric` and `error` are decided by the server for the same reason: it is
the side that knows which it was, and re-deriving client-side is how the two
ends drift apart. `CellDisplay` is deliberately not *drawn from* a `CellValue`
@@ -636,8 +789,9 @@ that `text` was formatted from: `evaluate_aggregated` over the view's
because the browser's `yy` has to copy what the TUI's `YankCell` copies, and
neither string on the cell can be turned back into it:
- `edit_text` is `CellValue::to_string()`, which writes a non-whole number
`{:.4}` — `1.23456789` becomes `1.2346`.
- `edit_text` was `CellValue::to_string()`, which writes a non-whole number
`{:.4}`, so `1.23456789` became `1.2346`. improvise-umi4 has since made it
exact, but the next bullet still holds.
- Re-parsing any text with `typed()` turns number-like text into a number —
`Text("007")` becomes `Number(7.0)`.
- In a pivot grid `edit_text` is the *stored* cell (`get_cell`), so an
@@ -647,10 +801,8 @@ So the value is **projected**, not re-derived (gotcha 47): a client that
wants the value asks for the value. It is kept separate from `edit_text`
rather than replacing it, because the two answer different questions — what
an editor starts with is a *string*, deliberately the one the TUI's buffer
holds, and changing it would change what the TUI's own editor shows.
It cost `Eq`: `CellValue` holds an `f64`, so `CellDisplay`, `GridCache` and
`GridUpdate` are `PartialEq` only. Nothing hashed or `Eq`-bounded them.
holds. (That string was itself lossy until improvise-umi4. The fix changed what
the TUI's own editor shows, which was the point.)
In a records grid the TUI does **not** yank this value: `YankCell` and
`PasteCell` resolve the synthetic `(_Index, _Dim)` key, so the TUI yanks
@@ -658,6 +810,102 @@ In a records grid the TUI does **not** yank this value: `YankCell` and
is right there and the TUI is wrong, which is why the lockstep yank test stays
on a pivot grid.
### `CellDisplay`'s equality is the wire's, not `f64`'s (improvise-4w09)
`CellDisplay`'s `==` has one job: the grid diff (`project::grid_updates`) and
the seed property (`GridCache::folded(&g.seed()) == g`, gotcha 32) run on it.
The question it answers is therefore **"could a client tell these apart?"**,
not "are these the same number?".
A derived `PartialEq` answered the second question. `value` holds an `f64`, and
`NaN != NaN`. So once #178 added `value`, a cell holding NaN, which `.improv`
carries as a first-class value (improvise-55v2), was unequal to itself. The
diff resent it on every grid-dirty projection, and the seed property was false
for exactly the documents that hold one. It also cost `Eq` on `CellDisplay`,
`GridCache` and `GridUpdate`.
So `PartialEq` is written by hand and compares `value` with
`same_on_the_wire`, which has two clauses:
- **Every NaN is equal**, whatever its bits. The wire spells them all `"nan"`
(`model/cell.rs::wire_f64`), and they cannot all share a payload: `0.0 / 0.0`
on x86 sets the sign bit, and a client that parsed `"nan"` holds `f64::NAN`.
Bit equality alone would fix the self-diff and still call the host's NaN and
the client's NaN different.
- **Any other number is its bits**, so `0.0` and `-0.0` differ. serde_json
writes them differently, and a change between them is one the client does
not yet have.
That is an equivalence relation, so `Eq` is back on all three types.
`CellValue`'s own `PartialEq` is unchanged: formula evaluation and model code
keep IEEE semantics. Only the projection, whose equality means "nothing to
send", adopts the wire's.
The hand-written impl destructures `CellDisplay` exhaustively, so adding a
field fails to compile until it is compared. The dangerous failure for a diff
is **under**-reporting (gotcha 35), and a field left out of `==` is exactly
that. Evidence: `ui::project::tests::a_grid_holding_nan_quiesces` and
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
### A number must read back as the number written (improvise-6oy4)
serde_json's default f64 parser is fast and best-effort: it does **not**
guarantee reading back the `f64` that serialized the text. Its
`float_roundtrip` feature does, at roughly twice the cost of float parsing.
Without it, the review of PR #178 measured about one ordinary decimal in seven
coming back one ULP off, both in `CellDisplay::value` (host to client, so `yy`
copied a neighbour) and in a `SetCell` (client to host, so a typed or pasted
number landed as a neighbour).
The feature is on for **every workspace crate with a normal dependency on
serde_json** (improvise-dz8c). Each crate's own manifest names it, because each
build is made from one crate alone: the `wasm` CI job and `wasm-pack` invoke
per crate, and feature unification cannot reach across separate invocations
(`context/repo-map.md`, `## Wasm`). Enabling it in one crate and relying on the
others to inherit it works in `cargo test` and nowhere else.
**Why "can parse" and not "parses a frame".** The first version of the rule
covered only the three crates that parse frames and listed them by hand.
Frames are not the only place a user's number crosses a JSON parser.
`improvise-io`'s legacy-JSON `.improv` load (removed since, improvise-tt22), `improvise-command`'s JSON import
(`StartImportWizard`, `ImportJsonHeadless`) and the root binary's
`improvise import` all parsed with the lossy default, and in the TUI binary
nothing enabled the feature (improvise-6yvg, reproduced: `0.24815714285714285`
stored as `…283`). The worker bundle happened to be exact through unification,
so one import gave different numbers in the two front ends. "Parses JSON input"
cannot be read off a manifest, but "has a normal serde_json dependency" can, and
it over-approximates in the cheap direction: the feature changes only parsing,
so a crate that only serializes pays nothing for it. Dev-dependencies are out,
because a test build ships nowhere. That also leaves out `gp-tree`, whose only
serde_json is a dev-dependency (its example CLI reads a saved model). Every
number it persists is an `f32`, and 3M random `f32`s all read back exactly
through the lossy parser, against about 30% of random `f64`s.
**Why a declaration and not the build graph.** The first check asked
`cargo tree` whether each listed crate's *build* had the feature. That answer
can be yes by accident. `improvise-command` depends on `improvise-io`, so with
the feature removed from command's manifest, command's own test build still
has it, and its import test stays green. The rule requires the crate's own
manifest to say it, so a later change to a dependency cannot take it away.
**Why not enable it once, in `improvise-protocol`.** That removes the list, but
it would give the protocol a normal serde_json dependency it has no use for
(its repo-map entry says core and serde only). It would also reach only builds
that include the protocol, and `improvise-io` does not depend on it. Under this
rule the protocol's one serde_json dependency, the optional `test-support` one,
carries the feature too. That is off in every shipped build, so it costs
nothing.
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
reads `cargo metadata --no-deps` and names every package, and every
target-specific entry, whose normal serde_json dependency lacks the feature. A
new crate is covered the day it declares the dependency. It does not reach
`harness/opfs-contract`, which is its own workspace and only serializes.
The end-to-end tests cannot guard this alone. They run in one unified build
with `improvise-ws-server` as a dev-dependency, so they stay green with the
feature removed from the browser bundle.
### `AxisFrame` — the grid's frame, and why it is *one* type
`AxisFrame` (improvise-anr9/s1xw) replaced a `Vec<String>` of labels that
@@ -687,9 +935,16 @@ one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to
smuggle a multi-level axis in flattened.
`FrameGroup::collapsed` is on the wire rather than left to the client because
it is a fact about the *view*, which only the server holds. `FrameGroup::cat`
travels so a client can echo it back in `ToggleGroup` without ever working out
which category a label came from — which slash-joined labels made impossible.
it is a fact about the *view*, which only the server holds. There is no
`FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a
client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs`
has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host
list), and neither renderer read the field. A promise about an interaction no
code performs is the stale-sibling defect, so the field went with it. It is
not a loss for `z`. The TUI's `ToggleGroupAtCursor` also places the cursor on
the layout the toggle *will produce*, which a thin client cannot compute. So
composing `z` in a browser needs its own design, and the operand should come
back with that design rather than wait for it.
### `AxisFrame`: four accessors that are **not for a draw loop**
@@ -1196,13 +1451,13 @@ frozen behaviour reads as decided afterwards and nothing says it was only frozen
for the duration of the move.
**The ticket's own proposed fix does not work, and that is worth more than the
fix.** h0iv suggested comparing the `(cat, name)` pair, `FrameGroup` carrying
`cat` precisely so a client need not work out which category a label came from.
fix.** h0iv suggested comparing the `(cat, name)` pair; `FrameGroup` carried
`cat` then (improvise-7xyx has since removed it).
But its own reachability case is *one* category: `Month` grouped into `Q1`,
nested under `Region`, giving `North/Q1` and `South/Q1` adjacent — the two
headers are `(Month, Q1)` and `(Month, Q1)`, **equal as values**, so the pair
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them`
pins that, so the next reader does not re-propose it. What separates them is
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them`
pins that as whole-value equality, so the next reader does not re-propose it. What separates them is
that they are two different `FrameEntry::Group` entries, and `groups()` already
borrows each from `self.entries` — so the comparison is `std::ptr::eq` on the
header, which is the literal statement of "the same header opened both".
@@ -1936,14 +2191,38 @@ nothing in the table to look up, and its hint is a hand-written tail. That is a
real hole and it is where it is visible, not papered over: a keymap cannot
describe keys a command resolves privately.
**`gg`, `yy`, `ZZ` and the ex-commands.** A prefix sequence is two addresses and
not one binding (gotcha 13), and `:q`/`:w` are parsed text rather than keys.
Both are `HelpLine::Literal`, which says out loud that the row is spelled rather
than derived.
**The ex-commands.** `:q`/`:w` are parsed text rather than keys, so they are
`HelpLine::Literal` — built only by the `ex!` macro, whose leading colon is its
own — and the parser, not the keymap, is what checks them
(`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`).
**The captions.** `i:edit` where `i` entered records mode would still pass every
test here. A caption is English beside a command name and only a reader judges
it — the same limit gotcha 14 recorded for the old hint test.
This paragraph used to say the same of `gg`, `yy` and `ZZ`: "a prefix sequence
is two addresses and not one binding". Two addresses is true and was no reason
to spell them by hand — `Keymap::prefixed_keys_running` looks the pair up the
way `keys_running` looks up one key, and `keys()` asks both (improvise-wk18). A
key string with no command name was also the one row no guard could reach.
**Every row the pages ask for is visible to a guard.** The page builders take
the `keys` lookup as a parameter; `pages()` passes the real one, and
`help_bindings()` (tests only) passes one that records `(mode, command, args,
description)`. So a check over the help overlay reads the *same builders* the
overlay is drawn from, never a list kept beside them. That premise is a type,
not a convention (improvise-v29m): `HelpLine::Keys` carries a `KeyRow` whose
only constructor does the lookup, so a builder cannot type a key row the
recorder would never see; and `ex!` refuses at compile time any usage with a
bare word after the command name, so an ex row cannot smuggle a key either.
`resolve::tests::every_advertised_command_answers_in_its_own_mode` walks it
beside `advertised`, which is how the browser's black holes on the help page
became visible (improvise-uln5).
**The captions.** `i:edit` where `i` entered records mode would still pass most
tests here: a caption is English beside a command name and only a reader judges
it — the same limit gotcha 14 recorded for the old hint test. Two checks now
bite at its edges. The browser guard requires an advertised key to *act*, so a
key whose caption promises an action and which only refuses must be named
(improvise-3t5x); and `only_the_edit_key_is_captioned_as_editing_the_cell` asks
by command that no other Normal row claims to edit (improvise-jhdf). A false
caption in words neither looks for still passes.
### The browser half
+66
View File
@@ -259,6 +259,72 @@ the deploy's and pass. `README.md` therefore states the rule and deliberately
**does not list the jobs**: a second copy with nothing holding it to the first
is what went stale, twice.
### pr-watch and mergeability (improvise-edpl, improvise-zlj2)
`scripts/pr-watch` is what the orchestrator's merge procedure stands on, and
until improvise-edpl its verdict failed only on `mergeable == CONFLICTING`.
GitHub computes mergeability lazily: for a while after the base moves it reports
`UNKNOWN` for both `mergeable` and `mergeStateStatus`. PRs #172 and #179 both
exited 0 at `UNKNOWN/UNKNOWN … 0 failed`. A PR that has *just become*
conflicting is in exactly that window, so it could pass the watch. It is the
same shape as improvise-b4ks: an uncertain state read as success.
The rule now:
- **`UNKNOWN` in either field is not an answer, so it is waited on.** On
timeout it exits 3, the same code as a check still running. It never exits 0.
- **`MERGEABLE/BLOCKED` with no failed check is waited on too** (improvise-zlj2).
A required check that never runs therefore times out at exit 3, and does not
exit 1.
- **A known answer ends the wait early**: a failed check (exit 1 whatever
mergeability says, since waiting would turn a known red into a timeout), or
`mergeable == CONFLICTING`.
- **The pass condition is an allowlist**: `mergeable == MERGEABLE` *and*
`mergeStateStatus` in `CLEAN`, `UNSTABLE` or `HAS_HOOKS`. Anything else exits
1. That includes `null` and any state GitHub adds later, so the check fails
closed.
Why each state is where it is. The `main` ruleset (`gh api
repos/fiddlerwoaroof/improvise/rules/branches/main`, read 2026-09-22) requires
three status checks. It requires no approvals, and its required checks are not
strict.
- `BLOCKED` with a `MERGEABLE` flag means a required check has not passed. A
failing one is already in the rollup, so the script has exited 1 before it
reads the state. What is left is a required check that **has not registered
yet**, and every healthy push passes through that state. The required
`Browser harness (OPFS contract + shell)` check comes from the `browser` job,
which `needs: build`. GitHub creates its check run only after `build`
completes. For a second or three per push, then, every registered check is
`COMPLETED/SUCCESS`, the required one does not exist, and GitHub reports
`MERGEABLE/BLOCKED`. The first version of this rule refused `BLOCKED`, and so
exited 1 on a healthy PR whenever a poll landed in that gap. The reviewer put
that at 5–15% of watches at the 20s interval. An "unrun" required check and a
"not yet registered" one look the same from the rollup, so the state is
waited on. A check that genuinely never runs costs a timeout, exit 3, not a
false exit 1. An earlier version of this section said that nothing else
produces `BLOCKED` here. That was wrong: the `needs:` gap produces it.
- `DIRTY` can wear `MERGEABLE` for a moment while the two fields settle. It is
refused.
- `BEHIND` needs strict required checks, which this repo does not set. `DRAFT`
cannot be merged. Both are refused.
- `UNSTABLE` means a non-required check is unhappy. It stays a pass, as it was
before. It is safe because the loop only reads the state once every
registered check is `COMPLETED`, and any failed one has already exited 1. So
whatever `UNSTABLE` is reporting, it is not a check in the rollup that is
still running or red.
- `HAS_HOOKS` is GitHub Enterprise's "mergeable, with pre-receive hooks". It is
harmless to allow.
The suite enumerates the whole `mergeable × mergeStateStatus` table, plus an
unshipped value and `null`, rather than hand-picking cases. Why: improvise-90ea.
The suite also plays the `needs:` gap through, poll by poll: build running, then
build done with no harness check, then the harness running, then `CLEAN`. That
sequence must exit 0. The rollup it starts from is PR #189's, copied from
`gh pr view --json`. Each earlier poll is derived from it with `jq`, by dropping
entries or rewinding them to `IN_PROGRESS`.
### Why the Pages deploy fails on "Found 0 artifact(s)" (improvise-864a)
`actions/deploy-pages@v5` intermittently fails a push to `main` with *No
+45
View File
@@ -379,3 +379,48 @@ again.
hint is width-constrained and the history lives on help page 5 under "View
history" with both halves. That is the #149 lesson — a hint that does not fit is
never drawn, so the test pinning the widget cannot see it.
## A status message lasts until the next key (improvise-cq3d)
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
nothing in production ever cleared `status_msg`: it was only replaced by the next
message. So after the first "Saved", "Loaded" or error of a session, the derived
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
any mode.
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
applies `SetStatus("")` and then dispatches the key, so:
- a message a key produces is drawn after that key, whatever it is, errors
included. It stays until the user presses something, which is the moment they
have acted on it. An error is never wiped before it has been drawn.
- a message that arrives outside a key, such as one from startup or an import,
also stands until the next key.
- the next key clears it *before* its own effects run. So a key that writes a
new message shows that message, and a key that writes none shows the hint.
**Rejected alternatives.**
- *Expire on mode change.* This fails the ticket's own case: a message written in
Normal would stand in Normal indefinitely.
- *Expire on the next successful command.* Effects carry no notion of success,
and a failed command would leave the stale message in place. That is exactly
the case where the user most needs the hint back.
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
test's middle step exists to catch that.
- *Compose message and hint on one line.* A long message still eats the hint, and
the stale message still stands forever.
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
browser after every key. Expiring there without changing the browser's resolver
too would make the two ends disagree. The expiry is also a presentation decision
about the TUI's one-line status row, so it belongs to the TUI front end.
**The browser already behaves this way.** Its `status_msg` is state that is never
cleared either, but `web/app.js` shows it in a toast that hides after
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
is always drawn. So a browser user sees a message briefly and never loses the
hint. The TUI now does the same, on a keypress rather than a timer, because a
terminal status row has one line to share and a keypress is the event the TUI
already has.
+3 -4
View File
@@ -311,10 +311,9 @@ candidate for the store.
- **And an argument must not live only in a commit message.** A message is
write-once and unauditable: it cannot be corrected without rewriting merged
history, and no check in this repo can read one — the ID audit greps
`context`, `README.md`, `AGENTS.md`, `CLAUDE.md`, `crates`, `src`, `tests`,
`examples`, `web`, `harness` and `docs`, and a commit message is in none of
them. So a reason recorded only there is a reason nothing can ever find wrong.
history, and no check in this repo can read one — the ID audit
(`AGENTS.md`, "Cite a ticket ID you have read back") greps files in the
tree, and a commit message is in none of them. So a reason recorded only there is a reason nothing can ever find wrong.
When one turns out to be wrong, correct `context/` and say in `context/` that
the message is superseded; that is the fix rather than a compromise. PR #51's
`7d25762` is the worked example, corrected by #56.
+3 -2
View File
@@ -298,8 +298,9 @@ The unit-level form of the headless test, against `CmdRegistry::interactive`
directly: cursor on `Type=Food, Month=Jan`, a decoy `edit` buffer, and an
explicit `5` for `Type/Clothing Month/Feb`. It asserts the built command's key
and value. Rival (a) above — a `contextualize` that overwrites the parsed
fields — fails it; this is the one hole the new registry shape leaves
representable, so it is the one that needs a test.
fields — failed it. improvise-yf7p then removed `contextualize` altogether
(`context/code/cli.md` §8), so rival (a) no longer compiles; the test stays as
the behavioural statement of what the signature now guarantees.
### every_argument_list_the_gate_accepts_the_run_accepts_too
+745 -33
View File
@@ -1458,6 +1458,17 @@ 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.
**improvise-jhdf closed the paraphrase, and the paragraph above is what it
found.** Rewording Enter's caption to "Edit the cell (same as i)" — the same lie
in new words — passed both help tests. The test now asks by **command**, which
wk18's `help::help_bindings()` made possible: every Normal-mode help row whose
caption says "edit" in any case must run `edit-or-drill`. It fails on that
rival naming `(Normal, "enter-advance", None, "Edit the cell (same as i)")`.
Two controls stop it passing by looking away: `edit-or-drill` and
`enter-advance` must each still have a Normal row, or the filter has nothing to
catch. What it still cannot see is a false caption that avoids the word — "Opens
the cell" — which is the honest limit of any check over prose.
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
improvise-iioi, and it is the **same trap** as the category click one level
@@ -1474,39 +1485,29 @@ view, or with a click on the row already under the cursor, the bug is invisible
— the same reason `a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row`
clicks Product rather than `_Index`.
### a_formula_row_click_navigates_and_activates_nothing
### a_formula_row_click_navigates_and_opens_the_editor_on_that_row
The negative arm, and it is a statement about the *rule* rather than about the
formula panel. The rule is "go there, then do what `Enter` does there";
`ModeKey::FormulaPanel`'s keymap binds `a`/`n`/`o` to `enter-mode formula-edit`
and `d`/`Delete` to delete, and **no `Enter` at all**. So the rule yields
navigation and nothing else, and that is the rule applying rather than the fix
stopping half way.
Formerly `a_formula_row_click_navigates_and_activates_nothing`. That was the
negative arm of the rule "go there, then do what `Enter` does there", true while
`ModeKey::FormulaPanel` bound no `Enter`. improvise-km71 bound `Enter` (and
`e`) to `edit-formula-at-cursor`, so the same rule now yields the editor on the
clicked row. The test asserts the exact command vector. The clicked row is
`Margin` at index 1 and the cursor stays at 0, so a click that read the cursor
would open `Profit`.
This test asserts the *exact* command vector, which is the whole of what it can
say. It once carried a second, explanatory `assert!` that no `FormulaEdit` mode
change appears — **which could never have been the failing assertion**, because
the `assert_eq!` above it pins the same three commands and aborts first. A
reviewer demonstrated it by injecting a `ChangeMode(FormulaEdit)` and watching
the panic land on the `assert_eq!` with the explanatory message never printed.
It was a comment wearing an `assert!`, and the argument now lives in
`context/code/clients.md` where a comment run cannot hold it.
The old version once carried an explanatory `assert!` that no `FormulaEdit`
mode change appeared, which could never have been the failing assertion,
because the `assert_eq!` above it pinned the same commands and aborted first.
That lesson stands: an `assert!` after an exact `assert_eq!` on the same vector
is a comment.
### the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does
The guard that the removed assertion was pretending to be. The asymmetry — a
view-row click activates, a formula-row click does not — is a fact about the
**keymap**, and nothing tied the click to it. The reviewer proved the gap by
adding `Enter -> enter-mode formula-edit` to `ModeKey::FormulaPanel` and running
the whole workspace: **1381 passing, nothing failed**, while `click_formula_row`
still activated nothing. The click and the keyboard then disagree about what a
formula row does, silently.
Injecting that same binding now fails this test and only this test. The failure
message names `click_formula_row` as what must change, because the person who
adds an `Enter` binding is not the person reading `resolve.rs`'s click arms. The
positive half (`ViewPanel` *does* bind `Enter`) is there so the test cannot pass
by asserting that no panel binds anything.
The guard that tied the click to the keymap was
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`. A reviewer
showed that nothing else tied them: adding an `Enter` binding to `FormulaPanel`
left 1381 tests passing while `click_formula_row` still activated nothing. That
test is replaced by `a_formula_row_click_ends_with_what_enter_does_on_that_row`,
below, which compares the click with what `Enter` actually resolves to rather
than asserting a binding's absence.
#### The view and formula row clicks in the browser (improvise-iioi)
@@ -2071,6 +2072,50 @@ mode `declined` refuses at the door, so a browser never stands in front of the
hint; the other two are improvise-gcvb's records-cell commit, which needs
`record_col`.
**improvise-3t5x and improvise-wk18 widened it again, in two directions, and the
list above is superseded by the one in the test.**
*What "answers" means.* It counted any `Local` as answered, so a lone
`SetStatus` refusal passed — which is why improvise-s8h7's false "Edit current
cell" stayed green. Injected to show it: make `edit_or_drill`'s `Record` arm
return only `SetStatus("RIVAL")`, so `i` refuses on every cell — the old guard
**passed**. The line drawn now: a caption is a promise, so an advertised key
must *act*. A refusal is legitimate — a feature a browser honestly lacks should
say so rather than go silent — but it is not what the caption says, so it has to
be **named** in the list with its reason, like a black hole. The test therefore
sorts every key into three outcomes, `Acts`, `Declines` (said something, did
nothing) and `Silent`, and lists the last two. The same injection now fails
naming `NORMAL: a/i edit-or-drill Declines`.
*Judged by what moved, not by the variant.* The first version of the new
classifier called anything but an all-status `Local` an action, and a rival that
unhooked `jump-first-row` from `composed` **passed** — because a chord's second
key always returns `Local([SetTransientPrefix(None), ..])`, the disarm, even
when nothing else happens. That is the laundering this entry already warns
about, one level down: `KeyOutcome::of` launders an empty vec into `Ignored`,
and `chord` launders a no-op into a `Local`. The disarm is now ignored when
deciding whether a key did anything, and the same rival fails naming `NORMAL:
gg jump-first-row Silent`.
*Which keys.* It read `help::advertised`, the status-bar table, and the help
overlay is a second body of advertising the browser also paints
(improvise-uln5). It now also reads `help::help_bindings()`, which records every
keys row the page builders ask for — the same builders `pages()` renders, so the
two cannot drift — including prefix sequences (`gg`, `yy`, `ZZ`), which it
presses by arming the prefix first. That is what added the five host-only
black holes (`/`, `n`, `H`, `z`, Normal's `Enter`) to the list: each was already
in `the_command_names_that_still_need_the_host_are_these`, and the help page
advertised it anyway.
*Which cells.* `furnished` stood on a record cell only, so `i` on an aggregate
(s8h7's whole subject) was outside its reach. Normal mode is now pressed on a
record, an aggregate and a derived cell, and a non-record cell is reported only
where it changes the answer, so the list does not triple. `furnished_on` also
carries a history in both directions and a yanked value, so `<`, `,` and `p`
are not refused by an empty stack — state refusals are correct behaviour and
not this test's subject, the same argument that gave `furnished` three of
everything.
### a_locally_committed_creating_column_agrees_with_the_host
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
@@ -3054,6 +3099,23 @@ The third is the gotcha 48 property, and it needs `timesArmed() > 0` first or it
denies an unreachable event: the precise reason must still be on screen after
the 8s window, not replaced by the watchdog. `page.clock` walks past it.
**Strengthened by improvise-i0v9's second pass.** Every assertion above read the
*final* `#status`, so two failures could still pass it. A watchdog that fired
first and was then replaced by a late notice passed. So did a success claim
shown before the notice. The test now records every status with `logStatus` and
asserts, **before** the page clock is walked, that the log ends in `read-only`
and never contained `did not answer` or the exact `in-page worker · <doc>`. A
reason that arrived after the watchdog would sit in that log behind "did not
answer"; that is the "well before 8s" property, stated without a wall-clock
bound. Measured failing against `gh/main` at `da2e5ab`, whose log was
`["in-page worker · two-tab-….improv", "read-only — …"]`: the premature claim
the ticket measured at 13ms is still there once 1wft's notice exists. The
brief's rival, `onNotice` calling `setShell` instead of `fail` (the watchdog not
disarmed), fails it at the first `toContainText("read-only")`. The Snapshot's
claim replaced the reason at once, which is why the eight-second form of this
rival is now exercised by the invalid-`?doc=` spec instead (see
`context/code/clients.md`, "The watchdog").
It runs on Chromium and Firefox — the two engines with OPFS — rather than
Chromium alone, because the exclusivity semantics are the engine's and the two
word the refusal completely differently.
@@ -3324,9 +3386,22 @@ over `Ctrl-R` is in `context/code/tui.md`), the test is renamed, and the second
half now asserts the binding **exists** — with `view-back` kept as the control,
which now guards the opposite failure: a table that binds everything, or a
sweep that reports names it never read. A third assertion pins the key itself,
`composed(&state, ev(Char(',')), "view-forward", &[]) == [Command::ViewForward]`,
so the binding cannot drift onto some other key while the presence check stays
happy.
`resolve(&state, ev(Char(','))) == Local([Command::ViewForward])`, so the
binding cannot drift onto some other key while the presence check stays happy.
**That third assertion was vacuous as first written, and the paragraph above
said it was not** (improvise-3ij0). It called `composed(&state, ev(Char(',')),
"view-forward", &[])`, and `composed` dispatches on the command **name**: the
`view-forward` arm never reads the event, so the `,` was decoration and the
assertion restated the first half of the test. Rebinding `view-forward` from `,`
to `~` in `keymap.rs` left it green; the key was pinned only by
`ui::app::tests::the_view_history_walks_both_ways_from_the_keyboard`, in another
crate. It now goes through `resolve`, which looks the key up in the shared
keymap before anything reads a name — so the rival fails **here**, at this
assertion (resolve.rs, "and the key is `,`"), and the pre-fix code under the
same rival was run and passes. The general shape: a helper that takes a key
*and* a name, and dispatches on the name, cannot pin the key; only the lookup
can.
**The rival: bind `,` to `view-back` instead** — the misspelling the original
control was written against, and the one a copy-paste actually produces. It
@@ -4332,6 +4407,15 @@ with no `SetBuffer`, then presses the keys: Enter must compose
`an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell` fails,
as it did against the same rival in `render.rs`.
**Amended by improvise-ixxx.** Its first `Enter` is untouched now, so it
composes no write. The assertion that it "must not clear" is therefore
`committed == None`, where `committed` is the first `Command::Model` of any
kind. It still fails against the pre-ktlk tree:
that tree's buffer is `""`, and `settle_editor` never ran, so no seed was
captured and the empty buffer composes `ClearCell`. The typed-`5` half is
unchanged. `settle_editor` now captures `OpenedEditor` beside the buffer,
because the editor opens at that moment.
## improvise-dsxq — the browser's `yy` copies the value, not a string
PR #161 (improvise-lut7) made the browser's `yank` arm compose
@@ -4392,6 +4476,73 @@ Records mode is deliberately not covered. There the TUI yanks `None` and its
case there would assert the bug. Since improvise-6p8q's fix it is covered, by
`yy_then_p_in_records_mode_writes_the_record_the_host_writes` below.
### `an_untouched_browser_edit_stores_the_identical_value`
**improvise-umi4**, the browser half. The client seeds `i` from the projected
`CellDisplay::edit_text` (`resolve.rs::edit_or_drill`), which was
`App::display_value_in` and so `CellValue`'s rounded `Display`. The client
resolves `i` and `Enter` itself, and what it composed is applied to the host
through `apply_on` (`Checked::against` plus `apply_command`). The stored value
is then compared by `f64::to_bits`. The fixture puts the value at row 1,
column 1 of a 2×2 pivot grid, with values that `Display` rounds (the list in
`context/test-evidence/command.md`). A records grid is not driven, because the
browser declines records editing (`edit_or_drill`).
Failed before the fix on `Number(1.23456789)` stored as `Number(1.2346)`.
**Rival:** `CellValue::edit_text`'s number arm set back to `Display`. The test
failed on the same case.
The test is in-process, so it cannot see the JSON wire. A float that does not
survive `serde_json` is improvise-6oy4, a separate fix.
### `every_projected_edit_text_commits_back_to_its_value`
The projection contract behind the test above, for **both** grid kinds. Every
`Record` cell's `edit_text`, fed through `typed()`, must give back the cell's
`value`. It runs over the whole projected grid in pivot mode and in records
mode (entered with `R`). A guard asserts that the precise value was projected
at all. This test exists because `project::cell_display`'s records arm spelled
`edit_text` itself, apart from `display_value_in`, and no key-path test reaches
that arm from a browser. **Rival:** that arm restored to `Some(text.clone())`,
the display text. It failed with `records=true: edit text "1.2346" does not
commit back to Some(Number(1.23456789))`.
### an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber
improvise-2wu3. `web/app.js` freezes the screen while an inline editor is open
(`repaint` returns early), but `on_frame` goes on folding. The editor kept its
viewport `(row, col)`, and `typed_cell` resolved it against the *current*
cache at commit.
The test follows the ticket's acceptance criteria against a real `Host`:
1. Client A reads the North/Revenue record at `(0, 0)`.
2. Client B's `HideItem("North")` is applied.
3. Only the frames addressed to A are folded into A, with no repaint, just as
the page does it.
4. A precondition asserts the rows really renumbered.
5. The write A composes must name the record A clicked.
**Red first**, at HEAD after improvise-ktlk (PR #169), which moved the editor's
seed but not its commit. Written against the position API as it stood
(`typed_cell(0, 0, "42")`), it failed. The write named `RecordHash(161215…)`,
which is South's record now sitting at row 0, where North's `RecordHash(246296…)`
was expected. So the ticket still reproduced. The frame would have passed the
basis check, because A had folded the fresh `Projection::Basis` along with the
rows.
**The fix makes the fault unwritable in the page.** The page's surface no
longer takes a position at commit. `edit_target(row, col)` is read when the
editor opens and returns the `RecordCell` as an opaque string, and
`typed_cell(target, text)` composes against it. The same test, rewritten for
that surface, passes. There is no position left at commit for a fault to
re-introduce. Rust-side `typed_cell_frame(row, col, …)` remains for tests
that compose at read time.
The page path is exercised end to end by `harness/tests/shell.spec.js`'s
"committing an edit sends one frame and shows the host's value", which goes
through `edit_target` and the new `typed_cell`. The whole harness passes
locally (69 passed, 4 skipped).
### yy_then_p_in_records_mode_writes_the_record_the_host_writes
improvise-6p8q in lockstep. The yank test in this file deliberately stayed on a
@@ -4405,3 +4556,564 @@ assertion. The tail yanks off the `Region` and `+` columns and requires the two
registers to agree; a TUI-only blank filter (`Text("")` to `None`) fails it on
`+`, which is why that filter was removed rather than kept. The evidence entries
for the host-side tests are in `command.md`.
### x_in_records_mode_clears_the_record_the_browser_clears
improvise-lx7w, both ends. The browser composes `ClearCell` of the projected
`CellId::Record`. Before the fix the host's `x` cleared the synthetic key and
the documents diverged in `both_ends`. South carries **two** measures. With one,
clearing it removes South's whole records row, the host resets its cursor on
the reframe, and `both_ends` reports a cursor divergence that belongs to the
fixture and not to `x`. The first version of the test did exactly that. The test
also asserts that South/Revenue is gone and North is untouched, because two ends
that both do nothing also agree.
### p_onto_a_formula_owned_cell_declines_on_both_ends
improvise-54g1, both ends, on `yank_host`'s `Profit` column (row 1, column 3).
The client must not compose a `Command::Model`, and the host's document must not
move and must say why. The control, Cost at column 2, must compose a write, so
a client that never writes cannot pass. The fold case,
`a_write_onto_a_fold_declines_on_both_ends`, runs the same helper for `p` and `x`
at row 2. Rival for all three: the host's paste/clear door restored to
`ctx.cell_key()`, which was `gh/main`'s code. All three fail.
### the worker is not called up until its first frame has arrived
improvise-i0v9. The worker transport wrote `in-page worker · <doc>` when it
spawned the worker, before anything had answered. A worker that then failed
therefore showed a success-looking status over a blank page. The spec records
every `#status` with `logStatus` and requires the whole log to be exactly
`["starting the worker · <doc>…", "in-page worker · <doc>"]`. It pins the order
as well as the final text, since a final read cannot see a claim made too early.
Measured failing against `gh/main` at `da2e5ab`. The rival, restoring the
immediate `setShell(\`in-page worker · ${DOC}\`)` in `workerTransport`, fails it
on the log's first entry. The final read is `toHaveText`, not `toContainText`.
The older specs' `toContainText("in-page worker")` could not tell the starting
text from the claim if the one contained the other, and the starting text
deliberately does not.
### only the first frame claims the peer answered, and not before it
improvise-i0v9, the unit half of the spec above. `arm(message, { answer })`
must write nothing until `sawFrame`, then write `answer` once, however many
frames follow. Fails against `gh/main`'s `createShell`, which has no `answer`
and so never writes the claim at all.
### a reason given before the first frame is not replaced by the frame's claim
The order `worker.js` really produces on a locked document: the `ReadOnly`
notice is posted after `start()` and **before** the Snapshot. So `fail` then
`sawFrame` must leave the notice's text as the only thing ever shown. The rival
is the obvious fix, `sawFrame` writing `answer` unconditionally. It fails both
this test and the one above: the first on its exact log, which is then written
twice. This test passes against `gh/main` by construction, since no claim exists
there. It is not evidence of the bug; it guards against the naive fix.
## Numbers across the serialized wire (improvise-6oy4)
No manifest enabled serde_json's `float_roundtrip`, so its default f64 parser
could read a number back one ULP away from the one written. Found by the
adversarial review of PR #178. That review measured 139,787 of 1M ordinary
decimals and 296,493 of 1M random-bit f64s coming back wrong. Every test that
passes `Command`s or caches **in-process**, which includes all of
`tests/lockstep.rs`, never serializes, so none of them could see it.
`LOSSY = 0.24815714285714285` is a value the default parser reads back as
`0.24815714285714283`. Both tests compare `f64::to_bits`, never `==`.
### `a_yanked_number_crosses_the_serialized_wire_exactly`
South/Revenue holds `LOSSY` at row 1, so the source is off index 0. The page
presses `j y y l p` through `press`, which serializes each frame to a real
`Host::handle` and folds the serialized replies. The value crosses the wire
twice: down as `CellDisplay::value` in the projection (#178), then up as the
`SetCell` the paste composes. Each hop has its own assertion, so a fix to one
hop cannot hide the other. `set_viewport` is called first. Without it the
client records no window and the cursor keys do nothing, which an early
version of this test found out.
**Fault injected:** no `float_roundtrip` in any manifest, the pre-fix tree at
`a6e3651`. Fails at the host-to-client assertion: yanked bits
`4598108823619341467` against `…468`.
### `a_typed_number_crosses_the_serialized_wire_exactly`
`typed_cell(1, 1, "0.24815714285714285")`. The client parses exactly with
`str::parse`, and the host re-parsed the JSON lossily. This path lost precision
before #178 too, as the review noted.
**Fault injected:** the same pre-fix tree. Fails: the host stored
`4598108823619341467` in South/Cost.
**Retired: `every_build_that_parses_a_frame_enables_float_roundtrip`.** It asked
`cargo tree` about three crates listed by hand. improvise-dz8c replaced it with
`every_crate_that_can_parse_json_enables_float_roundtrip`, whose entry is in
`tooling.md`. The injections recorded for it still hold: with the feature
removed from `improvise-wasm-client` alone, the two tests above stay green, so
they cannot guard the bundle alone.
### resolve::tests::every_grid_move_lands_where_its_binding_says
improvise-6562, found reviewing PR #180 at 949810c, and it is the same
laundering as the disarm, one level over. `heard()` said it judged by what moved
and in fact judged by command **type**: any `Local` command besides a status or a
disarm counted as `Acts`. The fixture sat at every edge — a 2x1 grid with the
cursor at (0,0), one tile, help page 0 — so about twenty movement keys that
clamp to nothing were counted as acting. Rebinding `jump-last-col` to
`GridMove::ToStart(false)`, so `$` and `End` go to the *first* column, left the
whole workspace green.
Two changes, because either alone is not enough. `heard()` now **folds** the
outcome and compares the view slice before and after (status and disarm aside),
counting a `Command::Model` as sent and a `Request` as an act; and `furnished_on`
stands on a 3x3 grid with the cursor at (1,1), three tiles with the cursor on
the middle one, a page axis on its middle item and help page 1, so no move is
clamped by an edge. Injecting `jump-last-col` as a `SetSelected` to where the
cursor already is now fails `every_advertised_command_answers_in_its_own_mode`
naming `NORMAL: $/End jump-last-col Silent`.
**The reviewer's own rival still passes that guard, and should.** From (1,1),
`ToStart(false)` moves to (1,0) — the view moved, so the key "acts". No
did-anything-happen check can see *direction*; only an expected destination can.
This test supplies one without a second table: a `move-selection` binding's
expected landing is read off its own args (the deltas), and the four jumps are
named by what they are called. Every key that runs one is pressed from the
centre — fourteen of them, with a floor so a blind walk fails. Under the rival
it fails naming `End` and `$`. The general point: the fixture that made the
laundering invisible (the edge) is also the one that makes a wrong-direction
jump visible, so moving off the edge fixed one blind spot and opened the other,
and each needs its own question.
### a_formula_row_click_ends_with_what_enter_does_on_that_row
improvise-km71, replacing `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
The old guard asserted that `Enter` was unbound, which tied the click to the
keyboard only by the message in its `assert!`. This one resolves `Enter` at
cursor 1 and asserts that the click on row 1, made with the cursor at 0, ends
with the same commands. Rival: `click_formula_row` activates at
`state.view.formula_cursor` instead of the clicked index. This test and
`a_formula_row_click_navigates_and_opens_the_editor_on_that_row` both fail. The
cursor and the index differ in the fixture, which is what lets them fail.
### renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host
improvise-km71. `editing_a_formula_from_the_panel_agrees_with_the_host` alone
cannot separate a browser that composes `ReplaceFormula` from one that composes
`AddFormula`: the upsert lands a same-target edit in place on the twin too.
Rival: `CommitKind::of` ignores the slot. Both lockstep tests fail. The edit
test fails only on the status line ("Formula added" against "Formula
updated"). This test fails on the **document**, which is the property. It also
pins a real divergence: on a rename the host says what was renamed and the
client cannot, because `FormulaId` is opaque and it does not know the old
target.
**improvise-w7qd, and the floor was the hole.** As first written the test
covered `move-selection` and a four-entry jump table, behind `pressed >= 14`.
Two things passed the whole workspace, both found in re-review of #180 at
544744c: negating the `scroll-rows` and `page-scroll` arms, so `Ctrl-d`/`Ctrl-u`
and `PgDn`/`PgUp` all scroll the wrong way (advertised, and still "acting" from
the centre); and dropping the jump table *with* `$` sent to the first column,
because the sixteen `move-selection` sequences met the floor on their own. The
test now walks **every** command Normal binds. `scroll-rows` and `page-scroll`
state their landing from their own argument exactly as `move-selection` does —
from the centre of a 3x3 grid any step of at least one lands at `1 + d`
clamped, so a sign is visible whatever the page size — and a command that moves
the cursor (in the same view) without a stated landing **fails** rather than
being skipped. So a dropped table entry is caught by derivation, not by a count:
under the second rival it fails naming every jump as moving with nothing stating
where. The first rival fails on `PgUp` (`page-scroll ["-1"]`) and, alone, on
`Ctrl-u` (`scroll-rows ["-5"]`). A last assertion that each kind was pressed at
least once guards the other direction — a binding vanishing — and is a named
list on purpose, since that is the one thing no walk over bindings can see.
**improvise-5kdr: the 3x3 fixture saw only a sign.** From the centre of a 3x3
grid every step of one or more clamps to an edge, so the w7qd version stated
`page-scroll` as `1 + d` — one row, which is not what it does — and passed
because the true landing clamped to the same edge. Measured on #198 at 8772db2:
`page-scroll` ignoring the page size together with `scroll-rows` moving one row,
or `move-selection` moving `2d`, left the test green. The walk now stands at
(10,10) of a 21x21 grid with an 8x8 window, far from every edge, so a landing
shows magnitude: `10+d` for `move-selection` and `scroll-rows`, `10 + page·d`
for `page-scroll`, 0/20 for the jumps. The page size is not written into the
test: `grid_move`'s page rule moved into `page_rows(window_rows)` and the test
asks it, so the rule has one home. Rivals, each run: `move-selection` doubled
fails on `Up` (`move-selection ["-1","0"]`); the page-size-and-one-row pair
fails on `PgUp` (`page-scroll ["-1"]`); the one-row `scroll-rows` alone fails on
`Ctrl-u` (`scroll-rows ["-5"]`); the jump-table rival still fails naming every
jump. The residual, stated: because the test reads `page_rows`, a change to the
page rule itself moves the test with it — whether that rule is the TUI's is
`tests/lockstep.rs`'s question, not this one's.
### an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open
improvise-0j0f, found by the review of PR #188 at 48f52e7 and confirmed
against a real `Host`. #188's first version captured the edited record's
*identity* when the editor opened, but claimed its *basis* at commit. Because
the fold runs on while the screen is frozen, the claim was the fresh counter.
The test runs the steps in this order:
1. A opens an editor on North/Revenue, which shows 1200.
2. B writes 999 to the same cell.
3. A folds only its own frames and does not repaint.
4. A commits "42".
It asserts that the host answers `Rejected(RecordMoved)` and still holds 999.
**Red first:** against the claim-at-commit version it failed, because the
commit was accepted. **Rival injected after the fix:** `typed_target_frame`
back to `basis.claim([record])`, which claims at commit. Only this test failed
(`12 passed; 1 failed`), and the 2wu3 test beside it stayed green. That is
right, because the two are separate properties: *which* record the commit
names, and *which version* of it the claim states. Reverted.
An earlier version of this entry said the reviewer's clear variant would be
`RecordGone`. It is not, and that claim was reasoning and not a test. The
clear variant is now tested by
`an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open`,
below, and the answer is `RecordMoved` (improvise-4img).
### an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open
improvise-4img: the clear variant of the 0j0f race above. The steps are the
same, except that in step 2 B *clears* North/Revenue instead of writing 999.
The test asserts that A's commit of "42" is refused with
`Rejected(RecordMoved)`, and that the cell is still absent afterwards.
**Why `RecordMoved` and not `RecordGone`.** After the clear, North has no cell
left, so `DataStore::resolve_record` answers `None`. But `App::resolve_record`
falls back to the layout, and the grid still draws the North row because
`prune_empty` is off. So the record still resolves, and the counter A claimed
at open has moved. `RecordGone` is for a record that nothing resolves at all;
this one is still on screen.
**Rival injected:** `typed_target_frame` back to `basis.claim([record])`, which
claims at commit. The commit was then *accepted*. With the refusal assertion
disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the
cell B cleared. Under that rival, the 0j0f test above failed as well. Both
files were restored.
### tile_select_opens_on_a_regular_category_on_both_ends
**What broke (improvise-oplp).** `EnterTileSelect` and the browser's
`enter-tile-select` resolver both set the tile cursor to 0, and tile 0 is
`_Index`, because `Model::category_names()` lists the virtual categories first.
In TileSelect, `Enter` and `Space` cycle the tile's axis and `r` sets it to
Row, so two keystrokes (`T Enter`, read as "confirm", or `T r`) put `_Index`
alone on Row and emptied a pivot grid. The browser worker persists every
accepted model command, so there the empty grid became the saved document at
once.
**The fix.** The cursor opens on the first regular category;
`_Index`/`_Dim` stay in the tile list, as #192 decided. The rule is
`improvise_protocol::opening_tile`, over `&[TileEntry]`, called by both ends.
The browser cannot tell `_Index` from a user category without sniffing names
(design-principles §2 forbids that), so `TileEntry` carries `regular`,
filled host-side from `CategoryKind` in `project::tiles_of`. That is the
"enrich the projection" shape of gotcha 47. The TUI's `EnterTileSelect` builds
the same list through `tiles_of` and asks the same function.
**The case.** `furnished_host`'s tiles are `_Index, _Dim, _Measure, Region,
Product`. Both ends press `T` then `Enter` through `both_ends`, which compares
the documents, mode, status and every cursor. The case then asserts that
`_Index` is still off-grid, that `Region` moved Row → Column, and that `_Index`
and `_Dim` are still tiles to the left of where the cursor opened.
**Red, and two rivals.**
- At base, both ends agreed and both put `_Index` on Row: "T Enter moved
_Index" (left `Row`, right `None`).
- The TUI alone reverted to opening at 0: fails at `T`, "client and host
disagree about a cursor".
- `regular` filled as `true` for every tile, so the rule opens on 0 on both
ends and they agree: fails on the axis assertion, "T Enter moved _Index".
The comparison alone cannot see this, which is why the case also pins which
category moved.
`harness/tests/contract.spec.js`'s "a resolved change repaints the widget it
moved" reached `Product` by pressing `ArrowRight` three times from tile 0,
which hard-coded the old opening position. It now asserts that `T` alone
selects `Product`, the demo's first regular category, so the same spec also
checks the fix in a real browser. At base it fails: tile select opens on
`_Index`.
### p_onto_a_sparse_record_writes_what_the_browser_writes
improvise-q9eg, both ends, on a **sparse** record: `Season` is added after
entering records mode, so no record carries it. `yy` on North/Revenue, then `p`
on South/Revenue (row 1), through `both_ends`, which compares the host's
document with the one the client's composed commands build. The host refused
while the browser composed `SetCell`, so the documents differed. The final
assertion that South holds 1200 is the half that keeps the test from passing
when both ends decline. Rival: `PasteAt::Resolved` back through `paste_into`.
This test fails.
### a_locally_resolved_page_scroll_lands_where_the_host_would_put_it
**What was missing (improvise-5xbq).** `tests/lockstep.rs` compared only `j`
and `G` against the host. The browser's page rule (`resolve::page_rows`) and
the TUI's (`Move`'s `Page` arm, `visible_rows * 3 / 4`) were two copies, and
two faults injected into the browser's copy left the whole workspace green:
`page_rows` returning `window_rows / 2`, and `grid_move` paging by
`window_cols`. The second was invisible for a further reason: the only window
the page-rule test used was square, `(8, 8)`.
**The fixture.** 20 rows by 3 columns; the host sees 8 rows and the client
announces `(8, 3)`. The three candidate rules then give three different
steps: 6 (three quarters of the rows), 4 (half the rows) and 2 (three quarters
of the columns). The walk starts off the edge (two `j`), then presses `PgDn`,
`PgUp`, `Ctrl-d` and `Ctrl-u`, including a `PgDn` that clamps at row 19.
**Why it pins the rows as well as comparing the ends.** `page_rows` is now the
one home (the TUI's `Move` calls it), so a wrong rule moves host and client
together, and a comparison alone passes. Each press therefore also asserts the
row the TUI's rule names.
**Injected, before and after the move to one home.**
| Fault | Two copies | One home |
|---|---|---|
| `page_rows` returns `window_rows / 2` | fails at press 2 (`PgDn`), "client and host disagree" | fails at press 2, "not the row the TUI's page rule names"; `app.rs`'s `page_down_scrolls_by_three_quarters_visible` and `page_up_scrolls_backward` fail too |
| `grid_move` pages by `window_cols` | fails at press 2, "client and host disagree" | fails at press 2, "client and host disagree" |
`Ctrl-d`/`Ctrl-u` are `scroll-rows ±5`, not the page rule. They are in the walk
because the ticket named them, and they move the start point of the next page
turn to a row the fixed rules alone would not reach.
### a narrow window still shows how to page through help — `harness/tests/help.spec.js`
**What broke (improvise-1c5m).** Below the width at which the app bar wraps,
`#help` (fixed, `inset: 2rem`, `z-index: 30`) covers `#hint`, and since PR #180
Welcome carries no paging instruction of its own. So a new user on Welcome at
phone width could not see how to reach page two.
**The fix is option (b) from the ticket.** The overlay draws `model.hint` in a
footer. In Help mode that is already `hint_line(Help)`, the same string `#hint`
carries, so it adds no derivation, and it sits outside `.help-body`, so
"Welcome is prose" still holds. The footer is `position: sticky` because the
overlay itself scrolls and Welcome is longer than a phone-height overlay.
**What the case asserts.** At 600x800 on Welcome, it reads the hint string from
`#hint`, so the spec spells no keys. It then requires some element showing
exactly that string to be the topmost element at its own centre, which is the
`elementFromPoint` measurement the ticket made by hand.
**Red, then a rival.** At base it failed with "the paging hint is under the
help overlay". With the fix and `position: sticky` changed to `static`, it
fails the same way: the footer exists but sits below the fold of a scrolling
overlay, so presence alone cannot satisfy the case.
**Faults injected:** the feature removed from `improvise-wasm-client` alone,
which fails naming that crate while the two tests above pass. Then removed from
`improvise-ws-server` alone, which fails naming it. The native path runs
without `--target`, so the two arms are different invocations.
### `an_untouched_browser_commit_writes_nothing_and_still_advances`
**improvise-ixxx**, browser keyboard. The fixture is a 3×1 grid with the cursor
on row 1. For each seed in `"007"`, `"1e3"`, `"inf"`, `"ERR:boom"` and
`"1.23456789"`, the test folds `i`, checks the buffer is that seed, and resolves
`Enter`. The composed commands must contain no `Command::Model`. They must also
contain `SetSelected { row: 2, col: 0 }`, the re-entry
`ChangeMode(editing)`, and `SetOpenedEditor` capturing `"800"`, the cell it
landed on.
Failed before the fix: `"007"` composed `SetCell(Number(7.0))`. **Rival:**
`resolve::commit` returning `[ChangeMode(Normal)]` when untouched, which skips
the advance along with the write. This test fails alone.
### `an_untouched_browser_edit_does_not_overwrite_a_write_made_while_it_was_open`
The captured-seed constraint, in the browser. The test opens the editor on
`"007"`, then folds a `GridUpdate::Cells` that moves the cell to `"5"`. That is
the projection another client's write produces. `Enter` with nothing typed must
compose no `Command::Model`.
**Rival:** `written` comparing against `edit_seed(state)`, which re-reads the
cache at commit time, instead of `ViewState::opened_editor`. This test fails
alone, composing `SetCell(Number(7.0))`. The basis claim is taken when the
commit is composed, so it would not have refused that write.
### `a_touched_browser_commit_still_writes`
Scope guard. The reviewer's fourth constraint says classification is out of
scope. `Backspace` over the seed `"7"` and then typing `007` must still compose
`SetCell(Number(7.0))`. It passes before and after. It is here so that a fix
which suppressed number-shaped text in general, rather than untouched text,
fails.
### `an_untouched_browser_edit_of_a_misread_value_writes_nothing`
The same shape as `an_untouched_browser_edit_stores_the_identical_value`, with
the values `typed()` misreads: `Text("007")`, `Text("1e3")`, `Text("inf")` and a
raw `Error("boom")`. It runs through the resolver and into a real host. Both
tests share `untouched_browser_edit`, which now also asserts that the buffer
after `i` is `value.edit_text()`. That assertion keeps the umi4 test
discriminating once untouched commits write nothing (see
`command.md`, `an_untouched_pivot_edit_of_a_misread_value_writes_nothing`).
With `project.rs` seeding from `to_string()`, both umi4 browser tests fail.
**Rival:** `untouched` always `false`. This test fails.
### `a_mouse_commit_of_the_untouched_seed_composes_nothing`
**improvise-ixxx**, browser mouse. The page's click editor used to commit
unconditionally, so an untouched click-edit re-sent the seed through `typed()`.
Since the merge with #188, the seed is part of `EditTarget` (`opened`).
`edit_target_at` captures it from the cache's `edit_text`, and
`typed_target_frame` starts with `untouched`. The page passes no seed, so the
JS rival of the first version (a page passing the wrong string) cannot be
written any more.
For `"007"`, `"ERR:boom"` and `"1.23456789"`, the test opens a target on a
record cell whose edit text is that value. It asserts the captured seed, then
commits the same text, which must compose nothing. The cell's **drawn** text is
different on purpose (`"drawn …"`). The control commits `"12000"` and `"1200 "`
against the seed `"1200"`, and both must compose a write, so a
`typed_target_frame` that never composes cannot pass, and neither can one that
trims. **Rivals:**
- `edit_target_at` capturing the drawn `text` instead of `edit_text` fails the
seed assertion.
- `typed_target_frame` without the `untouched` check fails this test and the
next.
- `seed.trim() == text.trim()` in `untouched` fails the `"1200 "` control.
### `a_mouse_commit_is_judged_against_the_seed_it_opened_with`
The captured-seed constraint, on the mouse path. The target is taken on
`"1200"`. A projected write then moves the cell to `"5"`, and a guard asserts
the cache really holds `"5"`. Committing that target with `"1200"` must compose
nothing. **Rival:** `typed_target_frame` comparing against the cache's
`edit_text` at commit instead of `target.opened`. This test fails alone.
The page half is `harness/tests/contract.spec.js`, "an untouched mouse editor
sends no write, and a touched one does". It wraps `Worker.prototype.postMessage`,
opens the editor on cell 4, and presses `Enter`. No `SetCell` may be posted.
Then it types `1` and presses `Enter`, and exactly one must be.
Both gaps this entry used to list are closed: the stale-paint seed
(improvise-ugj4) and the missing `touched` flag (improvise-jibp). See the
entries that follow.
### `a_mouse_target_marked_edited_writes_its_own_seed`
**improvise-jibp**, the Rust half. A target on the seed `"1200"` commits
`"1200"` and composes nothing: that is the control. `edited()` must leave the
seed where it was, and the same text committed through the edited target must
compose a write. **Rival:** `edited()` leaving `touched` false fails this test
and `the_page_opens_its_editor_on_what_the_target_captured`, and nothing else.
### `the_page_opens_its_editor_on_what_the_target_captured`
**improvise-ugj4 and improvise-jibp**, through the wasm-bindgen surface the
page calls. The cell is drawn `"1,200.50"` and stored `"1200.5"`, and
`edit_seed` must return `"1200.5"`. A projected write then moves the cell to
`"777"`. A target taken afterwards seeds `"777"`, and the first target still
seeds `"1200.5"`, because a target is one capture. `typed_cell(later, "777")`
composes nothing, and the same text through `edited_target(later)` composes a
write. **Rival:** as above, `edited()` leaving `touched` false fails here. I
wrote no rival for an `edit_seed` that re-reads the cache. `EditTarget` names a
record, not a position, so that rival would need a lookup the client does not
have.
### a mouse editor typed back to its own seed still writes — `harness/tests/contract.spec.js`
**improvise-jibp**, the page half. The editor opens on cell 4, and the test
types the seed over the selection and presses Enter. Exactly one `SetCell` must
be posted. Before the fix the page sent `typed_cell(target, seed)` with an
untouched target, which composes nothing. **Rival:** delete the page's `input`
listener. This test fails, and the rest of `contract.spec.js` passes (16).
### an editor opened after an unpainted write shows and commits what it captured — `harness/tests/contract.spec.js`
**improvise-ugj4.** The test opens an editor on cell 0, so `repaint` stops.
While it is open, it delivers a synthetic frame that moves cell 4 to `777`. It
builds that frame from the last `Cells` entry the page actually received for
cell 4, changing only `text`, `edit_text` and `value`, so the record and the
version are real. Then it opens cell 4. The `<input>` must show `777`, and
Enter with no edit must post no `SetCell`. **Rival:** seed the `<input>` from the
paint (`drawn.replaceAll(",", "")`). This test fails on the value, and so does
"an untouched mouse editor sends no write, and a touched one does", because the
drawn text is not the seed. The other 15 pass.
### `a_browser_editor_typed_back_to_its_seed_still_writes`
**improvise-2054**, the browser keyboard. The fixture is a 3×1 grid, cursor on
row 1, with the cell's edit text `"7"`. The test folds `i`, then a projected
write that moves the cell to `"5"`, then Backspace and `7`. Enter must compose
`SetCell(Number(7.0))`. After the Backspace, `opened_editor.touched` must be
set. **Rivals:**
- `untouched` ignoring `touched` fails this test and
`untouched_is_exact_and_honours_an_edit`.
- The `pop-char` arm not marking fails this test.
- The `append-char` arm not marking fails only
`only_an_edit_of_the_editors_buffer_marks_it_touched`, because the pop has
already marked the editor.
### `only_an_edit_of_the_editors_buffer_marks_it_touched`
`x` and Backspace, each on its own on a freshly opened editor, must each set
`touched`. `typed_into` of the `command` buffer with an editor open must
compose the `SetBuffer` and nothing else. **Rivals:**
- The `append-char` arm not marking fails the `x` case.
- `edited` without its `buffer == "edit"` check fails the last assertion.
That second rival is the one that matters. Without the buffer check, typing
`:enter-mode …` would mark the stale editor. That would make the `EnterMode`
guard redundant, and
`a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed`
could no longer see the guard.
### `a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed`
**improvise-wv3h**, the browser half. The steps:
1. `i` on a cell whose edit text is `""` captures the seed `""`.
2. Esc.
3. A projected write moves the cell to `"9"`.
4. `composed(enter-mode editing)` is folded.
5. Enter, with the buffer `""`.
Enter must compose `ClearCell`. **Rival:** the `SetOpenedEditor(None)` in
`resolve::entered` deleted. This test fails alone, composing no write.
No browser key is bound to `enter-mode editing`, and `:` is declined in the
browser (`stranding`). So `entered` is reached here through `composed` directly,
and that guard is currently defence in depth.
### `untouched_is_exact_and_honours_an_edit`
The shared function's contract, stated once: `"7"` against its own untouched
seed is untouched. `"7 "` and `" 7"` against the seed `"7"`, `"7"` against
`" 7"`, and `"A"` against `"a"` are all touched, and so is a `touched` editor spelling its seed. **Rival:** comparing
`seed.trim() == text.trim()`. This test and
`socket::tests::a_mouse_commit_of_the_untouched_seed_composes_nothing` fail. On
the keyboard paths a trimmed comparison cannot be seen through the keys, since
any whitespace edit also sets `touched`, so this unit test and the mouse path
are where it shows. The mouse test now also asserts that `"7 "` against the
seed `"7"` is a write.
### `resolve::tests::the_editor_an_advance_reopens_starts_untouched`
**improvise-465t**, the browser half. The fixture is a 3×1 grid with the cursor
on row 1. The test presses `i`, types `9`, and asserts the first editor really
is touched. Enter must then land on (2, 0), and the folded `opened_editor` must
be exactly `OpenedEditor::on("800")`. **Injection:** `resolve::advanced`
carrying the previous editor's `touched` flag. This test fails alone.
### an_edit_opened_before_a_document_swap_is_refused_in_the_new_document
improvise-vndi, through a real `Host` over `MemoryFs`. A opens an inline editor
on North/Revenue, which shows 1200. B sends `LoadModel` for a document that
differs only in that cell, which holds 5555. Every name, the view included, is
kept, per gotcha 39's fixture warning: a rename would relay a `SwitchView` and
hide the bug. A folds the rebase `Snapshot` with the editor still open, as
`web/app.js` does, and then commits "42". The `EditTarget` held `{cell,
version}` and `claim_as_read` paired that version with the *current*
generation. `RecordHash` is coordinate-derived, so the same record resolved
in the new document at a matching counter, and the host **accepted** the
commit. It failed at the `DocumentReplaced` assertion: the answer was
`Accepted`, and 5555 had become 42.
Rival injected: `claim_as_read` ignoring the captured generation and claiming
the current one. That is the pre-fix behaviour, restated after the fix. Only
this test failed in the whole workspace. The layout tier staying current is
still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`.
File diff suppressed because it is too large Load Diff
+159 -9
View File
@@ -476,12 +476,6 @@ cell in the row).
The pair `live_records` yields is not two facts that could drift apart: the
surrogate is the record's own, so it is the one that resolves it back.
### a_deserialized_store_can_resolve_its_records
The record index is derived state and is `#[serde(skip)]`-equivalent — it must
come back *with* the data, rebuilt through `set`, rather than being written
alongside it.
## `model/record.rs`
### a_records_surrogate_is_pinned_to_a_literal
@@ -939,7 +933,12 @@ the regression was live whenever `prune_empty` was on.
The fix distinguishes the two emptinesses **without consulting the `View`**,
which `GridLayout` has already dropped: a run that contained no `DataItem` at
all was never expanded, i.e. collapsed, and its header survives; a run that
contained items the predicate rejected is genuinely empty and goes. A
contained items the predicate rejected is genuinely empty and goes. **Superseded
twice** (see `an_outer_group_whose_rows_all_prune_loses_its_header`). Since
improvise-6qet a collapsed group emits its fold as a `DataItem`, so no flat run
is item-less. The one run that still was, an outer group's header followed
directly by an inner group's header, was kept by that arm when it should have
gone (improvise-b5zh). The arm is deleted. A
`GroupEnd` is kept whenever a run is open in the output, so a re-opened earlier
header is closed even though the header between them was pruned. That keeps
gotcha 1's shape — still a predicate over the data index, no `&View`
@@ -1341,8 +1340,8 @@ this fixture and the value is absent — this test, the next, and
Under `Axis::None` (`Store`), `Avg` and `Count` over `Jan/A=2, Jan/B=4, Feb/A=9`.
The group's average is `5` and its count `3`; folding the members' aggregates gives
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (the
formula-target branch taken for stored values) — this test fails. `Sum` cannot see
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (what the
formula-target branch did before improvise-eccl) — this test fails. `Sum` cannot see
the difference, which is why the fixture sets `measure_agg`.
### model::types::model_tests::an_item_named_like_a_group_is_read_as_the_item
@@ -1361,3 +1360,154 @@ fold is the row `z` names the group from. Note the narrowing this implies: an
**empty** collapsed group now has an empty fold row, which `prune_empty` drops, and
its header goes with it. lhi4 kept that header because it was the only way back to
the rows; the fold is now that way back.
### an_outer_group_whose_rows_all_prune_loses_its_header
improvise-b5zh asked whether `close_pending_run`'s "a header whose run saw no item
is collapsed, and survives" arm was dead since improvise-6qet, and proposed
deleting it. It was **not dead**, and where it ran it was wrong. With two grouped
categories on one axis (`Region` with `North[Upper]`, `South[Lower]`, outside
`Month` with `Jan`, `Feb` in `Q1`), `cross_product` puts each outer header
directly before an inner one: `[Upper, Q1, North/Jan, …]`. The inner header
closed the outer header's run as "saw no item", so `Upper` was kept even with
every North row pruned. At `1a6b877` the pruned axis was
`[Upper, GroupEnd, Lower, Q1, South/Jan, South/Feb]`, an orphan header drawn
over nothing. The fix keeps the pending headers as a stack: a header closes the
pending header of its **own** category and everything nested inside it, a kept
item flushes the whole stack, and a `GroupEnd` drops the innermost. Rival: a
header clears every pending header, which is the flat reading of "a header ends
the previous run". Only this test fails, because `Lower` is lost when `Q1`
arrives. The flat-case tests (lhi4, u6gc, the partly-pruned group) pass
unchanged under both versions, which is why none of them could see this.
One pre-existing shape is left alone and stated: two consecutive `GroupEnd`s
(an inner and an outer run ending together) keep only the first, because
`run_open` is a single flag. That was true before this change, pruned or not.
### records_steps_are_the_spine_then_every_non_index_dim_category_on_none
improvise-t0ma. Before this, `records_steps` was guarded by one incidental test: the
`is_index_or_dim() → is_measure()` rival failed only
`save_with_unfinished_record_is_readable`, a persistence test that happens to enter
records mode. This asserts the **steps**, not the effects `cmd/grid.rs` translates
them into, because the steps are the model-side answer a browser will also consume.
The expected list filters `_Index`/`_Dim` by **name**, not by `CategoryKind`, so it
is not a second copy of the predicate under test, and the setup asserts `_Measure`
and a regular category are both present, since a model with neither could not tell
the two predicates apart. Rival: `is_measure()` in place of `is_index_or_dim()`.
This test fails, because `_Measure` is dropped and `_Index`/`_Dim` are put on `None`
after the spine that just placed them.
### drill_steps_filter_each_coordinate_and_free_the_rest
improvise-t0ma's companion for `drill_steps`. The fixture includes a `none_cat` that
the key also fixes (`Region`) and one that is virtual (`_Index`), so both exclusions
are exercised. It also lists the key's coordinates out of sorted order, which checks
that the steps follow `CellKey`'s canonical order. Rival: `Axis::Page` in place of
`Axis::Filter` for a fixed coordinate, which reopens improvise-dz8 (a drilled
coordinate the user could page away from). This test fails.
### a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs
improvise-eccl. `Margin = (Revenue - Cost) / Revenue` with Jan `10/5` and Feb `30/3`
under `Q1`, and Apr in `Q2` with no data, so the Q1 fold is row 1. The member ratios
are 0.5 and 0.9, so the three candidate rules give three different answers: summed
1.4, averaged 0.7, pooled `(40 - 8) / 40` 0.8. The expected value is the **pooled**
one, and it is also asserted equal to the same document with Month on `Axis::None`,
which is the ticket's criterion. The None-axis document works because Apr carries
no data, so the None axis pools exactly Q1's months. At `a164726f` the fold read
**1.4**. The ticket's own fixture (0.5 and 0.5) would have separated sum from
pool, but not mean from pool. The second formula set chains
(`Profit = Revenue - Cost`, `Margin = Profit / Revenue`), so a reference to
another formula target is read over the fold as well. A rule that folds a formula
ref's members while pooling raw refs fails there.
### a_folds_aggregate_formula_pools_its_members_records
The guard on the other side of improvise-eccl. Once a formula at a fold is
evaluated at the group key, an aggregate inside it (`SUM`, `MAX`) must pool every
covered member's records, because `Month=Q1` matches no record. `MAX(Revenue)` is
the discriminating case. It **failed before the change too**: the old rule folded
the members' `MAX`es with the measure's `Sum`, giving 40 where the answer is 30.
`SUM` alone agrees under the old rule and the new one. Rival: `contexts_of` stops
pooling at a fold (`Reach::Fold { .. } => Vec::new()`). This test alone fails.
### collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was
improvise-f2oo. `Q1 = {Jan 3, Feb 4}`, `Q2 = {Apr 100}`, Feb hidden. Expanded, the
drawn rows total 103. At `a164726f` collapsing gave a Q1 fold of 7 and a total of
**107**: `z` moved the total. The ruling: a fold covers the members the grid
draws. The test asserts 103 both ways and a fold of 3. It then hides Jan too and
asserts that there is no Q1 row and a total of 100. That half already held, and it
is the inconsistency the ticket named: before, a hidden value appeared only while
a sibling was visible. Rival: `GridLayout::evaluate` covering every member
(`&|_, _| true`). This test alone fails, with 107.
### a_folds_aggregate_filtered_on_the_folded_category_counts_each_record_once
Found by the ChatGPT Codex review of PR #203, at head `14acf46d`. Pooling an
aggregate over a fold's member contexts ran `agg_values` once per member, and an
inline `WHERE` on the **folded** category overwrites that coordinate, as it does
at a record. Every member therefore became the same record set.
`SUM(Revenue WHERE Month = "Jan")` on the Q1 fold read **20**, Jan twice. The
test asserts 10, and equality with the Month-on-None document. The fix dedupes
the member contexts after the filter is applied, so each record set is pooled
once. The rival is the pre-fix code, which read 20. `a_folds_aggregate_formula_pools_its_members_records`
could not see this, because none of its aggregates carried a filter.
### a_fold_evaluates_each_formula_it_reaches_once
improvise-o1y0, from PR #203's review. A reference from a fold's formula to
another formula target is evaluated over the fold in turn, with nothing
remembered, so a doubling chain `F{i} = F{i-1} + F{i-1}` re-evaluated
exponentially. The test **counts** formula evaluations rather than timing them,
through a test-only thread-local counter. At `2a4f35d7` evaluating `F8` on one
fold took **511** evaluations (2⁹ − 1); with the memo it takes 9. It also
asserts the chain's value (7 × 256), so a memo that returned the wrong entry
cannot pass. Rival: keep the in-progress marker but drop finished values from
the memo (`.filter(Option::is_none)`). This test fails with 511.
### a_cycle_at_a_fold_is_still_circular
improvise-o1y0's other half. `P = Q + 1`, `Q = P + 1` at a fold must read
`Error("circular")`. The memo's in-progress marker is what answers it now,
because the depth bound the fold path used before is gone. Without the marker
the evaluation recurses until the stack runs out, which is why the marker is
there rather than being an optimisation.
### one_formula_at_two_fold_keys_is_evaluated_at_each
improvise-d7aj, found by the adversarial re-review of PR #203 at `b3379d1a`. The
fold memo in `fold_formula_once` is keyed by the whole `CellKey`, and nothing
pinned that: both tests above keep one key context throughout, so a memo keyed
by the formula alone passed the whole workspace. A reference to an item of
**another** category moves the key while `_Measure` names the same formula.
Stores A (Jan 3, Feb 4) and B (Jan 100, Feb 200), `G = Revenue * 2`,
`F = IF(Revenue > 50, G, G + B)`. At `(Q1, A, F)` Revenue is 7, so F is
`G + B`: G at A is 14, and `B` reads F at `(Q1, B)`, where Revenue is 300, so
that F is G at B, 600. The answer is **614**. Rival: the memo keyed by
`formula.target` alone. F is still in progress at `(Q1, A)` when F at `(Q1, B)`
is asked, so that key reads `circular`. Injected into a full workspace run, it
fails this test and nothing else. The reviewer's probe, now committed.
### a_diamond_at_a_fold_is_not_circular
improvise-d7aj's second case, which passed at `b3379d1a` and was pinned by
nothing. `A1 = B2 + C3`, `B2 = D * 2`, `C3 = D * 3`, `D = Revenue` reaches D by
two paths in one fold evaluation, so the second reach must find D *finished*
and not *in progress*. It asserts **35** over D = 7 and exactly four
evaluations, so a memo that re-evaluates D passes neither. Rival: never store a
finished value, so only the in-progress marker stays. This test fails with
`circular`, and so does `a_fold_evaluates_each_formula_it_reaches_once`, whose
doubling chain is also a diamond at every step. So this test is a named pin of
that shape rather than the only guard against it.
### a_fold_answers_is_and_where_on_its_category_as_the_none_axis_does
improvise-lnkb, ruled by the orchestrator: on a fold, the folded category reads
as absent. `Flag = Month IS "Q1"` and `Marked = Revenue * 2 WHERE Month = "Q1"`
are each compared with the same document with Month on `Axis::None`. At
`689c7665` `Flag` read **1** on the fold and 0 on None. The final assertion pins
`Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell
cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`).
This test fails.
+32
View File
@@ -1434,3 +1434,35 @@ rather than defaulted: the alternative — expanding the group in the export —
reintroduce the iot6 divergence between what the user sees and what they export.
On the pre-6qet tree the body is empty (the group had no row, and pruning left
nothing), so the test fails there.
## Legacy JSON documents (improvise-6yvg, improvise-tt22)
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit` (improvise-6yvg) pinned
that the legacy JSON load path read numbers exactly. improvise-tt22 removed that
path, so the test went with it. Its other role was to witness `improvise-io`'s own
`float_roundtrip` declaration. The manifest rule in
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
still covers that, because `improvise-io` keeps a normal `serde_json`
dependency for its import types (`context/test-evidence/tooling.md`).
### `a_legacy_json_workbook_is_refused_by_name`
improvise-tt22, on the user's direction on PR #210: `.improv` text is the only
native format. A document starting with `{` used to be deserialized as a
`Workbook`. The test writes a JSON workbook in the shape the old loader read,
both plain and gzipped, and asserts the error names "legacy JSON" and says "no
longer supported".
**Red at `52c6a05`**: `Failed to deserialize workbook: missing field
`next_category_id``. That is a parse error, which is exactly what the ticket said
the refusal must not be. A complete legacy document instead loaded successfully,
which the since-deleted bit-for-bit test above demonstrated.
**Rivals, each run:**
- **Deleting the refusal.** The text falls through to `parse_md`, and the test
fails on `legacy.improv` with "Parse error at line 1, column 1 (expected
version_line)". A parse error does not name the format.
- **Refusing only when the path is not gzipped.** The plain case passes and the
test fails on `legacy.improv.gz` with the same grammar error. So the gzip
case is what makes the check prove it runs on decompressed text.
+167 -5
View File
@@ -557,7 +557,7 @@ the evidence that nothing was relying on the permissive parse.
### render::tests::two_runs_under_two_headers_of_one_name_are_two_runs
### render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them
### render::tests::the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them
improvise-h0iv. `group_runs` coalesced adjacent positions by group **name**, so
two runs under two different headers sharing a name read as one and both front
@@ -571,10 +571,12 @@ and nothing ungrouped between the runs. Before the fix it returns
at the precondition rather than pass with a single run.
The second test exists because the ticket proposed the wrong fix and a reader
will propose it again. h0iv says `FrameGroup` carries `cat` and "comparing the
pair would fix it" — but its own reachability case is a single category nested
under an outer one, so both headers are `(Month, Q1)`, equal as values. The test
asserts that equality directly. What actually separates them is that they are
will propose it again. h0iv said `FrameGroup` carried `cat` and that "comparing
the pair would fix it". But its own reachability case is a single category
nested under an outer one, so both headers are `(Month, Q1)`, equal as values.
The test asserts that equality directly. Since improvise-7xyx removed
`FrameGroup::cat`, it asserts equality of the whole header, which is stronger:
no field-wise comparison can separate two equal values. What actually separates them is that they are
two distinct entries in `self.entries`, which `std::ptr::eq` on the borrowed
header states exactly.
@@ -970,6 +972,27 @@ give `None` and fail. Asserting only that `"command"` survived would pass under
either behaviour, which is what the old spelling amounted to for the cleared
key.
### render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart
`CellDisplay`'s equality is what the grid diff and the seed property run on
(improvise-4w09). It compares `value` by `same_on_the_wire`: bits, except that
every NaN is equal. It pins both clauses, since `a_grid_holding_nan_quiesces`
can see only one of them. That test compares a NaN with its own bits,
in-process.
- A NaN with the sign bit set, the shape `0.0 / 0.0` produces on x86, equals
`f64::NAN`, the shape `parse_nonfinite("nan")` gives a client. Both go out as
the one token `"nan"`.
- `0.0` differs from `-0.0`: serde_json writes them as `0.0` and `-0.0`, so a
change between them is real and must be sent.
- `Number(7.0)` differs from `Text("7")`, and a value differs from none.
**Faults injected:**
- `f64 ==` (the pre-fix semantics). Fails at the NaN case.
- Bit equality only. Fails at the NaN case, while `a_grid_holding_nan_quiesces`
**passes**, which is why this test exists.
- `f64 ==` plus the NaN clause. Fails at the `0.0`/`-0.0` case.
### vocabulary_is_composed::a_variant_named_only_in_a_comment_or_string_is_not_composed
improvise-c7mu, found by the post-merge review of PR #160. `constructed_in` ran
@@ -1029,3 +1052,142 @@ Faults injected, one per half, each compiling only `improvise-protocol`:
A field added to an existing variant is caught too, but not in isolation,
because every constructor of that variant also fails. Reverted with
`git restore`.
### source_scan::tests::a_test_only_item_hides_nothing_after_it
improvise-59q8, found by the PR #172 review. The scanner's line-based
`strip_test_items` looked for `}` at the attribute's indentation, or for an item
line ending in `;`. So three shapes were never bounded, and it dropped
**everything to the end of the file**:
- a test-only `const … = &[\n … \n];`;
- a `#[cfg(test)] let x = foo(\n 1,\n);` statement;
- a one-line `#[cfg(test)] use foo::bar;`, where the attribute's own line is
also the item line.
In `vocabulary_is_complete` that silently hides every effect after such an
item. Each shape is a case here, asserting that the production code after it
(`Hidden1`, `Visible`) survives. Against the line scan all three failed; the
first printed `"impl X {\n"` and nothing else. They now pass because items are
bounded by balanced brackets on the blanked code.
`an_unbounded_test_only_item_is_refused_not_swallowed` pins the other half of
the acceptance criteria: an item that cannot be bounded panics instead of
consuming the rest of the file. Against the line scan it failed, because
nothing panicked.
### source_scan::tests::a_test_only_cfg_is_recognised_whatever_its_spelling
improvise-59q8, the other direction: test code counted as production, which
makes `vocabulary_is_composed` weaker. `cfg(all(test, not(target_arch =
"wasm32")))` was kept because the old rule rejected any line containing
`not(`. A leading `#![cfg(test)]` was kept because only `#[cfg(` was
recognised. An attribute split across lines was not seen at all. All three
failed against the old scanner. The predicate is now parsed and evaluated in
three-valued logic (`context/code/protocol.md` §2). The controls
(`cfg(not(test))`, `cfg(any(test, feature = "serde"))` and `cfg(target_arch =
…)`) must stay production, so a scanner that dropped every `cfg` item could
not pass.
A consequence to know about: `cfg(any(test, feature = "x"))` for an ordinary
feature is now production. Only `feature = "test-support"`, the one test
feature this repo has, counts as a test build.
`test_only_items_and_modules_are_not_production` was updated to that spelling.
### source_scan::tests::a_c_string_literal_is_blanked
improvise-59q8's minor case. In `cr#"x" Command::InRawC(2) "y"#` the `r` has a
word character before it, so the literal was lexed as two ordinary strings,
and the text between the inner quotes leaked. It failed as
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
this test used a C string with no inner quotes and passed against the bug,
because the ordinary lexing happened to blank it too.
### help::tests::no_help_row_spells_a_key_by_hand
improvise-wk18. The help pages had one kind of row whose keys the keymap never
saw: `literal("gg", …)`, `literal("yy", …)`, `literal("ZZ", …)`, and a dim tip
reading "press Tab or l/n". Every other key row comes off `keys_for`, so a
rebinding reaches it; these would have gone on saying `gg` after `g g` stopped
meaning anything, and no guard could see them because a string carries no
command name. At HEAD the test failed naming all four.
The fix is structural rather than a second scanner. The three prefix rows are
ordinary `keys(..)` rows now — `keys` also asks `Keymap::prefixed_keys_running`
for two-key sequences. The tip is **gone** rather than derived: made a `keys` row
in `ModeKey::Help`, it gave the Welcome page its one row with a description,
and `harness/tests/help.spec.js` ("? opens help, and its rows carry the keys and
the words") failed in CI on `welcome.every(row => row.description === null)` —
Welcome is prose by contract. The Help mode's own status hint already derives
`next-page` from the keymap, so the literal was a second table (improvise-rr8n's
rule: keep the one derived from the thing it describes).
`literal()` and `dim()` are gone; the only way left to build a
`HelpLine::Literal` is the `ex!` macro, which writes the leading colon itself, so
what remains hand-written is ex-command text — checked against the parser by
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`, which reads
exactly the text a user would type. This test is what stops a direct
`HelpLine::Literal { keys: "gg", .. }` coming back past the macro.
### help::tests::every_keys_row_the_pages_ask_for_finds_its_keys
A `keys` row whose lookup finds nothing is **dropped**, by design — a page never
advertises a key that does not exist. The cost of that design is that a broken
lookup looks like a shorter page: `every_help_page_key_row_names_keys_that_exist`
only checks the rows that survived, plus a floor of twenty. So this asks the
question from the other end, over `help_bindings()` — every row the builders
*asked for* — and requires each to find a key.
The injection: comment out the `prefixed_labels` extension in `keys`. The three
prefix rows vanish from the page, every other help test stays green, and this
fails naming `Normal jump-first-row`, `Normal yank` and `Normal wq`.
### help::tests::an_ex_row_can_name_placeholders_and_nothing_else
improvise-v29m, found reviewing PR #180 at 949810c. `no_help_row_spells_a_key_by_hand`
read only `HelpLine::Literal`, and its premise — that `keys()` is the only way to
make a key row — was a convention: `HelpLine::Keys { keys: Vec<String>, .. }`
was a public variant with public fields, and `help_bindings()` records only what
the builders *ask* for, so `HelpLine::Keys { keys: vec!["Q"], description: "Quit
instantly" }` typed straight into `panels()`, with `Q` bound to nothing, passed
the whole workspace. So did `ex!("q or press Q", ..)`: the parser test splits on
colons and `parse_line` accepts trailing words.
Both are now **compile errors**, which is why this test is not where the guard
lives. `HelpLine::Keys` carries a `KeyRow` whose fields are private to a module
whose one constructor, `KeyRow::looked_up`, does the keymap lookup; the injected
row fails with E0559/E0451. `ex!` evaluates `ex_usage_is_placeholders` in a
`const` item, and the injected usage fails with `evaluation panicked: assertion
failed: ex_usage_is_placeholders("q or press Q")`. The rule cost one row:
`:add-items <cat> a b c ...` had three bare words and is now
`:add-items <cat> <item> ...`. This test pins the rule the macro asks, in both
directions, including that shipped usage and the rival.
### help::tests::every_keys_row_drawn_is_one_the_builders_asked_for
improvise-ssoe, found in re-review of #180 at 544744c. `KeyRow` closed
hand-typed keys, but `KeyRow::looked_up` was `pub(super)`, so a page builder
could call it directly instead of through its `keys` parameter — and
`help_bindings()` records only what goes through the parameter. Injected into
`editing()`: `KeyRow::looked_up(n, "clear-cell", None, "Edit the current
cell")`, which captions `x` as editing the cell — the exact lie `jhdf`'s test
exists for — and the whole workspace stayed green, because both guards that
would object read `help_bindings()`.
Two changes. `looked_up` is now private to the `key_row` module, whose one
caller is the module's own `keys` — the lookup `pages()` hands every builder —
so the reviewer's injection is `E0624: associated function looked_up is
private`. That narrows the door without closing it: a builder can still reach
the module-level `keys` by path (`self::keys(..)`) rather than through its
shadowing parameter. So this test states the premise directly — the number of
keys rows drawn equals the number of rows the builders asked for (every asked
row finds its keys, by `every_keys_row_the_pages_ask_for_finds_its_keys`) —
and the `self::keys` bypass fails it `51 != 50`.
**improvise-aszt: a count is not the premise.** The test above compared
*counts*, so a bypass that also drops one real row keeps them equal. Injected in
`editing()`: call the parameter `keys(e, "clear-buffer", …)` and discard it with
`let _ =`, then return `self::keys(n, "clear-cell", None, "Edit the current
cell")` in its place. The asked row is recorded and never drawn, the drawn row
is never recorded, 50 = 50, and the whole workspace stayed green. The test now
compares **content in order**: the drawn keys rows must equal
`help_bindings()` looked up through the same `keys`, row for row. The same
injection now fails. What still passes is a bypass that draws exactly what the
builders asked for, in the order they asked — which is harmless by definition.
+237
View File
@@ -610,3 +610,240 @@ to git. A later `add` is excused. After `git checkout --` and a hand loss, the
its `.base`, `.ledger`, `.removed` and `.stamped` siblings and `.lock`. `git
status --untracked-files=all` and `git add -A --dry-run` both show none of
them, and `git check-ignore -v` names `.gitignore:40` (`prolog-sessions/*`).
### The pr-watch mergeability cases — `scripts/pr-watch-test.sh`
improvise-edpl. The cases are injected faults in themselves. Like the other
`scripts/` suites, none of their names is a Rust `fn`, so the case list is the
evidence. The reasoning is in `context/code/tooling.md`, "pr-watch and
mergeability".
**The bug, reproduced before the fix.** Against the script as it stood at
`ae96b8c`, 19 of the 34 new cases failed and all 19 exited **0**.
`UNKNOWN that resolves to CONFLICTING fails` is PR #172's shape exactly: every
check green, `UNKNOWN/UNKNOWN` on the first poll, `CONFLICTING/DIRTY` on the
second. The old script broke out of the loop on the first poll and passed. The
fixtures are hand-built. They are the subset of the `gh pr view --json` shape
that the script reads: `headRefOid`, the two mergeability fields, and the
rollup's `status` and `conclusion`. They are not a captured payload. A stub that
errors instead would have exited 4, a wider shape than the fault.
**Rival 1: treat `UNKNOWN` as an answer.** The wait condition was replaced with
`if true`. 13 cases fail: every `UNKNOWN` row of the table,
`MERGEABLE/UNKNOWN`, the timeout case, and
`UNKNOWN that resolves to MERGEABLE/CLEAN passes`. That last one exits 1 under
this rival, so the fix cannot pass by refusing everything uncertain. It has to
wait. `UNKNOWN that resolves to CONFLICTING fails` still passes under this
rival, because the allowlist refuses `UNKNOWN` as well. Rival 2 does not
separate it either: its first poll is `UNKNOWN`, which rival 2 still waits on.
What separates it is the original fault, which combined both rivals. An earlier
version of this entry said rival 2 separated it. That was wrong.
**Rival 2: the old verdict.** The allowlist was replaced with `exit 0` after the
`MERGEABLE` test. Five cases fail: `MERGEABLE/BEHIND`, `DIRTY`, `DRAFT`,
`SOMETHING_NEW` and `null`. These are the states that look mergeable by the flag
and are not. At `128a2a0` it was six, with `BLOCKED` among them. Since
improvise-zlj2, `BLOCKED` is waited on, so this rival never reaches the verdict
for it. The entry below covers that.
`a failed check fails without waiting on mergeability` runs at `--timeout 0`.
A script that waited on `UNKNOWN` before reading the rollup would report that
known red as exit 3.
### The pr-watch needs-gap and pinned-head cases — `scripts/pr-watch-test.sh`
improvise-zlj2 and improvise-h3ni. These were found by review of PR #186 at
`128a2a0`, which was ruled do-not-merge.
**zlj2: a healthy PR exited 1.** The required `Browser harness` check run
exists only after the `build` job completes (`needs: build`). In that gap
GitHub reports `MERGEABLE/BLOCKED` while every registered check is green, and
the #186 rule refused `BLOCKED`. The rollup in the new cases is PR #189's,
captured verbatim with `gh pr view --json`. It has thirteen entries with every
field GitHub returns. Each gap state drops or rewinds entries of that rollup
with `jq`. Against `128a2a0` three cases failed, all with exit 1:
- `the needs-gap before the required check registers is not a failure`: the
healthy sequence should exit 0.
- `a required check that never registers is a timeout`: should exit 3.
- `table: MERGEABLE/BLOCKED`: should exit 3, not 1.
**Rival for zlj2:** remove `MERGEABLE/BLOCKED` from the wait. The same three
cases fail with exit 1. That rival is `128a2a0`'s code.
**h3ni: nothing pinned the head across the UNKNOWN wait.** The case is
`a head that moves during the UNKNOWN wait is not a pass`. Its polls are
UNKNOWN@a, then UNKNOWN@b, then CLEAN@b, and it must exit 2. It passed on the
first run, so it is a guard for behaviour the script already has, not a
reproduction.
**Rival for h3ni:** re-pin the head whenever `mergeable` is UNKNOWN. That case
alone fails, exiting 0. All 44 others pass. That is the review's point: the
older `a head that moves mid-wait is not a pass` starts from MERGEABLE, so it
cannot see this rival.
## Every JSON parser reads numbers exactly (improvise-dz8c)
### `every_crate_that_can_parse_json_enables_float_roundtrip`
It replaces a check over three crates listed by hand. It reads
`cargo metadata --no-deps` and fails naming every workspace package, and
every target-specific entry, whose **normal** serde_json dependency lacks
`float_roundtrip`. The rule and why it was chosen over the alternatives are in
`context/code/protocol.md`. It is red at `1158c23`, naming `improvise`,
`improvise-command`, `improvise-io` and `improvise-protocol`.
**Faults injected, each run:**
- **The feature removed from `improvise-io` alone.** The check fails naming
`improvise-io (all targets)`. So did
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit`, until improvise-tt22
removed the legacy load path and that test with it.
`a_json_import_stores_its_numbers_bit_for_bit` stays green, because
`improvise-command` enables the feature and unification reaches io in its
build.
- **The feature removed from `improvise-command` alone.** The check fails naming
`improvise-command (all targets)`. The command crate's own import test
**stays green**: `cargo tree -p improvise-command` shows io's declaration
switching the feature on. So the manifest check is the only guard that
sees this case. It is also the case the retired build-graph check would
have passed.
- **A throwaway workspace member**, `zz-throwaway`, with normal dependencies on
`improvise-protocol` and `serde_json` and no feature (the lockfile updated,
as a real new crate would be). The check fails naming
`zz-throwaway (all targets)`. With the unconditional dependency carrying the
feature and a `cfg(target_arch = "wasm32")` one lacking it, the check fails
naming `zz-throwaway (cfg(target_arch = "wasm32"))`. That is stricter than
the build needs, since a wasm build unifies the two entries, and cheap to
satisfy. The crate was then removed.
The `checked.len() >= 2` assertion is the positive signal. If a cargo change
renamed the metadata fields, the check would find no serde_json dependency and
pass vacuously without it.
### The re-poll a second boundary could skip — `scripts/pr-reviews-test.sh` (improvise-qnpi)
**What broke.** "an inline comment arriving mid-wait is seen" failed once in
two identical runs of `checkers.sh` on a loaded machine. `pr-reviews --wait`
computed `deadline = now + WAIT` and then tested `now < deadline` *before* its
first re-poll. With `--wait 1`, a second boundary falling between those two
reads of the clock made the loop run zero times: exit 1 with one poll, whatever
the second poll would have shown.
**Reproduced under load.** A loop running that fixture with one busy loop per
core failed once, as `exit 1, polls=1`, around run 52; the fixed script ran
200 times under the same load with no failure. That is evidence of the
mechanism, not proof: the window is a few milliseconds per run.
**The case that proves it.** "a second passing before the first re-poll does
not skip it" puts a `date` on `PATH` that advances one second per read (a
loaded machine, compressed) and a no-op `sleep`. Against the old loop it fails
every time, exit 1 (22 passed, 1 failed). The fix checks the deadline *after*
each poll, so any `--wait` above 0 polls at least once more. A larger timeout
would not have helped: the case uses the shimmed clock, where only the order of
check and poll matters.
### The harness lock that every build rewrote — `harness/build.sh` (improvise-mkbp)
**What broke.** `harness/opfs-contract/Cargo.lock` pinned `pest 2.9.1` while
its path dependencies require `pest = "2.9.2"`, so the committed lock was
invalid for its own manifest and every cargo invocation rewrote it.
**Why both halves.** The resolved lock is committed, and `build.sh` now starts
with `cargo metadata --locked` against it. The lock alone would repeat the bug
at the next workspace dependency bump; `--locked` alone fails today. The check
is its own step because `wasm-pack build … -- --locked` does not work: with the
stale lock restored, that version exited 0 and still rewrote the lock, because
wasm-pack re-resolves before the flag reaches `cargo build`. The standalone
check, against the same stale lock, exits 101 in seconds and leaves the file
untouched.
**The assemble half.** `web/assemble.sh` starts with `rm -rf` of its output,
which is what keeps the deployed tree free of stale files, so it is not taught
to spare a harness directory. `opfs.spec.js` instead asserts `/opfs/index.html`
is served, naming the fix. Injected by running `web/assemble.sh harness/serve`
alone: 4 chromium failures, each carrying that message, where it used to fail
with no hint of the cause.
### Polling past the second poll — `scripts/pr-reviews-test.sh` (improvise-7epn)
**What was unguarded.** Every multi-poll fixture resolved on poll 2, and the
timeout case expects exit 1 whether the loop polls twice or twenty times. So
replacing the post-poll deadline check with an unconditional `break` left the
suite at 23 passed, 0 failed, and `--wait N` would have polled at most twice.
**Two cases, both on the one-tick-per-read clock.** "a review arriving on the
third poll is seen" empties polls 1 and 2 and puts an inline comment at the head
in `last`, with `--wait 10`, and asserts exit 0 *and* exactly 3 polls. "--wait
keeps polling until its deadline" runs the empty fixture with `--wait 5` and
asserts exit 1 after at least 5 polls. The second one is the timeout path. Its
exit code alone cannot tell a loop that stopped early from one that reached the
deadline, so it counts polls.
**Rival, injected and watched.** `break` in place of
`[ "$(date +%s)" -lt "$deadline" ] || break`: 23 passed, 2 failed. The failures
were exactly the two new cases, each "exit 1 after 2 polls". The first attempt
to inject used `sed -i ''`, which the GNU sed on this PATH read as a filename.
Nothing changed and the suite passed 25/25, which looks like a vacuous rival.
The second attempt asserted the replacement landed before running.
**Still not covered.** Deleting the check outright makes the timeout case loop
forever rather than fail. The suite has no wall-clock guard, so that fault shows
as a hang, not a FAIL.
### A stale harness lock names its remedy — `harness/build.sh` (improvise-lnsh)
**What broke.** With a stale `harness/opfs-contract/Cargo.lock`, `build.sh`
exited 101 and printed only cargo's text, whose `help:` line says to remove
`--locked`. That is the one change that brings improvise-mkbp back. The real
remedy was only in a source comment.
**Reproduced, then fixed, by hand.** Changing `pest`'s locked version from
`2.9.2` to `2.9.1` in the lock reproduces the stale state. Before the fix:
exit 101 and cargo's hint only. After: exit 1, and the script's own two lines
follow cargo's, telling the reader not to remove `--locked` and naming
`cargo update -w --manifest-path harness/opfs-contract/Cargo.toml`. The lock
was untouched both times. Running that command on the stale lock restored it
byte for byte (`cmp` against the committed file). With the committed lock,
`build.sh` still runs to "harness/serve ready".
**Not automated.** There is no suite for `harness/build.sh`. A test would have
to corrupt a tracked file and call cargo, which needs the crates.io index, from
`checkers.sh`, whose environment is scrubbed. The by-hand run above is the
evidence.
### The dead-ID audit compares positively — `scripts/bd-audit-ids-test.sh` (improvise-52eq)
**What broke.** The audit recipe in `AGENTS.md` ended
`bd show $(cat ids) 2>&1 | grep -E '^(Issue … not found|…)' || echo "all resolve"`.
It looked for the *absence* of a negative line. So a bd that was missing,
failed, or never ran printed no such line, and the audit reported "all
resolve". In this session one attempt at the audit printed "all resolve"
because its output file did not exist.
**The fix.** `scripts/bd-audit-ids` keeps the file scan and the exclusions as
they were. It then takes the known ids from `bd list --all --limit 0 --json`
and `comm -23`s the cited set against them. It exits 3 if bd is missing, fails,
prints no JSON list, or lists no issues. On the tree at `12c81dd` it reports
460 cited ids, the same count the old pipeline produced there.
**The suite** runs a throwaway git repo and a fake bd that answers only `list`.
It has eight cases: a pass, a dead id (exit 1, named), four shapes of a bd that
does not answer (no output, `[]`, a failing exit, not on PATH), the real tree
against a silent bd, and `--help`. The passing fixture also cites
`improvise-core` and keeps a dead id in a git-ignored `harness/serve/` file.
So the pass needs both the exclusions and git's file list.
**Rivals, each run:**
- **The old recipe**, pasted in as the script's tail: 1 passed, 7 failed.
Every "bd did not answer" case exited 0 with "all resolve", which is the
bug. The dead-id case failed only because the fake bd refuses `show`. That is
one more bd that did not answer, so it is not evidence the old recipe misses a
dead id against a working bd, which it catches.
- **The dead-id comparison disabled** (`: >` for `comm -23`): only "a dead id
is exit 1, and named" failed, with exit 0.
- **An empty exclusion list**: only the passing case failed, reporting
`improvise-core` as dead.
After each rival the original was restored (`cmp` against a saved copy) and the
suite re-run at 8 passed.
+118 -8
View File
@@ -426,16 +426,69 @@ generator-driven proptests in `improvise-io`. What *those* cannot see is a
checked-in document drifting out of the format, because they generate their
inputs. This test is aimed at exactly that gap.
**It is deliberately a directory walk rather than a list of names**: a list is a
**It walks the files the repository tracks, not a list of names**: a list is a
second place to remember, and the file that gets forgotten is the one nobody
added to it. The scope is the two directories `.gitignore` un-ignores —
`examples/` and `web/`; everything else matching `*.improv` is somebody's
working file.
added to it. "Shipped" means "tracked", and `shipped_documents` reads it off
the index: every `*.improv` and `*.improv.gz` path that `ls-files` lists. It does
not include untracked files, so a working `.improv.gz` in someone's tree cannot
fail their run. A document not yet added is walked once it is, which is the
state CI tests.
**It matches on the full name, not on `Path::extension()`**: `.improv.gz` is the
same document gzipped and `extension()` calls it `gz`, so matching on the
extension alone would skip it and put back the very hole this test exists to
close.
**How the walk got here: two steps, each closing the previous one's holes.**
- **improvise-qlrh.** The test hard-coded `["examples", "web"]`. When
improvise-plcx un-ignored `harness/fixtures/*.improv`, nothing added that
directory. Its two fixtures were then loaded only by the Playwright job,
where a missing seed degrades to an empty model and fails as a missing label,
not a load error. The fix read `!<dir>/*.improv` lines from the root
`.gitignore`, with a per-directory precondition. Red at `705c92d`:
`harness/fixtures/tall.improv` replaced with `{"legacy": true}` passed the old
test.
- **improvise-c0aj.** That `.gitignore` reading was a parser for one shape of
one file, so four other ways of shipping a document were missed silently:
- a single-file un-ignore (`!web/demo.improv`);
- a `.improv.gz` anywhere, since `*.improv` never ignores it and so no
un-ignore line names it;
- a `!` line in a nested `.gitignore`;
- a force-added (`add -f`) document.
Each now has its own test, which builds the shape in a scratch repository
and asserts the walk lists the document. **All four were red** against the
`.gitignore` walk, each failing at its `walked` assertion with its setup
commands succeeding. Walking the index closes all four by construction and
deletes the parser, because git has already answered what the parser
approximated.
**The one new failure mode, answered: no index.** `ls-files` needs a checkout.
In CI (`actions/checkout`) and every worktree it has one. No nix build or
packaging step runs this test; the flake is a dev shell only. Where there is no
index, the walk must not answer "nothing", because an empty walk passes every
per-document assertion vacuously. So it asserts `ls-files` succeeded, and
`a_directory_that_is_not_a_checkout_fails_loudly` pins that. The whole-walk
precondition "at least one document" stays as the second line of defence.
**Rivals, each run against the new walk:**
- **The `.improv.gz` pathspec removed.** Fails `a_gzipped_document_is_walked`,
and nothing else.
- **A failed `ls-files` answered with an empty list instead of a panic.** Fails
`a_directory_that_is_not_a_checkout_fails_loudly`.
- **`harness/fixtures/tall.improv` replaced with `{"legacy": true}`.** Fails
`every_shipped_document_loads`, naming the file.
**It matches on the full name, not on `Path::extension()`.** `.improv.gz` is the
same document gzipped, and `extension()` calls it `gz`. The pathspec lists both
suffixes for the same reason.
### a_single_file_un_ignore_is_walked
improvise-c0aj. This test is the first of four shape tests. Its siblings are
`a_gzipped_document_is_walked`,
`a_document_un_ignored_by_a_nested_gitignore_is_walked` and
`a_force_added_document_is_walked`. Each builds one way of shipping a document
in a scratch repository and asserts the walk lists it. All four were red
against the `.gitignore` walk. The account, including the rivals, is under
`every_shipped_document_loads` above.
---
@@ -836,3 +889,60 @@ tests pass — including both of the ones *aimed* at the suppression — and onl
this one fails. That is the shape design-principles §7 names: the assertion next
door is satisfied by the wrong fix, and the discriminating input is the mode the
change was not supposed to touch.
### the_help_footer_names_the_keys_the_keymap_binds
**What was wrong (improvise-17ak).** `src/ui/help.rs::render_footer`
hand-wrote the popup's paging keys: `l/Tab: next`, `h/S-Tab: prev`,
`q/Esc: close`. No check read them, since `every_hint_key_resolves_in_its_mode`
cannot see a string literal in a widget (the blind spot improvise-rr8n and
improvise-wk18 name). The literal was also already incomplete: the keymap binds
`help-page-next` to `n` and `→` as well, and `?` closes help, and the footer
advertised none of them.
**Derive rather than delete.** rr8n's rule is to keep whichever table is
derived from the thing it describes, and the derived one here is the status
row's `hint_line(Help)`. The popup's margins always leave that row uncovered,
which argues for deleting the footer's keys. But `draw_status` prints
`status_msg` *instead of* `hint_text()` whenever the message is non-empty, and
nothing clears the message on entering Help. So after any message ("Saved to
…", an error), a user who presses `?` would see no paging keys at all. The
footer therefore now draws the page indicator followed by `hint_line(Help)`
itself, truncated in display cells (gotcha 50).
**The test.** It renders the popup at 140×30, wide enough that the whole hint
fits (the rr8n lesson: an 80-column render can hide the very thing asserted),
on every page. It requires the footer row to contain `hint_line(&AppMode::Help)`
exactly, and first asserts that hint is non-empty and pages.
**Red, and the rival that matters.**
- At base it fails on page 0: `"page 1 of 5 l/Tab: next q/Esc: close"` is
not `"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close"`.
- A footer holding a literal copy of today's derived string passes today, so it
was injected together with a rebinding (`n` removed from `help-page-next` in
the protocol keymap). The test then fails: the copy still advertises `n`,
which the keymap no longer binds. That rebinding case is exactly the one the
ticket said the old footer would lie through.
### a_status_message_gives_way_to_the_hint_on_the_next_key
**What broke (improvise-cq3d).** Nothing in production ever cleared
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
first message of a session, the TUI never drew the key hint again, in any mode.
**The case, in three steps, each guarding one direction.**
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
the one the message was written in, the row must hold TileSelect's hint and
not the stale message. This is the ticket's assertion.
2. `Space` cycles an axis and writes a message: that message must be on the row
after its own key. This guards against expiring too eagerly.
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
than `App::handle_key`, because the expiry is the front end's rule.
**Red, and the rival.**
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
at step 1: `" TILES Saved to x.improv …"`.
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
must say what it did", since every message is wiped before it can be drawn.
+3 -1
View File
@@ -14,7 +14,9 @@ improvise-io = { path = "../improvise-io" }
improvise-gp = { path = "../improvise-gp" }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses JSON a user's numbers cross, so it must read back the f64 written: per crate,
# because each build stands alone (improvise-6yvg, context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
unicode-width = "^0.2"
# Instant::now() panics on wasm32-unknown-unknown; web_time re-exports
# std::time off-wasm and uses performance.now() on it (improvise-ywd).
+191 -23
View File
@@ -1,9 +1,8 @@
use crate::model::Aggregation;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::effect::{self, Effect, ViewTarget};
use crate::view::synthetic_record_info;
use super::core::{Cmd, CmdContext};
use super::core::{AGGREGATE_REFUSAL, Cmd, CmdContext, INSIDE_A_DRILL_REFUSAL, NO_VALUE_REFUSAL};
#[cfg(test)]
mod tests {
@@ -66,7 +65,9 @@ mod tests {
("Type".into(), "Clothing".into()),
("Month".into(), "Feb".into()),
]);
let cmd = PasteCell { key: key.clone() };
let cmd = PasteCell {
at: PasteAt::Spelled(key.clone()),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let mut app = crate::ui::app::App::new(two_cat_model(), None);
@@ -88,7 +89,9 @@ mod tests {
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]);
let cmd = PasteCell { key };
let cmd = PasteCell {
at: PasteAt::Spelled(key),
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
@@ -332,9 +335,9 @@ mod tests {
app.cursor_mut().selected = (0, col_of(&app, "Revenue"));
press(&mut app, 'p');
let in_drill = app.view_state.status_msg.clone();
assert_eq!(
in_drill, PASTE_INSIDE_DRILL,
"setup: the drilled paste declines"
assert!(
in_drill.ends_with(INSIDE_A_DRILL_REFUSAL),
"setup: the drilled paste declines, got {in_drill:?}"
);
assert!(
!advice.to_lowercase().contains("drill"),
@@ -353,7 +356,10 @@ mod tests {
ctx.yanked = &yanked;
assert!(!ctx.none_cats().is_empty(), "setup: the view aggregates");
let key = record("West", "Revenue").with("Product", "Gadget");
let effects = PasteCell { key: key.clone() }.execute(&ctx);
let effects = PasteCell {
at: PasteAt::Spelled(key.clone()),
}
.execute(&ctx);
let mut app = aggregated_app(crate::view::Axis::None);
app.apply_effects(effects);
assert_eq!(
@@ -389,6 +395,163 @@ mod tests {
let dbg = effects_debug(&effects);
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
}
/// improvise-q9eg: a record that lacks a later category is still a record —
/// see context/test-evidence/command.md.
#[test]
fn p_onto_a_sparse_record_writes_it() {
let mut app = records_app();
app.model_state.workbook.add_category("Season").unwrap();
app.rebuild_layout();
assert!(
app.layout.none_cats.iter().any(|c| c == "Season"),
"setup: the new category is aggregated away, so every record lacks it"
);
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
press(&mut app, 'y');
press(&mut app, 'y');
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'p');
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(700.0)),
"status: {:?}",
app.view_state.status_msg
);
}
/// improvise-lx7w — see context/test-evidence/command.md.
#[test]
fn x_in_records_mode_clears_the_real_record_under_the_cursor() {
let mut app = records_app();
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'x');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
None,
"West/Cost"
);
assert_eq!(
wb.model.get_cell(&record("East", "Cost")),
Some(&CellValue::Number(700.0)),
"the neighbouring record is untouched"
);
let (text, back) = saved_and_reloaded(wb);
assert!(!text.contains("West, _Measure=Cost"), "saved:\n{text}");
assert_eq!(back.model.get_cell(&record("West", "Cost")), None);
assert_eq!(synthetic_cells(&back), Vec::<CellKey>::new());
}
/// improvise-lx7w: a coordinate column stores no value, so `x` declines there.
#[test]
fn x_on_a_records_coordinate_column_changes_nothing_and_says_so() {
let mut app = records_app();
let before = crate::persistence::format_md(&app.model_state.workbook);
app.cursor_mut().selected = (1, col_of(&app, "Region"));
press(&mut app, 'x');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before
);
assert!(
app.view_state.status_msg.ends_with(NO_VALUE_REFUSAL),
"declined out loud, got {:?}",
app.view_state.status_msg
);
}
/// Two regions by two raw measures and `Profit = Revenue - Cost`, the formula
/// in the third column, so the cursor on it is off row 0 and column 0.
fn formula_app() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.model.add_formula(
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
wb.active_view_mut()
.set_axis("Region", crate::view::Axis::Row);
wb.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
for (region, measure, n) in [
("East", "Revenue", 10.0),
("East", "Cost", 4.0),
("West", "Revenue", 9.0),
("West", "Cost", 2.0),
] {
wb.model
.set_cell(record(region, measure), CellValue::Number(n));
}
let mut app = App::new(wb, None);
app.view_state.yanked = Some(CellValue::Number(99.0));
app
}
/// improvise-54g1 — see context/test-evidence/command.md.
#[test]
fn p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 2);
assert_eq!(app.layout.col_label(2), "Profit", "setup: on the formula");
press(&mut app, 'p');
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
assert!(
app.view_state
.status_msg
.contains("Profit is computed by a formula"),
"declined out loud, got {:?}",
app.view_state.status_msg
);
// The control: one column left is a stored cell, and p writes it.
app.cursor_mut().selected = (1, 1);
press(&mut app, 'p');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(99.0))
);
}
/// The second door onto a formula cell: Tab advances the editor onto it, where
/// `i` would have declined. See context/test-evidence/command.md.
#[test]
fn tab_advancing_onto_a_formula_cell_commits_nothing_under_it() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 1);
press(&mut app, 'i');
assert!(app.view_state.mode.is_editing(), "setup: editing West/Cost");
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.cursor().selected,
(1, 2),
"setup: Tab moved onto Profit"
);
for c in "7".chars() {
press(&mut app, c);
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
}
}
// ── Cell operations ──────────────────────────────────────────────────────────
@@ -421,9 +584,7 @@ impl Cmd for YankCell {
// A records cell is the layout's one resolution, as the browser yanks it (gotcha 1).
let value = match synthetic_record_info(&self.key) {
Some((row, col)) => ctx.layout.records_value(row, col),
None => ctx
.model
.evaluate_aggregated(&self.key, Aggregation::of(ctx.none_cats())),
None => ctx.layout.evaluate(ctx.model, &self.key),
};
vec![
Box::new(effect::SetYanked(value)),
@@ -432,20 +593,31 @@ impl Cmd for YankCell {
}
}
/// Where a paste lands: a key the cursor's door already resolved, or one a
/// script spelled out, which only the key's own shape can vet (improvise-q9eg).
#[derive(Debug)]
pub enum PasteAt {
Resolved(CellKey),
Spelled(CellKey),
}
/// Paste the yanked value into a cell.
#[derive(Debug)]
pub struct PasteCell {
pub key: crate::model::cell::CellKey,
pub at: PasteAt,
}
impl Cmd for PasteCell {
fn name(&self) -> &'static str {
"paste"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
match ctx.yanked.clone() {
Some(value) => vec![paste_into(ctx, &self.key, value)],
None => vec![],
}
let Some(value) = ctx.yanked.clone() else {
return vec![];
};
vec![match &self.at {
PasteAt::Resolved(key) => Box::new(effect::SetCell(key.clone(), value)),
PasteAt::Spelled(key) => paste_into(ctx, key, value),
}]
}
}
@@ -454,7 +626,7 @@ impl Cmd for PasteCell {
fn paste_into(ctx: &CmdContext, key: &CellKey, value: CellValue) -> Box<dyn Effect> {
match synthetic_record_info(key) {
Some((row, col)) => paste_into_record(ctx, row, col, value),
None if aggregates(ctx, key) => effect::set_status(PASTE_ONTO_AGGREGATE),
None if aggregates(ctx, key) => effect::refuse(AGGREGATE_REFUSAL),
None => Box::new(effect::SetCell(key.clone(), value)),
}
}
@@ -466,9 +638,9 @@ fn paste_into_record(
value: CellValue,
) -> Box<dyn Effect> {
match (ctx.layout.record_cell_key(row, col), ctx.has_drill_state) {
(Some(_), true) => effect::set_status(PASTE_INSIDE_DRILL),
(Some(_), true) => effect::refuse(INSIDE_A_DRILL_REFUSAL),
(Some(real), false) => Box::new(effect::SetCell(real, value)),
(None, _) => effect::set_status(PASTE_ONTO_NO_VALUE),
(None, _) => effect::refuse(NO_VALUE_REFUSAL),
}
}
@@ -479,10 +651,6 @@ fn aggregates(ctx: &CmdContext, key: &CellKey) -> bool {
})
}
const PASTE_ONTO_AGGREGATE: &str = "Cannot paste onto an aggregate — it combines several records, so there is no one cell to write";
const PASTE_ONTO_NO_VALUE: &str = "Paste lands only on a measure column";
const PASTE_INSIDE_DRILL: &str = "Paste is not staged inside a drill — edit the cell instead";
// ── View commands ────────────────────────────────────────────────────────────
#[derive(Debug)]
@@ -1,8 +1,11 @@
use crate::formula::FormulaId;
use crate::model::category::MEASURE_CATEGORY;
use crate::model::cell::{CellKey, CellValue};
use crate::model::cell::CellKey;
use crate::protocol::FormulaSlot;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use crate::view::{RecordCol, RecordRow};
use crate::view::RecordCol;
use improvise_protocol::{OpenedEditor, untouched};
use super::core::{Cmd, CmdContext, read_buffer};
use super::grid::AddRecordRow;
@@ -14,6 +17,7 @@ mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::cell::CellValue;
use crate::workbook::Workbook;
fn commit(kind: CommitKind) -> CommitBuffer {
@@ -180,6 +184,7 @@ mod tests {
advance: super::AdvanceDir::Down,
cursor: super::CursorState::from_ctx(&ctx),
edit_mode: AppMode::records_editing(),
opened: None,
};
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
@@ -284,6 +289,7 @@ mod tests {
advance: super::AdvanceDir::Down,
cursor: super::CursorState::from_ctx(&ctx),
edit_mode: AppMode::records_editing(),
opened: None,
}
.execute(&ctx)
};
@@ -473,6 +479,357 @@ mod tests {
"bulk entry lands at the start of the new row"
);
}
/// Values whose `Display` (four decimals) and value disagree, plus text.
fn values_display_rounds() -> Vec<crate::model::cell::CellValue> {
use crate::model::cell::CellValue::{Number, Text};
vec![
Number(1.23456789),
Number(0.1 + 0.2),
Number(98765.4321987),
Number(123456789012345.67),
Number(-0.00001234),
Number(-0.0),
Number(f64::NEG_INFINITY),
Text("0.1 + 0.2".into()),
]
}
/// Two regions, two measures, with `value` at (West, Revenue): never index 0.
fn grid_holding(value: crate::model::cell::CellValue, records: bool) -> crate::ui::app::App {
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::App;
use crate::view::Axis;
let mut wb = Workbook::new("Seed");
wb.add_category("Region").unwrap();
for region in ["North", "West"] {
wb.add_item("Region", region).unwrap();
}
for measure in ["Cost", "Revenue"] {
wb.add_item("_Measure", measure).unwrap();
}
let key = |r: &str, m: &str| {
CellKey::new(vec![
("Region".into(), r.into()),
("_Measure".into(), m.into()),
])
};
wb.model
.set_cell(key("North", "Cost"), CellValue::Number(1.0));
wb.model
.set_cell(key("North", "Revenue"), CellValue::Number(2.0));
wb.model
.set_cell(key("West", "Cost"), CellValue::Number(3.0));
wb.model.set_cell(key("West", "Revenue"), value);
{
let v = wb.active_view_mut();
match records {
true => {
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
v.set_axis("Region", Axis::None);
v.set_axis("_Measure", Axis::None);
}
false => {
v.set_axis("Region", Axis::Row);
v.set_axis("_Measure", Axis::Column);
}
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
app
}
/// Open the editor on (West, Revenue) and commit it untouched; the stored value.
fn no_op_commit(records: bool, value: crate::model::cell::CellValue) {
use crate::model::cell::CellKey;
use improvise_protocol::key::KeyCode;
let mut app = grid_holding(value.clone(), records);
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let at = match records {
true => (1, 3),
false => (1, 1),
};
app.cursor_mut().selected = at;
if records {
app.view_state.mode = AppMode::RecordsNormal;
assert_eq!(
app.layout.record_col(at.1),
Some(&RecordCol::Measure("Revenue".into())),
"the cursor is not on the Revenue column"
);
}
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
Some(&value),
"the fixture does not hold the value under test"
);
press(&mut app, KeyCode::Char('i'));
assert!(
app.view_state.mode.is_editing(),
"`i` did not open an editor"
);
assert_eq!(
app.view_state.buffers.get("edit"),
Some(&value.edit_text()),
"the editor was not seeded from the stored value (improvise-umi4)"
);
press(&mut app, KeyCode::Enter);
let stored = app.model_state.workbook.model.get_cell(&target).cloned();
let same = match (&stored, &value) {
(Some(CellValue::Number(a)), CellValue::Number(b)) => a.to_bits() == b.to_bits(),
(stored, value) => stored.as_ref() == Some(value),
};
assert!(
same,
"{} mode: i then Enter on {value:?} stored {stored:?}",
if records { "records" } else { "pivot" }
);
}
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_pivot_edit_stores_the_identical_value() {
for value in values_display_rounds() {
no_op_commit(false, value);
}
}
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_records_edit_stores_the_identical_value() {
for value in values_display_rounds() {
no_op_commit(true, value);
}
}
/// Stored values whose edit text `typed()` reads back as something else.
fn values_typed_misreads() -> Vec<crate::model::cell::CellValue> {
use crate::model::cell::CellValue::{Error, Text};
vec![
Text("007".into()),
Text("1e3".into()),
Text("inf".into()),
Text("2024".into()),
Error("boom".into()),
]
}
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_pivot_edit_of_a_misread_value_writes_nothing() {
for value in values_typed_misreads() {
no_op_commit(false, value);
}
}
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_records_edit_of_a_misread_value_writes_nothing() {
for value in values_typed_misreads() {
no_op_commit(true, value);
}
}
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_edit_does_not_overwrite_a_write_made_while_it_was_open() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut app = grid_holding(CellValue::Text("007".into()), false);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char('i'));
app.apply_effects(vec![Box::new(effect::SetCell(
target.clone(),
CellValue::Number(5.0),
))]);
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
Some(&CellValue::Number(5.0)),
"an editor nobody typed in overwrote the write that landed while it was open"
);
}
/// BUG (improvise-2054) — see `context/test-evidence/command.md`.
#[test]
fn an_editor_typed_back_to_its_seed_still_writes() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut app = grid_holding(CellValue::Number(7.0), false);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char('i'));
app.apply_effects(vec![Box::new(effect::SetCell(
target.clone(),
CellValue::Number(5.0),
))]);
press(&mut app, KeyCode::Backspace);
assert_eq!(
app.view_state.opened_editor.as_ref().map(|o| o.touched),
Some(true),
"Backspace alone did not mark the editor touched"
);
press(&mut app, KeyCode::Char('7'));
assert_eq!(
app.view_state.buffers.get("edit").map(String::as_str),
Some("7")
);
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
Some(&CellValue::Number(7.0)),
"a value the user typed was dropped because it spelled the seed"
);
}
/// Either user edit marks the open editor, mirroring the browser's
/// `only_an_edit_of_the_editors_buffer_marks_it_touched`.
#[test]
fn appending_or_popping_marks_the_editor_touched() {
use crate::model::cell::CellValue;
use improvise_protocol::key::KeyCode;
for key in [KeyCode::Char('x'), KeyCode::Backspace] {
let mut app = grid_holding(CellValue::Number(7.0), false);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char('i'));
assert_eq!(
app.view_state.opened_editor.as_ref().map(|o| o.touched),
Some(false)
);
press(&mut app, key);
assert_eq!(
app.view_state.opened_editor.as_ref().map(|o| o.touched),
Some(true),
"{key:?} did not mark the editor touched"
);
}
}
/// BUG (improvise-wv3h) — see `context/test-evidence/command.md`.
#[test]
fn an_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let empty = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Cost".into()),
]);
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut app = grid_holding(CellValue::Number(2.0), false);
app.model_state.workbook.model.clear_cell(&empty);
app.rebuild_layout();
app.cursor_mut().selected = (0, 0);
press(&mut app, KeyCode::Char('i'));
assert_eq!(
app.view_state
.opened_editor
.as_ref()
.map(|o| o.seed.as_str()),
Some(""),
"the first editor did not open on the empty cell"
);
press(&mut app, KeyCode::Esc);
app.cursor_mut().selected = (1, 1);
press(&mut app, KeyCode::Char(':'));
for c in "enter-mode editing".chars() {
press(&mut app, KeyCode::Char(c));
}
press(&mut app, KeyCode::Enter);
assert!(
app.view_state.mode.is_editing(),
"`:enter-mode editing` did not open an editor"
);
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
None,
"the empty commit was skipped against the earlier editor's seed"
);
}
/// improvise-465t: the editor an advance reopens is a new open, so it starts
/// untouched even when the commit before it was typed. See command.md.
#[test]
fn the_editor_an_advance_reopens_starts_untouched() {
use improvise_protocol::key::KeyCode;
let mut app = grid_holding(CellValue::Number(4.0), false);
app.cursor_mut().selected = (1, 0);
press(&mut app, KeyCode::Char('i'));
press(&mut app, KeyCode::Char('9'));
assert_eq!(
app.view_state.opened_editor.as_ref().map(|o| o.touched),
Some(true),
"the first editor was never touched, so this proves nothing"
);
press(&mut app, KeyCode::Enter);
assert_eq!(app.cursor().selected, (0, 1), "the advance did not run");
assert_eq!(
app.view_state.opened_editor,
Some(OpenedEditor::on("2".into())),
"the reopened editor inherited the previous editor's touched flag"
);
}
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
#[test]
fn an_untouched_commit_still_advances_and_reopens_the_editor() {
use crate::model::cell::{CellKey, CellValue};
use improvise_protocol::key::KeyCode;
let target = CellKey::new(vec![
("Region".into(), "West".into()),
("_Measure".into(), "Cost".into()),
]);
let mut app = grid_holding(CellValue::Number(4.0), false);
app.model_state
.workbook
.model
.set_cell(target.clone(), CellValue::Text("007".into()));
app.rebuild_layout();
app.cursor_mut().selected = (1, 0);
press(&mut app, KeyCode::Char('i'));
press(&mut app, KeyCode::Enter);
assert_eq!(
app.model_state.workbook.model.get_cell(&target),
Some(&CellValue::Text("007".into())),
"the untouched commit wrote"
);
assert_eq!(app.cursor().selected, (0, 1), "the advance did not run");
assert!(
app.view_state.mode.is_editing(),
"the editor was not reopened"
);
assert_eq!(
app.view_state
.opened_editor
.as_ref()
.map(|o| o.seed.as_str()),
Some("2"),
"the reopened editor did not capture the cell it landed on"
);
}
}
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
@@ -497,47 +854,8 @@ fn stage_drill_edit(record_idx: usize, col: RecordCol, value: &str) -> Box<dyn E
})
}
/// Rename one coordinate of a records row. In wide format a row is a group of cells,
/// so the rename moves **every** one of them (improvise-c5v, gotcha 1).
fn rename_record_coordinate(
model: &crate::model::Model,
row: &RecordRow,
category: &str,
value: &str,
effects: &mut Vec<Box<dyn Effect>>,
) {
if value.is_empty() {
effects.push(effect::refuse(effect::RECORD_COORDS_CANNOT_BE_EMPTY));
return;
}
effects.push(Box::new(effect::AddItem {
category: category.to_string(),
item: value.to_string(),
}));
// Every cell in the row moves. A row seeded by `add-record-row` carries its
// coordinates on a lone measure-less cell, which must move too.
let cells: Vec<(CellKey, CellValue)> = if row.values.is_empty() {
model
.get_cell(&row.coords)
.map(|v| vec![(row.coords.clone(), v.clone())])
.unwrap_or_default()
} else {
row.values
.iter()
.map(|(measure, v)| (row.cell_key(measure), v.clone()))
.collect()
};
for (old_key, cell_value) in cells {
effects.push(Box::new(effect::ClearCell(old_key.clone())));
effects.push(Box::new(effect::SetCell(
old_key.with(category, value),
cell_value,
)));
}
}
/// Apply a records-mode edit directly to the model (no drill state).
/// Apply a records-mode edit directly to the model (no drill state). A drill's staged
/// edits go through the same `RecordRewrite`, all of a row's at once (gotcha 19).
fn commit_plain_records_edit(
ctx: &CmdContext,
record_idx: usize,
@@ -549,11 +867,8 @@ fn commit_plain_records_edit(
return;
};
match col {
RecordCol::Category(category) => {
rename_record_coordinate(ctx.model, row, category, value, effects);
}
RecordCol::Measure(measure) => {
commit_regular_cell_value(&row.cell_key(measure), value, effects);
RecordCol::Category(_) | RecordCol::Measure(_) => {
effects.extend(effect::RecordRewrite::of(ctx.model, row, [(col, value)]).effects());
}
RecordCol::NewMeasure => add_measure(value, effects),
RecordCol::NewCategory => add_category(value, effects),
@@ -645,6 +960,9 @@ pub struct CommitAndAdvance {
pub advance: AdvanceDir,
pub cursor: CursorState,
pub edit_mode: AppMode,
/// What the editor captured as it opened; `None` for a script line, which
/// always writes (improvise-ixxx).
pub opened: Option<OpenedEditor>,
}
impl Cmd for CommitAndAdvance {
fn name(&self) -> &'static str {
@@ -658,14 +976,18 @@ impl Cmd for CommitAndAdvance {
// Drop to normal **before** the commit, which can abort: tidy-up goes where
// an abort cannot reach it (improvise-2zq, gotcha 21).
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
// An untouched editor writes nothing; everything around the write still runs.
let writes = !untouched(self.opened.as_ref(), &self.value);
if writes {
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
}
// The commit may reshape the grid and so reset the cursor. Restore it before
// the advance, where an aborting advance cannot skip it (gotcha 21).
effects.push(Box::new(effect::SetSelected(
self.cursor.row,
self.cursor.col,
)));
if inserts_a_column(ctx, &self.key, &self.value) {
if writes && inserts_a_column(ctx, &self.key, &self.value) {
// Staying put is what lands on the inserted column (improvise-2zq); the
// restore above is what makes "staying put" something we do, not inherit.
effects.push(Box::new(effect::RebuildLayout));
@@ -722,6 +1044,12 @@ impl Cmd for CommitAndAdvance {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitKind {
Formula,
/// Edit the formula the editor was opened on. Never parsed: `execute` reads
/// it off the mode's slot, the capture made at open (improvise-km71).
ReplaceFormula {
id: FormulaId,
was: String,
},
Category,
/// Add an item to this category, resolved from [`AppMode::ItemAdd`] at
/// construction so `execute` stays pure over the kind and its buffer.
@@ -745,10 +1073,28 @@ pub fn parse_commit_kind(arg: &str, mode: &AppMode) -> Result<CommitKind, String
}
impl CommitKind {
/// A formula commit inside an editor opened on an existing formula edits
/// that one. Read in `execute`, never at parse — `context/code/command.md`.
fn in_mode(&self, mode: &AppMode) -> CommitKind {
match (self, mode) {
(
Self::Formula,
AppMode::FormulaEdit {
slot: FormulaSlot::Existing { id, was },
..
},
) => Self::ReplaceFormula {
id: id.clone(),
was: was.clone(),
},
(kind, _) => kind.clone(),
}
}
/// The minibuffer this kind consumes.
pub fn buffer(&self) -> &'static str {
match self {
Self::Formula => "formula",
Self::Formula | Self::ReplaceFormula { .. } => "formula",
Self::Category => "category",
Self::Item { .. } => "item",
}
@@ -758,7 +1104,7 @@ impl CommitKind {
/// minibuffer.
fn on_empty(&self) -> AppMode {
match self {
Self::Formula => AppMode::FormulaPanel,
Self::Formula | Self::ReplaceFormula { .. } => AppMode::FormulaPanel,
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
}
}
@@ -767,7 +1113,7 @@ impl CommitKind {
/// categories and items are entered in batches, formulas are not.
fn on_commit(&self) -> Option<AppMode> {
match self {
Self::Formula => Some(AppMode::FormulaPanel),
Self::Formula | Self::ReplaceFormula { .. } => Some(AppMode::FormulaPanel),
Self::Category | Self::Item { .. } => None,
}
}
@@ -775,6 +1121,7 @@ impl CommitKind {
fn status(&self, name: &str) -> String {
match self {
Self::Formula => "Formula added".to_string(),
Self::ReplaceFormula { .. } => "Formula updated".to_string(),
Self::Category => format!("Added category \"{name}\""),
Self::Item { .. } => format!("Added \"{name}\""),
}
@@ -788,6 +1135,11 @@ impl CommitKind {
raw: name.to_string(),
target_category: "_Measure".to_string(),
}),
Self::ReplaceFormula { id, was } => Box::new(effect::ReplaceFormula {
id: id.clone(),
was: was.clone(),
raw: name.to_string(),
}),
Self::Category => Box::new(effect::AddCategory(name.to_string())),
Self::Item { category } => Box::new(effect::AddItem {
category: category.clone(),
@@ -808,18 +1160,19 @@ impl Cmd for CommitBuffer {
"commit-buffer"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let raw = read_buffer(ctx, self.kind.buffer());
let kind = self.kind.in_mode(ctx.mode);
let raw = read_buffer(ctx, kind.buffer());
let name = raw.trim();
if name.is_empty() {
return vec![effect::change_mode(self.kind.on_empty())];
return vec![effect::change_mode(kind.on_empty())];
}
let mut effects: Vec<Box<dyn Effect>> = vec![
effect::set_status(self.kind.status(name)),
effect::set_status(kind.status(name)),
// The add goes last: it is the only party that knows whether it worked,
// so the optimistic status must not clobber its error (improvise-6t3).
self.kind.add_effect(name),
kind.add_effect(name),
];
effects.extend(self.kind.on_commit().map(effect::change_mode));
effects.extend(kind.on_commit().map(effect::change_mode));
effects
}
}
+122 -13
View File
@@ -3,12 +3,29 @@ use std::fmt::Debug;
use crate::command::key::KeyCode;
use super::navigation::CursorState;
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
use crate::ui::effect::{Effect, Panel};
use crate::view::{Axis, GridLayout, View};
use crate::workbook::Workbook;
use improvise_protocol::CellId;
/// What a command says when the cursor sits on a collapsed group's row (§14).
pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first";
const NO_CELL: &str = "no cell at cursor";
pub const AGGREGATE_REFUSAL: &str =
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
pub const NO_VALUE_REFUSAL: &str = "Only a measure column holds a value";
pub const INSIDE_A_DRILL_REFUSAL: &str =
"A value is not staged inside a drill — edit the cell instead";
/// What a command says when the cell it would write is a formula's result.
pub fn derived_refusal(target: &str) -> String {
format!("{target} is computed by a formula — edit the formula, not the cell")
}
/// Read-only context available to commands for decision-making: a `&Model`, the
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
@@ -37,6 +54,8 @@ pub struct CmdContext<'a> {
pub tile_cat_idx: usize,
/// Named text buffers
pub buffers: &'a HashMap<String, String>,
/// What the open cell editor captured as it opened (improvise-ixxx).
pub opened_editor: Option<&'a improvise_protocol::OpenedEditor>,
/// View navigation stacks (for drill back/forward)
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
@@ -62,10 +81,75 @@ impl<'a> CmdContext<'a> {
self.layout.is_records_mode()
}
/// The cell under the cursor, for a command that only READS it. The writers'
/// doors: `context/code/command.md`, "The cursor's cell has three doors".
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
/// The cursor's key for a command that resolves it itself (commit, drill),
/// refusing a fold: its key names a group, which no record carries (§14).
pub fn unfolded_cell_key(&self) -> Result<CellKey, String> {
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
true => Err(FOLD_REFUSAL.to_string()),
false => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
}
}
/// The cursor's key for a commit, which resolves a records position itself
/// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it.
pub fn committable_cell_key(&self) -> Result<CellKey, String> {
match self.cursor_cell_id() {
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => {
self.cell_key().ok_or_else(|| NO_CELL.to_string())
}
}
}
/// The stored cell a VALUE written at the cursor lands on, answered by the
/// `CellId` the browser is given: `Record` is the one settable arm (ADR 002).
pub fn value_cell_key(&self) -> Result<CellKey, String> {
match self.cursor_cell_id() {
Some(CellId::Record(_)) => self.stored_cell_key(),
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()),
None => Err(match self.cell_key() {
Some(_) => NO_VALUE_REFUSAL,
None => NO_CELL,
}
.to_string()),
}
}
/// What the cursor's cell is, asked exactly as the projection asks it.
fn cursor_cell_id(&self) -> Option<CellId> {
crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1)
}
/// An aggregate's refusal, naming the fold where the aggregate is one (§14).
fn aggregate_refusal(&self) -> String {
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
true => FOLD_REFUSAL,
false => AGGREGATE_REFUSAL,
}
.to_string()
}
/// A `Record`'s real key: a records position resolves through its row, and a
/// drill's records are staged, never written directly (gotcha 1, 19).
fn stored_cell_key(&self) -> Result<CellKey, String> {
let (row, col) = self.selected;
let stored = match (self.is_records_mode(), self.has_drill_state) {
(true, true) => return Err(INSIDE_A_DRILL_REFUSAL.to_string()),
(true, false) => self.layout.record_cell_key(row, col),
(false, _) => self.cell_key(),
};
stored.ok_or_else(|| NO_CELL.to_string())
}
/// The formula that owns the cell under the cursor, if one does. In records
/// mode the synthetic key names no measure, so the record's key is resolved.
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
@@ -139,13 +223,15 @@ pub trait Cmd: Debug + Send + Sync {
/// Factory that constructs a Cmd from text arguments (headless/script).
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
/// Fills the context-only fields of a command `parse` already built. It never
/// sees the args, so it cannot discard them — `context/code/cli.md` §8.
pub type ContextualizeFn<C> = fn(C, &CmdContext) -> C;
/// Builds a command from context, reached ONLY for args `parse` refused.
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
/// A command whose one context-only field is the cursor it moves from. The
/// registry fills it, and no registration can — `context/code/cli.md` §8.
pub trait AtCursor: Cmd {
fn cursor_mut(&mut self) -> &mut CursorState;
}
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
@@ -186,25 +272,48 @@ impl CmdRegistry {
name
}
/// Register a command whose interactive arm is `parse` then `contextualize`, so
/// args `parse` accepts are honoured whole — `context/code/cli.md` §8.
/// Register a command whose interactive arm is `parse`, reaching `fallback`
/// only where `parse` refused — `context/code/cli.md` §8.
pub fn register<C: Cmd + 'static>(
&mut self,
prototype: &C,
parse: fn(&[String]) -> Result<C, String>,
contextualize: ContextualizeFn<C>,
fallback: Option<FallbackFn<C>>,
) {
self.push(prototype.name(), parse, fallback, |_, _| {});
}
/// As [`register`](Self::register), then the interactive arm moves the
/// command's cursor to the live one. The cursor is the only field it writes.
pub fn register_at_cursor<C: AtCursor + 'static>(
&mut self,
prototype: &C,
parse: fn(&[String]) -> Result<C, String>,
fallback: Option<FallbackFn<C>>,
) {
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
});
}
fn push<C: Cmd + 'static>(
&mut self,
name: &'static str,
parse: fn(&[String]) -> Result<C, String>,
fallback: Option<FallbackFn<C>>,
at_cursor: fn(&mut C, &CmdContext),
) {
self.entries.push(CmdEntry {
name: prototype.name(),
name,
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
interactive: Box::new(move |args, ctx| {
let built = match (parse(args), fallback) {
(Ok(cmd), _) => Ok(contextualize(cmd, ctx)),
(Err(_), Some(fallback)) => fallback(args, ctx),
(Err(err), None) => Err(err),
let mut built = match (parse(args), fallback) {
(Ok(cmd), _) => cmd,
(Err(_), Some(fallback)) => fallback(args, ctx)?,
(Err(err), None) => return Err(err),
};
built.map(|c| Box::new(c) as Box<dyn Cmd>)
at_cursor(&mut built, ctx);
Ok(Box::new(built) as Box<dyn Cmd>)
}),
});
}
@@ -0,0 +1,242 @@
//! A command is a function of its context: two contexts built independently from
//! equal data give equal effects — `context/code/command.md`, improvise-hzjo.
use std::collections::BTreeSet;
use super::core::{Cmd, CmdContext};
use super::grid::DrillIntoCell;
use super::registry::tests::candidate_args;
use crate::command::key::{KeyCode, KeyModifiers};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::App;
use crate::ui::effect::{self, Effect};
/// Commands whose effects depend on something outside the context, with why.
const OUTSIDE_THE_CONTEXT: &[(&str, &str)] = &[];
const DOCUMENT: &str = include_str!("../../../../../web/demo.improv");
/// One `App` over the shipped demo. Every context map holds several entries that
/// stay distinct under an edit like `pop()` — see the evidence.
fn fixture() -> App {
let workbook = crate::persistence::parse_md(DOCUMENT).expect("the demo parses");
let mut app = App::new(workbook, None);
let vs = &mut app.view_state;
for i in 0..16 {
vs.buffers
.insert(format!("b{i}"), format!("value {i} of 16"));
}
for (name, text) in [
("edit", "42"),
("formula", "Margin = Profit / Revenue"),
("category", "Region"),
("command", "add-item Product Mocha"),
("export", "/tmp/improvise-hzjo.csv"),
] {
vs.buffers.insert(name.into(), text.into());
}
for cat in ["Product", "Quarter", "_Measure", "_Index", "_Dim"] {
vs.expanded_cats.insert(cat.into());
}
vs.yanked = Some(CellValue::Number(7.0));
vs.formula_panel_open = true;
vs.category_panel_open = true;
vs.view_panel_open = true;
vs.cat_panel_cursor = 2;
vs.view_panel_cursor = 1;
app.cursor_mut().selected = (1, 1);
app
}
/// Every item as a one-coordinate drill: each spans the other categories whole,
/// so the store has several records to order — see the evidence.
fn one_coordinate_drills(model: &Model) -> Vec<Vec<String>> {
let mut drills = Vec::new();
for cat in model.regular_category_names() {
for item in model.category(cat).expect("listed").ordered_item_names() {
drills.push(vec![format!("{cat}/{item}")]);
}
}
drills
}
/// Records a compared drill froze, counted only if it narrowed the store and its
/// ordered records are in `compared`, the text the law compared (improvise-btyw).
fn records_narrowed(scratch: &mut App, effects: Vec<Box<dyn Effect>>, compared: &str) -> usize {
let store = scratch
.model_state
.workbook
.model
.data
.live_records()
.count();
scratch.apply_effects(effects);
let frozen = scratch
.drill_state
.as_ref()
.filter(|drill| compared.contains(&format!("{:?}", drill.records)))
.map_or(0, |drill| drill.records.len());
if frozen < store { frozen } else { 0 }
}
/// The registry's candidates, plus names the demo really has: a drill or a fit
/// over coordinates that match nothing never reaches the records it would order.
fn args_the_document_names(model: &Model) -> Vec<Vec<String>> {
let mut all = candidate_args();
all.extend(one_coordinate_drills(model));
all.extend(
[
vec!["Revenue"],
vec!["Product/Latte", "Quarter/Q2"],
vec!["Product/Latte", "Quarter/Q2", "_Measure/Cost"],
vec![
"editing",
"5",
"Product/Latte",
"Quarter/Q2",
"_Measure/Cost",
],
]
.into_iter()
.map(|v| v.into_iter().map(String::from).collect()),
);
all
}
fn effects_of(name: &str, args: &[String], ctx: &CmdContext) -> Option<Vec<Box<dyn Effect>>> {
let cmd = ctx.registry.interactive(name, args, ctx).ok()?;
Some(cmd.execute(ctx))
}
/// What running every registered command under `a` and under each of `others` found.
#[derive(Default)]
struct Comparison {
diverged: BTreeSet<&'static str>,
shown: Vec<String>,
never_built: Vec<&'static str>,
compared: usize,
widest_drill: usize,
}
fn compare(a: &CmdContext, others: &[CmdContext]) -> Comparison {
let mut found = Comparison::default();
let candidates = args_the_document_names(a.model);
let drill = DrillIntoCell {
key: CellKey::new(vec![]),
};
let mut scratch = fixture();
for name in a.registry.names().collect::<Vec<_>>() {
let before = found.compared;
for args in &candidates {
let Some(effects) = effects_of(name, args, a) else {
continue;
};
let left = format!("{effects:?}");
if name == drill.name() {
found.widest_drill =
found
.widest_drill
.max(records_narrowed(&mut scratch, effects, &left));
}
found.compared += 1;
for b in others {
let right = effects_of(name, args, b)
.map(|e| format!("{e:?}"))
.unwrap_or_default();
if left != right {
found.diverged.insert(name);
found
.shown
.push(format!("{name} {args:?}:\n {left}\n {right}"));
}
}
}
if found.compared == before {
found.never_built.push(name);
}
}
found
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn every_command_gives_equal_effects_in_independently_built_equal_contexts() {
let apps: Vec<App> = (0..4).map(|_| fixture()).collect();
let contexts: Vec<CmdContext> = apps
.iter()
.map(|app| app.cmd_context(KeyCode::Null, KeyModifiers::NONE))
.collect();
let found = compare(&contexts[0], &contexts[1..]);
assert!(
found.widest_drill >= 4,
"the widest narrowing drill compared froze {} records, so the store's order is nearly a coin toss",
found.widest_drill
);
assert!(
found.never_built.is_empty(),
"no candidate args build these in the fixture, so the law never ran them: {:?}",
found.never_built
);
let listed: BTreeSet<&str> = OUTSIDE_THE_CONTEXT.iter().map(|(n, _)| *n).collect();
let unlisted: Vec<&str> = found
.shown
.iter()
.filter(|s| !listed.iter().any(|n| s.starts_with(&format!("{n} "))))
.map(String::as_str)
.collect();
assert!(
unlisted.is_empty(),
"effects depend on more than the context ({} pairs compared):\n{}",
found.compared,
unlisted.join("\n")
);
let stale: Vec<&&str> = listed
.iter()
.filter(|n| !found.diverged.contains(**n))
.collect();
assert!(
stale.is_empty(),
"listed as depending on something outside the context, but did not: {stale:?}"
);
}
/// Iterates a context `HashMap` and emits in that order: the one nondeterminism
/// this repo has shipped (gotcha 18). The law must see it; a reused context cannot.
#[derive(Debug)]
struct EmitsInHashOrder;
impl Cmd for EmitsInHashOrder {
fn name(&self) -> &'static str {
"emits-in-hash-order"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
ctx.buffers
.iter()
.map(|(name, value)| {
Box::new(effect::SetBuffer {
name: name.clone(),
value: value.clone(),
}) as Box<dyn Effect>
})
.collect()
}
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn only_distinct_instances_can_see_a_command_that_follows_hash_order() {
let (first, second) = (fixture(), fixture());
let a = first.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let b = second.cmd_context(KeyCode::Null, KeyModifiers::NONE);
let run = |ctx: &CmdContext| format!("{:?}", EmitsInHashOrder.execute(ctx));
assert_eq!(
run(&a),
run(&a),
"one reused context iterates its maps the same way twice"
);
assert_ne!(
run(&a),
run(&b),
"two independently built contexts must iterate differently, or the law is vacuous"
);
}
@@ -11,6 +11,96 @@ mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
fn press(app: &mut crate::ui::app::App, c: char) {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
/// improvise-gw18 — see context/test-evidence/command.md.
#[test]
fn drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate() {
let mut app = app_on_a_fold();
let origin = app.view_state.active_view.clone();
press(&mut app, '>');
assert_eq!(app.view_state.active_view, origin, "no drill was opened");
assert!(
app.view_state.status_msg.contains("ollapsed group"),
"the refusal is out loud, got: {:?}",
app.view_state.status_msg
);
press(&mut app, 'o');
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
let data = text.split("## Data").nth(1).expect("a data section");
assert!(
!data.contains("Q2"),
"a group coordinate was saved:\n{text}"
);
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
}
/// The commit door onto a fold: Enter advances the editor onto it, and the next
/// commit is what must refuse. See context/test-evidence/command.md.
#[test]
fn enter_advancing_onto_a_fold_commits_nothing_there() {
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let mut app = app_on_a_fold();
app.cursor_mut().selected = (1, 1);
press(&mut app, 'i');
app.handle_key(enter).unwrap();
assert_eq!(
app.cursor().selected,
(2, 1),
"setup: Enter moved onto the fold"
);
press(&mut app, '7');
app.handle_key(enter).unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
let data = text.split("## Data").nth(1).expect("a data section");
assert!(
!data.contains("Q2"),
"a group coordinate was saved:\n{text}"
);
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
}
/// The control for the refusal above: the same key one row up drills.
#[test]
fn drilling_into_the_row_above_a_fold_still_opens_a_drill() {
let mut app = app_on_a_fold();
app.cursor_mut().selected = (1, 1);
let origin = app.view_state.active_view.clone();
press(&mut app, '>');
assert_ne!(app.view_state.active_view, origin, "Feb × Cost drills");
}
/// improvise-yk2s — see context/test-evidence/command.md.
#[test]
fn hiding_a_folds_row_is_refused_and_persists_no_hidden_group() {
let mut app = app_on_a_fold();
press(&mut app, 'H');
let view = app.model_state.workbook.active_view();
assert!(!view.is_hidden("Month", "Q2"), "no ghost hidden entry");
assert!(
app.view_state.status_msg.contains("ollapsed group"),
"the refusal is out loud, got: {:?}",
app.view_state.status_msg
);
let (text, _) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("hidden:"),
"a hidden entry was saved:\n{text}"
);
// The control: the row above is an item, and H hides it.
app.cursor_mut().selected = (1, 1);
press(&mut app, 'H');
let view = app.model_state.workbook.active_view();
assert!(view.is_hidden("Month", "Feb"), "H on Feb hides Feb");
}
#[test]
fn toggle_group_under_cursor_returns_empty_without_groups() {
let m = two_cat_model();
@@ -505,6 +595,10 @@ impl Cmd for HideSelectedRowItem {
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
return vec![];
};
// A fold's row names a group, not an item, so there is nothing to hide (§14).
if ctx.layout.row_folds_a_group(ctx.selected.0) {
return vec![effect::refuse(super::core::FOLD_REFUSAL)];
}
let sel_row = ctx.selected.0;
let Some(items) = ctx
.layout
@@ -13,6 +13,9 @@ pub mod text_buffer;
pub mod tile;
pub mod wizard;
#[cfg(test)]
mod determinism;
// Re-export items used by external code
pub use self::core::{Cmd, CmdContext, CmdRegistry};
pub use registry::default_registry;
@@ -71,14 +74,15 @@ pub(super) mod test_helpers {
view_panel_cursor: 0,
tile_cat_idx: 0,
buffers: &EMPTY_BUFFERS,
opened_editor: None,
view_back_stack: &[],
view_forward_stack: &[],
has_drill_state: false,
display_value: {
let key = layout.cell_key(sr, sc);
key.as_ref()
.and_then(|k| workbook.model.get_cell(k).cloned())
.map(|v| v.to_string())
.and_then(|k| workbook.model.get_cell(k))
.map(crate::model::cell::CellValue::edit_text)
.unwrap_or_default()
},
visible_rows: 20,
@@ -117,6 +121,22 @@ pub(super) mod test_helpers {
wb
}
/// `two_cat_model` with one `_Measure` formula per target, in order.
pub fn with_formulas(targets: &[&str]) -> Workbook {
let mut wb = two_cat_model();
for target in targets {
wb.model.add_formula(crate::formula::ast::Formula {
raw: format!("{target} = 0"),
target: target.to_string(),
target_category: "_Measure".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
}
wb
}
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
format!("{:?}", effects)
}
@@ -124,4 +144,62 @@ pub(super) mod test_helpers {
pub fn make_registry() -> CmdRegistry {
default_registry()
}
/// Two quarters of months on Row, two measures on Column, every cell funded,
/// Q2 collapsed: rows `[Jan, Feb, Q2]`, and the cursor on the fold at `(2, 1)`.
pub fn app_on_a_fold() -> crate::ui::app::App {
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
for measure in ["Revenue", "Cost"] {
let key = CellKey::new(vec![
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
]);
wb.model.set_cell(key, CellValue::Number(10.0 + i as f64));
}
}
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
let mut app = crate::ui::app::App::new(wb, None);
app.rebuild_layout();
app.cursor_mut().selected = (2, 1);
assert!(
app.layout.folds_a_group(2, 1),
"setup: the cursor is on the fold"
);
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is Q2's row");
app
}
/// Every stored coordinate naming no item of its category — what a write to a
/// fold leaves behind (core.md §14).
pub fn non_item_coordinates(wb: &Workbook) -> Vec<(String, String)> {
let model = &wb.model;
model
.data
.iter_cells()
.flat_map(|(key, _)| key.0.clone())
.filter(|(cat, item)| {
!model
.category(cat)
.is_some_and(|c| c.items.contains_key(item.as_str()))
})
.collect()
}
/// The document saved and read back: what reaches the file, not the store.
pub fn saved_and_reloaded(wb: &Workbook) -> (String, Workbook) {
let text = crate::persistence::format_md(wb);
let back = crate::persistence::parse_md(&text).expect("the saved text reloads");
(text, back)
}
}
@@ -117,7 +117,7 @@ mod tests {
edit_mode: AppMode::editing(),
}
.execute(&ctx);
assert_eq!(effects.len(), 2);
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
let dbg = effects_debug(&effects);
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
}
@@ -135,7 +135,7 @@ mod tests {
edit_mode: AppMode::records_editing(),
}
.execute(&ctx);
assert_eq!(effects.len(), 2);
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
let dbg = effects_debug(&effects);
assert!(
dbg.contains("RecordsEditing"),
@@ -281,6 +281,10 @@ impl Cmd for EnterMode {
if let Some(mb) = self.0.minibuffer() {
effects.push(write_buffer(&mb.buffer_key, String::new()));
}
// An editor opened this way captured no seed, so no earlier one may stand.
if self.0.is_editing() {
effects.push(Box::new(effect::SetOpenedEditor(None)));
}
effects.push(effect::change_mode(self.0.clone()));
effects
}
@@ -355,7 +359,7 @@ impl Cmd for EditOrDrill {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
// Neither edit nor drill: no record carries a group's name (core.md §14).
if ctx.layout.folds_a_group(ctx.selected.0, ctx.selected.1) {
return vec![effect::set_status(
return vec![effect::refuse(
"Collapsed group — press z to expand it, then edit an item",
)];
}
@@ -380,9 +384,8 @@ impl Cmd for EditOrDrill {
// The same question `cell_id` asks, off the same predicate. In records mode
// the synthetic key names no measure, so the record's key is resolved.
if let Some(formula) = ctx.owning_formula() {
return vec![effect::refuse(format!(
"{} is computed by a formula — edit the formula, not the cell",
formula.target
return vec![effect::refuse(super::core::derived_refusal(
&formula.target,
))];
}
vec![
@@ -390,6 +393,9 @@ impl Cmd for EditOrDrill {
name: "edit".to_string(),
value: ctx.display_value.clone(),
}),
Box::new(effect::SetOpenedEditor(Some(
improvise_protocol::OpenedEditor::on(ctx.display_value.clone()),
))),
effect::change_mode(self.edit_mode.clone()),
]
}
@@ -442,8 +448,11 @@ impl Cmd for EnterTileSelect {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let count = ctx.model.category_names().len();
if count > 0 {
let tiles = crate::ui::project::tiles_of(ctx.model, ctx.view);
vec![
Box::new(effect::SetTileCatIdx(0)),
Box::new(effect::SetTileCatIdx(improvise_protocol::opening_tile(
&tiles,
))),
effect::change_mode(AppMode::TileSelect),
]
} else {
@@ -118,7 +118,7 @@ impl Cmd for Move {
}
}
MoveKind::Page(dir) => {
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir;
let delta = improvise_protocol::resolve::page_rows(self.cursor.visible_rows) * dir;
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
(nr, self.cursor.col)
}
+108 -75
View File
@@ -4,7 +4,7 @@ use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect, Panel, ViewTarget};
use crate::view::Axis;
use super::core::{Cmd, CmdContext};
use super::core::{Cmd, CmdContext, write_buffer};
#[cfg(test)]
mod tests {
@@ -192,11 +192,7 @@ mod tests {
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
@@ -208,11 +204,7 @@ mod tests {
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_panel_open = true;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: false,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = format!("{:?}", effects[0]);
@@ -231,11 +223,7 @@ mod tests {
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_panel_open = true;
ctx.category_panel_open = true;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -256,11 +244,7 @@ mod tests {
ctx.formula_panel_open = true;
ctx.category_panel_open = true;
ctx.mode = &AppMode::FormulaPanel;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -281,11 +265,7 @@ mod tests {
ctx.category_panel_open = true;
ctx.view_panel_open = true;
ctx.mode = &AppMode::CategoryPanel;
let cmd = CyclePanelFocus {
formula_open: false,
category_open: true,
view_open: true,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -305,11 +285,7 @@ mod tests {
ctx.formula_panel_open = true;
ctx.view_panel_open = true;
ctx.mode = &AppMode::ViewPanel;
let cmd = CyclePanelFocus {
formula_open: true,
category_open: false,
view_open: true,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
@@ -329,11 +305,7 @@ mod tests {
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.category_panel_open = true;
ctx.mode = &AppMode::CategoryPanel;
let cmd = CyclePanelFocus {
formula_open: false,
category_open: true,
view_open: false,
};
let cmd = CyclePanelFocus;
let effects = cmd.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
@@ -344,15 +316,13 @@ mod tests {
#[test]
fn move_panel_cursor_down_from_zero() {
let m = two_cat_model();
let m = with_formulas(&["A", "B"]);
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: 1,
current: 0,
max: 5,
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
@@ -363,17 +333,35 @@ mod tests {
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn move_panel_cursor_moves_from_the_named_panels_own_cursor() {
let m = with_formulas(&["A", "B", "C", "D", "E"]);
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_cursor = 1;
let args = ["formula".to_string(), "2".to_string()];
let effects = reg
.interactive("move-panel-cursor", &args, &ctx)
.expect("move-panel-cursor resolves")
.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SetPanelCursor { panel: Formula, cursor: 3 }"),
"must move from the formula cursor (1) by the parsed delta (2), got: {dbg}"
);
}
#[test]
fn move_panel_cursor_clamps_at_zero() {
let m = two_cat_model();
let m = with_formulas(&["A", "B"]);
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: -1,
current: 0,
max: 5,
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
@@ -388,46 +376,55 @@ mod tests {
let cmd = MovePanelCursor {
panel: effect::Panel::Formula,
delta: 1,
current: 0,
max: 0,
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn delete_formula_at_cursor_with_formulas() {
let mut m = two_cat_model();
m.model.add_formula(crate::formula::ast::Formula {
raw: "Profit = Revenue - Cost".to_string(),
target: "Profit".to_string(),
target_category: "Type".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
for (target, raw) in [("Profit", "Profit = 1"), ("Tax", "Tax = 2")] {
m.model.add_formula(crate::formula::ast::Formula {
raw: raw.to_string(),
target: target.to_string(),
target_category: "Type".to_string(),
expr: crate::formula::ast::Expr::Number(0.0),
filter: None,
format: None,
});
}
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.formula_cursor = 1;
let effects = DeleteFormulaAtCursor.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("RemoveFormula { target: \"Profit\", target_category: \"Type\" }"),
"must remove the formula AT THE CURSOR, got: {dbg}"
dbg.contains("RemoveFormula { target: \"Tax\", target_category: \"Type\" }"),
"must remove the formula AT THE CURSOR (Tax, index 1), got: {dbg}"
);
assert!(
!dbg.contains("\"Profit\""),
"removed the first formula, not the one under the cursor: {dbg}"
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn switch_view_at_cursor_with_valid_cursor() {
let m = two_cat_model();
let mut m = two_cat_model();
m.create_view("Other");
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_panel_cursor = 1;
let effects = SwitchViewAtCursor.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SwitchView(\"Default\")"),
"must switch to the view UNDER THE CURSOR, got: {dbg}"
dbg.contains("SwitchView(\"Other\")"),
"must switch to the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
);
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
}
@@ -504,6 +501,27 @@ mod tests {
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor() {
let mut m = two_cat_model();
m.create_view("Other");
m.create_view("Third");
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.view_panel_cursor = 1;
let dbg = effects_debug(&DeleteViewAtCursor.execute(&ctx));
assert!(
dbg.contains("DeleteView(\"Other\")"),
"must delete the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
);
assert!(
dbg.contains("SetPanelCursor { panel: View, cursor: 0 }"),
"a delete off position 0 steps the cursor back, got: {dbg}"
);
}
#[test]
fn delete_view_at_cursor_zero_does_not_adjust_cursor() {
let m = two_cat_model();
@@ -559,16 +577,15 @@ impl Cmd for TogglePanelAndFocus {
#[derive(Debug)]
pub struct TogglePanelVisibility {
pub panel: Panel,
pub currently_open: bool,
}
impl Cmd for TogglePanelVisibility {
fn name(&self) -> &'static str {
"toggle-panel-visibility"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::SetPanelOpen {
panel: self.panel,
open: !self.currently_open,
open: !ctx.panel_open(self.panel),
})]
}
}
@@ -576,20 +593,16 @@ impl Cmd for TogglePanelVisibility {
/// Tab through open panels Formula → Category → View, wrapping, starting from the
/// focused one. One open panel self-transitions; none open is a no-op.
#[derive(Debug)]
pub struct CyclePanelFocus {
pub formula_open: bool,
pub category_open: bool,
pub view_open: bool,
}
pub struct CyclePanelFocus;
impl Cmd for CyclePanelFocus {
fn name(&self) -> &'static str {
"cycle-panel-focus"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let order = [
(self.formula_open, AppMode::FormulaPanel),
(self.category_open, AppMode::CategoryPanel),
(self.view_open, AppMode::ViewPanel),
(ctx.formula_panel_open, AppMode::FormulaPanel),
(ctx.category_panel_open, AppMode::CategoryPanel),
(ctx.view_panel_open, AppMode::ViewPanel),
];
// Index of the first candidate: the panel after the focused one,
// or the first panel when invoked from a non-panel mode.
@@ -614,16 +627,17 @@ impl Cmd for CyclePanelFocus {
pub struct MovePanelCursor {
pub panel: Panel,
pub delta: i32,
pub current: usize,
pub max: usize,
}
impl Cmd for MovePanelCursor {
fn name(&self) -> &'static str {
"move-panel-cursor"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let cursor = self.current;
let max = self.max;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let (cursor, max) = match self.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
if max == 0 {
return vec![];
}
@@ -671,6 +685,25 @@ impl Cmd for DeleteFormulaAtCursor {
}
}
/// Open the formula editor on the formula under the cursor, seeded with its
/// text and naming it from here on — the commit never re-reads the cursor.
#[derive(Debug)]
pub struct EditFormulaAtCursor;
impl Cmd for EditFormulaAtCursor {
fn name(&self) -> &'static str {
"edit-formula-at-cursor"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
match ctx.model.formulas().get(ctx.formula_cursor) {
Some(f) => vec![
effect::change_mode(AppMode::formula_replace(f.id(), f.raw.clone())),
write_buffer("formula", f.raw.clone()),
],
None => vec![],
}
}
}
// ── Category panel commands ─────────────────────────────────────────────────
/// Cycle the axis assignment of the category at the cursor.
@@ -22,24 +22,37 @@ use super::text_buffer::*;
use super::tile::*;
use super::wizard::*;
/// The cell under the cursor: what a coordinate command falls back to when given none.
/// The cell under the cursor for `drill-into-cell`, which opens aggregates but
/// not a fold. Which command takes which door: `context/code/command.md`.
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().clone().ok_or("no cell at cursor".into())
ctx.unfolded_cell_key()
}
/// 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
/// The stored cell a value lands on, for `clear-cell` and `paste`.
fn cursor_value_key(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.value_cell_key()
}
/// [`cursor_key`] for the one command that only READS the cell: `yank`.
fn cursor_key_to_read(ctx: &CmdContext) -> Result<CellKey, String> {
ctx.cell_key().ok_or("no cell at cursor".into())
}
impl AtCursor for Move {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
/// A commit built from args still advances from the live cursor.
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
CommitAndAdvance {
cursor: CursorState::from_ctx(ctx),
..cmd
impl AtCursor for EnterAdvance {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
impl AtCursor for CommitAndAdvance {
fn cursor_mut(&mut self) -> &mut CursorState {
&mut self.cursor
}
}
@@ -55,17 +68,13 @@ fn commit_at_cursor(
Ok(CommitAndAdvance {
edit_mode: parse_mode_name(mode)?,
value: read_buffer(ctx, "edit"),
key: cursor_key(ctx)?,
key: ctx.committable_cell_key()?,
advance,
cursor: CursorState::from_ctx(ctx),
opened: ctx.opened_editor.cloned(),
})
}
/// 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 {
@@ -90,10 +99,9 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(ClearCellCommand {
key: cursor_key(ctx)?,
key: cursor_value_key(ctx)?,
})
}),
);
@@ -121,7 +129,7 @@ pub fn default_registry() -> CmdRegistry {
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
// ── Navigation (unified Move) ──────────────────────────────────────
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
@@ -137,13 +145,12 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "move-selection",
})
},
move_from_cursor,
None,
);
// Jump-to-edge commands: first/last row/col
macro_rules! reg_jump {
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
$r.register(
$r.register_at_cursor(
&Move {
kind: if $to_end {
MoveKind::ToEnd($is_row)
@@ -164,7 +171,6 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: $name,
})
},
move_from_cursor,
None,
);
};
@@ -173,7 +179,7 @@ pub fn default_registry() -> CmdRegistry {
reg_jump!(r, true, true, "jump-last-row");
reg_jump!(r, false, false, "jump-first-col");
reg_jump!(r, false, true, "jump-last-col");
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Relative(0, 0),
cursor: CursorState::default(),
@@ -188,10 +194,9 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "scroll-rows",
})
},
move_from_cursor,
None,
);
r.register(
r.register_at_cursor(
&Move {
kind: MoveKind::Page(0),
cursor: CursorState::default(),
@@ -206,10 +211,9 @@ pub fn default_registry() -> CmdRegistry {
cmd_name: "page-scroll",
})
},
move_from_cursor,
None,
);
r.register(
r.register_at_cursor(
&EnterAdvance {
cursor: CursorState::default(),
},
@@ -227,9 +231,6 @@ pub fn default_registry() -> CmdRegistry {
},
})
},
|_, ctx| EnterAdvance {
cursor: CursorState::from_ctx(ctx),
},
None,
);
@@ -246,29 +247,27 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(YankCell {
key: cursor_key(ctx)?,
key: cursor_key_to_read(ctx)?,
})
}),
);
r.register(
&PasteCell {
key: CellKey::new(vec![]),
at: PasteAt::Spelled(CellKey::new(vec![])),
},
|args| {
if args.is_empty() {
return Err("paste requires at least one Cat/Item coordinate".into());
}
Ok(PasteCell {
key: parse_cell_key_from_args(args),
at: PasteAt::Spelled(parse_cell_key_from_args(args)),
})
},
as_parsed,
Some(|_, ctx| {
Ok(PasteCell {
key: cursor_key(ctx)?,
at: PasteAt::Resolved(cursor_value_key(ctx)?),
})
}),
);
@@ -318,7 +317,6 @@ pub fn default_registry() -> CmdRegistry {
key: parse_cell_key_from_args(args),
})
},
as_parsed,
Some(|_, ctx| {
Ok(DrillIntoCell {
key: cursor_key(ctx)?,
@@ -356,81 +354,35 @@ pub fn default_registry() -> CmdRegistry {
});
Ok(TogglePanelAndFocus { panel, target })
},
as_parsed,
None,
);
r.register(
&TogglePanelVisibility {
panel: Panel::Formula,
currently_open: false,
},
|args| {
require_args(args, 1)?;
let panel = parse_panel(&args[0])?;
Ok(TogglePanelVisibility {
panel,
currently_open: false,
})
},
|cmd, ctx| TogglePanelVisibility {
currently_open: ctx.panel_open(cmd.panel),
..cmd
},
None,
);
r.register(
&CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
},
|_| {
Ok(CyclePanelFocus {
formula_open: false,
category_open: false,
view_open: false,
})
},
|_, ctx| CyclePanelFocus {
formula_open: ctx.formula_panel_open,
category_open: ctx.category_panel_open,
view_open: ctx.view_panel_open,
Ok(TogglePanelVisibility { panel })
},
None,
);
r.register_nullary(|| Box::new(CyclePanelFocus));
r.register(
&MovePanelCursor {
panel: Panel::Formula,
delta: 0,
current: 0,
max: 0,
},
|args| {
require_args(args, 2)?;
let panel = parse_panel(&args[0])?;
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
Ok(MovePanelCursor {
panel,
delta,
current: 0,
max: 0,
})
},
|cmd, ctx| {
let (current, max) = match cmd.panel {
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
};
MovePanelCursor {
current,
max,
..cmd
}
Ok(MovePanelCursor { panel, delta })
},
None,
);
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
r.register_nullary(|| Box::new(EditFormulaAtCursor));
r.register_nullary(|| Box::new(AddRecordRow));
r.register_nullary(|| Box::new(TogglePruneEmpty));
r.register_nullary(|| Box::new(ToggleRecordsMode));
@@ -493,13 +445,14 @@ pub fn default_registry() -> CmdRegistry {
// ── Commit ───────────────────────────────────────────────────────────
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
r.register(
r.register_at_cursor(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
opened: None,
},
|args| {
if args.len() < 3 {
@@ -512,21 +465,21 @@ pub fn default_registry() -> CmdRegistry {
advance: AdvanceDir::Down,
cursor: CursorState::default(),
edit_mode,
opened: None,
})
},
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
);
r.register(
r.register_at_cursor(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Right,
cursor: CursorState::default(),
edit_mode: AppMode::editing(),
opened: None,
},
|_| Err("commit-and-advance-right requires context".into()),
commit_from_cursor,
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
);
r.register(
@@ -538,7 +491,6 @@ pub fn default_registry() -> CmdRegistry {
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
Ok(CommitBuffer { kind })
},
as_parsed,
Some(|args, ctx| {
require_args(args, 1)?;
let kind = parse_commit_kind(&args[0], ctx.mode)?;
@@ -562,13 +514,13 @@ pub fn default_registry() -> CmdRegistry {
}
#[cfg(test)]
mod tests {
pub(super) mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
/// Argument vectors to try when constructing a command, most specific
/// first. A command is exercised by the first one its parser accepts.
fn candidate_args() -> Vec<Vec<String>> {
pub(in crate::command::cmd) fn candidate_args() -> Vec<Vec<String>> {
[
vec![],
vec!["1"],
@@ -863,12 +815,16 @@ mod panel_open_tests {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
for asked in Panel::ALL {
let args = [asked.name().to_string()];
let cmd = ctx
let effects = ctx
.registry
.interactive("toggle-panel-visibility", &args, &ctx)
.expect("toggle-panel-visibility resolves");
let dbg = format!("{cmd:?}");
let expected = format!("currently_open: {}", asked == opened);
.expect("toggle-panel-visibility resolves")
.execute(&ctx);
let dbg = format!("{effects:?}");
let expected = format!(
"SetPanelOpen {{ panel: {asked:?}, open: {} }}",
asked != opened
);
assert!(
dbg.contains(&expected),
"with only {opened:?} open, toggling {asked:?} read another \
@@ -1,4 +1,3 @@
use crate::model::Aggregation;
use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect, Panel};
@@ -113,10 +112,7 @@ impl Cmd for SearchNavigate {
Some(k) => k,
None => return false,
};
let s = match ctx
.model
.evaluate_aggregated(&key, Aggregation::of(ctx.none_cats()))
{
let s = match ctx.layout.evaluate(ctx.model, &key) {
Some(CellValue::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t,
Some(CellValue::Error(e)) => format!("ERR:{e}"),
@@ -297,7 +297,7 @@ impl Cmd for AppendChar {
if let KeyCode::Char(c) = ctx.key_code {
let mut val = read_buffer(ctx, &self.buffer);
val.push(c);
vec![write_buffer(&self.buffer, val)]
typed_into(ctx, &self.buffer, val)
} else {
vec![]
}
@@ -316,10 +316,21 @@ impl Cmd for PopChar {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut val = read_buffer(ctx, &self.buffer);
val.pop();
vec![write_buffer(&self.buffer, val)]
typed_into(ctx, &self.buffer, val)
}
}
/// A user's edit of buffer `name`: the write, then the open editor marked
/// touched, so an edit back to the seed still commits (improvise-2054).
fn typed_into(ctx: &CmdContext, name: &str, value: String) -> Vec<Box<dyn Effect>> {
let mut effects = vec![write_buffer(name, value)];
effects.extend(
improvise_protocol::edited(ctx.opened_editor, name)
.map(|opened| Box::new(effect::SetOpenedEditor(Some(opened))) as Box<dyn Effect>),
);
effects
}
/// Handle backspace in command mode — pop char or return to Normal if empty.
#[derive(Debug)]
pub struct CommandModeBackspace;
@@ -59,6 +59,26 @@ mod tests {
assert!(effects.is_empty());
}
/// improvise-jgzw: `_Index`/`_Dim` stay tileable on purpose, and `_Measure` on
/// a column must too — `context/test-evidence/command.md`.
#[test]
fn the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included() {
let mut m = two_cat_model();
m.active_view_mut().set_axis("_Measure", Axis::Column);
let layout = make_layout(&m);
let reg = make_registry();
let mut ctx = make_ctx(&m, &layout, &reg);
let mut offered = Vec::new();
for idx in 0..8 {
ctx.tile_cat_idx = idx;
let dbg = effects_debug(&TileAxisOp { axis: None }.execute(&ctx));
if let Some(rest) = dbg.split("CycleAxis(Active, \"").nth(1) {
offered.push(rest.split('"').next().unwrap_or_default().to_string());
}
}
assert_eq!(offered, ["_Index", "_Dim", "_Measure", "Type", "Month"]);
}
#[test]
fn move_tile_cursor_right() {
let m = two_cat_model();
@@ -477,6 +477,69 @@ mod tests {
out
}
/// improvise-gw18: every Normal key, prefixes walked, pressed on a fold with a
/// value yanked — see context/test-evidence/command.md.
#[test]
fn no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate() {
use improvise_protocol::key::KeyEvent;
let ks = KeymapSet::default_keymaps();
let normal = ks
.maps()
.get(&ModeKey::Normal)
.expect("a Normal keymap")
.clone();
let mods = [
KeyModifiers::NONE,
KeyModifiers::SHIFT,
KeyModifiers::CONTROL,
KeyModifiers::ALT,
];
let mut strokes: Vec<Vec<(KeyCode, KeyModifiers)>> = Vec::new();
for key in probe_keys() {
for m in mods {
match normal.lookup(key, m) {
Some(Binding::Prefix(sub)) => strokes.extend(
probe_keys()
.into_iter()
.flat_map(|k2| mods.map(|m2| (k2, m2)))
.filter(|&(k2, m2)| sub.lookup(k2, m2).is_some())
.map(|second| vec![(key, m), second]),
),
Some(_) => strokes.push(vec![(key, m)]),
None => {}
}
}
}
assert!(
strokes
.iter()
.any(|s| s == &[(KeyCode::Char('p'), KeyModifiers::NONE)]),
"the sweep reaches paste"
);
// No key on a fold may touch the stored data or the hidden set at all.
let touched = |app: &crate::ui::app::App| {
let wb = &app.model_state.workbook;
let text = crate::persistence::format_md(wb);
let data = text.split("## Data").nth(1).unwrap_or_default().to_string();
(data, wb.active_view().hidden_items.clone())
};
let before = touched(&test_helpers::app_on_a_fold());
for stroke in &strokes {
let mut app = test_helpers::app_on_a_fold();
app.view_state.yanked = Some(crate::model::cell::CellValue::Number(5.0));
for &(key, m) in stroke {
let _ = app.handle_key(KeyEvent::new(key, m));
}
let stray = test_helpers::non_item_coordinates(&app.model_state.workbook);
assert!(stray.is_empty(), "{stroke:?} on a fold stored {stray:?}");
assert_eq!(
touched(&app),
before,
"{stroke:?} on a fold changed the document"
);
}
}
/// A representative `AppMode` for each keymap, so a test can put a
/// `CmdContext` in the mode whose bindings it is walking.
fn app_mode_for(mode: &ModeKey) -> AppMode {
+865 -11
View File
@@ -27,7 +27,7 @@ use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_
use crate::view::{GridLayout, View};
use crate::workbook::Workbook;
use improvise_core::model::record::{AggregateHash, RecordHash};
use improvise_protocol::RecordCell;
use improvise_protocol::{CellId, RecordCell};
/// A cell's coordinate tuple with `_Measure` dropped: the unit of atomicity
/// ADR 001 settles on, and not the cell — see `context/code/command.md`.
@@ -386,7 +386,9 @@ impl App {
let view = workbook.active_view();
GridLayout::with_frozen_records(&workbook.model, view, None)
};
Self {
// A document may open on a records grid, so the first mode is settled
// by the rule every batch ends with (improvise-y5eo).
let mut app = Self {
model_state: ModelState {
workbook,
file_path,
@@ -416,7 +418,9 @@ impl App {
last_reframed_elsewhere: std::collections::BTreeSet::new(),
keymap_set: KeymapSet::default_keymaps(),
storage,
}
};
app.settle_normal_mode();
app
}
/// This client's cursor in the active view. Absent means the origin, so
@@ -444,6 +448,9 @@ impl App {
pub fn install_workbook(&mut self, workbook: Workbook) {
self.view_state.active_view = workbook.active_view.clone();
self.view_state.cursors.clear();
// The history names the outgoing document's views too (improvise-lrrx).
self.view_state.view_back_stack.clear();
self.view_state.view_forward_stack.clear();
self.model_state.workbook = workbook;
// Every client's mirror is scoped to the outgoing document too, and an
// effect cannot reach the session list. The generation is how it hears.
@@ -569,24 +576,79 @@ impl App {
.map(|key| record_of(&key))
}
/// Why a wire write to `cell` is refused: the host itself projects it as no
/// `CellId::Record` (improvise-ciw8, ADR 002). `None` for a writable cell.
pub fn unwritable(&self, cell: &RecordCell) -> Option<String> {
let key = self.resolve_cell(cell)?;
let model = &self.model_state.workbook.model;
let derived = model.formula_for(&key).map(|f| {
format!(
"{} is computed by a formula — edit the formula, not the cell",
f.target
)
});
let refusal = self.group_refusal(&key).or(derived);
refusal.or_else(|| self.aggregate_refusal(cell.record, &key))
}
/// A coordinate naming a group rather than an item is no cell in ANY view, so
/// this asks the document, never the layout (improvise-b114).
fn group_refusal(&self, key: &CellKey) -> Option<String> {
let model = &self.model_state.workbook.model;
key.0.iter().find_map(|(cat, name)| {
let category = model.category(cat)?;
let group = !category.items.contains_key(name.as_str())
&& !category.items_in_group(name).is_empty();
group.then(|| format!("{name} is a group of {cat}, so there is no one cell to write"))
})
}
/// A record the store does not hold is known only by its layout position, and
/// is a record only where the projection at ITS CELL says so (improvise-sjdb).
fn aggregate_refusal(&self, hash: RecordHash, key: &CellKey) -> Option<String> {
let stored = self.model_state.workbook.model.data.resolve_record(hash);
let (row, col) = stored.map_or_else(|| self.cell_position(key), |_| None)?;
let projected =
crate::ui::project::cell_id(&self.model_state.workbook.model, &self.layout, row, col);
matches!(projected, Some(CellId::Aggregate(_))).then(|| {
"an aggregate combines several records, so there is no one cell to write".into()
})
}
/// Where in the current layout the cell `key` sits, if anywhere. By the whole
/// key: a `RecordHash` drops `_Measure`, so a row's columns all share one.
fn cell_position(&self, key: &CellKey) -> Option<(usize, usize)> {
self.layout_cells()
.find(|(_, _, at)| at == key)
.map(|(row, col, _)| (row, col))
}
/// Every cell key the current grid names. Records mode is excluded: its
/// `cell_key` carries *positions*, the trap ADR 002 rejected `_Index` for.
fn layout_cell_keys(&self) -> impl Iterator<Item = CellKey> + '_ {
self.layout_cells().map(|(_, _, key)| key)
}
/// [`Self::layout_cell_keys`] with each key's position.
fn layout_cells(&self) -> impl Iterator<Item = (usize, usize, CellKey)> + '_ {
let layout = &self.layout;
let rows = match layout.is_records_mode() {
true => 0,
false => layout.row_count(),
};
(0..rows)
.flat_map(move |r| (0..layout.col_count()).filter_map(move |c| layout.cell_key(r, c)))
(0..rows).flat_map(move |r| {
(0..layout.col_count()).filter_map(move |c| layout.cell_key(r, c).map(|k| (r, c, k)))
})
}
/// Point **this client's** slice at `name`, if the document has such a view.
/// The document's own default stays put (improvise-jytm, gotcha 29).
pub fn switch_to_view(&mut self, name: &str) {
if self.model_state.workbook.views.contains_key(name) {
/// Point **this client's** slice at `name`, if the document has such a view,
/// and say whether it did. The document's default stays put (gotcha 29).
pub fn switch_to_view(&mut self, name: &str) -> bool {
let accepted = self.model_state.workbook.views.contains_key(name);
if accepted {
self.view_state.active_view = name.to_string();
}
accepted
}
/// What `name` names in this document, falling back to the default for a name
@@ -761,14 +823,14 @@ impl App {
match crate::view::synthetic_record_info(key) {
Some((idx, col)) => self
.staged_drill_edit(layout, idx, col)
.or_else(|| layout.resolve_display(key))
.or_else(|| layout.records_value(idx, col).map(|v| v.edit_text()))
.unwrap_or_default(),
None => self
.model_state
.workbook
.model
.get_cell(key)
.map(|v| v.to_string())
.map(CellValue::edit_text)
.unwrap_or_default(),
}
}
@@ -807,6 +869,7 @@ impl App {
category_panel_open: self.view_state.category_panel_open,
view_panel_open: self.view_state.view_panel_open,
buffers: &self.view_state.buffers,
opened_editor: self.view_state.opened_editor.as_ref(),
formula_cursor: self.view_state.formula_cursor,
cat_panel_cursor: self.view_state.cat_panel_cursor,
view_panel_cursor: self.view_state.view_panel_cursor,
@@ -2753,6 +2816,29 @@ mod tests {
}
}
/// improvise-y5eo: a document that opens on a records grid opens the host in
/// `RecordsNormal`. See `context/test-evidence/command.md`.
#[test]
fn a_document_opening_on_a_records_view_opens_the_host_in_records_normal() {
use crate::view::Axis;
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.active_view_mut().set_axis("_Index", Axis::Row);
wb.active_view_mut().set_axis("_Dim", Axis::Column);
let mut app = App::new(wb, None);
assert!(app.layout.is_records_mode(), "setup: the grid is records");
assert_eq!(app.view_state.mode, AppMode::RecordsNormal);
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
.unwrap();
assert!(
app.view_state.mode.is_records(),
"the host's first `i` left records mode: {:?}",
app.view_state.mode
);
}
/// The settle's `RecordsNormal` arm, driven from the mode it names. The
/// `:` crossing above enters the settle in `Normal` both ways — the evidence.
#[test]
@@ -2990,6 +3076,516 @@ mod tests {
);
}
/// improvise-cz8d: a refusal need not abort, so a failed document swap must
/// ask for the abort itself. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_failed_load_or_import_does_not_run_the_quit_after_it() {
use improvise_protocol::keymap::Keymap;
// One case per `refuse_and_stop` site (improvise-514o).
let cases: [(&str, &[&str], &str); 7] = [
("load", &["absent.improv"], "Load error"),
("import-json", &["absent.json"], "Cannot read 'absent.json'"),
("import-json", &["absent.csv"], "Cannot read 'absent.csv'"),
("import-json", &["bad.csv"], "CSV error"),
("import-json", &["bad.json"], "JSON parse error"),
(
"import-json",
&["nested.json", "M", "nope"],
"No array at path",
),
("import-json", &["measures.json"], "Import error"),
];
for (command, args, arm) in cases {
let path = args.join(" ");
let mut app = App::new(Workbook::new("T"), None);
app.storage = Box::new(crate::ui::host::import_fixture_storage());
let key = KeyCode::Char('x');
let mods = KeyModifiers::NONE;
let mut km = Keymap::new();
km.bind_seq(
key,
mods,
vec![
(command, args.iter().map(|a| a.to_string()).collect()),
("force-quit", vec![]),
],
);
let effects = {
let ctx = app.cmd_context(key, mods);
app.keymap_set.dispatch_transient(&km, &ctx, key, mods)
}
.expect("both steps resolve in the registry");
app.apply_effects(effects);
assert!(
app.last_batch_refused() && app.view_state.status_msg.contains(arm),
"setup: `{command} {path}` must fail in the {arm:?} arm, got {:?}",
app.view_state.status_msg
);
assert_ne!(
app.view_state.mode,
AppMode::Quit,
"`{command} {path} . q` quit having loaded nothing"
);
}
}
/// improvise-v4hg: Enter on a formula that does not parse threw the typed text
/// away. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text() {
let type_and_commit = |text: &str| {
let mut app = two_col_model();
app.view_state.mode = AppMode::FormulaPanel;
let press = |app: &mut App, code| {
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
.unwrap()
};
press(&mut app, KeyCode::Char('a'));
assert!(
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
"setup: `a` in the formula panel opens the editor"
);
for c in text.chars() {
press(&mut app, KeyCode::Char(c));
}
press(&mut app, KeyCode::Enter);
app
};
// The control: a formula that parses is stored and the editor closes.
let ok = type_and_commit("Total = 1");
assert_eq!(ok.view_state.mode, AppMode::FormulaPanel);
assert!(
ok.model_state
.workbook
.model
.formulas()
.iter()
.any(|f| f.target == "Total"),
"the control formula must land"
);
let bad = type_and_commit("Total = (");
assert!(
bad.last_batch_refused() && bad.view_state.status_msg.contains("Formula error"),
"the parse error must be reported, got {:?}",
bad.view_state.status_msg
);
assert!(
matches!(bad.view_state.mode, AppMode::FormulaEdit { .. }),
"a refused formula must leave the user in the editor, got {:?}",
bad.view_state.mode
);
assert_eq!(
bad.view_state.buffers.get("formula").map(String::as_str),
Some("Total = ("),
"and must keep what they typed"
);
}
/// Run a `:` line the way `improvise cmd` does, one batch per segment; true
/// when any segment refused, which is what its exit code reads.
fn run_line(app: &mut App, line: &str) -> bool {
let mut refused = false;
for segment in crate::command::parse::segments(line) {
let effects = {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
crate::command::parse::parse_segment_interactive(segment, &ctx)
.unwrap_or_else(|e| panic!("`{segment}` must parse: {e}"))
.map(|cmd| cmd.execute(&ctx))
.unwrap_or_default()
};
app.apply_effects(effects);
refused |= app.last_batch_refused();
}
refused
}
/// Commit `text` in a minibuffer mode with Enter; true when that batch refused.
fn commit_in(app: &mut App, mode: AppMode, buffer: &str, text: &str) -> bool {
app.view_state.mode = mode;
app.view_state
.buffers
.insert(buffer.to_string(), text.to_string());
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
app.last_batch_refused()
}
fn with_import_fixtures(mut app: App) -> App {
app.storage = Box::new(crate::ui::host::import_fixture_storage());
app
}
/// Eleven regular categories: one short of the limit.
fn one_category_short_of_the_limit() -> App {
let mut wb = Workbook::new("T");
for n in 0..11 {
wb.add_category(format!("C{n}")).unwrap();
}
App::new(wb, None)
}
/// Stage one coordinate rename in a drill over the records fixture, then commit it.
fn commit_drill_rename(app: &mut App, to: &str) -> bool {
let records = app.layout.records.clone().expect("records mode");
let mut pending_edits = HashMap::new();
pending_edits.insert(
(1, crate::view::RecordCol::Category("Region".into())),
to.into(),
);
app.drill_state = Some(DrillState {
records,
pending_edits,
});
app.apply_effects(vec![Box::new(crate::ui::effect::ApplyAndClearDrill)]);
app.last_batch_refused()
}
/// Records mode with `Region` paged to `page`: `add-record-row` then names
/// the row whose coordinates are exactly that page.
fn records_paged_to(app: &mut App, page: &str) {
app.model_state.workbook.add_item("Region", "West").unwrap();
let view = app
.view_mut_of(&ViewTarget::Active)
.expect("the active view");
view.set_axis("Region", crate::view::Axis::Page);
view.set_page_selection("Region", page);
app.rebuild_layout();
}
/// A grid with a regular category on None, so every cell is an aggregate.
fn aggregated_grid(with_items: bool) -> App {
let mut wb = Workbook::new("T");
for cat in ["Row", "Col", "Hidden"] {
wb.add_category(cat).unwrap();
if with_items {
wb.add_item(cat, "A").unwrap();
}
}
wb.active_view_mut()
.set_axis("Hidden", crate::view::Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// `Region` down, `_Measure` across: `Revenue` at column 0, then `Total`,
/// which a formula owns, at column 1.
fn a_formula_column() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
let mut app = App::new(wb, None);
app.apply_effects(vec![Box::new(crate::ui::effect::AddFormula {
raw: "Total = Revenue * 2".into(),
target_category: "_Measure".into(),
})]);
app
}
fn with_a_cell(mut app: App) -> App {
app.model_state.workbook.model.set_cell(
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
CellValue::Number(42.0),
);
app.rebuild_layout();
app
}
/// Months grouped into quarters with Q2 collapsed: row 2 is the fold.
fn folded_quarter() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn yanked(mut app: App) -> App {
app.view_state.yanked = Some(CellValue::Number(5.5));
app
}
/// A drill frozen over the records the grid already shows.
fn in_a_drill(mut app: App) -> App {
let records = app.layout.records.clone().expect("records mode");
app.drill_state = Some(DrillState {
records,
pending_edits: HashMap::new(),
});
app
}
fn with_a_second_view(mut app: App) -> App {
app.model_state.workbook.create_view("Second");
app
}
fn switch_to_second(app: &mut App) {
app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView(
"Second".into(),
))]);
}
/// improvise-0v8l: one case per refusal site no other test pinned, each with a
/// succeeding twin. Evidence: `context/test-evidence/command.md`.
#[test]
fn every_refusal_site_refuses_its_failure_and_only_its_failure() {
type Setup = fn() -> App;
type Drive = fn(&mut App) -> bool;
let cases: Vec<(&str, Setup, Drive, &str, Setup, Drive)> = vec![
(
"add-category past the limit",
one_category_short_of_the_limit,
|app| run_line(app, "add-category A . add-category B"),
"Maximum of 12 categories",
one_category_short_of_the_limit,
|app| run_line(app, "add-category A"),
),
(
"a drill commit that empties a coordinate",
records_app_for_drill,
|app| commit_drill_rename(app, ""),
crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY,
records_app_for_drill,
|app| commit_drill_rename(app, "West"),
),
(
"import of a JSON that does not parse",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import bad.json"),
"JSON parse error",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import good.json"),
),
(
"import of an absent file",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import absent.json"),
"Cannot read file",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import good.json"),
),
(
"import-json of an absent CSV",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json absent.csv"),
"Cannot read 'absent.csv'",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json good.json"),
),
(
"import-json of a CSV that is not text",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json bad.csv"),
"CSV error",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json good.json"),
),
(
"import-json of JSON that does not parse",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json bad.json"),
"JSON parse error",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json good.json"),
),
(
"import-json at a path with no array",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json nested.json M nope"),
"No array at path",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json good.json"),
),
(
"import-json that builds no model",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json measures.json"),
"Import error",
|| with_import_fixtures(two_col_model()),
|app| run_line(app, "import-json good.json"),
),
(
"a records coordinate edited to nothing",
records_model_with_two_rows,
|app| {
app.cursor_mut().selected = (1, 0);
commit_in(app, AppMode::records_editing(), "edit", "")
},
crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY,
records_model_with_two_rows,
|app| {
app.cursor_mut().selected = (1, 0);
commit_in(app, AppMode::records_editing(), "edit", "West")
},
),
(
"an export with no path",
|| with_import_fixtures(two_col_model()),
|app| commit_in(app, AppMode::export_prompt(), "export", ""),
"not exported",
|| with_import_fixtures(two_col_model()),
|app| commit_in(app, AppMode::export_prompt(), "export", "out.csv"),
),
(
"view-forward with nowhere to go",
|| with_a_second_view(two_col_model()),
|app| run_line(app, "view-forward"),
"No forward view",
|| with_a_second_view(two_col_model()),
|app| {
switch_to_second(app);
run_line(app, "view-back");
run_line(app, "view-forward")
},
),
(
"view-back with nowhere to go",
|| with_a_second_view(two_col_model()),
|app| run_line(app, "view-back"),
"No previous view",
|| with_a_second_view(two_col_model()),
|app| {
switch_to_second(app);
run_line(app, "view-back")
},
),
(
"add-record-row outside records mode",
two_col_model,
|app| run_line(app, "add-record-row"),
"only works in records mode",
records_model_with_two_rows,
|app| run_line(app, "add-record-row"),
),
(
"add-record-row onto a row that is already there",
records_model_with_two_rows,
|app| {
records_paged_to(app, "South");
run_line(app, "add-record-row")
},
"already here",
records_model_with_two_rows,
|app| {
records_paged_to(app, "West");
run_line(app, "add-record-row")
},
),
(
"edit-or-drill on an aggregate grid with no cell",
|| aggregated_grid(false),
|app| run_line(app, "edit-or-drill editing"),
"No cell at cursor",
|| aggregated_grid(true),
|app| run_line(app, "edit-or-drill editing"),
),
(
"edit-or-drill on a cell a formula owns",
a_formula_column,
|app| {
app.cursor_mut().selected = (0, 1);
run_line(app, "edit-or-drill editing")
},
"computed by a formula",
a_formula_column,
|app| {
app.cursor_mut().selected = (0, 0);
run_line(app, "edit-or-drill editing")
},
),
(
"a search with no matches",
|| with_a_cell(two_col_model()),
|app| {
app.view_state.search_query = "zzz".into();
run_line(app, "search-navigate forward")
},
"No matches",
|| with_a_cell(two_col_model()),
|app| {
app.view_state.search_query = "42".into();
run_line(app, "search-navigate forward")
},
),
(
"a : line that does not parse",
two_col_model,
|app| commit_in(app, AppMode::command_mode(), "command", "no-such-command"),
":no-such-command",
two_col_model,
|app| commit_in(app, AppMode::command_mode(), "command", "add-category Z"),
),
// improvise-i3o5: these four wrote their decline with `set_status`.
(
"edit-or-drill on a collapsed group's row",
folded_quarter,
|app| {
app.cursor_mut().selected = (2, 0);
run_line(app, "edit-or-drill editing")
},
"Collapsed group",
folded_quarter,
|app| {
app.cursor_mut().selected = (1, 0);
run_line(app, "edit-or-drill editing")
},
),
(
"a spelled paste onto an aggregate",
|| yanked(aggregated_grid(true)),
|app| run_line(app, "paste Row/A Col/A"),
crate::command::cmd::core::AGGREGATE_REFUSAL,
|| yanked(aggregated_grid(true)),
|app| run_line(app, "paste Row/A Col/A Hidden/A"),
),
(
"a spelled paste onto a records coordinate column",
|| yanked(records_model_with_two_rows()),
|app| run_line(app, "paste _Index/1 _Dim/0"),
crate::command::cmd::core::NO_VALUE_REFUSAL,
|| yanked(records_model_with_two_rows()),
|app| run_line(app, "paste _Index/1 _Dim/2"),
),
(
"a spelled paste inside a drill",
|| yanked(in_a_drill(records_model_with_two_rows())),
|app| run_line(app, "paste _Index/1 _Dim/2"),
crate::command::cmd::core::INSIDE_A_DRILL_REFUSAL,
|| yanked(records_model_with_two_rows()),
|app| run_line(app, "paste _Index/1 _Dim/2"),
),
];
for (what, fail_setup, fails, says, ok_setup, succeeds) in cases {
let mut app = fail_setup();
let refused = fails(&mut app);
assert!(
refused && app.view_state.status_msg.contains(says),
"{what}: expected a refusal saying {says:?}, got refused={refused} {:?}",
app.view_state.status_msg
);
let mut app = ok_setup();
assert!(
!succeeds(&mut app),
"{what}: its succeeding twin was refused: {:?}",
app.view_state.status_msg
);
}
}
/// A prefix is consumed whether or not the next key matches, so a stray key
/// after `g` does nothing rather than acting as a fresh Normal binding.
#[test]
@@ -4281,3 +4877,261 @@ mod basis_tiers {
}
}
}
/// Editing a formula from the panel (improvise-km71), driven through
/// `handle_key` at a NONZERO cursor over three formulas.
#[cfg(test)]
mod formula_edit_tests {
use super::*;
use crate::formula::parse_formula;
const PROFIT: &str = "Profit = Revenue - Cost";
const TAX: &str = "Tax = Revenue * 0.1";
const MARGIN: &str = "Margin = Profit / Revenue";
fn app_with_three_formulas() -> App {
let mut wb = Workbook::new("T");
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for raw in [PROFIT, TAX, MARGIN] {
wb.model
.add_formula(parse_formula(raw, "_Measure").unwrap());
}
let mut app = App::new(wb, None);
app.view_state.formula_panel_open = true;
app.view_state.mode = AppMode::FormulaPanel;
app.view_state.formula_cursor = 1;
app
}
fn press(app: &mut App, code: KeyCode) {
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
.unwrap();
}
/// Clear the minibuffer by keys and type `text`, as a user would.
fn retype(app: &mut App, text: &str) {
let len = app.view_state.buffers.get("formula").map_or(0, String::len);
for _ in 0..len {
press(app, KeyCode::Backspace);
}
for c in text.chars() {
press(app, KeyCode::Char(c));
}
}
fn raws(app: &App) -> Vec<String> {
app.model_state
.workbook
.model
.formulas()
.iter()
.map(|f| f.raw.clone())
.collect()
}
fn buffer(app: &App) -> Option<&str> {
app.view_state.buffers.get("formula").map(String::as_str)
}
fn open_editor(app: &mut App) {
press(app, KeyCode::Enter);
assert!(
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
"Enter on a formula must open the editor, got {:?}",
app.view_state.mode
);
}
fn in_editor(app: &App) -> bool {
matches!(app.view_state.mode, AppMode::FormulaEdit { .. })
}
#[test]
fn enter_opens_the_editor_seeded_with_the_formula_under_the_cursor() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
assert_eq!(buffer(&app), Some(TAX), "cursor 1 is Tax, not the first");
}
#[test]
fn e_opens_the_same_editor() {
let mut app = app_with_three_formulas();
press(&mut app, KeyCode::Char('e'));
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert_eq!(buffer(&app), Some(TAX));
}
#[test]
fn committing_an_edit_replaces_the_formula_in_place() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = Revenue * 0.2");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.2", MARGIN]);
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
}
/// The rival "add instead of replace" passes the test above, because
/// `add_formula` replaces by target. A rename is where it cannot hide.
#[test]
fn changing_the_target_renames_the_formula_in_its_own_place() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Levy = Revenue * 0.1");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, "Levy = Revenue * 0.1", MARGIN]);
let measures = app.model_state.workbook.model.measure_item_names();
assert!(
!measures.contains(&"Tax".to_string()),
"Tax was a formula target only, so it goes: {measures:?}"
);
}
#[test]
fn a_rename_says_which_formulas_still_name_the_old_target() {
let mut app = app_with_three_formulas();
app.view_state.formula_cursor = 0;
open_editor(&mut app);
retype(&mut app, "Gross = Revenue - Cost");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app)[0], "Gross = Revenue - Cost");
let status = &app.view_state.status_msg;
assert!(
status.contains("Profit") && status.contains("Margin"),
"Margin still refers to Profit, and the user must be told: {status:?}"
);
}
#[test]
fn a_rename_onto_another_formulas_target_refuses_and_keeps_both() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Margin = Revenue * 0.1");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert!(app.view_state.status_msg.contains("Margin"));
}
#[test]
fn a_parse_error_keeps_the_editor_the_text_and_the_formula() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = = ");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert_eq!(buffer(&app), Some("Tax = = "));
assert!(
app.view_state.status_msg.contains("Formula error"),
"{:?}",
app.view_state.status_msg
);
}
/// The editor names its formula when it OPENS. Deleting an earlier one
/// moves the edited formula to index 0 and puts `Margin` under cursor 1.
#[test]
fn an_edit_lands_on_its_formula_after_an_earlier_one_is_deleted_meanwhile() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = Revenue * 0.2");
let model = &mut app.model_state.workbook.model;
model.remove_formula("Profit", "_Measure");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec!["Tax = Revenue * 0.2", MARGIN]);
}
#[test]
fn an_edit_whose_formula_was_deleted_meanwhile_refuses_out_loud() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = Revenue * 0.2");
let model = &mut app.model_state.workbook.model;
model.remove_formula("Tax", "_Measure");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, MARGIN], "nothing re-added");
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert_eq!(buffer(&app), Some("Tax = Revenue * 0.2"));
assert!(app.last_batch_refused());
}
#[test]
fn an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = Revenue * 0.2");
let theirs = parse_formula("Tax = Revenue * 0.3", "_Measure").unwrap();
app.model_state.workbook.model.add_formula(theirs);
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.3", MARGIN]);
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert!(app.last_batch_refused());
}
#[test]
fn esc_leaves_the_formula_untouched() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
retype(&mut app, "Tax = 0");
press(&mut app, KeyCode::Esc);
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
}
/// `n` still adds: the replace slot must not leak into a fresh editor.
#[test]
fn a_new_formula_after_an_edit_is_added_not_replaced() {
let mut app = app_with_three_formulas();
open_editor(&mut app);
press(&mut app, KeyCode::Esc);
press(&mut app, KeyCode::Char('n'));
assert_eq!(buffer(&app), Some(""), "a new formula starts empty");
retype(&mut app, "Levy = Revenue * 0.3");
press(&mut app, KeyCode::Enter);
assert_eq!(
raws(&app),
vec![PROFIT, TAX, MARGIN, "Levy = Revenue * 0.3"]
);
}
/// improvise-kb1d: a Region-targeted formula at index 1. Renaming it would
/// strand `Total` as an empty item, so the edit refuses and stays open.
#[test]
fn renaming_a_formula_outside_measure_refuses_and_keeps_the_editor() {
let mut wb = Workbook::new("T");
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
for (raw, cat) in [
(PROFIT, "_Measure"),
("Total = East + West", "Region"),
(MARGIN, "_Measure"),
] {
wb.model.add_formula(parse_formula(raw, cat).unwrap());
}
let mut app = App::new(wb, None);
app.view_state.formula_panel_open = true;
app.view_state.mode = AppMode::FormulaPanel;
app.view_state.formula_cursor = 1;
let regions = |app: &App| {
let region = app.model_state.workbook.model.category("Region").unwrap();
region
.ordered_item_names()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
};
let before = regions(&app);
open_editor(&mut app);
retype(&mut app, "Sum = East + West");
press(&mut app, KeyCode::Enter);
assert_eq!(raws(&app), vec![PROFIT, "Total = East + West", MARGIN]);
assert_eq!(regions(&app), before, "no ghost item, nothing appended");
assert!(in_editor(&app), "{:?}", app.view_state.mode);
assert_eq!(buffer(&app), Some("Sum = East + West"));
}
}
File diff suppressed because it is too large Load Diff
+700 -8
View File
@@ -162,15 +162,33 @@ impl Host {
let undo = self.app.snapshot();
let mirrors = self.sessions.clone();
let mut out = Vec::new();
let mut asker_reset = false;
for cmd in batch {
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
return self.undo_frame(id, undo, mirrors, reason);
}
// Only a mutation runs `apply_effects`, and only `apply_effects`
// resets the flag — so after anything else it is a STALE answer.
// Only a mutation runs `apply_effects`, which resets both flags — so
// after anything else they are STALE. A refusal need not abort (cz8d).
let mutation = matches!(cmd.command(), Command::Model { .. });
out.extend(self.command(id, cmd));
if mutation && self.app.last_batch_aborted() {
let command = cmd.command();
let mut answered = self.command(id, cmd);
// The asker folded this placement BEFORE hearing its reset, so it is owed
// again after it (improvise-wdmp, `context/code/command.md`).
if asker_reset && places_cursor(command) {
answered.insert(
0,
Addressed {
to: id,
msg: ServerFrame::Commands(vec![command.clone()]),
},
);
}
asker_reset |= answered
.iter()
.any(|a| a.to == id && is_cursor_reset(&a.msg));
out.extend(answered);
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
if mutation && failed {
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
}
}
@@ -265,6 +283,40 @@ impl Host {
}
}
/// A command that writes this client's cursor, and so survives a reset only if
/// it is folded after it.
fn places_cursor(cmd: &Command) -> bool {
matches!(
cmd,
Command::SetSelected { .. } | Command::SetRowOffset(_) | Command::SetColOffset(_)
)
}
fn is_cursor_reset(msg: &ServerFrame) -> bool {
matches!(msg, ServerFrame::Commands(cmds) if cmds.contains(&Command::ResetCursor))
}
/// One file per way `ImportJsonHeadless` can fail, plus one that imports; the
/// `absent.*` paths are left unwritten. Shared by the frame and key-path tests.
#[cfg(test)]
pub(crate) fn import_fixture_storage() -> crate::storage::MemoryFs {
let storage = crate::storage::MemoryFs::new();
let files: [(&str, &[u8]); 5] = [
// Every field numeric, so no category is accepted and the build fails.
("measures.json", br#"[{"x": 1}, {"x": 2}]"#),
("bad.json", b"{not json"),
("nested.json", br#"{"data": [{"Region": "North"}]}"#),
// A record that is not UTF-8 is a CSV read error, not a warning.
("bad.csv", b"Region,Revenue\n\xff\xfe,1\n"),
("good.json", br#"[{"Region": "North", "Revenue": 1}]"#),
];
for (name, bytes) in files {
crate::storage::Storage::write(&storage, std::path::Path::new(name), bytes)
.expect("fixture writes");
}
storage
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
@@ -1336,6 +1388,312 @@ mod tests {
);
}
fn month_cell(month: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
])
}
/// Months in two quarters on Row, Revenue/Cost and `Profit = Revenue - Cost` on
/// Column, every month funded, Q2 collapsed: rows `[Jan, Feb, Q2]`.
fn host_on_a_fold() -> Host {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
for measure in ["Revenue", "Cost"] {
wb.add_item("_Measure", measure).unwrap();
let value = CellValue::Number(10.0);
wb.model.set_cell(month_cell(month, measure), value);
}
}
let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure");
wb.model.add_formula(profit.unwrap());
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
let mut app = App::new(wb, None);
app.rebuild_layout();
assert!(
app.layout.folds_a_group(2, 1),
"setup: row 2 is the Q2 fold"
);
Host::new(app)
}
/// One wire write, as a hand-rolled client composes it: a surrogate built from
/// a key, and a basis claiming whatever the host says is current.
fn wire_write(host: &mut Host, key: &CellKey, clear: bool) -> Vec<Addressed> {
use improvise_protocol::ModelCommand;
let cell = RecordCell {
record: RecordHash::of(key),
measure: key.get("_Measure").map(String::from),
};
let cmd = match clear {
true => ModelCommand::ClearCell(cell),
false => ModelCommand::SetCell {
cell,
value: CellValue::Number(7.0),
},
};
let (asker, _) = host.connect(10, 10);
let frame = crate::ui::reduce::caught_up(host.app(), cmd);
host.handle(asker, ClientFrame::Commands(vec![frame]))
}
fn refused(answer: &[Addressed]) -> bool {
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::MutationRefused)))
}
fn document_text(host: &Host) -> String {
crate::persistence::format_md(&host.app().model_state.workbook)
}
/// improvise-ciw8 — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_collapsed_fold_is_rejected_and_stores_nothing() {
for clear in [false, true] {
let mut host = host_on_a_fold();
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Q2", "Cost"), clear);
assert!(refused(&answer), "clear={clear}: not refused: {answer:?}");
assert_eq!(
document_text(&host),
before,
"clear={clear}: the fold was written"
);
}
}
/// improvise-ciw8: a formula's result is `CellId::Derived`, no write's target.
#[test]
fn a_wire_write_under_a_formula_is_rejected_and_stores_nothing() {
let mut host = host_on_a_fold();
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Feb", "Profit"), false);
assert!(refused(&answer), "not refused: {answer:?}");
assert_eq!(
document_text(&host),
before,
"a value was stored under the formula"
);
}
/// improvise-ciw8: a key the None axis aggregates over is `CellId::Aggregate`.
#[test]
fn a_wire_write_to_a_none_axis_aggregate_is_rejected_and_stores_nothing() {
let mut host = host();
host.app
.model_state
.workbook
.add_category("Product")
.unwrap();
host.app
.model_state
.workbook
.add_item("Product", "Widget")
.unwrap();
let view = host.app.model_state.workbook.active_view_mut();
view.set_axis("Product", Axis::None);
host.app.rebuild_layout();
let before = document_text(&host);
let answer = wire_write(&mut host, &cell("South"), false);
assert!(refused(&answer), "not refused: {answer:?}");
assert_eq!(document_text(&host), before, "the partial key was written");
}
/// improvise-ciw8's second question: a write naming a record that never existed
/// is refused by the frame, not dropped — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_record_that_never_existed_is_rejected() {
use improvise_protocol::ModelCommand;
let mut host = host_on_a_fold();
let before = document_text(&host);
// Claimed by a caught-up basis: structural staleness, answered up front.
let answer = wire_write(&mut host, &month_cell("Nowhere", "Cost"), false);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
"claimed: {answer:?}"
);
// Unclaimed: the create exemption covers only LATER commands in a frame,
// so a lone write is answered up front too.
let key = month_cell("Nowhere", "Cost");
let cmd = ModelCommand::SetCell {
cell: RecordCell {
record: RecordHash::of(&key),
measure: Some("Cost".into()),
},
value: CellValue::Number(7.0),
};
let basis = improvise_protocol::Basis {
document_generation: host.app().document_generation(),
layout_version: host.app().layout_version(),
records: vec![],
};
let (asker, _) = host.connect(10, 10);
let frame = ClientFrame::Commands(vec![Command::Model { basis, cmd }]);
let answer = host.handle(asker, frame);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
"unclaimed: {answer:?}"
);
assert_eq!(document_text(&host), before);
}
/// The control for the three above: the stored cell beside the fold is written.
#[test]
fn a_wire_write_to_a_stored_cell_beside_a_fold_is_accepted() {
let mut host = host_on_a_fold();
let answer = wire_write(&mut host, &month_cell("Feb", "Cost"), false);
assert!(
answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted)),
"{answer:?}"
);
let model = &host.app().model_state.workbook.model;
let value = model.get_cell(&month_cell("Feb", "Cost"));
assert_eq!(value, Some(&CellValue::Number(7.0)));
}
/// improvise-sjdb: an empty record is judged at its own cell, not at the first
/// column carrying it — see context/test-evidence/command.md.
#[test]
fn an_empty_cell_beside_a_collapsed_measure_group_is_writable() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
}
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
}
wb.add_item("_Measure", "Units").unwrap();
let jan = month_cell("Jan", "Revenue");
wb.model.set_cell(jan, CellValue::Number(10.0));
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money");
let mut app = App::new(wb, None);
app.rebuild_layout();
let target = month_cell("Feb", "Units");
let units = (0..app.layout.col_count())
.find(|&c| app.layout.cell_key(1, c).as_ref() == Some(&target))
.expect("setup: Feb x Units is drawn");
let fold = (0..app.layout.col_count())
.find(|&c| app.layout.folds_a_group(1, c))
.expect("setup: a folded measure column");
assert!(fold < units, "setup: the fold comes first on Feb's row");
let id =
crate::ui::project::cell_id(&app.model_state.workbook.model, &app.layout, 1, units);
let projected = matches!(id, Some(improvise_protocol::CellId::Record(_)));
assert!(projected, "setup: Feb x Units projects as {id:?}");
let mut host = Host::new(app);
let answer = wire_write(&mut host, &target, false);
let accepted = answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted));
assert!(accepted, "a projected Record was refused: {answer:?}");
let model = &host.app().model_state.workbook.model;
assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0)));
}
/// improvise-b114: a coordinate naming a group is no item, in any view — see
/// context/test-evidence/command.md.
#[test]
fn a_wire_write_naming_a_measure_group_is_rejected_in_any_view() {
for collapsed in [true, false] {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
for month in ["Jan", "Feb"] {
wb.add_item("Month", month).unwrap();
for measure in ["Revenue", "Cost"] {
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
let key = month_cell(month, measure);
wb.model.set_cell(key, CellValue::Number(10.0));
}
}
wb.add_item("_Measure", "Units").unwrap();
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
if collapsed {
wb.active_view_mut()
.toggle_group_collapse("_Measure", "Money");
}
let mut app = App::new(wb, None);
app.rebuild_layout();
let mut host = Host::new(app);
let before = document_text(&host);
let answer = wire_write(&mut host, &month_cell("Feb", "Money"), false);
assert!(refused(&answer), "collapsed={collapsed}: {answer:?}");
let after = document_text(&host);
assert_eq!(after, before, "collapsed={collapsed}: a group was stored");
}
}
/// improvise-xur5 (the reviewer's `probe_fold_hidden_measure`): a fold's record
/// with a measure the host draws nowhere — see context/test-evidence/command.md.
#[test]
fn a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected() {
let hidden = month_cell("Q2", "Cost");
let bogus = month_cell("Q2", "Bogus");
let bare = CellKey::new(vec![("Month".to_string(), "Q2".to_string())]);
for key in [hidden, bogus, bare] {
let mut host = host_on_a_fold();
let view = host.app.model_state.workbook.active_view_mut();
view.hide_item("_Measure", "Cost");
host.app.rebuild_layout();
let before = document_text(&host);
let answer = wire_write(&mut host, &key, false);
assert!(refused(&answer), "{key:?} accepted: {answer:?}");
assert_eq!(document_text(&host), before, "{key:?} stored a group");
}
}
/// A STORED record is writable whatever the host's view does to it — see
/// context/test-evidence/command.md (improvise-sjdb).
#[test]
fn a_stored_record_the_hosts_view_aggregates_is_writable() {
let mut wb = workbook();
wb.model.set_cell(cell("South"), CellValue::Number(1.0));
wb.add_category("Product").unwrap();
wb.add_item("Product", "Widget").unwrap();
wb.active_view_mut().set_axis("Product", Axis::None);
let mut app = App::new(wb, None);
app.rebuild_layout();
let south = (0..app.layout.row_count())
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
.find(|&(r, c)| app.layout.cell_key(r, c).as_ref() == Some(&cell("South")))
.expect("setup: South's cell is drawn");
let id = crate::ui::project::cell_id(
&app.model_state.workbook.model,
&app.layout,
south.0,
south.1,
);
let aggregate = matches!(id, Some(improvise_protocol::CellId::Aggregate(_)));
assert!(aggregate, "setup: the host's view aggregates South: {id:?}");
let mut host = Host::new(app);
let answer = wire_write(&mut host, &cell("South"), false);
let accepted = answer
.iter()
.any(|a| matches!(a.msg, ServerFrame::Accepted));
assert!(accepted, "a stored record was refused: {answer:?}");
let model = &host.app().model_state.workbook.model;
assert_eq!(
model.get_cell(&cell("South")),
Some(&CellValue::Number(7.0))
);
}
/// Clause 4 must not fire on a frame that merely names a record it is
/// creating. See `context/test-evidence/command.md`.
#[test]
@@ -1430,6 +1788,71 @@ mod tests {
}
}
/// improvise-wdmp: the asker's own frame placed its cursor after the reframe,
/// and the relayed reset undid it. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed() {
use improvise_protocol::{AppMode, ClientState};
// With a placement after the reframe, and without one: the asker ends where
// the TUI would, and its client agrees with its mirror either way.
for (placed, expected) in [(true, (1, 0)), (false, (0, 0))] {
let mut host = host();
let (asker, snapshot) = host.connect(10, 10);
let (bystander, _) = host.connect(10, 10);
let mut client = ClientState::default();
match snapshot.msg {
ServerFrame::Snapshot(s) => client.bootstrap(*s),
other => panic!("setup: connect answers a Snapshot, got {other:?}"),
}
let send = |host: &mut Host, client: &mut ClientState, frame: Vec<Command>| {
client.fold_outgoing(&frame);
let answer = host.handle(asker, ClientFrame::Commands(frame));
for msg in to(&answer, asker) {
match msg {
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
ServerFrame::Accepted => client.accepted(),
other => panic!("the frame must be accepted: {other:?}"),
}
}
answer
};
// Off the origin first, so a reset that never arrived is visible.
send(
&mut host,
&mut client,
vec![Command::SetSelected { row: 1, col: 0 }],
);
// The `+cat` commit's shape: tidy-up, the schema change, then (maybe)
// the cursor put back where the user was (resolve.rs::create_column).
let add = ModelCommand::AddCategory("Product".into());
let mut frame = vec![
Command::ChangeMode(AppMode::Normal),
crate::ui::reduce::caught_up(host.app(), add),
];
if placed {
frame.push(Command::SetSelected { row: 1, col: 0 });
}
let answer = send(&mut host, &mut client, frame);
assert!(
to(&answer, bystander).contains(&ServerFrame::Commands(vec![Command::ResetCursor])),
"setup: the frame must really reframe, or nothing here is at stake"
);
let mirror = host.sessions.get(asker).unwrap().view().cursor();
assert_eq!(
mirror.selected, expected,
"setup, placed={placed}: the mirror"
);
assert_eq!(
client.view.cursor(),
mirror,
"placed={placed}: the asker's client and its mirror disagree"
);
}
}
/// A refused batch is answered to the **asker and nobody else** (ADR 001):
/// nothing happened, so there is nothing to tell the others.
#[test]
@@ -1541,10 +1964,12 @@ mod tests {
.any(|f| matches!(f.msg, ServerFrame::Snapshot(_))),
"a failed load cost every client its cursors: {frames:?}"
);
assert!(
host.app().view_state.status_msg.starts_with("Load error"),
"the fixture did not actually fail the load: {:?}",
host.app().view_state.status_msg
// Since improvise-bok4 a failed load is REFUSED, so the refusal is the
// witness that it failed; the host's status line is rolled back with it.
assert_eq!(
to(&frames, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"the fixture did not actually fail the load"
);
}
@@ -1778,4 +2203,271 @@ mod tests {
let json = serde_json::to_string(&inbound).unwrap();
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
}
/// Two model commands, each caught up against the document as the client
/// saw it, so no basis tier can refuse the frame first (improvise-bok4).
fn two_command_frame(host: &Host, first: ModelCommand, then: ModelCommand) -> ClientFrame {
ClientFrame::Commands(vec![
crate::ui::reduce::caught_up(host.app(), first),
crate::ui::reduce::caught_up(host.app(), then),
])
}
/// improvise-bok4, the view-addressed case: the second command's view is
/// gone. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_frame_writing_a_view_its_own_first_command_deleted_is_refused() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::DeleteView("Scratch".into()),
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.views
.contains_key("Scratch"),
"the refused frame's deletion must be undone"
);
}
/// improvise-bok4, the category-addressed case: it narrated its failure into
/// the HOST's status line and told the client nothing.
#[test]
fn a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"half the frame did nothing and was reported accepted: {answer:?}"
);
assert!(
host.app()
.model_state
.workbook
.model
.category("Region")
.is_some(),
"the refused frame's removal must be undone"
);
}
/// The one-command case of the same class: deleting the last view was
/// swallowed with `let _`, so the frame was accepted having done nothing.
#[test]
fn deleting_the_documents_last_view_is_refused_rather_than_accepted() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let only = host.app().view_name().to_string();
let answer = host.model(asker, ModelCommand::DeleteView(only.clone()));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a deletion the workbook refused was reported accepted: {answer:?}"
);
assert!(host.app().model_state.workbook.views.contains_key(&only));
}
/// The control: two commands that both land are accepted, so the refusals
/// above are about a failure and not about the frame's shape.
#[test]
fn a_frame_whose_commands_all_land_is_accepted() {
let mut host = host();
host.app_mut().model_state.workbook.create_view("Scratch");
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::SetNumberFormat {
view: "Scratch".into(),
format: ",.2f".into(),
},
ModelCommand::AddItem {
category: "Region".into(),
item: "East".into(),
},
),
);
assert!(
to(&answer, asker).contains(&ServerFrame::Accepted),
"a frame with nothing to refuse was refused: {answer:?}"
);
}
/// improvise-40j4: the formula was stored for a category the frame's own
/// first command removed, and it survived save and reload.
#[test]
fn a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused() {
let mut host = host();
let (asker, _) = host.connect(10, 10);
let answer = host.handle(
asker,
two_command_frame(
&host,
ModelCommand::RemoveCategory("Region".into()),
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Region".into(),
},
),
);
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"a formula for a vanished category was accepted: {answer:?}"
);
let model = &host.app().model_state.workbook.model;
assert!(
model.category("Region").is_some(),
"the removal must be undone"
);
assert!(
model.formulas().iter().all(|f| f.target != "Total"),
"and the formula must not be stored"
);
}
/// A host whose storage holds one file per way `ImportJsonHeadless` can
/// fail, plus nothing at the two paths that must fail to read.
fn host_with_import_fixtures() -> Host {
let storage = super::import_fixture_storage();
// A second view, so deleting an absent one is not refused as "the last".
let mut wb = workbook();
wb.create_view("Scratch");
let mut app = App::new(wb, None);
app.storage = Box::new(storage);
app.rebuild_layout();
Host::new(app)
}
fn import(path: &str, array_path: Option<&str>) -> ModelCommand {
ModelCommand::ImportJsonHeadless {
path: PathBuf::from(path),
model_name: None,
array_path: array_path.map(String::from),
}
}
/// improvise-9fpl: one case per refusal #181 converted that no other test
/// witnesses. Evidence: `context/test-evidence/command.md`.
#[test]
fn every_failing_model_command_refuses_its_whole_frame() {
let cases: Vec<(&str, ModelCommand)> = vec![
(
"AddItemInGroup into an absent category",
ModelCommand::AddItemInGroup {
category: "Ghost".into(),
item: "East".into(),
group: "Coastal".into(),
},
),
(
"AddFormula that does not parse",
ModelCommand::AddFormula {
raw: "Total = (".into(),
target_category: "_Measure".into(),
},
),
(
"ImportJsonHeadless of an absent JSON",
import("absent.json", None),
),
(
"ImportJsonHeadless of an absent CSV",
import("absent.csv", None),
),
(
"ImportJsonHeadless of a CSV that is not text",
import("bad.csv", None),
),
(
"ImportJsonHeadless of JSON that does not parse",
import("bad.json", None),
),
(
"ImportJsonHeadless at a path with no array",
import("nested.json", Some("nope")),
),
(
"ImportJsonHeadless that builds no model",
import("measures.json", None),
),
// improvise-40j4: `delete_view` answers Ok for a name it lacks.
(
"DeleteView of a view the document lacks",
ModelCommand::DeleteView("Ghost".into()),
),
(
"AddFormula into a category the document lacks",
ModelCommand::AddFormula {
raw: "Total = 1".into(),
target_category: "Ghost".into(),
},
),
];
for (what, failing) in cases {
let mut host = host_with_import_fixtures();
let (asker, _) = host.connect(10, 10);
let landed = ModelCommand::AddItem {
category: "Region".into(),
item: "Landed".into(),
};
let answer = host.handle(asker, two_command_frame(&host, landed, failing));
assert_eq!(
to(&answer, asker),
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
"{what}: the frame was not refused: {answer:?}"
);
assert!(
!host
.app()
.model_state
.workbook
.model
.category("Region")
.is_some_and(|c| c.ordered_item_names().contains(&"Landed")),
"{what}: the half of the frame that landed was not undone"
);
}
}
}
+297 -50
View File
@@ -15,9 +15,9 @@ use improvise_io::import::analyzer::{DateComponent, FieldKind};
use improvise_io::import::wizard::{ImportWizard, WizardStep};
use improvise_protocol::{
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
CellId, Command, CreateKind, DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache,
GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache, TileEntry,
ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
WizardFieldKind, WizardProposal, WizardStepKind, basis, reduce_view,
};
@@ -222,7 +222,6 @@ fn axis_frame(
pending.clear();
}
pending.push(FrameEntry::Group(FrameGroup {
cat: cat_name.clone(),
name: group_name.clone(),
collapsed: view.is_group_collapsed(cat_name, group_name),
}));
@@ -277,11 +276,11 @@ fn cell_display(
// with `parse::<f64>()`, which called a coordinate `2024` a number.
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
return CellDisplay {
edit_text: Some(text.clone()),
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
text,
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, layout, row, col),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
value: value.clone(),
};
}
@@ -290,7 +289,7 @@ fn cell_display(
text: plan.at(key.as_ref()).value(value.as_ref()),
numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, layout, row, col),
id: cell_id(&app.model_state.workbook.model, layout, row, col),
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
// beats a client un-formatting `text` (gotcha 47).
edit_text: key.map(|key| app.display_value_in(layout, &key)),
@@ -300,8 +299,12 @@ fn cell_display(
/// What the cell at `(row, col)` *is*, as the surrogate the client echoes back. The
/// fork is `EditOrDrill`'s, asked the same way — see `context/code/command.md`.
fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<CellId> {
let model = &app.model_state.workbook.model;
pub(crate) fn cell_id(
model: &Model,
layout: &GridLayout,
row: usize,
col: usize,
) -> Option<CellId> {
if layout.is_records_mode() {
// A measure can be BOTH an explicit `_Measure` item and a formula target,
// so the document is asked about the record's real key (improvise-ir7l).
@@ -324,7 +327,7 @@ fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<Cel
RecordCol::NewCategory => return Some(CellId::Create(CreateKind::Category)),
// Renaming a coordinate moves every measure cell in the row, which is a
// different intent from `SetCell` and still the host's (gotcha 1).
RecordCol::Category(_) => return None,
RecordCol::Category(_) => return Some(CellId::Coordinate),
};
return Some(CellId::Record(RecordCell {
record: RecordHash::of(record),
@@ -372,14 +375,15 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
.collect()
}
/// The raw text of every formula, which is all the formula panel draws.
pub fn formula_texts(app: &App) -> Vec<String> {
/// Every formula's text, which the panel draws, and its identity, which an
/// edit names it by (improvise-km71).
pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
app.model_state
.workbook
.model
.formulas()
.iter()
.map(|f| f.raw.clone())
.map(FormulaEntry::from)
.collect()
}
@@ -404,12 +408,17 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
/// The tiles the tile bar offers — the same set the TUI's tile bar draws,
/// including the virtual categories, minus the ones a drill has fixed.
pub fn tile_entries(app: &App, view: &View) -> Vec<TileEntry> {
let workbook = &app.model_state.workbook;
view.tileable_cats(&workbook.model.category_names())
tiles_of(&app.model_state.workbook.model, view)
}
/// The same list from a `Model` alone, for a command that has no `App`.
pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
view.tileable_cats(&model.category_names())
.into_iter()
.map(|cat| TileEntry {
category: cat.to_string(),
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
regular: model.category(cat).is_some_and(|c| c.kind.is_regular()),
})
.collect()
}
@@ -431,10 +440,10 @@ pub fn category_tree_in(app: &App, view: &View) -> CategoryTreeCache {
}
}
/// The formula panel's cache: the raw text of every formula.
/// The formula panel's cache: every formula's text and identity.
pub fn formulas_of(app: &App) -> FormulaCache {
FormulaCache {
formulas: formula_texts(app),
formulas: formula_entries(app),
}
}
@@ -493,7 +502,10 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis {
.flatten()
.filter_map(|cell| match cell.id.as_ref()? {
CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate => None,
})
.collect();
basis::published(app.document_generation(), app.layout_version(), records)
@@ -1117,12 +1129,11 @@ fn view_names(app: &App) -> std::collections::BTreeSet<String> {
/// The view slice a newly-connected client starts from: empty, but pointed at the
/// document's active view, because that is what keys its cursor map (gotcha 29).
fn fresh_view(app: &App) -> ViewState {
let mut view = ViewState::default();
reduce_view(
&mut view,
&Command::SwitchView(app.model_state.workbook.active_view.clone()),
);
view
// Pointed, not switched: a fold would record a visit to `Default` (improvise-lxg0).
ViewState {
active_view: app.model_state.workbook.active_view.clone(),
..ViewState::default()
}
}
#[cfg(test)]
@@ -1283,6 +1294,71 @@ mod tests {
);
}
/// `.improv` carries `nan` as a value (improvise-55v2); the NaN sits at
/// (1, 1), off the origin. Evidence file.
fn app_with_a_nan_cell() -> App {
let wb = crate::persistence::parse_md(
"v2025-04-09\n# NaN\nInitial View: Default\n\n\
## View: Default\nRegion: row\n_Measure: column\n_Index: none\n_Dim: none\n\n\
## Category: _Measure\n- Revenue, Cost\n\n\
## Category: Region\n- North, South\n\n\
## Data\n\
Region=North, _Measure=Revenue = 1\n\
Region=North, _Measure=Cost = 2\n\
Region=South, _Measure=Revenue = 3\n\
Region=South, _Measure=Cost = nan\n",
)
.expect("a document holding nan parses");
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
/// A NaN cell compares unequal to itself under `CellValue`'s `PartialEq`,
/// so the grid never quiesced (improvise-4w09). Evidence file.
#[test]
fn a_grid_holding_nan_quiesces() {
let app = app_with_a_nan_cell();
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 10,
cols: 10,
},
);
assert!(
matches!(grid.cell(1, 1).and_then(|c| c.value.clone()),
Some(CellValue::Number(n)) if n.is_nan()),
"precondition: the NaN must be projected, or nothing below is tested"
);
assert_eq!(
grid_updates(&grid, &grid),
vec![],
"a grid diffed against itself owes the client nothing"
);
assert!(
GridCache::folded(&grid.seed()) == grid,
"the seed must compact to the cache it came from (gotcha 32)"
);
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
sessions.project_all(&app, Dirty::EVERYTHING);
let again = sessions.project_all(&app, Dirty::EVERYTHING);
let grid_again: Vec<_> = again
.iter()
.flat_map(|(_, projections)| projections)
.filter(|p| matches!(p, Projection::Grid(_)))
.collect();
assert!(
grid_again.is_empty(),
"an unchanged grid must not be re-sent: {grid_again:?}"
);
}
/// Region on Row with `East` in a group that comes **last**, so the header is
/// the final entry and nothing follows it: `axis_frame`'s trailing rule.
fn app_with_a_trailing_group(collapsed: bool) -> App {
@@ -1502,6 +1578,82 @@ mod tests {
app
}
/// A document with the views `names`, opening on `active`.
fn opening_on(names: &[&str], active: &str) -> Workbook {
let mut wb = Workbook::new("Fresh");
for name in names.iter().filter(|n| **n != "Default") {
wb.create_view(name.to_string());
}
wb.active_view = active.to_string();
if !names.contains(&"Default") {
wb.delete_view("Default")
.expect("setup: drop the Default view");
}
wb
}
/// improvise-lxg0: a new session has visited nothing, and `<` must not
/// take it anywhere. Evidence: `context/test-evidence/command.md`.
#[test]
fn a_fresh_session_has_no_history_to_step_back_through() {
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
let app = App::new(opening_on(names, "Second"), None);
let mut sessions = Sessions::default();
let (_, snapshot) = sessions.connect(&app, 10, 10);
assert_eq!(snapshot.view.active_view, "Second", "setup: {names:?}");
assert_eq!(snapshot.view.view_back_stack, vec![], "{names:?}");
let mut client = snapshot.view.clone();
reduce_view(&mut client, &Command::ViewBack);
assert_eq!(
client.active_view, "Second",
"`<` moved a fresh {names:?} client"
);
}
}
/// improvise-v8ab: a fresh client starts from the host's own startup slice.
/// See `context/test-evidence/command.md`.
#[test]
fn a_fresh_sessions_slice_is_the_hosts_startup_slice() {
for (names, records) in [
(&["Default", "Second"][..], false),
(&["Main", "Second"][..], false),
(&["Main", "Second"][..], true),
] {
let mut wb = opening_on(names, "Second");
if records {
// `Second` opens on a records grid (improvise-y5eo).
wb.active_view_mut().set_axis("_Index", Axis::Row);
wb.active_view_mut().set_axis("_Dim", Axis::Column);
}
let app = App::new(wb, None);
assert_eq!(app.view_state.active_view, "Second", "setup: {names:?}");
assert_eq!(app.layout.is_records_mode(), records, "setup: {names:?}");
let (_, snapshot) = Sessions::default().connect(&app, 10, 10);
assert_eq!(
snapshot.view, app.view_state,
"{names:?}, records {records}"
);
}
}
/// improvise-lxg0: a document swap reseeds through the same door.
#[test]
fn a_rebased_session_has_no_history_to_step_back_through() {
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
let mut app = App::new(opening_on(&["Default", "Other"], "Default"), None);
let mut sessions = Sessions::default();
sessions.connect(&app, 10, 10);
app.install_workbook(opening_on(names, "Second"));
let snapshots = sessions.rebase(&app);
assert_eq!(snapshots.len(), 1, "setup: one session rebased");
let view = &snapshots[0].1.view;
assert_eq!(view.active_view, "Second", "setup: {names:?}");
assert_eq!(view.view_back_stack, vec![], "{names:?}");
}
}
/// improvise-el9h: the mirror must settle against the grid it ends up
/// holding. Evidence: `context/test-evidence/command.md`.
#[test]
@@ -1675,8 +1827,13 @@ mod tests {
client.fold(&projections);
}
assert_eq!(
client.formulas.formulas,
vec!["Total = Revenue".to_string()],
client
.formulas
.formulas
.iter()
.map(|f| f.raw.as_str())
.collect::<Vec<_>>(),
vec!["Total = Revenue"],
"the client must be holding the formula before it can lose it"
);
@@ -1690,7 +1847,7 @@ mod tests {
);
assert_eq!(
client.formulas.formulas,
Vec::<String>::new(),
Vec::<FormulaEntry>::new(),
"the client is still showing a formula the document no longer has"
);
}
@@ -1715,8 +1872,13 @@ mod tests {
let mut client = ClientCaches::default();
client.fold(&snapshot.seed);
assert_eq!(
client.formulas.formulas,
vec!["Total = Revenue".to_string()],
client
.formulas
.formulas
.iter()
.map(|f| f.raw.as_str())
.collect::<Vec<_>>(),
vec!["Total = Revenue"],
"the client must be holding the formula before it can lose it"
);
@@ -1739,7 +1901,7 @@ mod tests {
);
assert_eq!(
client.formulas.formulas,
Vec::<String>::new(),
Vec::<FormulaEntry>::new(),
"the client is still showing a formula the document no longer has"
);
}
@@ -2612,7 +2774,7 @@ mod tests {
}
for name in ["Default", "Second"] {
if name != "Default" {
wb.create_view(name);
wb.create_view(name.to_string());
}
let v = &mut wb.views[name];
v.set_axis("Region", Axis::Row);
@@ -2716,7 +2878,7 @@ mod tests {
);
for name in ["Default", "Second", "Third"] {
if name != "Default" {
wb.create_view(name);
wb.create_view(name.to_string());
}
let v = &mut wb.views[name];
v.set_axis("Region", Axis::Row);
@@ -4788,20 +4950,6 @@ mod cell_identity {
);
}
/// A coordinate column is not a cell to write into (gotcha 1), so it is projected
/// with no id and a client cannot compose a write against it.
#[test]
fn a_coordinate_column_is_projected_with_no_identity() {
let app = records_app();
let region = column_named(&app, "Region");
assert_eq!(
grid_of(&app, WHOLE)
.cell(0, region)
.and_then(|c| c.id.clone()),
None
);
}
/// A document where `Profit` is BOTH an explicit `_Measure` item and a
/// formula target — which the `+` column lets a user build.
fn shadowed_measure_records_app() -> App {
@@ -5073,10 +5221,9 @@ mod cell_identity {
let ids: Vec<Option<CellId>> = (0..app.layout.col_count())
.map(|c| grid.cell(0, c).and_then(|cell| cell.id.clone()))
.collect();
assert_eq!(
ids.iter().filter(|id| id.is_none()).count(),
1,
"only the one Region coordinate column may be nameless: {ids:?}"
assert!(
ids.iter().all(Option::is_some),
"no records column is nameless since improvise-exdg: {ids:?}"
);
assert_eq!(
ids.iter()
@@ -5097,6 +5244,46 @@ mod cell_identity {
);
}
/// improvise-exdg: a coordinate cell is drawn and holds text, so it is not
/// "no cell". `context/test-evidence/command.md`.
#[test]
fn a_coordinate_cell_is_named_rather_than_left_as_no_cell() {
let app = records_app();
let grid = grid_of(&app, WHOLE);
let region = column_named(&app, "Region");
let north = grid.cell(1, region).expect("the second row's coordinate");
assert_eq!(north.text, "North", "setup: a visible coordinate cell");
assert_eq!(
north.id,
Some(CellId::Coordinate),
"a drawn coordinate is named"
);
// Each cell's kind against its column's, not merely "some kind" (improvise-w1bu).
let misnamed: Vec<(usize, usize, Option<CellId>)> = (0..app.layout.row_count())
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
.map(|(r, c)| (r, c, grid.cell(r, c).and_then(|cell| cell.id.clone())))
.filter(|(_, c, id)| !names_its_column(app.layout.record_col(*c), id.as_ref()))
.collect();
assert!(
misnamed.is_empty(),
"a records cell of the wrong kind: {misnamed:?}"
);
}
/// Whether `id` is the kind `col` projects in a document with **no formula**,
/// so `Derived` is never right here (improvise-hoxy).
fn names_its_column(col: Option<&RecordCol>, id: Option<&CellId>) -> bool {
match (col, id) {
(Some(RecordCol::Category(_)), Some(CellId::Coordinate)) => true,
(Some(RecordCol::Measure(m)), Some(CellId::Record(cell))) => {
cell.measure.as_deref() == Some(m.as_str())
}
(Some(RecordCol::NewMeasure), Some(CellId::Create(CreateKind::Measure))) => true,
(Some(RecordCol::NewCategory), Some(CellId::Create(CreateKind::Category))) => true,
_ => false,
}
}
/// The version counter is keyed by the surrogate the client holds, so one
/// identity answers ADR 002's question and ADR 001's.
#[test]
@@ -5122,6 +5309,66 @@ mod cell_identity {
"another record's basis was invalidated for nothing"
);
}
/// improvise-eccl/f2oo at the projection boundary: the grid a client is sent and
/// the CSV an export writes agree on a fold row — see the evidence file.
#[test]
fn the_grid_and_a_csv_export_agree_on_a_fold_row() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
for month in ["Jan", "Feb", "Mar"] {
wb.add_item_in_group("Month", month, "Q1").unwrap();
}
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)]
{
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
wb.add_item("_Measure", measure).unwrap();
let key = CellKey::new(vec![
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
]);
wb.model.set_cell(key, CellValue::Number(n));
}
}
wb.model.add_formula(
crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure")
.unwrap(),
);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().hide_item("Month", "Mar");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
let app = App::new(wb, None);
let grid = grid_of(&app, WHOLE);
let q1 = grid.row_frame.leaf_labels().iter().position(|l| l == "Q1");
let q1 = q1.expect("a Q1 fold row");
let drawn: Vec<String> = (0..grid.col_frame.leaf_labels().len())
.map(|c| grid.cell(q1, c).map(|d| d.text.clone()).unwrap_or_default())
.collect();
let storage = crate::storage::MemoryFs::new();
let path = std::path::Path::new("out.csv");
crate::persistence::export_csv_to(&storage, &app.model_state.workbook, "Default", path)
.expect("the export writes");
let bytes = crate::storage::Storage::read(&storage, path).unwrap();
let csv = String::from_utf8(bytes).unwrap();
let exported: Vec<String> = csv
.lines()
.find_map(|l| l.strip_prefix("Q1,"))
.expect("a Q1 line")
.split(',')
.map(String::from)
.collect();
assert_eq!(
drawn,
["40.00", "8.00", "0.80"],
"March is hidden, so not folded"
);
assert_eq!(exported, drawn, "the export and the grid disagree:\n{csv}");
}
}
/// ADR 001's basis check, both arms per tier (improvise-xwi6/fkfg). The fixtures
+62 -15
View File
@@ -135,18 +135,20 @@ pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
/// was derived from (improvise-kmgs). Exhaustive on purpose.
pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
let one: Box<dyn Effect> = match cmd {
// A cell the host projects as no `Record` is refused, so the frame is
// Rejected rather than written (improvise-ciw8, ADR 002).
ModelCommand::SetCell { cell, value } => {
let Some(key) = app.resolve_cell(cell) else {
return vec![];
};
Box::new(effect::SetCell(key, value.clone()))
}
ModelCommand::ClearCell(cell) => {
let Some(key) = app.resolve_cell(cell) else {
return vec![];
};
Box::new(effect::ClearCell(key))
match (app.unwritable(cell), app.resolve_cell(cell)) {
(Some(why), _) => effect::refuse(why),
(None, None) => return vec![],
(None, Some(key)) => Box::new(effect::SetCell(key, value.clone())),
}
}
ModelCommand::ClearCell(cell) => match (app.unwritable(cell), app.resolve_cell(cell)) {
(Some(why), _) => effect::refuse(why),
(None, None) => return vec![],
(None, Some(key)) => Box::new(effect::ClearCell(key)),
},
ModelCommand::AddItem { category, item } => Box::new(effect::AddItem {
category: category.clone(),
item: item.clone(),
@@ -178,6 +180,11 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
target: target.clone(),
target_category: target_category.clone(),
}),
ModelCommand::ReplaceFormula { id, was, raw } => Box::new(effect::ReplaceFormula {
id: id.clone(),
was: was.clone(),
raw: raw.clone(),
}),
ModelCommand::CleanEmptyRecords => Box::new(effect::CleanEmptyRecords),
ModelCommand::SortData => Box::new(effect::SortData),
@@ -685,6 +692,13 @@ pub(crate) mod tests {
target: "Profit".into(),
target_category: "_Measure".into(),
},
ModelCommand::ReplaceFormula {
id: crate::formula::parse_formula("Profit = Revenue", "_Measure")
.unwrap()
.id(),
was: "Profit = Revenue".into(),
raw: "Profit = Revenue - Cost".into(),
},
ModelCommand::CleanEmptyRecords,
ModelCommand::SortData,
ModelCommand::AddCategory("Product".into()),
@@ -749,6 +763,7 @@ pub(crate) mod tests {
ModelCommand::RemoveItem { .. } => "RemoveItem",
ModelCommand::AddFormula { .. } => "AddFormula",
ModelCommand::RemoveFormula { .. } => "RemoveFormula",
ModelCommand::ReplaceFormula { .. } => "ReplaceFormula",
ModelCommand::CleanEmptyRecords => "CleanEmptyRecords",
ModelCommand::SortData => "SortData",
ModelCommand::AddCategory(_) => "AddCategory",
@@ -916,15 +931,47 @@ pub(crate) mod tests {
/// to the same mutation lands, `Dirty` included.
#[test]
fn a_model_command_lands_where_its_keypress_lands() {
/// Two categories on different axes: a one-category fixture cannot tell
/// a transpose from a cycle (`doctrine(check_the_checker)`).
/// Two categories on two axes, plus cells and 2+-entry view maps: why each
/// is needed is in `context/test-evidence/command.md` (improvise-djcn).
fn pivoted() -> App {
use crate::model::cell::{CellKey, CellValue};
let mut app = app();
app.model_state.workbook.add_category("Month").unwrap();
app.model_state.workbook.add_item("Month", "Jan").unwrap();
let view = app.model_state.workbook.active_view_mut();
let wb = &mut app.model_state.workbook;
let items = [
("Region", ["South", "East", "West"]),
("Month", ["Jan", "Feb", "Mar"]),
("Channel", ["Web", "Store", "Phone"]),
("Year", ["2024", "2025", "2026"]),
];
for (category, names) in items {
wb.add_category(category).ok();
names.iter().for_each(|n| drop(wb.add_item(category, *n)));
}
for (n, (region, month)) in [("North", "Jan"), ("South", "Feb"), ("East", "Jan")]
.into_iter()
.enumerate()
{
let key = CellKey::new(vec![
("Region".into(), region.into()),
("Month".into(), month.into()),
("Channel".into(), "Web".into()),
("Year".into(), "2024".into()),
]);
wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0));
}
let view = wb.active_view_mut();
view.set_axis("Region", Axis::Row);
view.set_axis("Month", Axis::Column);
view.set_axis("Channel", Axis::Page);
view.set_axis("Year", Axis::Page);
view.set_page_selection("Channel", "Web");
view.set_page_selection("Year", "2024");
view.hide_item("Region", "West");
view.hide_item("Region", "South");
view.hide_item("Month", "Mar");
view.toggle_group_collapse("Region", "Coastal");
view.toggle_group_collapse("Region", "Inland");
view.toggle_group_collapse("Month", "Q1");
app.rebuild_layout();
app
}
+2 -4
View File
@@ -6,8 +6,8 @@ use unicode_width::UnicodeWidthStr;
use improvise_protocol::header_level_widths;
use crate::format::{FormatPlan, NumberFormat};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::model::{Aggregation, Model};
use crate::view::{AxisEntry, GridLayout};
/// Minimum column width — enough for short numbers/labels + 1 char gap.
@@ -110,9 +110,7 @@ fn cell_value(
true => (None, layout.records_value(row, col)),
false => {
let key = layout.cell_key(row, col);
let value = key
.as_ref()
.and_then(|k| model.evaluate_aggregated(k, Aggregation::of(&layout.none_cats)));
let value = key.as_ref().and_then(|k| layout.evaluate(model, k));
(key, value)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
[dependencies]
improvise-formula = { path = "../improvise-formula" }
anyhow = "1"
indexmap = { version = "2", features = ["serde"] }
indexmap = "2"
serde = { version = "1", features = ["derive"] }
# SipHash-2-4 under a constant key: the record surrogate must not move
# between processes, which rules out anything built on `RandomState`
+4 -6
View File
@@ -1,10 +1,9 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
pub type CategoryId = usize;
pub type ItemId = usize;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Item {
pub id: ItemId,
pub name: String,
@@ -27,7 +26,7 @@ impl Item {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Group {
pub name: String,
/// Parent group name for nested hierarchies
@@ -50,7 +49,7 @@ impl Group {
/// Regular categories store their items explicitly; virtual ones are
/// synthesized at query time by the layout layer. context/code/core.md §9.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CategoryKind {
#[default]
Regular,
@@ -125,7 +124,7 @@ impl CategoryKind {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Category {
pub id: CategoryId,
pub name: String,
@@ -136,7 +135,6 @@ pub struct Category {
/// Next item id counter
next_item_id: ItemId,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
+41 -35
View File
@@ -7,7 +7,7 @@ use super::symbol::{Symbol, SymbolTable};
/// A cell key: (category, item) pairs, always sorted by category name. The
/// constructors are the only way to build one — context/code/core.md §1.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CellKey(pub Vec<(String, String)>);
impl CellKey {
@@ -134,6 +134,17 @@ impl CellValue {
}
}
impl CellValue {
/// The text an editor opens on, which commits back to this value unchanged.
/// `Display` rounds for the screen; this must not (`context/code/core.md` §17).
pub fn edit_text(&self) -> String {
match self {
CellValue::Number(n) => nonfinite_token(*n).map_or_else(|| n.to_string(), String::from),
CellValue::Text(_) | CellValue::Error(_) => self.to_string(),
}
}
}
impl std::fmt::Display for CellValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -183,9 +194,7 @@ impl RecordEntry {
}
}
/// Serialized as a list of (key, value) pairs, so `CellKey` need not
/// serialize as a string for JSON object keys.
#[derive(Debug, Clone, Default)]
#[derive(Clone, Default)]
pub struct DataStore {
/// Primary storage: interned keys, insertion-ordered so records mode can
/// show rows in entry order. That order is correctness — core.md §8.
@@ -199,26 +208,34 @@ pub struct DataStore {
records: HashMap<RecordHash, RecordEntry>,
}
impl Serialize for DataStore {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
for (k, v) in &self.cells {
let cell_key = self.to_cell_key(k);
seq.serialize_element(&(cell_key, v))?;
}
seq.end()
}
}
impl<'de> Deserialize<'de> for DataStore {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
let mut store = DataStore::default();
for (key, value) in pairs {
store.set(key, value);
}
Ok(store)
/// Resolved through the symbol table and sorted wherever the storage is a
/// `HashMap`, so two equal stores print alike (improvise-djcn).
impl std::fmt::Debug for DataStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let DataStore {
cells,
symbols,
index,
records,
} = self;
let cells: Vec<_> = cells
.iter()
.map(|(key, value)| (self.to_cell_key(key), value))
.collect();
let index: std::collections::BTreeMap<_, std::collections::BTreeSet<_>> = index
.iter()
.map(|((cat, item), keys)| {
let pair = (symbols.resolve(*cat), symbols.resolve(*item));
(pair, keys.iter().map(|k| self.to_cell_key(k).0).collect())
})
.collect();
let records: std::collections::BTreeMap<_, _> = records.iter().collect();
f.debug_struct("DataStore")
.field("cells", &cells)
.field("symbols", symbols)
.field("index", &index)
.field("records", &records)
.finish()
}
}
@@ -1170,15 +1187,4 @@ mod record_index {
assert_eq!(store.resolve_record(hash), Some(record));
}
}
/// Derived state must come back *with* the data, rebuilt through `set`,
/// rather than be written alongside it.
#[test]
fn a_deserialized_store_can_resolve_its_records() {
let json = serde_json::to_string(&store()).expect("serializes");
let back: DataStore = serde_json::from_str(&json).expect("deserializes");
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
assert_eq!(back.resolve_record(RecordHash::of(&east)), Some(&east));
assert_eq!(back.live_records().count(), 2);
}
}
+11 -1
View File
@@ -5,12 +5,22 @@ use std::collections::HashMap;
pub struct Symbol(u64);
/// Bidirectional string ↔ Symbol mapping.
#[derive(Debug, Clone, Default)]
#[derive(Clone, Default)]
pub struct SymbolTable {
to_id: HashMap<String, Symbol>,
to_str: Vec<String>,
}
/// The interned strings, sorted: which `Symbol` each got is interning order,
/// not content, so two equal stores print alike (improvise-djcn).
impl std::fmt::Debug for SymbolTable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let SymbolTable { to_id, to_str: _ } = self;
let interned: std::collections::BTreeSet<&String> = to_id.keys().collect();
f.debug_set().entries(interned).finish()
}
}
impl SymbolTable {
#[allow(dead_code)]
pub fn new() -> Self {
+471 -72
View File
@@ -2,11 +2,10 @@ use std::collections::HashMap;
use anyhow::{Result, anyhow};
use indexmap::{IndexMap, IndexSet};
use serde::{Deserialize, Serialize};
use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::{AggFunc, Expr, Filter, Formula};
use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId};
const MAX_CATEGORIES: usize = 12;
@@ -51,17 +50,50 @@ fn member_records(model: &Model, partial: &CellKey) -> Vec<CellKey> {
seen.into_iter().collect()
}
/// A formula-level aggregate over the data matching `context` minus the
/// target. Deliberately *not* [`apply_agg`]'s emptiness rule — §2.
/// A formula-level aggregate over the data matching each of `contexts` minus the
/// target, pooled. Deliberately *not* [`apply_agg`]'s emptiness rule — §2.
fn eval_agg(
model: &Model,
context: &CellKey,
contexts: &[CellKey],
target_category: &str,
func: &AggFunc,
inner: &Expr,
agg_filter: Option<&Filter>,
eval_at: &dyn Fn(&Expr, &CellKey) -> Result<f64, String>,
) -> Result<f64, String> {
// A filter overrides its coordinate, so members it rewrites to one item name
// one record set: pool each distinct context once (core.md §14).
let distinct: IndexSet<CellKey> = contexts
.iter()
.map(|c| match agg_filter {
Some(f) => c.clone().with(&f.category, &f.item),
None => c.clone(),
})
.collect();
let values = distinct
.iter()
.map(|c| agg_values(model, c, target_category, inner, agg_filter, eval_at))
.collect::<Result<Vec<_>, String>>()?
.concat();
match func {
AggFunc::Sum => Ok(values.iter().sum()),
AggFunc::Count => Ok(values.len() as f64),
AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
apply_agg(&values, func).ok_or_else(|| "empty".to_string())
}
}
}
/// The values an aggregate folds at one context: a stored ref's cells, or the
/// expression asked of each member record — §13.
fn agg_values(
model: &Model,
context: &CellKey,
target_category: &str,
inner: &Expr,
agg_filter: Option<&Filter>,
eval_at: &dyn Fn(&Expr, &CellKey) -> Result<f64, String>,
) -> Result<Vec<f64>, String> {
let mut partial = context.without(target_category);
// The fast path is the fold specialised to a ref that names a STORED
// cell; where it does not, the premise fails — §13, s08t, s2w3.
@@ -83,15 +115,15 @@ fn eval_agg(
if let Some(f) = agg_filter {
partial = partial.with(&f.category, &f.item);
}
let values: Vec<f64> = match select_raw {
match select_raw {
// A bare ref asks for the values that exist; anything else asks the
// expression of each member record and folds the answers — §13.
true => model
true => Ok(model
.data
.matching_values(&partial.0)
.into_iter()
.filter_map(|v| v.as_f64())
.collect(),
.collect()),
false => member_records(model, &partial)
.into_iter()
.map(|record| {
@@ -101,28 +133,48 @@ fn eval_agg(
.fold(partial.clone(), |k, (cat, item)| k.with(cat, item));
eval_at(inner, &at)
})
.collect::<Result<Vec<f64>, String>>()?,
};
match func {
AggFunc::Sum => Ok(values.iter().sum()),
AggFunc::Count => Ok(values.len() as f64),
AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
apply_agg(&values, func).ok_or_else(|| "empty".to_string())
}
.collect(),
}
}
// How many formulas a fold has evaluated: what improvise-o1y0's regression counts.
#[cfg(test)]
thread_local! {
static FOLD_FORMULA_EVALUATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// One fold evaluation's memory: each formula key is evaluated once, and a key met
/// while still in progress (`None`) is a cycle (improvise-o1y0).
type FoldMemo = std::cell::RefCell<HashMap<CellKey, Option<Option<CellValue>>>>;
/// Which members of a collapsed group its fold covers. The model knows no
/// view, so the grid passes the members it draws (core.md §14).
pub type Covers<'a> = &'a dyn Fn(&str, &str) -> bool;
/// Where a formula's references are read: at one record, or across the members
/// a collapsed group's fold covers, as a None axis reads them (core.md §14).
#[derive(Clone, Copy)]
enum Reach<'a> {
Record,
Fold {
agg: Aggregation,
covers: Covers<'a>,
memo: &'a FoldMemo,
},
}
/// The **record-level** evaluator: no cache, because a member key is not a stem
/// the cache holds. Errors propagate immediately — §3, §13.
/// the cache holds. Errors propagate immediately — §3, §13. `reach`: §14.
fn eval_expr(
expr: &Expr,
context: &CellKey,
model: &Model,
target_category: &str,
depth: u8,
reach: Reach,
) -> Result<f64, String> {
use crate::formula::BinOp;
let eval = |e: &Expr| eval_expr(e, context, model, target_category, depth, reach);
match expr {
Expr::Number(n) => Ok(*n),
Expr::Ref(name) => {
@@ -130,15 +182,21 @@ fn eval_expr(
.find_item_category(name)
.ok_or_else(|| format!("ref:{name}"))?;
let new_key = context.clone().with(cat, name);
match model.evaluate_depth(&new_key, depth) {
let value = match reach {
Reach::Record => model.evaluate_depth(&new_key, depth),
Reach::Fold { agg, covers, memo } => {
model.evaluate_fold(&new_key, agg, covers, memo)
}
};
match value {
Some(CellValue::Number(n)) => Ok(n),
Some(CellValue::Error(e)) => Err(e),
_ => Err(format!("ref:{name}")),
}
}
Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category, depth)?;
let rv = eval_expr(r, context, model, target_category, depth)?;
let lv = eval(l)?;
let rv = eval(r)?;
match op {
BinOp::Add => Ok(lv + rv),
BinOp::Sub => Ok(lv - rv),
@@ -153,22 +211,26 @@ fn eval_expr(
}
}
}
Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?),
Expr::UnaryMinus(e) => Ok(-eval(e)?),
// A fold's aggregate pools every member record it covers, never the
// members' own aggregates (§14).
Expr::Agg(func, inner, agg_filter) => eval_agg(
model,
context,
&model.contexts_of(context, reach),
target_category,
func,
inner.as_ref(),
agg_filter.as_ref(),
&|e, at| eval_expr(e, at, model, target_category, depth),
&|e, at| eval_expr(e, at, model, target_category, depth, Reach::Record),
),
Expr::Is(filter) => Ok(eval_is(context, filter)),
Expr::Is(filter) => Ok(eval_is(
model.read_coordinate(context, &filter.category, reach),
filter,
)),
Expr::If(cond, then, else_) => {
let cv = eval_bool(cond, context, model, target_category, depth)?;
match cv {
true => eval_expr(then, context, model, target_category, depth),
false => eval_expr(else_, context, model, target_category, depth),
match eval_bool(cond, context, model, target_category, depth, reach)? {
true => eval(then),
false => eval(else_),
}
}
}
@@ -182,12 +244,13 @@ fn eval_bool(
model: &Model,
target_category: &str,
depth: u8,
reach: Reach,
) -> Result<bool, String> {
use crate::formula::BinOp;
match expr {
Expr::BinOp(op, l, r) => {
let lv = eval_expr(l, context, model, target_category, depth)?;
let rv = eval_expr(r, context, model, target_category, depth)?;
let lv = eval_expr(l, context, model, target_category, depth, reach)?;
let rv = eval_expr(r, context, model, target_category, depth, reach)?;
match op {
BinOp::Eq => Ok((lv - rv).abs() < FLOAT_EQ_EPSILON),
BinOp::Ne => Ok((lv - rv).abs() >= FLOAT_EQ_EPSILON),
@@ -211,10 +274,8 @@ fn eval_bool(
/// `Cat IS Item` as a number. A cell with no `Cat` coordinate is **not** that
/// item, matching `Formula::filter`'s own `unwrap_or(false)` — §13.
fn eval_is(context: &CellKey, filter: &Filter) -> f64 {
context
.get(&filter.category)
.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
fn eval_is(coordinate: Option<&str>, filter: &Filter) -> f64 {
coordinate.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
}
/// Whether a grid hides dimensions a cell key does not name. The emptiness of
@@ -238,7 +299,7 @@ impl Aggregation {
/// Pure-data document model: categories, cells, formulas. It knows nothing
/// about views — see [`crate::workbook::Workbook`] and core.md §10.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone)]
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>,
@@ -247,13 +308,40 @@ pub struct Model {
next_category_id: CategoryId,
/// Per-measure aggregation, used when collapsing `Axis::None`
/// categories. Defaults to `Sum`.
#[serde(default)]
pub measure_agg: HashMap<String, AggFunc>,
/// Cached formula evaluation results. Recomputed by `recompute_formulas`.
#[serde(skip)]
formula_cache: HashMap<CellKey, CellValue>,
}
/// Every field, with the two `HashMap`s in key order: a recompute refills the
/// cache in hash order, which is not a change (improvise-pc3b, gotcha 18).
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Destructured, so a new field is a compile error here (improvise-djcn).
let Model {
name,
categories,
data,
formulas,
next_category_id,
measure_agg,
formula_cache,
} = self;
let aggs: std::collections::BTreeMap<_, _> = measure_agg.iter().collect();
let mut cache: Vec<_> = formula_cache.iter().collect();
cache.sort_by(|a, b| a.0.0.cmp(&b.0.0));
f.debug_struct("Model")
.field("name", name)
.field("categories", categories)
.field("data", data)
.field("formulas", formulas)
.field("next_category_id", next_category_id)
.field("measure_agg", &aggs)
.field("formula_cache", &cache)
.finish()
}
}
impl Model {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
@@ -417,6 +505,38 @@ impl Model {
&self.formulas
}
pub fn formula(&self, id: &FormulaId) -> Option<&Formula> {
self.formulas.iter().find(|f| f.id() == *id)
}
/// Put `formula` where `id` is, keeping its position; refuses rather than
/// let a new target displace another formula — `context/code/core.md` §18.
pub fn replace_formula(&mut self, id: &FormulaId, formula: Formula) -> Result<()> {
let new_id = formula.id();
let renames = new_id != *id;
// A regular category's target is a real item; renaming it is sboz's (kb1d).
if renames && !CategoryKind::of_name(&formula.target_category).is_measure() {
return Err(anyhow!(
"Renaming a formula outside _Measure is not supported yet; delete and re-add it"
));
}
if renames && self.formula(&new_id).is_some() {
return Err(anyhow!("A formula for {} already exists", formula.target));
}
let pos = self
.formulas
.iter()
.position(|f| f.id() == *id)
.ok_or_else(|| anyhow!("That formula no longer exists"))?;
if !CategoryKind::of_name(&formula.target_category).is_measure()
&& let Some(cat) = self.categories.get_mut(&formula.target_category)
{
cat.add_item(&formula.target);
}
self.formulas[pos] = formula;
Ok(())
}
/// The formula that owns `key`, if one does. **The one home** of "is this
/// cell derived" — every other answer to it is a second derivation.
pub fn formula_for(&self, key: &CellKey) -> Option<&Formula> {
@@ -523,33 +643,88 @@ impl Model {
}
pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
let members = self.group_expansion(key);
if !members.is_empty() {
return self.evaluate_group(key, &members, agg);
self.evaluate_covering(key, agg, &|_, _| true)
}
/// [`Self::evaluate_aggregated`], with a collapsed group's fold covering only
/// the members `covers` admits — the ones a grid draws (core.md §14).
pub fn evaluate_covering(
&self,
key: &CellKey,
agg: Aggregation,
covers: Covers,
) -> Option<CellValue> {
self.evaluate_fold(key, agg, covers, &FoldMemo::default())
}
/// A key naming a group folds; any other key reads as before. A formula is
/// evaluated OVER the fold, never folded itself — why: core.md §14.
fn evaluate_fold(
&self,
key: &CellKey,
agg: Aggregation,
covers: Covers,
memo: &FoldMemo,
) -> Option<CellValue> {
let members = self.group_expansion(key, covers);
match (members.is_empty(), self.formula_for(key)) {
(true, _) => self.evaluate_unfolded(key, agg),
(false, Some(formula)) => {
self.fold_formula_once(formula, key, &members, (agg, covers, memo))
}
(false, None) => self.fold_stored(key, &members, agg),
}
}
/// A formula at a group key, evaluated at most once per fold evaluation; a key
/// already in progress is a cycle, so `circular` still comes out (improvise-o1y0).
fn fold_formula_once(
&self,
formula: &Formula,
key: &CellKey,
members: &[CellKey],
(agg, covers, memo): (Aggregation, Covers, &FoldMemo),
) -> Option<CellValue> {
let seen = memo.borrow().get(key).cloned();
match seen {
Some(Some(value)) => value,
Some(None) => Some(CellValue::Error("circular".into())),
None => {
memo.borrow_mut().insert(key.clone(), None);
#[cfg(test)]
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1));
let reach = Reach::Fold { agg, covers, memo };
let value = self.eval_formula_folded(formula, key, members, agg, reach);
memo.borrow_mut().insert(key.clone(), Some(value.clone()));
value
}
}
}
/// A key that names no group: exact, the formula cache, or raw aggregation.
fn evaluate_unfolded(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
if agg == Aggregation::Exact {
return self.evaluate(key);
}
// Check the formula cache first.
if let Some(cached) = self.formula_cache.get(key) {
return Some(cached.clone());
}
// Aggregate raw data across all None-axis categories.
aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key))
}
/// One key per member where `key` names a collapsed GROUP rather than an item —
/// the cross product over every such coordinate. Empty when none does (§14).
fn group_expansion(&self, key: &CellKey) -> Vec<CellKey> {
/// One key per covered member where `key` names a collapsed GROUP rather than
/// an item — the cross product over every such coordinate. Empty when none does.
fn group_expansion(&self, key: &CellKey, covers: Covers) -> Vec<CellKey> {
let mut expanded = vec![key.clone()];
for (cat_name, name) in &key.0 {
let members = self
let members: Vec<&str> = self
.category(cat_name)
.filter(|cat| !cat.items.contains_key(name.as_str()))
.map(|cat| cat.items_in_group(name))
.unwrap_or_default();
.unwrap_or_default()
.into_iter()
.filter(|m| covers(cat_name, m))
.collect();
if members.is_empty() {
continue;
}
@@ -564,29 +739,85 @@ impl Model {
}
}
/// A group's cell: stored values fold ONCE over every record the group covers,
/// a derived one folds its members' results — why the two differ: core.md §14.
fn evaluate_group(
/// A coordinate as a formula reads it. At a fold a group's name is no item, so
/// the folded category reads as absent, as on `Axis::None` (improvise-lnkb).
fn read_coordinate<'k>(
&self,
context: &'k CellKey,
cat: &str,
reach: Reach,
) -> Option<&'k str> {
let value = context.get(cat)?;
match reach {
Reach::Fold { .. } if self.names_a_group(cat, value) => None,
Reach::Fold { .. } | Reach::Record => Some(value),
}
}
/// Whether `name` is a group of `cat` rather than one of its items.
fn names_a_group(&self, cat: &str, name: &str) -> bool {
self.category(cat)
.is_some_and(|c| !c.items.contains_key(name) && !c.items_in_group(name).is_empty())
}
/// The contexts an aggregate inside a formula pools over at `context`.
fn contexts_of(&self, context: &CellKey, reach: Reach) -> Vec<CellKey> {
let members = match reach {
Reach::Record => Vec::new(),
Reach::Fold { covers, .. } => self.group_expansion(context, covers),
};
match members.is_empty() {
true => vec![context.clone()],
false => members,
}
}
/// A group's stored values fold ONCE over every record the group covers.
fn fold_stored(
&self,
key: &CellKey,
members: &[CellKey],
agg: Aggregation,
) -> Option<CellValue> {
let values: Vec<CellValue> = match self.formula_for(key) {
Some(_) => members
.iter()
.filter_map(|k| self.evaluate_aggregated(k, agg))
.collect(),
None => members
.iter()
.flat_map(|k| match agg {
Aggregation::Exact => self.data.get(k).into_iter().collect(),
Aggregation::OverHidden => self.data.matching_values(&k.0),
})
.cloned()
.collect(),
};
aggregate_cell_values(&values.iter().collect::<Vec<_>>(), self.agg_for(key))
let values: Vec<&CellValue> = members
.iter()
.flat_map(|k| match agg {
Aggregation::Exact => self.data.get(k).into_iter().collect(),
Aggregation::OverHidden => self.data.matching_values(&k.0),
})
.collect();
aggregate_cell_values(&values, self.agg_for(key))
}
/// A formula at a group key, its refs read through the fold — the value the
/// same formula has with the group's category on `Axis::None` (§14).
fn eval_formula_folded(
&self,
formula: &Formula,
key: &CellKey,
members: &[CellKey],
agg: Aggregation,
reach: Reach,
) -> Option<CellValue> {
let applies = formula
.filter
.as_ref()
.is_none_or(|f| self.read_coordinate(key, &f.category, reach) == Some(f.item.as_str()));
if !applies {
return self.fold_stored(key, members, agg);
}
let depth = Self::MAX_EVAL_DEPTH;
match eval_expr(
&formula.expr,
key,
self,
&formula.target_category,
depth,
reach,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
}
}
/// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell.
@@ -744,16 +975,19 @@ impl Model {
)?),
Expr::Agg(func, inner, agg_filter) => eval_agg(
model,
context,
std::slice::from_ref(context),
target_category,
func,
inner.as_ref(),
agg_filter.as_ref(),
// The fold evaluates at a MEMBER key, which is not a stem the
// cache holds — so the record-level evaluator, in both paths.
&|e, at| eval_expr(e, at, model, target_category, Model::MAX_EVAL_DEPTH),
&|e, at| {
let depth = Model::MAX_EVAL_DEPTH;
eval_expr(e, at, model, target_category, depth, Reach::Record)
},
),
Expr::Is(filter) => Ok(eval_is(context, filter)),
Expr::Is(filter) => Ok(eval_is(context.get(&filter.category), filter)),
Expr::If(cond, then, else_) => {
let cv = eval_bool_cached(cond, context, model, target_category, none_cats)?;
if cv {
@@ -841,6 +1075,7 @@ impl Model {
self,
&formula.target_category,
depth,
Reach::Record,
) {
Ok(n) => Some(CellValue::Number(n)),
Err(e) => Some(CellValue::Error(e)),
@@ -891,7 +1126,17 @@ mod agg_helper_tests {
let empty = Expr::Ref("Revenue".to_string());
let ctx = CellKey::new(vec![]);
let never = |_: &Expr, _: &CellKey| unreachable!("a bare Ref never folds per record");
let run = |func| eval_agg(&model, &ctx, "_Measure", &func, &empty, None, &never);
let run = |func| {
eval_agg(
&model,
std::slice::from_ref(&ctx),
"_Measure",
&func,
&empty,
None,
&never,
)
};
assert_eq!(run(AggFunc::Sum), Ok(0.0));
assert_eq!(run(AggFunc::Count), Ok(0.0));
@@ -1493,6 +1738,78 @@ mod model_tests {
);
}
/// improvise-o1y0: a fold evaluates each formula it reaches once — see
/// context/test-evidence/core.md.
#[test]
fn a_fold_evaluates_each_formula_it_reaches_once() {
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
m.add_formula(crate::formula::parse_formula("F0 = Revenue", "_Measure").unwrap());
for i in 1..=8 {
let text = format!("F{i} = F{p} + F{p}", p = i - 1);
m.add_formula(crate::formula::parse_formula(&text, "_Measure").unwrap());
}
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "F8")]);
super::FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
let value = m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true);
let evaluations = super::FOLD_FORMULA_EVALUATIONS.with(|n| n.get());
assert_eq!(
value,
Some(CellValue::Number(7.0 * 256.0)),
"the chain's value"
);
assert_eq!(evaluations, 9, "F0..F8 once each, not 2^9 - 1");
}
/// improvise-o1y0's other half: a key met while still in progress is a cycle.
#[test]
fn a_cycle_at_a_fold_is_still_circular() {
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
m.add_formula(crate::formula::parse_formula("P = Q + 1", "_Measure").unwrap());
m.add_formula(crate::formula::parse_formula("Q = P + 1", "_Measure").unwrap());
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "P")]);
assert_eq!(
m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true),
Some(CellValue::Error("circular".into()))
);
}
/// improvise-d7aj: the memo is per KEY, not per formula — one formula met at two
/// fold keys in one evaluation. Evidence: context/test-evidence/core.md.
#[test]
fn one_formula_at_two_fold_keys_is_evaluated_at_each() {
let mut m = quarter_model(&[
("Jan", "A", 3.0),
("Feb", "A", 4.0),
("Jan", "B", 100.0),
("Feb", "B", 200.0),
]);
for text in ["G = Revenue * 2", "F = IF(Revenue > 50, G, G + B)"] {
m.add_formula(crate::formula::parse_formula(text, "_Measure").unwrap());
}
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "F")]);
assert_eq!(
m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true),
Some(CellValue::Number(614.0)),
"G at A (14) plus F at B, which is G at B (600)"
);
}
/// improvise-d7aj: a formula reached twice by two paths is finished, not in
/// progress, the second time. Evidence: context/test-evidence/core.md.
#[test]
fn a_diamond_at_a_fold_is_not_circular() {
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
for text in ["A1 = B2 + C3", "B2 = D * 2", "C3 = D * 3", "D = Revenue"] {
m.add_formula(crate::formula::parse_formula(text, "_Measure").unwrap());
}
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "A1")]);
super::FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
let value = m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true);
let evaluations = super::FOLD_FORMULA_EVALUATIONS.with(|n| n.get());
assert_eq!(value, Some(CellValue::Number(35.0)), "14 + 21 over D = 7");
assert_eq!(evaluations, 4, "A1, B2, C3 and D once each");
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn a_groups_stored_values_fold_once_not_per_member() {
@@ -1861,6 +2178,88 @@ mod formula_tests {
assert_eq!(m.evaluate(&k), None);
}
fn three_formulas() -> Model {
let mut m = revenue_cost_model();
for raw in [
"Profit = Revenue - Cost",
"Tax = Revenue * 0.1",
"Margin = Profit / Revenue",
] {
m.add_formula(parse_formula(raw, "_Measure").unwrap());
}
m
}
fn targets(m: &Model) -> Vec<&str> {
m.formulas.iter().map(|f| f.target.as_str()).collect()
}
#[test]
fn replace_formula_renames_in_place_and_evaluates_under_the_new_name() {
let mut m = three_formulas();
let id = m.formulas[1].id();
m.replace_formula(
&id,
parse_formula("Levy = Revenue * 0.5", "_Measure").unwrap(),
)
.unwrap();
assert_eq!(targets(&m), vec!["Profit", "Levy", "Margin"]);
let levy = coord(&[("_Measure", "Levy"), ("Region", "East")]);
assert_eq!(m.evaluate(&levy).and_then(|v| v.as_f64()), Some(500.0));
}
#[test]
fn replace_formula_refuses_to_displace_another_formula() {
let mut m = three_formulas();
let id = m.formulas[1].id();
let onto_margin = parse_formula("Margin = Revenue", "_Measure").unwrap();
assert!(m.replace_formula(&id, onto_margin).is_err());
assert_eq!(targets(&m), vec!["Profit", "Tax", "Margin"]);
}
#[test]
fn replace_formula_refuses_an_id_that_names_nothing() {
let mut m = three_formulas();
let id = m.formulas[1].id();
m.remove_formula("Tax", "_Measure");
let tax = parse_formula("Tax = Revenue", "_Measure").unwrap();
assert!(m.replace_formula(&id, tax).is_err());
assert_eq!(targets(&m), vec!["Profit", "Margin"]);
}
/// improvise-kb1d: a Region-targeted formula at index 1. A rename would
/// leave `Total` as an empty item and append `Sum`, so it is refused.
#[test]
fn replace_formula_refuses_to_rename_a_target_outside_measure() {
let mut m = three_formulas();
m.formulas
.insert(1, parse_formula("Total = East + West", "Region").unwrap());
let shape = |m: &Model| {
let region = m.category("Region").unwrap().ordered_item_names();
let owned: Vec<String> = region.iter().map(|s| s.to_string()).collect();
(
targets(m).iter().map(|s| s.to_string()).collect::<Vec<_>>(),
owned,
)
};
let before = shape(&m);
let id = m.formulas[1].id();
let sum = parse_formula("Sum = East + West", "Region").unwrap();
assert!(m.replace_formula(&id, sum).is_err());
assert_eq!(shape(&m), before, "nothing moved, nothing added");
let edit = parse_formula("Total = East - West", "Region").unwrap();
m.replace_formula(&id, edit).unwrap();
assert_eq!(targets(&m), vec!["Profit", "Total", "Tax", "Margin"]);
}
#[test]
fn a_formula_mentions_what_its_expression_or_its_where_names() {
let margin = parse_formula("Margin = Profit / Revenue", "_Measure").unwrap();
assert!(margin.mentions("Profit") && !margin.mentions("Tax"));
let scoped = parse_formula("East = SUM(Revenue) WHERE Region = \"East\"", "_Measure");
assert!(scoped.unwrap().mentions("Region"));
}
#[test]
fn sum_aggregation_across_region() {
let mut m = revenue_cost_model();
+318 -41
View File
@@ -1,3 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
@@ -282,60 +283,53 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
.nth(data_idx)
}
/// The run a header opened has ended. A header still pending saw either no
/// item at all — it is collapsed, and survives — or only rejected ones.
fn close_pending_run(
kept: &mut Vec<AxisEntry>,
pending: Option<AxisEntry>,
had_item: bool,
) -> bool {
pending
.filter(|_| !had_item)
.map(|h| kept.push(h))
.is_some()
/// A header ends the pending run of its OWN category and every run nested inside
/// it; an outer category's header is still waiting for an item (improvise-b5zh).
fn close_runs_of(pending: &mut Vec<AxisEntry>, header: &AxisEntry) {
let cat_of = |e: &AxisEntry| match e {
AxisEntry::GroupHeader { cat_name, .. } => Some(cat_name.clone()),
AxisEntry::GroupEnd | AxisEntry::DataItem { .. } => None,
};
if let Some(at) = pending.iter().position(|h| cat_of(h) == cat_of(header)) {
pending.truncate(at);
}
}
/// Drop the data items `keep` rejects, keeping a header only if an item under
/// it survives or the group is collapsed. A predicate — why: core.md §6.
/// it survives. A predicate — why: core.md §6.
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new();
let mut pending_header: Option<AxisEntry> = None;
let mut run_had_item = false;
// Headers no kept item has reached yet, outermost first.
let mut pending: Vec<AxisEntry> = Vec::new();
let mut run_open = false;
let mut data_idx = 0usize;
for entry in items.drain(..) {
match &entry {
// A header ends the previous run: another group's header is where
// `expand_category` stops emitting a `GroupEnd` (improvise-gd83).
// Another group's header is where `expand_category` stops emitting a
// `GroupEnd` (improvise-gd83), so it closes that group's run.
AxisEntry::GroupHeader { .. } => {
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
pending_header = Some(entry);
run_had_item = false;
close_runs_of(&mut pending, &entry);
pending.push(entry);
}
// Kept whenever a run is open in `kept`, even where the header it
// closed was pruned: an earlier one is then re-opened (u6gc).
AxisEntry::GroupEnd => {
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
run_had_item = false;
pending.pop();
if run_open {
kept.push(entry);
run_open = false;
}
}
AxisEntry::DataItem { .. } => {
run_had_item = true;
if keep(data_idx) {
if let Some(h) = pending_header.take() {
kept.push(h);
run_open = true;
}
run_open |= !pending.is_empty();
kept.append(&mut pending);
kept.push(entry);
}
data_idx += 1;
}
}
}
close_pending_run(&mut kept, pending_header.take(), run_had_item);
*items = kept;
}
@@ -384,6 +378,8 @@ pub struct GridLayout {
/// In records mode: what each column holds, parallel to `col_items`.
/// Empty for normal pivot views.
record_cols: Vec<RecordCol>,
/// The view's hidden items: a fold covers only the members this grid draws.
hidden: HashMap<String, HashSet<String>>,
}
impl GridLayout {
@@ -467,6 +463,7 @@ impl GridLayout {
none_cats,
records: None,
record_cols: Vec::new(),
hidden: view.hidden_items.clone(),
}
}
}
@@ -500,6 +497,7 @@ impl GridLayout {
none_cats,
records: Some(Rc::new(records)),
record_cols,
hidden: HashMap::new(),
}
}
@@ -618,7 +616,7 @@ impl GridLayout {
for (ci, cell) in row.iter_mut().enumerate() {
*cell = self
.cell_key(ri, ci)
.and_then(|k| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats)))
.and_then(|k| self.evaluate(model, &k))
.is_some();
}
}
@@ -656,11 +654,11 @@ impl GridLayout {
axis_label(&self.col_items, col)
}
/// Display string for a synthetic records-mode key; `None` for a pivot
/// key.
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
let (row, col) = synthetic_record_info(key)?;
self.records_display(row, col)
/// The value of a pivot key as THIS grid reads it: aggregated over its None
/// axis, and a fold over the members it draws (core.md §14). One evaluation.
pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option<CellValue> {
let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item));
model.evaluate_covering(key, Aggregation::of(&self.none_cats), &drawn)
}
/// Display text for (row, col) in either mode.
@@ -676,8 +674,7 @@ impl GridLayout {
} else {
self.cell_key(row, col)
.and_then(|key| {
let value =
model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))?;
let value = self.evaluate(model, &key)?;
Some(plan.at(Some(&key)).value(Some(&value)))
})
.unwrap_or_default()
@@ -750,7 +747,12 @@ impl GridLayout {
/// Whether the cell at `(row, col)` folds a collapsed group: an aggregate
/// with no record under it. The one home of that answer (§14).
pub fn folds_a_group(&self, row: usize, col: usize) -> bool {
folds(&self.row_items, row) || folds(&self.col_items, col)
self.row_folds_a_group(row) || folds(&self.col_items, col)
}
/// The row half of [`Self::folds_a_group`], for a command about the row alone.
pub fn row_folds_a_group(&self, row: usize) -> bool {
folds(&self.row_items, row)
}
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
@@ -1045,7 +1047,7 @@ mod tests {
}
#[test]
fn records_mode_resolve_display_returns_values() {
fn records_mode_records_display_returns_values() {
let mut wb = records_workbook();
let v = wb.active_view_mut();
v.set_axis("_Index", Axis::Row);
@@ -1057,13 +1059,11 @@ mod tests {
// A measure column resolves to that measure's value
let value_col = cols.iter().position(|c| c == "Revenue").unwrap();
let key = layout.cell_key(0, value_col).unwrap();
assert_eq!(layout.resolve_display(&key).as_deref(), Some("100"));
assert_eq!(layout.records_display(0, value_col).as_deref(), Some("100"));
// Category column resolves to the coordinate value
let region_col = cols.iter().position(|c| c == "Region").unwrap();
let key = layout.cell_key(0, region_col).unwrap();
let display = layout.resolve_display(&key).unwrap();
let display = layout.records_display(0, region_col).unwrap();
assert!(
!display.is_empty(),
"Region column should resolve to a value"
@@ -1706,6 +1706,46 @@ mod tests {
);
}
/// improvise-b5zh: an OUTER header is followed by an inner one, not by an item
/// — see context/test-evidence/core.md.
#[test]
fn an_outer_group_whose_rows_all_prune_loses_its_header() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item_in_group("Region", "North", "Upper").unwrap();
wb.add_item_in_group("Region", "South", "Lower").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item("Type", "Food").unwrap();
for month in ["Jan", "Feb"] {
wb.model.set_cell(
CellKey::new(vec![
("Region".into(), "South".into()),
("Month".into(), month.into()),
("Type".into(), "Food".into()),
]),
CellValue::Number(1.0),
);
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("Type", Axis::Column);
wb.active_view_mut().prune_empty = true;
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
assert_eq!(layout.row_count(), 2, "only South's rows are funded");
assert!(
has_header(&layout.row_items, "Lower"),
"South's group stays"
);
assert!(
!has_header(&layout.row_items, "Upper"),
"every North row pruned, so Upper has nothing under it: {:?}",
layout.row_items
);
}
/// The commonest case and the one the other three cannot see: SOME of a
/// group's items prune. context/test-evidence/core.md.
#[test]
@@ -1750,4 +1790,241 @@ mod tests {
"Sep is in no group — Q2's GroupEnd closed Q1's re-opened run"
);
}
/// Q2 = {Apr}, Q1 = {Jan, Feb}, so the Q1 fold is row 1. Jan and Feb carry
/// ratios 0.5 and 0.9 whose sum, mean and pooled ratio all differ; Apr is empty.
fn margin_book(formulas: &[&str]) -> Workbook {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
wb.model.set_cell(
CellKey::new(vec![
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
]),
CellValue::Number(n),
);
}
}
for f in formulas {
wb.model
.add_formula(crate::formula::parse_formula(f, "_Measure").unwrap());
}
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
/// `measure` at the row labelled `row_label`, as the grid reads it.
fn grid_value(wb: &mut Workbook, row_label: &str, measure: &str) -> Option<f64> {
wb.model.recompute_formulas(&wb.active_view().none_cats());
let layout = GridLayout::new(&wb.model, wb.active_view());
let row = (0..layout.row_count()).find(|&r| layout.row_label(r) == row_label)?;
let col = (0..layout.col_count()).find(|&c| layout.col_label(c) == measure)?;
layout
.evaluate(&wb.model, &layout.cell_key(row, col)?)
.and_then(|v| v.as_f64())
}
/// improvise-eccl — see context/test-evidence/core.md.
#[test]
fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() {
for formulas in [
&["Margin = (Revenue - Cost) / Revenue"][..],
&["Profit = Revenue - Cost", "Margin = Profit / Revenue"][..],
] {
let mut folded = margin_book(formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
let mut none = margin_book(formulas);
none.active_view_mut().set_axis("Month", Axis::None);
let over_none = grid_value(&mut none, "", "Margin").expect("a None-axis cell");
assert!((fold - 0.8).abs() < 1e-9, "{formulas:?}: fold {fold}");
assert!(
(fold - over_none).abs() < 1e-9,
"{formulas:?}: {fold} vs {over_none}"
);
}
}
/// improvise-lnkb: the folded category reads as absent, as on `Axis::None` — see
/// context/test-evidence/core.md.
#[test]
fn a_fold_answers_is_and_where_on_its_category_as_the_none_axis_does() {
let formulas = [
"Flag = Month IS \"Q1\"",
"Marked = Revenue * 2 WHERE Month = \"Q1\"",
];
for measure in ["Flag", "Marked"] {
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
let fold = grid_value(&mut folded, "Q1", measure);
let mut none = margin_book(&formulas);
none.active_view_mut().set_axis("Month", Axis::None);
let over_none = grid_value(&mut none, "", measure);
assert_eq!(
fold, over_none,
"{measure}: the fold and the None axis differ"
);
}
let mut folded = margin_book(&formulas);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
assert_eq!(
grid_value(&mut folded, "Q1", "Flag"),
Some(0.0),
"false, not absent"
);
}
/// improvise-eccl's guard on the other side: an aggregate INSIDE the formula
/// still pools every record the fold covers.
#[test]
fn a_folds_aggregate_formula_pools_its_members_records() {
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0));
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
}
/// An inline `WHERE` on the folded category makes every member context the
/// same record set — see context/test-evidence/core.md.
#[test]
fn a_folds_aggregate_filtered_on_the_folded_category_counts_each_record_once() {
let formula = ["Jans = SUM(Revenue WHERE Month = \"Jan\")"];
let mut folded = margin_book(&formula);
folded
.active_view_mut()
.toggle_group_collapse("Month", "Q1");
let fold = grid_value(&mut folded, "Q1", "Jans");
let mut none = margin_book(&formula);
none.active_view_mut().set_axis("Month", Axis::None);
assert_eq!(fold, Some(10.0), "Jan's revenue, once");
assert_eq!(
fold,
grid_value(&mut none, "", "Jans"),
"the None-axis answer"
);
}
/// Every row's value in column 0, summed: the totals strip's arithmetic.
fn column_total(wb: &Workbook) -> f64 {
let layout = GridLayout::new(&wb.model, wb.active_view());
(0..layout.row_count())
.filter_map(|r| layout.evaluate(&wb.model, &layout.cell_key(r, 0)?))
.filter_map(|v| v.as_f64())
.sum()
}
/// improvise-f2oo — see context/test-evidence/core.md.
#[test]
fn collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item("Type", "Food").unwrap();
for (month, quarter, n) in [("Jan", "Q1", 3.0), ("Feb", "Q1", 4.0), ("Apr", "Q2", 100.0)] {
wb.add_item_in_group("Month", month, quarter).unwrap();
wb.model.set_cell(
CellKey::new(vec![
("Month".into(), month.into()),
("Type".into(), "Food".into()),
]),
CellValue::Number(n),
);
}
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("Type", Axis::Column);
wb.active_view_mut().hide_item("Month", "Feb");
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn");
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
assert_eq!(column_total(&wb), 103.0, "z moved the total");
assert_eq!(
grid_value(&mut wb, "Q1", "Food"),
Some(3.0),
"Feb is hidden"
);
// Every member hidden: no fold is drawn, and the total still agrees.
wb.active_view_mut().hide_item("Month", "Jan");
assert_eq!(grid_value(&mut wb, "Q1", "Food"), None, "no Q1 row");
assert_eq!(column_total(&wb), 100.0);
}
/// improvise-t0ma: the steps themselves, not their translation into effects —
/// see context/test-evidence/core.md.
#[test]
fn records_steps_are_the_spine_then_every_non_index_dim_category_on_none() {
use super::{ViewStep, records_spine, records_steps};
let mut m = crate::model::Model::new("T");
m.add_category("Region").unwrap();
m.add_category("Product").unwrap();
let none = |c: &str| ViewStep::Axis {
category: c.to_string(),
axis: Axis::None,
};
let mut expected = records_spine();
expected.extend(
m.categories
.keys()
.filter(|c| !matches!(c.as_str(), "_Index" | "_Dim"))
.map(|c| none(c)),
);
assert!(
expected.contains(&none("_Measure")) && expected.contains(&none("Region")),
"setup: the model carries _Measure and a regular category"
);
assert_eq!(records_steps(&m), expected);
}
/// improvise-t0ma: a drill FIXES its coordinates on Filter and frees only the
/// unfixed, non-virtual aggregated categories.
#[test]
fn drill_steps_filter_each_coordinate_and_free_the_rest() {
use super::{ViewStep, drill_steps, records_spine};
let key = CellKey::new(vec![
("Region".into(), "North".into()),
("Month".into(), "Feb".into()),
("_Measure".into(), "Revenue".into()),
]);
let none_cats: Vec<String> = ["Product", "Region", "_Index", "Customer"]
.map(String::from)
.to_vec();
let fixed = |c: &str, i: &str| {
[
ViewStep::Axis {
category: c.into(),
axis: Axis::Filter,
},
ViewStep::PageSelection {
category: c.into(),
item: i.into(),
},
]
};
let mut expected = records_spine();
expected.extend(fixed("Month", "Feb"));
expected.extend(fixed("Region", "North"));
expected.extend(fixed("_Measure", "Revenue"));
expected.extend(["Product", "Customer"].map(|c| ViewStep::Axis {
category: c.into(),
axis: Axis::None,
}));
assert_eq!(drill_steps(&key, &none_cats), expected);
}
}
+33 -7
View File
@@ -1,14 +1,9 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::axis::Axis;
use crate::model::category::CategoryKind;
fn default_prune() -> bool {
true
}
/// Scratch views the app rebuilds on demand rather than persisting, because
/// they put the never-persisted `_Index`/`_Dim` on axes (improvise-60z).
pub const VIRTUAL_VIEW_NAMES: [&str; 2] = ["_Records", "_Drill"];
@@ -32,7 +27,7 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator<Item = &'a str>) -> Stri
.expect("a name is free below usize::MAX, there being fewer views")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone)]
pub struct View {
pub name: String,
/// Axis assignment for each category
@@ -46,10 +41,41 @@ pub struct View {
/// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String,
/// When true, empty rows/columns are pruned from the display.
#[serde(default = "default_prune")]
pub prune_empty: bool,
}
/// Sorted wherever the storage is a `HashMap`, so two equal views print alike;
/// destructured, so a new field is a compile error here (improvise-djcn).
impl std::fmt::Debug for View {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::collections::{BTreeMap, BTreeSet};
let View {
name,
category_axes,
page_selections,
hidden_items,
collapsed_groups,
number_format,
prune_empty,
} = self;
let sorted = |m: &HashMap<String, HashSet<String>>| -> BTreeMap<String, BTreeSet<String>> {
m.iter()
.map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
.collect()
};
let pages: BTreeMap<_, _> = page_selections.iter().collect();
f.debug_struct("View")
.field("name", name)
.field("category_axes", category_axes)
.field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items))
.field("collapsed_groups", &sorted(collapsed_groups))
.field("number_format", number_format)
.field("prune_empty", prune_empty)
.finish()
}
}
impl View {
/// True for the on-demand scratch views, rebuilt rather than persisted.
pub fn is_virtual(&self) -> bool {
+1 -2
View File
@@ -3,13 +3,12 @@
use anyhow::{Result, anyhow};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::model::Model;
use crate::model::category::{CategoryId, CategoryKind, ItemId};
use crate::view::{Axis, DEFAULT_VIEW, View};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Workbook {
pub model: Model,
pub views: IndexMap<String, View>,
+1
View File
@@ -10,6 +10,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
anyhow = "1"
pest = "2.9.2"
pest_derive = "2.9.2"
# For FormulaId alone, which crosses the wire (improvise-km71); no AST derive.
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
+48 -8
View File
@@ -1,6 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub enum AggFunc {
Sum,
Avg,
@@ -11,7 +9,7 @@ pub enum AggFunc {
/// Arithmetic and comparison operators. An enum rather than a `String`, so an
/// invalid operator is caught at parse time — design-principles §2.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
Add,
Sub,
@@ -26,13 +24,13 @@ pub enum BinOp {
Ge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Filter {
pub category: String,
pub item: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub enum Expr {
Number(f64),
Ref(String),
@@ -45,7 +43,7 @@ pub enum Expr {
Is(Filter),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone)]
pub struct Formula {
/// The raw formula text, e.g. "Profit = Revenue - Cost"
pub raw: String,
@@ -59,7 +57,6 @@ pub struct Formula {
pub filter: Option<Filter>,
/// How this formula's target is drawn, overriding the view's `format:` for
/// that one measure. `None` means the view answers — `context/code/core.md`.
#[serde(default)]
pub format: Option<String>,
}
@@ -87,4 +84,47 @@ impl Formula {
self.format = format;
self
}
/// Whether this formula's expression or its `WHERE` names `name`.
pub fn mentions(&self, name: &str) -> bool {
self.expr.mentions(name) || self.filter.as_ref().is_some_and(|f| f.names(name))
}
/// Which formula this is. The one constructor of a [`FormulaId`].
pub fn id(&self) -> FormulaId {
FormulaId {
target: self.target.clone(),
target_category: self.target_category.clone(),
}
}
}
/// Which formula an edit means, opaque so its content can change in one place:
/// today a target and its category — `context/code/core.md` §18.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FormulaId {
target: String,
target_category: String,
}
impl Expr {
/// Whether this expression names `name` anywhere, as a reference or as
/// either half of a coordinate filter.
pub fn mentions(&self, name: &str) -> bool {
match self {
Expr::Number(_) => false,
Expr::Ref(r) => r == name,
Expr::BinOp(_, l, r) => l.mentions(name) || r.mentions(name),
Expr::UnaryMinus(e) => e.mentions(name),
Expr::Agg(_, e, f) => e.mentions(name) || f.as_ref().is_some_and(|f| f.names(name)),
Expr::If(c, t, e) => c.mentions(name) || t.mentions(name) || e.mentions(name),
Expr::Is(f) => f.names(name),
}
}
}
impl Filter {
fn names(&self, name: &str) -> bool {
self.category == name || self.item == name
}
}
+1 -1
View File
@@ -2,6 +2,6 @@ pub mod ast;
pub mod parser;
pub mod quote;
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula};
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula, FormulaId};
pub use parser::parse_formula;
pub use quote::{is_bare_name, quote_name};
+4 -2
View File
@@ -13,11 +13,13 @@ anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
csv = "1"
flate2 = "1"
indexmap = { version = "2", features = ["serde"] }
indexmap = "2"
pest = "2.9.2"
pest_derive = "2.9.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses JSON a user's numbers cross, so it must read back the f64 written: per crate,
# because each build stands alone (improvise-6yvg, context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
# The browser backend. Only the wasm build pulls these in; the native
# binary never sees them (improvise-i34).
+36 -12
View File
@@ -1,4 +1,4 @@
//! `.improv` document lifecycle — save, load, gzip, legacy JSON — over a
//! `.improv` document lifecycle — save, load, gzip — over a
//! [`Storage`] backend. The text format is in `format`, `parse` and `export`.
mod export;
@@ -96,11 +96,16 @@ pub fn load_from(storage: &dyn Storage, path: &Path) -> Result<Workbook> {
};
let text =
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?;
// `.improv` text is the only native format; a JSON workbook is named, not
// handed to the grammar to fail on its first byte (improvise-tt22).
if text.trim_start().starts_with('{') {
serde_json::from_str(&text).context("Failed to deserialize workbook")
} else {
parse_md(&text)
anyhow::bail!(
"{} is a legacy JSON workbook, which is no longer supported: \
only .improv text can be opened",
path.display()
);
}
parse_md(&text)
}
pub fn autosave_path(path: &Path) -> std::path::PathBuf {
@@ -194,15 +199,34 @@ mod tests {
);
}
/// improvise-tt22: `.improv` text is the only native format, so a legacy
/// JSON workbook is refused by name. Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn load_dispatcher_detects_legacy_json_by_brace() {
// The load() function routes to JSON deserializer when text starts with '{'
let m = two_cat_model();
let json = serde_json::to_string_pretty(&m).unwrap();
assert!(json.trim_start().starts_with('{'), "sanity check");
// Deserialise via the JSON path
let m2: Workbook = serde_json::from_str(&json).unwrap();
assert_eq!(m2.model.name, "Budget");
fn a_legacy_json_workbook_is_refused_by_name() {
use crate::storage::{MemoryFs, Storage};
// The shape the removed loader read: `Workbook`'s serde form.
let legacy = r#"
{"model": {"name": "Budget", "categories": {}, "data": [], "formulas": []},
"views": {}, "active_view": "Default"}"#;
for name in ["legacy.improv", "legacy.improv.gz"] {
let fs = MemoryFs::new();
let path = std::path::Path::new(name);
let bytes = if name.ends_with(".gz") {
use std::io::Write;
let mut enc =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(legacy.as_bytes()).unwrap();
enc.finish().unwrap()
} else {
legacy.as_bytes().to_vec()
};
fs.write(path, &bytes).unwrap();
let err = format!("{:#}", super::load_from(&fs, path).unwrap_err());
assert!(
err.contains("legacy JSON") && err.contains("no longer supported"),
"{name}: expected the format named and refused, got {err:?}"
);
}
}
// ── save/load roundtrip via file ────────────────────────────────────
+2 -1
View File
@@ -14,7 +14,8 @@ serde = { version = "1", features = ["derive"] }
# `char`, and the TUI has always measured it this way.
unicode-width = "^0.2"
# Only for `variants`, which is off unless a test build asks for it.
serde_json = { version = "1", optional = true }
# float_roundtrip because every normal serde_json dep carries it (improvise-dz8c).
serde_json = { version = "1", optional = true, features = ["float_roundtrip"] }
[features]
# `variants` reaches OUT of this crate: improvise-command bounds its own
+17
View File
@@ -93,6 +93,23 @@ impl BasisCache {
}
}
impl BasisCache {
/// A basis for what was read EARLIER, such as when an editor opened: the
/// document and its records then, the layout now (improvise-0j0f, improvise-vndi).
pub fn claim_as_read(
&self,
document_generation: u64,
read: impl IntoIterator<Item = (RecordHash, u64)>,
) -> Option<Basis> {
let current = self.current.as_ref()?;
Some(Basis {
document_generation,
layout_version: current.layout_version,
records: read.into_iter().collect(),
})
}
}
impl WidgetCache for BasisCache {
type Update = Basis;
+3 -1
View File
@@ -172,7 +172,8 @@ impl ClientState {
.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);
self.view.buffers.insert(key, seed.clone());
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
}
}
@@ -280,6 +281,7 @@ mod tests {
])));
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
regular: true,
axis: Axis::Row,
}])));
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
+277 -91
View File
@@ -101,6 +101,7 @@ pub(crate) fn advertised(mode: &AppMode) -> Vec<(&'static str, Option<Vec<String
],
AppMode::FormulaPanel => vec![
("enter-mode", arg("formula-edit"), "new"),
("edit-formula-at-cursor", None, "edit"),
("delete-formula-at-cursor", None, "delete"),
("move-panel-cursor", None, "nav"),
("enter-mode", arg("normal"), "back"),
@@ -304,14 +305,11 @@ pub enum HelpLine {
Heading(&'static str),
Text(&'static str),
Accent(&'static str),
/// A keys-and-description row whose keys came off the keymap, so it
/// cannot go stale against the bindings it documents.
Keys {
keys: Vec<String>,
description: &'static str,
},
/// A row naming something the keymap does not own: an ex-command, or a
/// prefix sequence, which is two addresses and not one binding.
/// A keys-and-description row whose keys came off the keymap. A [`KeyRow`]
/// has no public constructor, so no builder can type one (improvise-v29m).
Keys(KeyRow),
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
/// colon is what stops a key being typed here (improvise-wk18).
Literal {
keys: &'static str,
description: &'static str,
@@ -326,38 +324,127 @@ pub struct HelpPage {
pub lines: Vec<HelpLine>,
}
/// A keys row, looked up in `mode`. An unbound command yields no keys and the
/// row is dropped, so the page never advertises a key that does not exist.
fn keys(
mode: &ModeKey,
command: &str,
args: Option<&[&str]>,
description: &'static str,
) -> Option<HelpLine> {
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
let found = keys_for(mode, command, args.as_deref());
match found.is_empty() {
true => None,
false => Some(HelpLine::Keys {
keys: found.iter().map(|(c, m)| key_label(c, *m)).collect(),
description,
}),
pub use key_row::KeyRow;
use key_row::keys;
/// The one door to a keys row: its fields are private to this module, and its
/// only constructor looks the keys up.
mod key_row {
use super::{HelpLine, ModeKey, key_label, keys_for, prefixed_labels};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyRow {
keys: Vec<String>,
description: &'static str,
}
impl KeyRow {
pub fn keys(&self) -> &[String] {
&self.keys
}
pub fn description(&self) -> &'static str {
self.description
}
/// An unbound command yields no keys and no row, so the page never
/// advertises a key that does not exist.
fn looked_up(
mode: &ModeKey,
command: &str,
args: Option<&[String]>,
description: &'static str,
) -> Option<Self> {
let mut keys: Vec<String> = keys_for(mode, command, args)
.iter()
.map(|(c, m)| key_label(c, *m))
.collect();
keys.extend(prefixed_labels(mode, command, args));
(!keys.is_empty()).then_some(Self { keys, description })
}
}
/// A keys row, looked up in `mode`: the one caller of [`KeyRow::looked_up`],
/// and the lookup `pages` hands every builder (improvise-ssoe).
pub(super) fn keys(
mode: &ModeKey,
command: &'static str,
args: Option<&[&str]>,
description: &'static str,
) -> Option<HelpLine> {
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
KeyRow::looked_up(mode, command, args.as_deref(), description).map(HelpLine::Keys)
}
}
fn literal(keys: &'static str, description: &'static str) -> HelpLine {
HelpLine::Literal {
keys,
description,
dim: false,
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Vec<String> {
let mut labels: Vec<String> = match Keymaps::shared().get(mode) {
Some(keymap) => keymap.prefixed_keys_running(command, args),
None => Vec::new(),
}
.iter()
.map(|[(c1, m1), (c2, m2)]| format!("{}{}", key_label(c1, *m1), key_label(c2, *m2)))
.collect();
labels.sort();
labels.dedup();
labels
}
fn dim(keys: &'static str, description: &'static str) -> HelpLine {
HelpLine::Literal {
keys,
description,
dim: true,
/// How a page asks for a keys row. `pages` answers with [`keys`]; a test
/// answers by recording the question, so a guard reads commands, not text.
type KeysRow<'a> =
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
/// An ex-command row. The colon is the macro's, and every word after the
/// command's name must be a placeholder, checked when the page compiles.
macro_rules! ex {
($text:literal, $description:expr $(,)?) => {{
const _: () = assert!(ex_usage_is_placeholders($text));
HelpLine::Literal {
keys: concat!(":", $text),
description: $description,
dim: false,
}
}};
(dim $text:literal, $description:expr $(,)?) => {{
const _: () = assert!(ex_usage_is_placeholders($text));
HelpLine::Literal {
keys: concat!(" :", $text),
description: $description,
dim: true,
}
}};
}
/// After the name, every word opens with `<`, `[` or `"`, or is `...`: so an
/// ex row cannot also advertise a key ("q or press Q"). Quotes span spaces.
const fn ex_usage_is_placeholders(text: &str) -> bool {
let b = text.as_bytes();
let mut i = 0;
while i < b.len() && b[i] != b' ' {
i += 1;
}
loop {
while i < b.len() && b[i] == b' ' {
i += 1;
}
if i == b.len() {
return true;
}
let start = i;
let mut quoted = false;
while i < b.len() && (quoted || b[i] != b' ') {
if b[i] == b'"' {
quoted = !quoted;
}
i += 1;
}
let ellipsis =
i - start == 3 && b[start] == b'.' && b[start + 1] == b'.' && b[start + 2] == b'.';
if !(ellipsis || b[start] == b'<' || b[start] == b'[' || b[start] == b'"') {
return false;
}
}
}
@@ -367,30 +454,48 @@ pub fn pages() -> Vec<HelpPage> {
let pages = vec![
HelpPage {
title: "Welcome",
lines: welcome(),
lines: welcome(&keys),
},
HelpPage {
title: "Navigation",
lines: navigation(),
lines: navigation(&keys),
},
HelpPage {
title: "Editing",
lines: editing(),
lines: editing(&keys),
},
HelpPage {
title: "Panels & Views",
lines: panels(),
lines: panels(&keys),
},
HelpPage {
title: "Commands",
lines: commands(),
lines: commands(&keys),
},
];
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
pages
}
fn welcome() -> Vec<HelpLine> {
/// Every keys row the pages ask for, as `(mode, command, args, description)`
/// — the same builders, so a guard over it cannot drift from what is drawn.
#[cfg(test)]
pub(crate) fn help_bindings() -> Vec<(ModeKey, &'static str, Option<Vec<String>>, &'static str)> {
let asked = std::cell::RefCell::new(Vec::new());
let record: KeysRow = &|mode, command, args, description| {
let args = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
asked
.borrow_mut()
.push((mode.clone(), command, args, description));
None
};
for build in [welcome, navigation, editing, panels, commands] {
build(record);
}
asked.into_inner()
}
fn welcome(_: KeysRow) -> Vec<HelpLine> {
vec![
HelpLine::Blank,
HelpLine::Banner(" improvise"),
@@ -427,12 +532,10 @@ fn welcome() -> Vec<HelpLine> {
),
HelpLine::Text(" Tile The row/column/page assignment of a category."),
HelpLine::Text(" Formula A computed item: derives its value from other items."),
HelpLine::Blank,
dim("Tip: press Tab or l/n to go to the next page.", ""),
]
}
fn navigation() -> Vec<HelpLine> {
fn navigation(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -445,7 +548,7 @@ fn navigation() -> Vec<HelpLine> {
None,
"Move down, wrapping to the next column",
),
Some(literal("gg", "Jump to first row")),
keys(n, "jump-first-row", None, "Jump to first row"),
keys(n, "jump-last-row", None, "Jump to last row"),
keys(n, "jump-first-col", None, "Jump to first column"),
keys(n, "jump-last-col", None, "Jump to last column"),
@@ -512,7 +615,7 @@ fn navigation() -> Vec<HelpLine> {
.collect()
}
fn editing() -> Vec<HelpLine> {
fn editing(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
let e = &ModeKey::Editing;
[
@@ -555,7 +658,7 @@ fn editing() -> Vec<HelpLine> {
Some(HelpLine::Blank),
Some(HelpLine::Heading("Copy and paste")),
Some(HelpLine::Blank),
Some(literal("yy", "Yank (copy) the current cell value")),
keys(n, "yank", None, "Yank (copy) the current cell value"),
keys(
n,
"paste",
@@ -593,7 +696,7 @@ fn editing() -> Vec<HelpLine> {
.collect()
}
fn panels() -> Vec<HelpLine> {
fn panels(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -618,6 +721,12 @@ fn panels() -> Vec<HelpLine> {
Some(&["formula-edit"]),
"New formula",
),
keys(
&ModeKey::FormulaPanel,
"edit-formula-at-cursor",
None,
"Edit selected formula",
),
keys(
&ModeKey::FormulaPanel,
"delete-formula-at-cursor",
@@ -741,14 +850,14 @@ fn panels() -> Vec<HelpLine> {
"Toggle collapse of nearest group above cursor",
),
keys(n, "hide-selected-row-item", None, "Hide current row item"),
Some(dim(" :show-item <cat> <item>", "Restore a hidden item")),
Some(ex!(dim "show-item <cat> <item>", "Restore a hidden item")),
]
.into_iter()
.flatten()
.collect()
}
fn commands() -> Vec<HelpLine> {
fn commands(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -763,55 +872,40 @@ fn commands() -> Vec<HelpLine> {
Some(HelpLine::Blank),
Some(HelpLine::Heading("File operations")),
Some(HelpLine::Blank),
Some(literal(
":w [path]",
"Save (path optional after first save)",
)),
Some(literal(":wq", "Save and quit")),
Some(literal(":q", "Quit (warns if unsaved changes)")),
Some(literal(":q!", "Force quit without saving")),
Some(literal("ZZ", "Save and quit (same as :wq)")),
Some(ex!("w [path]", "Save (path optional after first save)",)),
Some(ex!("wq", "Save and quit")),
Some(ex!("q", "Quit (warns if unsaved changes)")),
Some(ex!("q!", "Force quit without saving")),
keys(n, "wq", None, "Save and quit (same as :wq)"),
keys(n, "save", None, "Save (same as :w)"),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Import and export")),
Some(HelpLine::Blank),
Some(literal(":import <path>", "Open the JSON/CSV import wizard")),
Some(literal(
":export [path.csv]",
"Export the active view to CSV",
)),
Some(ex!("import <path>", "Open the JSON/CSV import wizard")),
Some(ex!("export [path.csv]", "Export the active view to CSV",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Model building")),
Some(HelpLine::Blank),
Some(literal(":add-cat <name>", "Add a new category")),
Some(literal(
":add-item <cat> <item>",
"Add one item to a category",
)),
Some(literal(
":add-items <cat> a b c ...",
Some(ex!("add-cat <name>", "Add a new category")),
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
Some(ex!(
"add-items <cat> <item> ...",
"Add multiple items at once",
)),
Some(literal(
":formula <cat> \"<Name> = <expr>\"",
"Add a formula",
)),
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Views")),
Some(HelpLine::Blank),
Some(literal(":add-view [name]", "Create a new view")),
Some(ex!("add-view [name]", "Create a new view")),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Display")),
Some(HelpLine::Blank),
Some(literal(
":set-format <fmt>",
"Set number format (e.g. ',.2')",
)),
Some(ex!("set-format <fmt>", "Set number format (e.g. ',.2')",)),
Some(HelpLine::Blank),
Some(HelpLine::Heading("Other keys")),
Some(HelpLine::Blank),
keys(n, "enter-mode", Some(&["help"]), "Open this help screen"),
Some(literal(":help", "Open this help screen")),
Some(ex!("help", "Open this help screen")),
]
.into_iter()
.flatten()
@@ -884,8 +978,12 @@ mod tests {
for page in &pages {
assert!(!page.title.is_empty());
for line in &page.lines {
if let HelpLine::Keys { keys, description } = line {
assert!(!keys.is_empty(), "{description} resolved to no keys");
if let HelpLine::Keys(row) = line {
assert!(
!row.keys().is_empty(),
"{} resolved to no keys",
row.description()
);
rows += 1;
}
}
@@ -896,6 +994,84 @@ mod tests {
);
}
/// improvise-wk18: a key the pages name is looked up, never typed. The only
/// hand-written row left is an ex-command. See `context/test-evidence/protocol.md`.
#[test]
fn no_help_row_spells_a_key_by_hand() {
let spelled: Vec<&str> = pages()
.iter()
.flat_map(|page| page.lines.iter())
.filter_map(|line| match line {
HelpLine::Literal { keys, .. } => Some(*keys),
_ => None,
})
.filter(|keys| !keys.trim_start().starts_with(':'))
.collect();
assert!(
spelled.is_empty(),
"these rows name keys the keymap never saw: {spelled:?}"
);
}
/// improvise-v29m: `ex!` refuses a usage with a bare word in it at compile
/// time; this pins the rule it asks. See `context/test-evidence/protocol.md`.
#[test]
fn an_ex_row_can_name_placeholders_and_nothing_else() {
for usage in [
"w [path]",
"q!",
"formula <cat> \"<Name> = <expr>\"",
"add-items <cat> <item> ...",
] {
assert!(ex_usage_is_placeholders(usage), "{usage} is refused");
}
for usage in ["q or press Q", "add-items <cat> a b c", "help ?"] {
assert!(!ex_usage_is_placeholders(usage), "{usage} is accepted");
}
}
/// improvise-ssoe: a keys row drawn but never asked for through the builders'
/// parameter is invisible to every guard. See `context/test-evidence/protocol.md`.
#[test]
fn every_keys_row_drawn_is_one_the_builders_asked_for() {
let drawn: Vec<HelpLine> = pages()
.into_iter()
.flat_map(|page| page.lines)
.filter(|line| matches!(line, HelpLine::Keys(_)))
.collect();
// Content, in order, not a count: a bypass that also drops a real row
// keeps the count and changes this (improvise-aszt).
let asked: Vec<HelpLine> = help_bindings()
.iter()
.filter_map(|(mode, command, args, description)| {
let args: Option<Vec<&str>> = args
.as_ref()
.map(|a| a.iter().map(String::as_str).collect());
keys(mode, command, args.as_deref(), description)
})
.collect();
assert_eq!(drawn, asked);
}
/// A row whose lookup finds no key is dropped silently, so a broken lookup
/// looks like a shorter page. See `context/test-evidence/protocol.md`.
#[test]
fn every_keys_row_the_pages_ask_for_finds_its_keys() {
let asked = help_bindings();
assert!(asked.len() > 20, "only {} rows asked", asked.len());
let dropped: Vec<String> = asked
.iter()
.filter(|(mode, command, args, description)| {
let args: Option<Vec<&str>> = args
.as_ref()
.map(|a| a.iter().map(String::as_str).collect());
keys(mode, command, args.as_deref(), description).is_none()
})
.map(|(mode, command, _, _)| format!("{mode:?} {command}"))
.collect();
assert!(dropped.is_empty(), "rows with no key: {dropped:?}");
}
/// `compact`'s three shapes, including the one no shipped mode reaches:
/// keys that are all chords leave nothing to draw.
#[test]
@@ -966,7 +1142,7 @@ mod tests {
.into_iter()
.flat_map(|page| page.lines)
.filter_map(|line| match line {
HelpLine::Keys { keys, description } => Some((keys, description)),
HelpLine::Keys(row) => Some((row.keys().to_vec(), row.description())),
_ => None,
})
.collect()
@@ -998,16 +1174,26 @@ mod tests {
/// 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()
let normal: Vec<_> = help_bindings()
.into_iter()
.filter(|(_, d)| d.contains("Edit current cell"))
.filter(|(mode, ..)| *mode == ModeKey::Normal)
.collect();
assert!(!claims.is_empty(), "the check needs a row to check");
for (keys, description) in claims {
// By COMMAND, not phrase (improvise-jhdf): any wording of "edit" on a
// Normal row that is not edit-or-drill's is the lie s8h7 removed.
let liars: Vec<_> = normal
.iter()
.filter(|(_, command, _, d)| {
d.to_lowercase().contains("edit") && *command != "edit-or-drill"
})
.collect();
assert!(
liars.is_empty(),
"captioned as editing but do not: {liars:?}"
);
for command in ["edit-or-drill", "enter-advance"] {
assert!(
keys.iter().all(|k| edit.contains(k)),
"{keys:?} is captioned {description:?} but only {edit:?} edit"
normal.iter().any(|(_, c, ..)| *c == command),
"{command} has no Normal help row, so the check above cannot see it"
);
}
}
@@ -1024,8 +1210,8 @@ mod tests {
println!("\n=== {} ===", page.title);
for line in &page.lines {
match line {
HelpLine::Keys { keys, description } => {
println!(" {:<28}{description}", keys.join(" "))
HelpLine::Keys(row) => {
println!(" {:<28}{}", row.keys().join(" "), row.description())
}
HelpLine::Literal {
keys, description, ..
+28
View File
@@ -235,6 +235,32 @@ impl Keymap {
keys
}
/// Every two-key sequence here or in a parent that runs `command`: a
/// prefix key, then a key of the sub-keymap it opens (flat, gotcha 13).
pub fn prefixed_keys_running(
&self,
command: &str,
args: Option<&[String]>,
) -> Vec<[(KeyCode, KeyModifiers); 2]> {
let mut keys: Vec<[(KeyCode, KeyModifiers); 2]> = self
.bindings
.iter()
.filter_map(|(pattern, binding)| match (pattern, binding) {
(KeyPattern::Key(code, mods), Binding::Prefix(sub)) => Some((*code, *mods, sub)),
_ => None,
})
.flat_map(|(code, mods, sub)| {
sub.keys_running(command, args)
.into_iter()
.map(move |second| [(code, mods), second])
})
.collect();
if let Some(parent) = &self.parent {
keys.extend(parent.prefixed_keys_running(command, args));
}
keys
}
/// Look up the binding for a key in this keymap's own bindings.
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
@@ -740,6 +766,8 @@ impl Keymaps {
"enter-mode",
vec!["formula-edit".into()],
)
.bind(KeyCode::Enter, "edit-formula-at-cursor")
.bind(KeyCode::Char('e'), "edit-formula-at-cursor")
.delete("delete-formula-at-cursor")
.finish(),
),
+36 -6
View File
@@ -30,13 +30,14 @@ pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view};
pub use render::{
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate,
MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry,
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
WizardProposal, WizardStepKind, header_level_widths,
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField,
WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, opening_tile,
};
pub use resolve::{
Click, KeyOutcome, Request, click, read_buffer, resolve, typed, typed_cell_command,
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
typed_cell_command, untouched,
};
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
// `improvise-core`'s, because the index that resolves one lives in
@@ -48,6 +49,9 @@ pub use improvise_core::view::layout::PageAxis;
// without depending on `improvise-core` directly.
pub use improvise_core::model::cell::CellValue;
// On the wire in `ModelCommand::ReplaceFormula` and in `AppMode::FormulaEdit`.
pub use improvise_core::formula::FormulaId;
/// Number of pages in the Help screen. Lives here with the `help_page` state
/// it bounds, so help-page effects stay terminal-independent.
pub const HELP_PAGE_COUNT: usize = 5;
@@ -139,6 +143,7 @@ pub enum AppMode {
},
FormulaEdit {
minibuf: MinibufferConfig,
slot: FormulaSlot,
},
FormulaPanel,
CategoryPanel,
@@ -176,6 +181,18 @@ pub enum AppMode {
},
}
/// What a formula editor commits to, captured when it OPENS and never
/// recomputed at commit — `context/code/protocol.md` §1 (improvise-km71).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FormulaSlot {
New,
/// Replace the formula `id` names, provided it still reads `was`.
Existing {
id: FormulaId,
was: String,
},
}
/// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode an
/// end holds must answer for the grid it holds — `context/code/protocol.md`.
pub fn settled_mode(mode: &AppMode, records: bool) -> AppMode {
@@ -333,12 +350,22 @@ impl AppMode {
}
pub fn formula_edit() -> Self {
Self::formula_editor("formula: ", FormulaSlot::New)
}
/// The editor opened on an existing formula, which it names from here on.
pub fn formula_replace(id: FormulaId, was: String) -> Self {
Self::formula_editor("edit formula: ", FormulaSlot::Existing { id, was })
}
fn formula_editor(prompt: &str, slot: FormulaSlot) -> Self {
Self::FormulaEdit {
minibuf: MinibufferConfig {
buffer_key: "formula".into(),
prompt: "formula: ".into(),
prompt: prompt.into(),
color: MinibufColor::Cyan,
},
slot,
}
}
@@ -433,6 +460,8 @@ pub struct ViewState {
/// The prefix key currently armed (g→gg, y→yy, Z→ZZ). The key that opened
/// it, not the sub-keymap it names — gotcha 13, improvise-jc8.
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
/// What the open cell editor captured when it opened (improvise-ixxx).
pub opened_editor: Option<OpenedEditor>,
}
impl Default for ViewState {
@@ -458,6 +487,7 @@ impl Default for ViewState {
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_prefix: None,
opened_editor: None,
}
}
}
+20 -2
View File
@@ -10,7 +10,8 @@ use improvise_core::view::Axis;
use crate::basis::Basis;
use crate::render::{Projection, RecordCell};
use crate::{AppMode, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
use crate::resolve::OpenedEditor;
use crate::{AppMode, FormulaId, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
use improvise_core::model::record::RecordHash;
/// One message on the wire, in either direction.
@@ -39,6 +40,9 @@ pub enum Command {
value: String,
},
SetTileCatIdx(usize),
/// What a cell editor captured as it opened, or `None` for one opened
/// with nothing to compare against (improvise-ixxx).
SetOpenedEditor(Option<OpenedEditor>),
SetPanelOpen {
panel: Panel,
open: bool,
@@ -75,7 +79,7 @@ pub enum Command {
Project(Projection),
}
/// A mutation of the shared document: 24 variants, derived not invented (§2). A
/// A mutation of the shared document: 25 variants, derived not invented (§2). A
/// view-configuration variant **names its view** (improvise-lltt).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ModelCommand {
@@ -107,6 +111,13 @@ pub enum ModelCommand {
target: String,
target_category: String,
},
/// Edit the formula `id` names in place, if it still reads `was` — an edit
/// of that formula, not a remove-then-add (improvise-km71).
ReplaceFormula {
id: FormulaId,
was: String,
raw: String,
},
CleanEmptyRecords,
SortData,
@@ -176,6 +187,7 @@ pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
| ModelCommand::RemoveItem { .. }
| ModelCommand::AddFormula { .. }
| ModelCommand::RemoveFormula { .. }
| ModelCommand::ReplaceFormula { .. }
| ModelCommand::CleanEmptyRecords
| ModelCommand::SortData
| ModelCommand::AddCategory(_)
@@ -268,6 +280,7 @@ pub fn reduce_view(view: &mut ViewState, cmd: &Command) {
view.buffers.insert(name.clone(), value.clone());
}
Command::SetTileCatIdx(i) => view.tile_cat_idx = *i,
Command::SetOpenedEditor(opened) => view.opened_editor = opened.clone(),
Command::SetPanelOpen { panel, open } => *view.panel_open_mut(*panel) = *open,
Command::SetPanelCursor { panel, cursor } => *view.panel_cursor_mut(*panel) = *cursor,
Command::ToggleCatExpand(cat) => {
@@ -499,6 +512,7 @@ mod tests {
}])),
Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
regular: true,
axis: Axis::Page,
}])),
// One populated value per payload family: `RecordCell`, `Axis`,
@@ -587,6 +601,9 @@ mod tests {
fn surrogate(_: &RecordHash) -> usize {
1
}
fn formula(_: &FormulaId) -> usize {
1
}
fn record(RecordCell { record, measure }: &RecordCell) -> usize {
surrogate(record) + optional_name(measure)
}
@@ -621,6 +638,7 @@ mod tests {
target,
target_category,
} => name(target) + name(target_category),
ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw),
ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0,
ModelCommand::AddCategory(category)
| ModelCommand::RemoveCategory(category)
+138 -30
View File
@@ -9,7 +9,8 @@ use improvise_core::model::record::{AggregateHash, RecordHash};
use improvise_core::view::Axis;
use improvise_core::view::layout::PageAxis;
use crate::ViewState;
use crate::{FormulaId, ViewState};
use improvise_core::formula::Formula;
/// A widget's local copy of what it draws. **Total and deterministic**: an
/// update naming something absent is dropped, never a panic.
@@ -55,6 +56,9 @@ pub enum CellId {
/// A records-mode creating column: typing a **name** into it adds that
/// measure or category and writes no cell at all (gotcha 1, improvise-poia).
Create(CreateKind),
/// A records-mode coordinate. Renaming it moves every measure cell in the
/// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg).
Coordinate,
}
/// What a creating column creates. The name typed into it is the whole
@@ -67,7 +71,7 @@ pub enum CreateKind {
/// One cell as the client draws it. Drawn from `text`, never from `value` — the
/// formatting has already happened, and re-deriving it is how ends drift.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CellDisplay {
pub text: String,
/// Right-align numerics, left-align text — decided by the server, which
@@ -82,7 +86,7 @@ pub struct CellDisplay {
/// **typed** it, not as it is drawn. The difference corrupts data (§3).
pub edit_text: Option<String>,
/// The value the host evaluated here, unrounded and untyped-by-text: what
/// `yank` copies. Neither `text` nor `edit_text` can be re-parsed into it (§3).
/// `yank` copies. `text` cannot be re-parsed into it, nor can text like `007` (§3).
pub value: Option<CellValue>,
}
@@ -127,6 +131,43 @@ impl CellDisplay {
}
}
/// Equal when the client could not tell them apart, so a NaN equals itself and
/// the diff quiesces (improvise-4w09) — `context/code/protocol.md` §3.
impl PartialEq for CellDisplay {
fn eq(&self, other: &Self) -> bool {
let CellDisplay {
text,
numeric,
error,
id,
edit_text,
value,
} = self;
*text == other.text
&& *numeric == other.numeric
&& *error == other.error
&& *id == other.id
&& *edit_text == other.edit_text
&& match (value, &other.value) {
(Some(a), Some(b)) => same_on_the_wire(a, b),
(a, b) => a.is_none() && b.is_none(),
}
}
}
impl Eq for CellDisplay {}
/// Every NaN is one token on the wire; any other number is its bits, so `0.0`
/// and `-0.0` differ. An equivalence relation, which `f64`'s `==` is not.
fn same_on_the_wire(a: &CellValue, b: &CellValue) -> bool {
match (a, b) {
(CellValue::Number(a), CellValue::Number(b)) => {
a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())
}
(a, b) => a == b,
}
}
/// What a title bar draws: facts about the **document**, shared by every
/// client. Why a cache and not a `ViewState` field: `context/code/protocol.md` §3.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -157,9 +198,6 @@ pub const MAX_ROW_HEADER_W: u16 = 24;
/// because it is a fact about the *view*, which only the server holds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameGroup {
/// The category the group belongs to, echoed back in `ToggleGroup` so a
/// client never works out which category a label came from.
pub cat: String,
pub name: String,
pub collapsed: bool,
}
@@ -422,7 +460,7 @@ fn shows(items: &[&Vec<String>], idx: usize, first_rendered: usize, level: usize
/// The grid widget's copy of the visible cells (improvise-n10), indexed by
/// position within the **viewport** and never by `CellKey`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GridCache {
/// The row axis's frame: levels, label tuples and groups. **Not** a list
/// of strings — see [`AxisFrame`].
@@ -569,7 +607,7 @@ enum LabelAxis {
/// What can change about the grid: cell values, the frames that name them,
/// the widths, the totals, the page bar and the extent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GridUpdate {
/// Display values for cells, by viewport position.
Cells(Vec<((usize, usize), CellDisplay)>),
@@ -691,17 +729,42 @@ impl WidgetCache for CategoryTreeCache {
}
}
/// The formula panel's copy of the formula list (improvise-pca). One
/// mutation: add and delete are the only effects, and both send whole.
/// The formula panel's copy of the formula list (improvise-pca). Every
/// formula effect sends it whole.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormulaCache {
pub formulas: Vec<String>,
pub formulas: Vec<FormulaEntry>,
}
/// One formula as a client holds it: its text, and the identity an edit names
/// it by, which the text alone cannot give a client (improvise-km71).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormulaEntry {
pub id: FormulaId,
pub raw: String,
}
impl From<&Formula> for FormulaEntry {
fn from(f: &Formula) -> Self {
Self {
id: f.id(),
raw: f.raw.clone(),
}
}
}
/// A `_Measure` formula's entry, identified exactly as the host identifies it.
#[cfg(any(test, feature = "test-support"))]
impl From<&str> for FormulaEntry {
fn from(raw: &str) -> Self {
Self::from(&improvise_core::formula::parse_formula(raw, "_Measure").expect(raw))
}
}
impl WidgetCache for FormulaCache {
type Update = Vec<String>;
type Update = Vec<FormulaEntry>;
fn apply(&mut self, update: &Vec<String>) {
fn apply(&mut self, update: &Vec<FormulaEntry>) {
self.formulas = update.clone();
}
}
@@ -730,11 +793,19 @@ impl WidgetCache for ViewListCache {
}
}
/// One tile in the tile bar.
/// One tile in the tile bar. `regular` is the host's `CategoryKind` answer: a
/// client cannot tell `_Index` from a user category without sniffing names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileEntry {
pub category: String,
pub axis: Axis,
pub regular: bool,
}
/// Where tile select opens: the first regular category, so `T Enter` cannot
/// put `_Index` alone on Row (improvise-oplp). Virtuals stay reachable.
pub fn opening_tile(tiles: &[TileEntry]) -> usize {
tiles.iter().position(|tile| tile.regular).unwrap_or(0)
}
/// The tile bar's tileable categories and axes (improvise-pca). Its overlap
@@ -837,8 +908,8 @@ pub struct WizardCache {
pub enum Projection {
Grid(GridUpdate),
CategoryTree(CategoryTreeUpdate),
/// The raw text of every formula.
Formulas(Vec<String>),
/// Every formula: its text and its identity.
Formulas(Vec<FormulaEntry>),
ViewList(Vec<ViewSummary>),
TileBar(Vec<TileEntry>),
/// Name, dirty marker and where the host keeps it (improvise-z754).
@@ -860,6 +931,45 @@ pub struct Snapshot {
mod tests {
use super::*;
fn valued(value: CellValue) -> CellDisplay {
CellDisplay {
value: Some(value),
..CellDisplay::number("x")
}
}
/// Cells are equal when the wire cannot tell them apart: every NaN is one
/// token, and `-0.0` is not `0.0`. Evidence file.
#[test]
fn a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart() {
let host_nan = f64::from_bits(f64::NAN.to_bits() | (1 << 63));
assert_ne!(
host_nan.to_bits(),
f64::NAN.to_bits(),
"precondition: two NaNs whose bits differ"
);
assert_eq!(
valued(CellValue::Number(host_nan)),
valued(CellValue::Number(f64::NAN)),
"a NaN the host computed and the NaN the client parsed are one value"
);
assert_ne!(
valued(CellValue::Number(0.0)),
valued(CellValue::Number(-0.0)),
"the wire spells them differently, so a change between them is sent"
);
assert_ne!(
valued(CellValue::Number(7.0)),
valued(CellValue::Text("7".into())),
"a number and number-like text are different values"
);
assert_ne!(
valued(CellValue::Number(1.0)),
CellDisplay::number("x"),
"a value and no value differ"
);
}
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
CategoryRow {
name: name.into(),
@@ -1061,7 +1171,6 @@ mod tests {
FrameEntry::Item(vec!["A".into(), "x".into()]),
FrameEntry::Item(vec!["A".into(), "y".into()]),
FrameEntry::Group(FrameGroup {
cat: "Type".into(),
name: "Late".into(),
collapsed: true,
}),
@@ -1074,7 +1183,6 @@ mod tests {
/// ungrouped between them — improvise-h0iv's reachable shape.
fn repeated_group_frame() -> AxisFrame {
let q1 = || FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: false,
};
@@ -1100,7 +1208,6 @@ mod tests {
cats: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: false,
}),
@@ -1138,7 +1245,6 @@ mod tests {
cats: vec!["Month".into()],
entries: vec![
FrameEntry::Group(FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: true,
}),
@@ -1190,7 +1296,7 @@ mod tests {
/// The pair `FrameGroup` carries cannot separate them — h0iv's own proposed
/// fix. See `context/test-evidence/protocol.md`.
#[test]
fn the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them() {
fn the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them() {
let frame = repeated_group_frame();
let headers: Vec<&FrameGroup> = frame
.entries
@@ -1201,10 +1307,9 @@ mod tests {
})
.collect();
assert_eq!(
(&headers[0].cat, &headers[0].name),
(&headers[1].cat, &headers[1].name),
"both headers name the same category AND the same group, so the \
ticket's (cat, name) comparison would still merge them"
headers[0], headers[1],
"both headers are the same group in the same state, so no \
comparison of their fields can split them"
);
}
@@ -1268,7 +1373,7 @@ mod tests {
assert_eq!(frame.item(2), Some(&vec!["B".to_string(), "z".to_string()]));
assert!(frame.group_of(0).is_none(), "[A,x] is in no group");
let group = frame.group_of(2).expect("[B,z] sits under Late");
assert_eq!((group.cat.as_str(), group.name.as_str()), ("Type", "Late"));
assert_eq!(group.name, "Late");
assert!(group.collapsed, "the collapse state travels with the group");
}
@@ -1411,10 +1516,10 @@ mod tests {
#[test]
fn the_list_caches_hold_the_last_update() {
let formulas = FormulaCache::folded(&[
vec!["Profit = Revenue - Cost".to_string()],
vec!["Profit = Revenue - Cost".into()],
vec![
"Profit = Revenue - Cost".to_string(),
"Tax = Revenue * 0.1".to_string(),
"Profit = Revenue - Cost".into(),
"Tax = Revenue * 0.1".into(),
],
]);
assert_eq!(formulas.formulas.len(), 2);
@@ -1428,6 +1533,7 @@ mod tests {
let tiles = TileBarCache::folded(&[vec![TileEntry {
category: "Region".into(),
axis: Axis::Page,
regular: true,
}]]);
assert_eq!(tiles.tiles[0].axis, Axis::Page);
}
@@ -1771,7 +1877,7 @@ mod tests {
assert_eq!(booted, (grid, formulas));
assert_eq!(booted.0.cell(0, 0).unwrap().text, "5");
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1"]);
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1".into()]);
}
/// Two subscribers, each folding only what is addressed to it — the
@@ -1825,6 +1931,7 @@ mod tests {
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
axis: Axis::Page,
regular: true,
}]),
];
for projection in &projections {
@@ -1861,6 +1968,7 @@ mod tests {
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
axis: Axis::Page,
regular: true,
}]),
],
};
File diff suppressed because it is too large Load Diff
+417 -75
View File
@@ -11,28 +11,28 @@ pub fn production_code(roots: &[PathBuf]) -> Vec<(PathBuf, String)> {
for root in roots {
collect_rust(root, &mut files);
}
let code: Vec<(PathBuf, String)> = files
let read: Vec<(PathBuf, Source)> = files
.into_iter()
.map(|file| {
let text = std::fs::read_to_string(&file).expect("a listed source reads");
let code = code_only(&text);
(file, code)
let source = Source::of(&text, &file);
(file, source)
})
.collect();
let test_modules: BTreeSet<PathBuf> = code
let test_modules: BTreeSet<PathBuf> = read
.iter()
.flat_map(|(file, code)| test_module_paths(file, code))
.flat_map(|(file, source)| test_module_paths(file, source))
.collect();
code.into_iter()
read.into_iter()
.filter(|(file, _)| !test_modules.iter().any(|m| is_module_file(m, file)))
.map(|(file, code)| (file, strip_test_items(&code)))
.map(|(file, source)| (file, source.production()))
.collect()
}
/// One file's text as [`production_code`] cleans it. Which *files* are test
/// modules needs their declaring file too, so only `production_code` knows.
pub fn production_text(text: &str) -> String {
strip_test_items(&code_only(text))
Source::of(text, Path::new("<text>")).production()
}
fn collect_rust(dir: &Path, into: &mut Vec<PathBuf>) {
@@ -55,83 +55,319 @@ fn is_module_file(m: &Path, file: &Path) -> bool {
file == m.with_extension("rs") || file.starts_with(m)
}
/// One file, read once. Its `cfg` predicates are read off the TEXT, whose
/// `feature` values are literals; every bracket off the code (improvise-59q8).
struct Source {
code: Vec<char>,
/// Test-only items, as `(attribute start, item start, item end)`.
items: Vec<(usize, usize, usize)>,
/// A leading `#![cfg(test)]`: the whole file is test code.
whole_file: bool,
}
impl Source {
fn of(text: &str, file: &Path) -> Self {
let code: Vec<char> = code_only(text).chars().collect();
let text: Vec<char> = text.chars().collect();
let (items, whole_file) = test_only_items(&text, &code, file);
Self {
code,
items,
whole_file,
}
}
/// The code with every test-only item removed.
fn production(&self) -> String {
if self.whole_file {
return String::new();
}
let mut kept = String::with_capacity(self.code.len());
let mut at = 0;
for &(start, _, end) in &self.items {
kept.extend(&self.code[at..start]);
at = end;
}
kept.extend(&self.code[at..]);
kept
}
fn item_code(&self, start: usize, end: usize) -> String {
self.code[start..end].iter().collect()
}
}
/// The out-of-line modules `file` declares under a test-only `cfg`, as paths
/// without extension (`#[cfg(test)] mod x;` is `<dir>/x`).
fn test_module_paths(file: &Path, code: &str) -> Vec<PathBuf> {
fn test_module_paths(file: &Path, source: &Source) -> Vec<PathBuf> {
let dir = match file.file_stem().and_then(|s| s.to_str()) {
Some("lib" | "main" | "mod") => file.parent().map(Path::to_path_buf),
Some(stem) => file.parent().map(|p| p.join(stem)),
None => None,
}
.expect("a source file has a directory and a stem");
let lines: Vec<&str> = code.lines().map(str::trim).collect();
let mut found = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !is_test_cfg(line) {
continue;
}
let item = lines[i + 1..]
.iter()
.find(|l| !l.starts_with("#[") && !l.is_empty());
if let Some(name) = item.and_then(|l| out_of_line_mod(l)) {
found.push(dir.join(name));
}
}
found
source
.items
.iter()
.filter_map(|&(_, start, end)| out_of_line_mod(source.item_code(start, end).trim()))
.map(|name| dir.join(name))
.collect()
}
/// `mod x;`, `pub mod x;`, `pub(crate) mod x;` → `x`.
fn out_of_line_mod(line: &str) -> Option<&str> {
let rest = line.split_once("mod ")?.1.strip_suffix(';')?;
let prefix = line.split_once("mod ")?.0.trim();
(prefix.is_empty() || prefix.starts_with("pub")).then_some(rest.trim())
fn out_of_line_mod(item: &str) -> Option<String> {
let (prefix, rest) = item.split_once("mod ")?;
let name = rest.strip_suffix(';')?.trim();
let prefix = prefix.trim();
(prefix.is_empty() || prefix.starts_with("pub")).then(|| name.to_string())
}
/// A `cfg` attribute that compiles its item only under `test`. `cfg(not(test))`
/// is production, and `"test-support"` is a literal, already blanked.
fn is_test_cfg(line: &str) -> bool {
let line = line.trim();
line.starts_with("#[cfg(")
&& !line.contains("not(")
&& line
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| word == "test")
/// Every test-only item in a file, bounded by balanced brackets on the code
/// rather than by indentation. An item it cannot bound is a panic, never EOF.
fn test_only_items(
text: &[char],
code: &[char],
file: &Path,
) -> (Vec<(usize, usize, usize)>, bool) {
let mut items = Vec::new();
let mut k = 0;
while k < code.len() {
let Some((inner, end)) = attribute_at(code, k) else {
k += 1;
continue;
};
let predicate: String = text[k..end].iter().collect();
if !is_test_only(&predicate) {
k = end;
continue;
}
if inner {
// An inner attribute heads the file or it heads an inline module;
// only the first is a shape this scan can honour.
let leading = code[..k].iter().all(|c| c.is_whitespace());
assert!(
leading,
"{}: an inner test-only cfg could not be bounded",
file.display()
);
return (Vec::new(), true);
}
let start = past_attributes(code, end);
let item_end = item_end(code, start).unwrap_or_else(|| {
panic!(
"{}: a test-only item at char {k} could not be bounded",
file.display()
)
});
items.push((k, start, item_end));
k = item_end;
}
(items, false)
}
/// Drop every test-only item. rustfmt puts an item's closing brace at the
/// attribute's own indentation, which is what makes this a line scan (§2).
fn strip_test_items(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut kept = String::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
match is_test_cfg(line) {
false => {
kept.push_str(line);
kept.push('\n');
i += 1;
}
true => {
let indent = line.len() - line.trim_start().len();
let close = format!("{}}}", " ".repeat(indent));
i += 1;
while i < lines.len() && lines[i].trim_start().starts_with("#[") {
i += 1;
}
match lines.get(i).is_some_and(|l| l.trim_end().ends_with(';')) {
true => i += 1,
false => {
while i < lines.len() && lines[i].trim_end() != close {
i += 1;
}
i += 1;
}
/// An attribute starting at `k`: whether it is inner (`#!`) and where it ends.
fn attribute_at(code: &[char], k: usize) -> Option<(bool, usize)> {
if code[k] != '#' || (k > 0 && (code[k - 1].is_alphanumeric() || code[k - 1] == '_')) {
return None;
}
let inner = code.get(k + 1) == Some(&'!');
let open = k + 1 + usize::from(inner);
(code.get(open) == Some(&'[')).then(|| matching(code, open).map(|close| (inner, close + 1)))?
}
/// The index of the bracket closing the one at `open`, counting all three kinds.
fn matching(code: &[char], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (k, &c) in code.iter().enumerate().skip(open) {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => {
depth -= 1;
if depth == 0 {
return Some(k);
}
}
_ => {}
}
}
kept
None
}
/// Skip whitespace and any further outer attributes on the same item.
fn past_attributes(code: &[char], mut k: usize) -> usize {
loop {
while code.get(k).is_some_and(|c| c.is_whitespace()) {
k += 1;
}
let next = (k < code.len()).then(|| attribute_at(code, k)).flatten();
match next {
Some((false, end)) => k = end,
_ => return k,
}
}
}
/// Where the item starting at `start` ends: a `;` at depth zero, or the `}`
/// closing its body. `None` at the end of the file, which is not an end.
fn item_end(code: &[char], start: usize) -> Option<usize> {
let mut depth = 0usize;
let mut k = start;
while k < code.len() {
match code[k] {
'(' | '[' | '{' => depth += 1,
';' if depth == 0 => return Some(k + 1),
// The enclosing block closed first: the item was its last expression.
')' | ']' | '}' if depth == 0 => return Some(k),
')' | ']' => depth -= 1,
'}' => {
depth -= 1;
if depth == 0 && !continues(code, k + 1) {
return Some(k + 1);
}
}
_ => {}
}
k += 1;
}
None
}
/// Whether the item goes on after a `}` at depth zero: `S { .. };`, a method
/// call on a block, or an `if` with an `else`.
fn continues(code: &[char], after: usize) -> bool {
let rest: String = code[after..]
.iter()
.skip_while(|c| c.is_whitespace())
.take(5)
.collect();
rest.starts_with([';', '.', '?']) || rest.starts_with("else")
}
/// A `cfg` attribute whose predicate no shipped build satisfies. Evaluated,
/// not substring-matched (improvise-59q8): `context/code/protocol.md` §2.
fn is_test_only(attribute: &str) -> bool {
let tokens = cfg_tokens(attribute);
match tokens.as_slice() {
[Token::Word(cfg), Token::Open, rest @ ..] if cfg == "cfg" => {
let mut at = 0;
predicate(rest, &mut at) == Truth::No
}
_ => false,
}
}
/// What a `cfg` predicate is in a build without `test`. `Unknown` for what a
/// source scan cannot know, such as a target or an ordinary feature.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Truth {
Yes,
No,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Word(String),
Str(String),
Open,
Close,
Comma,
Equals,
}
/// The tokens of an attribute's text, from its first word: `#`, `!` and the
/// brackets around it are dropped.
fn cfg_tokens(attribute: &str) -> Vec<Token> {
let inner = attribute
.trim_start_matches('#')
.trim_start_matches('!')
.trim()
.trim_start_matches('[')
.trim_end()
.trim_end_matches(']');
let chars: Vec<char> = inner.chars().collect();
let mut tokens = Vec::new();
let mut k = 0;
while k < chars.len() {
let c = chars[k];
match c {
'(' => tokens.push(Token::Open),
')' => tokens.push(Token::Close),
',' => tokens.push(Token::Comma),
'=' => tokens.push(Token::Equals),
'"' => {
let end = quoted_end(&chars, k + 1, '"');
tokens.push(Token::Str(chars[k + 1..end - 1].iter().collect()));
k = end;
continue;
}
c if c.is_alphanumeric() || c == '_' => {
let word: String = chars[k..]
.iter()
.take_while(|c| c.is_alphanumeric() || **c == '_')
.collect();
k += word.chars().count();
tokens.push(Token::Word(word));
continue;
}
_ => {}
}
k += 1;
}
tokens
}
/// One predicate, advancing `at` past it. `test` is false; so is the
/// `test-support` feature, which this repo never ships (improvise-26dm).
fn predicate(tokens: &[Token], at: &mut usize) -> Truth {
let Some(Token::Word(word)) = tokens.get(*at) else {
return Truth::Unknown;
};
*at += 1;
match (word.as_str(), tokens.get(*at)) {
(op @ ("all" | "any" | "not"), Some(Token::Open)) => {
*at += 1;
let mut args = Vec::new();
while !matches!(tokens.get(*at), Some(Token::Close) | None) {
args.push(predicate(tokens, at));
if tokens.get(*at) == Some(&Token::Comma) {
*at += 1;
}
}
*at += 1;
combine(op, &args)
}
(key, Some(Token::Equals)) => {
*at += 1;
let value = match tokens.get(*at) {
Some(Token::Str(v)) => v.clone(),
_ => String::new(),
};
*at += 1;
match (key, value.as_str()) {
("feature", "test-support") => Truth::No,
_ => Truth::Unknown,
}
}
("test", _) => Truth::No,
_ => Truth::Unknown,
}
}
/// Kleene's three-valued `all`, `any` and `not`.
fn combine(op: &str, args: &[Truth]) -> Truth {
match op {
"all" if args.contains(&Truth::No) => Truth::No,
"all" if args.iter().all(|a| *a == Truth::Yes) => Truth::Yes,
"any" if args.contains(&Truth::Yes) => Truth::Yes,
"any" if args.iter().all(|a| *a == Truth::No) => Truth::No,
"not" => match args.first() {
Some(Truth::Yes) => Truth::No,
Some(Truth::No) => Truth::Yes,
_ => Truth::Unknown,
},
_ => Truth::Unknown,
}
}
/// `text` with comments, string, byte-string, raw-string and char literals
@@ -166,8 +402,8 @@ fn literal_or_comment_end(c: &[char], i: usize) -> Option<usize> {
(Some('/'), Some('/')) => Some(line_end(c, i)),
(Some('/'), Some('*')) => Some(block_end(c, i)),
(Some('"'), _) => Some(quoted_end(c, i + 1, '"')),
(Some('b'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
(Some('r' | 'b'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
(Some('b' | 'c'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
(Some('r' | 'b' | 'c'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
(Some('\''), _) => char_end(c, i),
_ => None,
}
@@ -211,9 +447,10 @@ fn quoted_end(c: &[char], mut k: usize, quote: char) -> usize {
c.len()
}
/// `r"…"`, `r#"…"#`, `br#"…"#`. `None` when the `r` is only an identifier.
/// `r"…"`, `r#"…"#`, `br#"…"#`, `cr#"…"#`. `None` when the `r` is only an
/// identifier.
fn raw_end(c: &[char], i: usize) -> Option<usize> {
let mut k = i + usize::from(c[i] == 'b');
let mut k = i + usize::from(matches!(c[i], 'b' | 'c'));
if c.get(k) != Some(&'r') {
return None;
}
@@ -283,21 +520,20 @@ mod tests {
"}\n",
"#[cfg(test)]\n",
"mod out_of_line;\n",
"#[cfg(any(test, feature = \"x\"))]\n",
"#[cfg(any(test, feature = \"test-support\"))]\n",
"pub mod gated;\n",
"#[cfg(not(test))]\n",
"fn production_only() {}\n",
"fn also_kept() {}\n",
);
let code = code_only(text);
let stripped = strip_test_items(&code);
let stripped = production_text(text);
for kept in ["fn kept", "fn production_only", "fn also_kept"] {
assert!(stripped.contains(kept), "{kept} was dropped: {stripped}");
}
assert!(!stripped.contains("gone_inline"), "{stripped}");
let lib = Path::new("/c/src/lib.rs");
assert_eq!(
test_module_paths(lib, &code),
test_module_paths(lib, &Source::of(text, lib)),
vec![
PathBuf::from("/c/src/out_of_line"),
PathBuf::from("/c/src/gated")
@@ -305,7 +541,7 @@ mod tests {
);
let nested = Path::new("/c/src/ui/reduce.rs");
assert_eq!(
test_module_paths(nested, &code)[0],
test_module_paths(nested, &Source::of(text, nested))[0],
PathBuf::from("/c/src/ui/reduce/out_of_line")
);
assert!(is_module_file(
@@ -322,6 +558,112 @@ mod tests {
));
}
/// improvise-59q8, the shapes that HID production code: a test-only item
/// whose end the line scan could not find. Evidence: test-evidence/protocol.md.
#[test]
fn a_test_only_item_hides_nothing_after_it() {
let cases = [
(
"a multi-line item ending in `];`",
concat!(
"impl X {\n",
" #[cfg(test)]\n",
" const NAMES: &[&str] = &[\n",
" \"a\",\n",
" ];\n",
" fn prod() { let c = Command::Hidden1(1); }\n",
"}\n",
"fn after() { Command::Visible(2) }\n",
),
),
(
"a multi-line statement ending in `);`",
concat!(
"fn f() {\n",
" #[cfg(test)]\n",
" let x = foo(\n",
" 1,\n",
" );\n",
" let c = Command::Hidden1(1);\n",
"}\n",
"fn after() { Command::Visible(2) }\n",
),
),
(
"a one-line `#[cfg(test)] use`",
concat!(
"#[cfg(test)] use foo::bar;\n",
"fn prod() { Command::Hidden1(1) }\n",
"fn after() { Command::Visible(2) }\n",
),
),
];
for (what, text) in cases {
let kept = production_text(text);
for name in ["Hidden1", "Visible"] {
assert!(kept.contains(name), "{what}: {name} was hidden: {kept:?}");
}
}
}
/// improvise-59q8, the shapes that COUNTED test code as production.
#[test]
fn a_test_only_cfg_is_recognised_whatever_its_spelling() {
let cases = [
(
"`all(test, not(..))`",
"#[cfg(all(test, not(target_arch = \"wasm32\")))]\nfn t() { Command::TestOnly(1) }\n",
),
(
"an inner `#![cfg(test)]` heading the file",
"#![cfg(test)]\nfn t() { Command::TestOnly(1) }\n",
),
(
"an attribute split across lines",
"#[cfg(\n test\n)]\nfn t() { Command::TestOnly(1) }\n",
),
];
for (what, text) in cases {
let kept = production_text(text);
assert!(
!kept.contains("TestOnly"),
"{what} was read as production: {kept:?}"
);
}
// The controls: a predicate a shipped build can satisfy is production.
for text in [
"#[cfg(not(test))]\nfn p() { Command::Shipped(1) }\n",
"#[cfg(any(test, feature = \"serde\"))]\nfn p() { Command::Shipped(1) }\n",
"#[cfg(target_arch = \"wasm32\")]\nfn p() { Command::Shipped(1) }\n",
] {
assert!(
production_text(text).contains("Shipped"),
"{text:?} was dropped"
);
}
}
/// improvise-59q8's minor case: a C string literal leaked its contents.
#[test]
fn a_c_string_literal_is_blanked() {
// The raw one quotes inside itself, so lexing it as ordinary strings
// leaves the text BETWEEN the inner quotes in the open.
let kept = code_only(
"let a = c\"Command::InC(1)\"; let b = cr#\"x\" Command::InRawC(2) \"y\"#;\n",
);
for gone in ["InC", "InRawC"] {
assert!(!kept.contains(gone), "{gone} survived: {kept}");
}
}
/// A test-only item the scan cannot bound is REFUSED, never read to the
/// end of the file, which is what silently hid every shape above.
#[test]
#[should_panic(expected = "could not be bounded")]
fn an_unbounded_test_only_item_is_refused_not_swallowed() {
let _ = production_text("#[cfg(test)]\nfn broken() {\n Command::X(1)\n");
}
#[test]
fn comments_and_literals_are_blanked_and_code_is_kept() {
let text = concat!(
+3 -1
View File
@@ -12,7 +12,9 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
improvise-protocol = { path = "../improvise-protocol" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses frames, so it must read back the f64 that was written: per crate, because
# the wasm bundle builds alone (improvise-6oy4, context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
wasm-bindgen = "0.2"
# Deliberately NOT a dependency: `improvise-command`. A keymap binding names
+97 -12
View File
@@ -8,6 +8,7 @@ pub mod socket;
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
pub use keys::BrowserKey;
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
pub use socket::EditTarget;
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
use wasm_bindgen::prelude::*;
@@ -98,16 +99,41 @@ impl Client {
self.model_json().map_err(|e| JsError::new(&e.to_string()))
}
/// The frame for "the user typed `text` into the cell at this viewport
/// position", or `None` — which is a race, not a normal path.
/// Which stored cell an editor opened at this viewport position edits, as
/// an opaque string the page holds until commit, or `None` (improvise-2wu3).
#[wasm_bindgen]
pub fn typed_cell(
&mut self,
row: usize,
col: usize,
text: &str,
) -> Result<Option<String>, JsError> {
self.typed_cell_frame(row, col, text)
pub fn edit_target(&self, row: usize, col: usize) -> Result<Option<String>, JsError> {
self.edit_target_at(row, col)
.map(|target| serde_json::to_string(&target))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))
}
/// What the editor opened on `target` shows: the seed the target captured,
/// never the page's paint, which can be stale (improvise-ugj4).
#[wasm_bindgen]
pub fn edit_seed(&self, target: &str) -> Result<String, JsError> {
let target: EditTarget =
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
Ok(target.opened.seed)
}
/// `target` after the user's first edit of its `<input>`, still opaque to
/// the page (improvise-jibp).
#[wasm_bindgen]
pub fn edited_target(&self, target: &str) -> Result<String, JsError> {
let target: EditTarget =
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&target.edited()).map_err(|e| JsError::new(&e.to_string()))
}
/// The frame for "the user typed `text` into the cell `target` names". By
/// identity, never position: rows renumber while an editor is open.
#[wasm_bindgen]
pub fn typed_cell(&mut self, target: &str, text: &str) -> Result<Option<String>, JsError> {
let target: EditTarget =
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
self.typed_target_frame(target, text)
.map(|f| frame_json(&f))
.transpose()
.map_err(|e| JsError::new(&e.to_string()))
@@ -126,8 +152,8 @@ impl Client {
self.clicked(Click::ViewRow(index))
}
/// The user clicked row `index` of the formula panel. Navigation only —
/// that panel binds no `Enter` (improvise-iioi).
/// The user clicked row `index` of the formula panel: focus it, move there,
/// then open the editor on that formula, as `Enter` does (improvise-km71).
#[wasm_bindgen]
pub fn click_formula(&mut self, index: usize) -> Result<Option<String>, JsError> {
self.clicked(Click::FormulaRow(index))
@@ -172,7 +198,10 @@ impl Client {
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
match self.state.grid.cell(row, col)?.id.clone()? {
CellId::Record(cell) => Some(cell),
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate => None,
}
}
@@ -256,6 +285,62 @@ mod tests {
assert_eq!(client.typed_cell_frame(0, 0, "42"), None);
}
/// BUG (improvise-ugj4, improvise-jibp) — see `context/test-evidence/clients.md`.
#[test]
fn the_page_opens_its_editor_on_what_the_target_captured() {
let id = improvise_protocol::RecordCell {
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
measure: Some("Revenue".into()),
};
let at = |text: &str, stored: &str| {
let mut cell = improvise_protocol::CellDisplay::number(text);
cell.edit_text = Some(stored.into());
cell.id = Some(CellId::Record(id.clone()));
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)]))
};
let mut client = init();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
at("1,200.50", "1200.5"),
Projection::Basis(improvise_protocol::basis::published(
0,
0,
[(id.record, 0)].into_iter().collect(),
)),
],
});
let first = client.edit_target(0, 0).unwrap().expect("a record cell");
assert_eq!(
client.edit_seed(&first).unwrap(),
"1200.5",
"not the drawn text"
);
client.fold_frame(ServerFrame::Commands(vec![Command::Project(at(
"777", "777",
))]));
let later = client.edit_target(0, 0).unwrap().expect("a record cell");
assert_eq!(
client.edit_seed(&later).unwrap(),
"777",
"not the cache at open"
);
assert_eq!(
client.edit_seed(&first).unwrap(),
"1200.5",
"a target is one capture"
);
assert!(client.typed_cell(&later, "777").unwrap().is_none());
let edited = client.edited_target(&later).unwrap();
assert!(
client.typed_cell(&edited, "777").unwrap().is_some(),
"an edited editor typed back to its seed composed nothing"
);
}
/// The end-to-end shape a JS harness drives: bootstrap, key, message.
#[test]
fn a_client_can_be_driven_entirely_through_json() {
+31 -19
View File
@@ -133,9 +133,6 @@ pub struct RenderCell {
/// May this client compose a write for it? `CellId::Record` alone.
pub editable: bool,
pub selected: bool,
/// What an editor should start with: the stored value, not the formatted
/// one. Seeding from `text` turns `1200` into the string `"1,200"`.
pub edit_text: String,
}
impl RenderCell {
@@ -144,9 +141,17 @@ impl RenderCell {
text: cell.text.clone(),
numeric: cell.numeric,
error: cell.error,
editable: matches!(cell.id, Some(CellId::Record(_))),
editable: match cell.id {
Some(CellId::Record(_)) => true,
Some(
CellId::Aggregate(_)
| CellId::Derived { .. }
| CellId::Create(_)
| CellId::Coordinate,
)
| None => false,
},
selected,
edit_text: cell.edit_text.clone().unwrap_or_default(),
}
}
}
@@ -368,8 +373,8 @@ fn formula_rows(state: &ClientState) -> Option<Vec<RenderFormulaRow>> {
.formulas
.iter()
.enumerate()
.map(|(i, text)| RenderFormulaRow {
text: text.clone(),
.map(|(i, entry)| RenderFormulaRow {
text: entry.raw.clone(),
selected: i == state.view.formula_cursor,
})
.collect()
@@ -418,9 +423,9 @@ impl RenderHelpRow {
L::Heading(t) => prose("heading", t),
L::Text(t) => prose("text", t),
L::Accent(t) => prose("accent", t),
L::Keys { keys, description } => Self {
keys: keys.join(" "),
description: description.to_string(),
L::Keys(row) => Self {
keys: row.keys().join(" "),
description: row.description().to_string(),
kind: "keys",
},
L::Literal {
@@ -563,9 +568,8 @@ mod tests {
/// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so
/// one fixture answers every editability question.
fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
fn group(name: &str, collapsed: bool) -> FrameEntry {
FrameEntry::Group(improvise_protocol::FrameGroup {
cat: cat.into(),
name: name.into(),
collapsed,
})
@@ -583,11 +587,11 @@ mod tests {
first: 0,
cats: vec!["Month".into()],
entries: vec![
group("Month", "Q1", false),
group("Q1", false),
item("Jan"),
group("Fiscal", "Q1", true),
group("Q1", true),
item("Feb"),
group("Month", "Q2", false),
group("Q2", false),
item("Apr"),
],
};
@@ -628,6 +632,10 @@ mod tests {
}),
),
((1, 0), CellDisplay::text("pending")),
(
(1, 2),
CellDisplay::text("North").with_id(CellId::Coordinate),
),
])),
],
});
@@ -659,6 +667,10 @@ mod tests {
!model.rows[1].cells()[1].editable,
"and neither is a slot fit_to_labels padded"
);
assert!(
!model.rows[1].cells()[2].editable,
"renaming a coordinate moves the whole row, which is not a cell write"
);
}
/// The numeric/error flags are the server's answers, echoed — so the test
@@ -786,10 +798,12 @@ mod tests {
TileEntry {
category: "Region".into(),
axis: Axis::Row,
regular: true,
},
TileEntry {
category: "Product".into(),
axis: Axis::None,
regular: true,
},
])));
client
@@ -1008,10 +1022,6 @@ mod tests {
"a row says which of the two it is; the page does not guess"
);
assert_eq!(value["rows"][0]["labels"][0], "North");
assert_eq!(
value["rows"][0]["cells"][0]["edit_text"], "1200",
"an editor seeds from the stored value, never from the drawn one"
);
}
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
@@ -1113,10 +1123,12 @@ mod tests {
TileEntry {
category: "Quarter".into(),
axis: Axis::Page,
regular: true,
},
TileEntry {
category: "Region".into(),
axis: Axis::Column,
regular: true,
},
])));
let tiles = client.model().tiles;
+143 -4
View File
@@ -2,7 +2,38 @@
//! compositions a page makes. See `context/code/clients.md`.
use crate::Client;
use improvise_protocol::{ClientFrame, Command, Rejection, ServerFrame, typed_cell_command};
use improvise_protocol::{
ClientFrame, Command, OpenedEditor, RecordCell, Rejection, ServerFrame, typed_cell_command,
untouched,
};
use serde::{Deserialize, Serialize};
/// Everything an editor captures when it opens, held opaquely by the page.
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditTarget {
/// The document the editor opened on; a swap under it refuses the commit (improvise-vndi).
pub document_generation: u64,
pub cell: RecordCell,
/// The record's write counter as this client had it at open.
pub version: u64,
/// The text the editor opened on, so an untouched commit writes nothing (improvise-ixxx).
pub opened: OpenedEditor,
}
impl EditTarget {
/// The same target after the user's first edit: a commit of its seed is then
/// a write (improvise-jibp). The page's `<input>` is not a buffer `resolve` sees.
pub fn edited(self) -> Self {
Self {
opened: OpenedEditor {
touched: true,
..self.opened
},
..self
}
}
}
/// What a refused batch says to the person who composed it. Worded here rather
/// than on `Rejection`, and why the wording matters: `context/code/clients.md`.
@@ -80,11 +111,39 @@ impl Client {
/// Compose the write a user made by typing `text` into a viewport position.
/// The four `None` shapes, and why nothing folds here: clients.md.
pub fn typed_cell_frame(&mut self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
let target = self.edit_target_at(row, col)?;
self.typed_target_frame(target, text)
}
/// What an editor opened at this position edits: the record, and the
/// version of it the user is looking at. `None` before any basis arrives.
pub fn edit_target_at(&self, row: usize, col: usize) -> Option<EditTarget> {
let cell = self.record_cell_at(row, col)?;
let basis = self.state().basis.claim([cell.record])?;
let basis = self.state().basis.current()?;
let version = basis.record(cell.record)?;
let document_generation = basis.document_generation;
let seed = self.state().grid.cell(row, col)?.edit_text.clone();
Some(EditTarget {
document_generation,
cell,
version,
opened: OpenedEditor::on(seed.unwrap_or_default()),
})
}
/// The write an editor opened earlier commits: to the record it opened on,
/// claimed at the version it read then (improvise-2wu3, improvise-0j0f).
pub fn typed_target_frame(&mut self, target: EditTarget, text: &str) -> Option<ClientFrame> {
if untouched(Some(&target.opened), text) {
return None;
}
let basis = self.state().basis.claim_as_read(
target.document_generation,
[(target.cell.record, target.version)],
)?;
let cmds = vec![Command::Model {
basis,
cmd: typed_cell_command(&improvise_protocol::CellId::Record(cell), text)?,
cmd: typed_cell_command(&improvise_protocol::CellId::Record(target.cell), text)?,
}];
// Folds nothing — a `Command::Model` is not the view slice's business —
// but it **registers**, or the host's answers address the wrong frame.
@@ -243,6 +302,86 @@ mod tests {
client
}
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
#[test]
fn a_mouse_commit_of_the_untouched_seed_composes_nothing() {
for seed in ["007", "ERR:boom", "1.23456789"] {
let mut cell = CellDisplay::text(format!("drawn {seed}"));
cell.edit_text = Some(seed.into());
let mut client = client_holding(cell);
let target = client.edit_target_at(0, 0).expect("a record cell");
assert_eq!(target.opened, OpenedEditor::on(seed.into()), "{seed}");
assert_eq!(client.typed_target_frame(target, seed), None, "{seed}");
}
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
for text in ["12000", "1200 "] {
let target = client.edit_target_at(0, 0).expect("a record cell");
assert!(
client.typed_target_frame(target, text).is_some(),
"{text:?} differs from the seed 1200, whitespace included, so it is a write"
);
}
}
/// BUG (improvise-jibp) — see `context/test-evidence/clients.md`.
#[test]
fn a_mouse_target_marked_edited_writes_its_own_seed() {
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
let target = client.edit_target_at(0, 0).expect("a record cell");
assert_eq!(
client.typed_target_frame(target.clone(), "1200"),
None,
"control: an untouched editor committing its seed writes nothing"
);
let edited = target.edited();
assert_eq!(edited.opened.seed, "1200", "marking must not move the seed");
assert!(
client.typed_target_frame(edited, "1200").is_some(),
"an edited editor typed back to its seed was dropped"
);
}
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
#[test]
fn a_mouse_commit_is_judged_against_the_seed_it_opened_with() {
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
let target = client.edit_target_at(0, 0).expect("a record cell");
let mut moved = CellDisplay::number("5");
moved.id = Some(CellId::Record(record("Revenue")));
client.fold_frame(ServerFrame::Commands(vec![Command::Project(
Projection::Grid(GridUpdate::Cells(vec![((0, 0), moved)])),
)]));
assert_eq!(
client
.state()
.grid
.cell(0, 0)
.and_then(|c| c.edit_text.clone()),
Some("5".into()),
"the other client's write did not reach this cache, so this proves nothing"
);
assert_eq!(
client.typed_target_frame(target, "1200"),
None,
"an untouched editor wrote its stale seed over the other client's value"
);
}
fn client_holding(mut cell: CellDisplay) -> Client {
cell.id = Some(CellId::Record(record("Revenue")));
let mut client = init();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
Projection::Basis(published_basis()),
],
});
client
}
/// The basis a server would publish for the one record this fixture's
/// grid names.
fn published_basis() -> improvise_protocol::Basis {
@@ -300,7 +439,7 @@ mod tests {
);
assert_eq!(client_showing(None).typed_cell_frame(0, 0, "42"), None);
assert_eq!(
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42"),
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42",),
None,
"and neither is a position outside the grid"
);
@@ -135,8 +135,12 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
this fixture has no group headers, so the two row coordinates \
coincide here — see a_grouped_row_axis_does_not_shift_the_write"
);
let target = client
.edit_target(at.data_row, at.col)
.expect("serialization must not fail")
.expect("an editable cell has an edit target");
let frame = client
.typed_cell(at.data_row, at.col, "7500")
.typed_cell(&target, "7500")
.expect("serialization must not fail")
.expect("an editable cell must compose a write");
let frames = send(&mut host, id, &frame);
@@ -218,8 +222,12 @@ fn a_grouped_row_axis_does_not_shift_the_write() {
coordinates coincide and nothing below is being tested"
);
let target = client
.edit_target(at.data_row, at.col)
.expect("serialization must not fail")
.expect("an editable cell has an edit target");
let frame = client
.typed_cell(at.data_row, at.col, "4200")
.typed_cell(&target, "4200")
.expect("serialization must not fail")
.expect("an editable cell must compose a write");
send(&mut host, id, &frame);
@@ -287,9 +295,9 @@ fn an_aggregated_cell_reaches_the_page_as_not_editable() {
"every cell here aggregates over Product, so none may be written"
);
assert_eq!(
client.typed_cell(0, 0, "42").unwrap(),
client.edit_target(0, 0).unwrap(),
None,
"and typing into one composes nothing, because the id is of the wrong kind"
"and there is no cell to type into, because the id is of the wrong kind"
);
}
@@ -634,3 +642,344 @@ fn a_refused_commit_leaves_the_client_where_the_host_says_it_is() {
"the rollback restores the typing rather than costing it (improvise-3al1)"
);
}
/// improvise-2wu3: the page freezes the screen while an editor is open, but the
/// fold goes on. Evidence: `context/test-evidence/clients.md`.
#[test]
fn an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{Command, ModelCommand};
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on the North/Revenue cell it is looking at.
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B hides North; A folds the result without repainting, as the page does.
let view = host.app().view_name().to_string();
let hide = caught_up(
host.app(),
ModelCommand::HideItem {
view,
category: "Region".into(),
item: "North".into(),
},
);
let relayed = host.handle(b, ClientFrame::command(hide));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
assert_ne!(
editor.record_cell_at(0, 0),
Some(clicked.clone()),
"the precondition: the rows really renumbered under the editor"
);
let json = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let ClientFrame::Commands(cmds) = serde_json::from_str(&json).expect("a client frame") else {
panic!("a write is a Commands frame");
};
let written = match cmds.as_slice() {
[
Command::Model {
cmd: ModelCommand::SetCell { cell, .. },
..
},
] => cell.clone(),
other => panic!("expected one SetCell, got {other:?}"),
};
assert_eq!(
written, clicked,
"the write landed on a row the user never looked at"
);
}
/// improvise-0j0f: the editor's identity was captured at open but its basis at
/// commit, so another client's write was lost. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on North/Revenue, which shows 1200.
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B writes 999 there; A folds the fresh value and basis, and does not repaint.
let write = caught_up(
host.app(),
ModelCommand::SetCell {
cell: clicked,
value: CellValue::Number(999.0),
},
);
let relayed = host.handle(b, ClientFrame::command(write));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
"the edit was composed against 1200 and must be refused: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
Some(&CellValue::Number(999.0)),
"B's write was silently overwritten"
);
}
/// improvise-4img: B CLEARS the cell while A's editor is open. Claimed at
/// commit, A's write re-created it. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
let mut host = host();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
let clicked = editor
.record_cell_at(0, 0)
.expect("North/Revenue is stored");
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
let clear = caught_up(host.app(), ModelCommand::ClearCell(clicked));
let relayed = host.handle(b, ClientFrame::command(clear));
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
None,
"the precondition: B really cleared the cell"
);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
"the edit was composed against 1200 and must be refused: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
None,
"A's commit re-created a cell B had cleared"
);
}
/// improvise-vndi: an editor opened before another client's `LoadModel` must not
/// write into the loaded document. Evidence: test-evidence/clients.md.
#[test]
fn an_edit_opened_before_a_document_swap_is_refused_in_the_new_document() {
use improvise_command::ui::reduce::caught_up;
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let north = CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]);
// The incoming document keeps every name, view included (gotcha 39's
// fixture warning), and differs only in the one value the edit targets.
let path = std::path::PathBuf::from("other.improv");
let mut host = host();
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
let mut other = host.app().model_state.workbook.clone();
other
.model
.set_cell(north.clone(), CellValue::Number(5555.0));
improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap();
let mut editor = Client::new();
let (a, snapshot) = host.connect(10, 10);
deliver(&mut editor, &[snapshot]);
let (b, _) = host.connect(10, 10);
// A opens an editor on North/Revenue, which shows 1200.
let target = editor
.edit_target(0, 0)
.expect("serialization must not fail")
.expect("a stored cell has an edit target");
// B loads the other document; A folds its rebase Snapshot, editor still open.
let generation = host.app().document_generation();
let load = caught_up(host.app(), ModelCommand::LoadModel(path));
let relayed = host.handle(b, ClientFrame::command(load));
assert_eq!(
host.app().document_generation(),
generation + 1,
"setup: the load must really replace the document"
);
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
deliver(&mut editor, &for_a);
let commit = editor
.typed_cell(&target, "42")
.expect("serialization must not fail")
.expect("a stored cell composes a write");
let answer = send(&mut host, a, &commit);
assert!(
answer
.iter()
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::DocumentReplaced))),
"the edit was composed against the outgoing document: {answer:?}"
);
assert_eq!(
host.app().model_state.workbook.model.get_cell(&north),
Some(&CellValue::Number(5555.0)),
"the loaded document's value was overwritten by an edit its user never saw"
);
}
// ── numbers across the serialized wire (improvise-6oy4) ─────────────────
/// A value serde_json's default f64 parser reads back one ULP low. Evidence file.
const LOSSY: f64 = 0.248_157_142_857_142_85;
fn south(measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), "South".into()),
("_Measure".into(), measure.into()),
])
}
/// `host()` with the lossy value in South/Revenue: row 1, off the cursor's origin.
fn lossy_host() -> Host {
let mut host = host();
host.app_mut()
.model_state
.workbook
.model
.set_cell(south("Revenue"), CellValue::Number(LOSSY));
host.app_mut().rebuild_layout();
host
}
fn bits(value: Option<&CellValue>) -> Option<u64> {
match value {
Some(CellValue::Number(n)) => Some(n.to_bits()),
other => panic!("expected a number, got {other:?}"),
}
}
/// Both hops of `yy`/`p` in a browser are serialized: the value down in the
/// projection, the paste back up in a `SetCell`. Evidence file.
#[test]
fn a_yanked_number_crosses_the_serialized_wire_exactly() {
let mut host = lossy_host();
let mut client = Client::new();
// `set_viewport` records the window; without it the cursor keys do nothing.
client.set_viewport(10, 10);
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
for key in ["j", "y", "y"] {
press(&mut host, id, &mut client, key);
}
assert_eq!(
client.state().view.cursor().selected,
(1, 0),
"the source must be South/Revenue, off index 0"
);
assert_eq!(
bits(client.state().view.yanked.as_ref()),
Some(LOSSY.to_bits()),
"host to client: the projected value must arrive bit for bit"
);
for key in ["l", "p"] {
press(&mut host, id, &mut client, key);
}
assert_eq!(
bits(
host.app()
.model_state
.workbook
.model
.get_cell(&south("Cost"))
),
Some(LOSSY.to_bits()),
"client to host: the pasted value must land bit for bit"
);
}
/// A typed number is parsed exactly in the client and re-parsed from JSON by
/// the host. Evidence file.
#[test]
fn a_typed_number_crosses_the_serialized_wire_exactly() {
let mut host = host();
let mut client = Client::new();
let (id, snapshot) = host.connect(10, 10);
deliver(&mut client, &[snapshot]);
let target = client
.edit_target(1, 1)
.expect("serialization must not fail")
.expect("South/Cost is a stored cell");
let frame = client
.typed_cell(&target, "0.24815714285714285")
.expect("serialization must not fail")
.expect("South/Cost is a stored cell");
send(&mut host, id, &frame);
assert_eq!(
bits(
host.app()
.model_state
.workbook
.model
.get_cell(&south("Cost"))
),
Some(LOSSY.to_bits()),
"the host must store the number the user typed, not its neighbour"
);
}
@@ -266,6 +266,70 @@ fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() {
);
}
/// Twenty rows by three columns, the host's window eight rows by three: not
/// square, so a rule keyed off the wrong axis lands somewhere else.
fn paging_host() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
for i in 0..20 {
wb.add_item("Region", format!("R{i:02}")).unwrap();
}
for m in ["A", "B", "C"] {
wb.add_item("_Measure", m).unwrap();
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
let mut app = App::new(wb, None);
app.term_height = 8 + 8;
app.rebuild_layout();
app
}
/// Page and half-page scrolls land in the same place on both ends, and in the
/// place the TUI's rule names. Why the fixture and the pinned rows: the evidence file.
#[test]
fn a_locally_resolved_page_scroll_lands_where_the_host_would_put_it() {
let window = (8, 3);
let mut app = paging_host();
let mut client = client_of(&app, window);
let key = |code, mods| KeyEvent::new(code, mods);
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
let presses = [
(key(KeyCode::Char('j'), none), 1),
(key(KeyCode::Char('j'), none), 2),
(key(KeyCode::PageDown, none), 8),
(key(KeyCode::PageDown, none), 14),
(key(KeyCode::PageUp, none), 8),
(key(KeyCode::Char('d'), ctrl), 13),
(key(KeyCode::PageDown, none), 19),
(key(KeyCode::Char('u'), ctrl), 14),
(key(KeyCode::PageUp, none), 8),
(key(KeyCode::PageUp, none), 2),
];
for (press, (event, want_row)) in presses.into_iter().enumerate() {
match resolve(&client, event) {
KeyOutcome::Local(cmds) => {
for cmd in &cmds {
client.fold(cmd);
}
}
other => panic!("press {press}: the client must answer {event:?}, got {other:?}"),
}
reduce_key(&mut app, event);
assert_eq!(
client.view.cursor(),
app.view_state.cursor(),
"press {press} ({event:?}): client and host disagree"
);
assert_eq!(
client.view.cursor().selected,
(want_row, 0),
"press {press} ({event:?}): not the row the TUI's page rule names"
);
}
}
/// The same for `G`, the key `GridExtent` exists for: it is *defined* as "the
/// last row", so a window-only client would have nothing to jump to.
#[test]
@@ -790,6 +854,53 @@ fn a_locally_resolved_tile_axis_change_agrees_with_the_host() {
);
}
/// `T` then `Enter` from Normal: the tile cursor must open on a regular
/// category, not `_Index` (improvise-oplp). Why these rivals: the evidence file.
#[test]
fn tile_select_opens_on_a_regular_category_on_both_ends() {
let mut app = furnished_host();
let mut twin = twin_of(&app);
let mut client = furnished_client(&app, (2, 1));
let axis_of = |app: &App, cat: &str| app.view().axis_of(cat);
assert_eq!(
axis_of(&app, "_Index"),
Axis::None,
"the fixture's _Index is off-grid"
);
assert_eq!(axis_of(&app, "Region"), Axis::Row);
both_ends(&mut app, &mut twin, &mut client, "T");
both_ends(&mut app, &mut twin, &mut client, "Enter");
assert_eq!(
axis_of(&app, "_Index"),
Axis::None,
"T Enter moved _Index; the tile cursor opened on a virtual category"
);
assert_eq!(
axis_of(&app, "Region"),
Axis::Column,
"the first regular category is the one T Enter should cycle"
);
// The virtuals stay tileable, and reachable from where the cursor opens
// (#192): the browser's only way into records mode.
let names: Vec<&str> = client
.tiles
.tiles
.iter()
.map(|t| t.category.as_str())
.collect();
let opened = client.view.tile_cat_idx;
for virtual_cat in ["_Index", "_Dim"] {
let at = names.iter().position(|n| *n == virtual_cat);
assert!(
at.is_some_and(|i| i < opened),
"{virtual_cat} must stay a tile left of {opened}"
);
}
}
/// The category panel's four bindings, each of which reads the flattened tree
/// both ends share (improvise-edp): Space, `a`, Enter and `d`.
#[test]
@@ -1830,3 +1941,422 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
"client and host yanked different values over an aggregate"
);
}
// The cursor's cell as a write target (improvise-lx7w, improvise-54g1, improvise-gw18).
/// improvise-lx7w: `x` on a records measure clears the real record on both ends.
/// Evidence: `context/test-evidence/clients.md`.
#[test]
fn x_in_records_mode_clears_the_record_the_browser_clears() {
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
let two_records = || {
let mut app = furnished_host();
let wb = &mut app.model_state.workbook;
wb.add_item("_Measure", "Cost").unwrap();
// Two measures, so clearing one leaves South's row where the cursor is.
for (measure, n) in [("Revenue", 900.0), ("Cost", 40.0)] {
let key = north_revenue()
.with("Region", "South")
.with("_Measure", measure);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(n));
}
app.rebuild_layout();
reduce_key(&mut app, r);
app
};
let (mut app, mut twin) = (two_records(), two_records());
let revenue = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Revenue")
.expect("a Revenue column");
assert_eq!(
app.layout.record_row(1).unwrap().coords.get("Region"),
Some("South"),
"setup: row 1 is South"
);
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (1, revenue);
}
let mut client = records_client(&app);
client.view.cursor_mut().selected = (1, revenue);
both_ends(&mut app, &mut twin, &mut client, "x");
let model = &app.model_state.workbook.model;
assert_eq!(
model.get_cell(&north_revenue().with("Region", "South")),
None
);
assert!(
model.get_cell(&north_revenue()).is_some(),
"North is untouched"
);
}
/// improvise-q9eg: `yy` then `p` onto a record that lacks a later category,
/// on both ends. Evidence: `context/test-evidence/clients.md`.
#[test]
fn p_onto_a_sparse_record_writes_what_the_browser_writes() {
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
let sparse = || {
let mut app = furnished_host();
let south = north_revenue().with("Region", "South");
app.model_state
.workbook
.model
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
app.rebuild_layout();
reduce_key(&mut app, r);
// As `+cat` does: every existing record now lacks `Season`.
app.model_state.workbook.add_category("Season").unwrap();
app.rebuild_layout();
app
};
let (mut app, mut twin) = (sparse(), sparse());
assert!(
app.layout.none_cats.iter().any(|c| c == "Season"),
"setup: the new category is aggregated away"
);
let revenue = (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == "Revenue")
.expect("a Revenue column");
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (0, revenue);
}
let mut client = records_client(&app);
client.view.cursor_mut().selected = (0, revenue);
both_ends(&mut app, &mut twin, &mut client, "y");
both_ends(&mut app, &mut twin, &mut client, "y");
for host in [&mut app, &mut twin] {
host.cursor_mut().selected = (1, revenue);
}
client.view.cursor_mut().selected = (1, revenue);
both_ends(&mut app, &mut twin, &mut client, "p");
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&north_revenue().with("Region", "South")),
Some(&improvise_protocol::CellValue::Number(1200.0)),
"the paste must land, or both ends agreeing is both declining"
);
}
/// `key` at `at` on a host and on a client seeded from it: neither writes.
fn declines_on_both_ends(mut app: App, at: (usize, usize), key: char) {
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
app.cursor_mut().selected = at;
let client = client_ready_to_edit(&app, (6, 6));
assert!(
!composes_a_write(&client, event),
"the browser composed a write"
);
let before = document_text(&app);
reduce_key(&mut app, event);
assert_eq!(
document_text(&app),
before,
"the TUI wrote where the browser declines"
);
assert!(
!app.view_state.status_msg.is_empty(),
"the TUI declined silently"
);
}
/// improvise-54g1: `p` on a formula-owned cell. The control is the stored cell
/// beside it. Evidence: `context/test-evidence/clients.md`.
#[test]
fn p_onto_a_formula_owned_cell_declines_on_both_ends() {
let app = yank_host();
assert_eq!(
app.layout.col_label(3),
"Profit",
"setup: column 3 is the formula"
);
declines_on_both_ends(app, (1, 3), 'p');
let mut app = yank_host();
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
app.cursor_mut().selected = (1, 2);
let client = client_ready_to_edit(&app, (6, 6));
let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
assert!(
composes_a_write(&client, p),
"the control: Cost is settable"
);
}
/// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin.
#[test]
fn a_write_onto_a_fold_declines_on_both_ends() {
let fold_host = || {
let mut wb = Workbook::new("Test");
wb.add_category("Month").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for month in ["Jan", "Feb", "Apr", "May"] {
for measure in ["Revenue", "Cost"] {
let key = improvise_command::model::cell::CellKey::new(vec![
("Month".to_string(), month.to_string()),
("_Measure".to_string(), measure.to_string()),
]);
wb.model
.set_cell(key, improvise_protocol::CellValue::Number(3.0));
}
}
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
app
};
declines_on_both_ends(fold_host(), (2, 1), 'p');
declines_on_both_ends(fold_host(), (2, 1), 'x');
}
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
/// so "the cell under the cursor" is not "the first cell".
fn precise_host(value: improvise_protocol::CellValue) -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (region, measure, v) in [
("North", "Cost", improvise_protocol::CellValue::Number(1.0)),
(
"North",
"Revenue",
improvise_protocol::CellValue::Number(2.0),
),
("South", "Cost", improvise_protocol::CellValue::Number(3.0)),
("South", "Revenue", value),
] {
wb.model.set_cell(region_measure(region, measure), v);
}
let mut app = App::new(wb, None);
app.term_height = 2 + 8;
app.rebuild_layout();
app.cursor_mut().selected = (1, 1);
app
}
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
#[test]
fn an_untouched_browser_edit_stores_the_identical_value() {
use improvise_protocol::CellValue::{Number, Text};
for value in [
Number(1.23456789),
Number(0.1 + 0.2),
Number(98765.4321987),
Number(123456789012345.67),
Number(-0.00001234),
Number(-0.0),
Number(f64::NEG_INFINITY),
Text("0.1 + 0.2".into()),
] {
untouched_browser_edit(value);
}
}
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
#[test]
fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() {
use improvise_protocol::CellValue::{Error, Text};
for value in [
Text("007".into()),
Text("1e3".into()),
Text("inf".into()),
Error("boom".into()),
] {
untouched_browser_edit(value);
}
}
/// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver
/// and a real host: the seed is the stored value's, and the cell is unchanged.
fn untouched_browser_edit(value: improvise_protocol::CellValue) {
use improvise_protocol::CellValue::Number;
let mut host = precise_host(value.clone());
let mut client = client_ready_to_edit(&host, (2, 2));
for key in ["i", "Enter"] {
let event = BrowserKey::new(key).to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("{key}: the client must answer this key locally");
};
for cmd in &cmds {
client.fold(cmd);
}
apply_on(&mut host, &cmds);
if key == "i" {
assert_eq!(
improvise_protocol::read_buffer(&client.view, "edit"),
value.edit_text(),
"the editor was not seeded from the stored value (improvise-umi4)"
);
}
}
assert!(
!client.view.mode.is_editing(),
"Enter did not commit, so this proves nothing"
);
let stored = host
.model_state
.workbook
.model
.get_cell(&region_measure("South", "Revenue"))
.cloned();
let same = match (&stored, &value) {
(Some(Number(a)), Number(b)) => a.to_bits() == b.to_bits(),
(stored, value) => stored.as_ref() == Some(value),
};
assert!(same, "i then Enter on {value:?} stored {stored:?}");
}
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
#[test]
fn every_projected_edit_text_commits_back_to_its_value() {
use improvise_protocol::CellValue::Number;
let value = Number(1.23456789);
for records in [false, true] {
let mut app = precise_host(value.clone());
if records {
reduce_key(
&mut app,
KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE),
);
}
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: 8,
cols: 8,
},
);
let cells: Vec<_> = grid
.cells
.iter()
.flatten()
.filter(|cell| matches!(cell.id, Some(improvise_protocol::CellId::Record(_))))
.collect();
assert!(
cells.iter().any(|cell| cell.value.as_ref() == Some(&value)),
"records={records}: the precise value was never projected, so this proves nothing"
);
for cell in cells {
let text = cell.edit_text.clone().unwrap_or_default();
assert_eq!(
improvise_protocol::typed(&text),
cell.value,
"records={records}: edit text {text:?} does not commit back to {:?}",
cell.value
);
}
}
}
const FORMULAS: [&str; 3] = [
"Profit = Revenue * 2",
"Tax = Revenue * 0.1",
"Margin = Profit / Revenue",
];
fn add_formulas(app: &mut App) {
for raw in FORMULAS {
let f = improvise_command::formula::parse_formula(raw, "_Measure").unwrap();
app.model_state.workbook.model.add_formula(f);
}
app.rebuild_layout();
}
fn raws(app: &App) -> Vec<String> {
let formulas = app.model_state.workbook.model.formulas();
formulas.iter().map(|f| f.raw.clone()).collect()
}
/// The formula panel's edit (improvise-km71), opened at a NONZERO cursor over
/// three formulas, typed into and committed, with both ends agreeing throughout.
#[test]
fn editing_a_formula_from_the_panel_agrees_with_the_host() {
let mut app = furnished_host();
add_formulas(&mut app);
app.view_state.formula_panel_open = true;
app.view_state.mode = AppMode::FormulaPanel;
app.view_state.formula_cursor = 1;
let mut twin = twin_of(&app);
add_formulas(&mut twin);
let mut client = furnished_client(&app, (2, 1));
client.formulas = improvise_command::ui::project::formulas_of(&app);
both_ends(&mut app, &mut twin, &mut client, "Enter");
assert_eq!(
client.view.buffers.get("formula"),
app.view_state.buffers.get("formula"),
"the two ends seeded the editor differently"
);
assert_eq!(
app.view_state.buffers.get("formula").map(String::as_str),
Some(FORMULAS[1]),
"Enter must seed the formula under cursor 1"
);
for key in ["Backspace", "2", "Enter"] {
both_ends(&mut app, &mut twin, &mut client, key);
}
assert_eq!(
raws(&app),
vec![FORMULAS[0], "Tax = Revenue * 0.2", FORMULAS[2]],
"the commit must replace the formula it opened on, in place"
);
}
/// A rename through the browser: the one input that separates "replace" from
/// "add", since `add_formula` upserts by target. The status diverges on purpose.
#[test]
fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() {
let mut app = furnished_host();
add_formulas(&mut app);
app.view_state.formula_panel_open = true;
app.view_state.mode = AppMode::FormulaPanel;
app.view_state.formula_cursor = 1;
let mut twin = twin_of(&app);
add_formulas(&mut twin);
let mut client = furnished_client(&app, (2, 1));
client.formulas = improvise_command::ui::project::formulas_of(&app);
both_ends(&mut app, &mut twin, &mut client, "Enter");
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
view.buffers
.insert("formula".into(), "Levy = Revenue * 0.1".into());
}
let event = BrowserKey::new("Enter").to_key_event().unwrap();
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
panic!("Enter in the formula editor resolves here");
};
for cmd in &cmds {
client.fold(cmd);
}
apply_on(&mut twin, &cmds);
reduce_key(&mut app, event);
assert_eq!(document_text(&twin), document_text(&app));
assert_eq!(
raws(&app),
vec![FORMULAS[0], "Levy = Revenue * 0.1", FORMULAS[2]]
);
assert_eq!(client.view.mode, app.view_state.mode);
assert_eq!(client.view.status_msg, "Formula updated");
assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy");
}
+3 -1
View File
@@ -17,7 +17,9 @@ improvise-protocol = { path = "../improvise-protocol" }
improvise-command = { path = "../improvise-command" }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses frames, so it must read back the f64 that was written: per crate, because
# the wasm bundle builds alone (improvise-6oy4, context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
# The browser half. Only the wasm build pulls these in, so `cargo test` on the
# host builds and drives `worker.rs`/`vfs.rs` with no JS anywhere near them —
+3 -1
View File
@@ -17,7 +17,9 @@ improvise-io = { path = "../improvise-io" }
improvise-protocol = { path = "../improvise-protocol" }
improvise-command = { path = "../improvise-command" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Parses frames, so it must read back the f64 that was written (improvise-6oy4,
# context/code/protocol.md).
serde_json = { version = "1", features = ["float_roundtrip"] }
anyhow = "1"
clap = { version = "4.6.7", features = ["derive"] }
tokio = { version = "1", features = ["rt", "macros", "net", "sync"] }
Generated
+16
View File
@@ -45,6 +45,21 @@
"type": "github"
}
},
"crane": {
"locked": {
"lastModified": 1789760033,
"narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=",
"owner": "ipetkov",
"repo": "crane",
"rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix",
@@ -341,6 +356,7 @@
"root": {
"inputs": {
"beads": "beads",
"crane": "crane",
"crate2nix": "crate2nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_4",
+85 -22
View File
@@ -9,6 +9,7 @@
};
flake-utils.url = "github:numtide/flake-utils";
crate2nix.url = "github:nix-community/crate2nix";
crane.url = "github:ipetkov/crane";
};
outputs = {
@@ -18,6 +19,7 @@
rust-overlay,
flake-utils,
crate2nix,
crane,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
@@ -36,12 +38,76 @@
cargoNix = import ./Cargo.nix {
inherit nixpkgs pkgs;
};
# wasm-pack downloads wasm-bindgen and binaryen from GitHub releases
# unless they are on PATH, and its downloader ignores SSL_CERT_FILE, so
# behind the runners' TLS-auditing proxy those downloads fail (binaryen
# with no fallback). On PATH, it downloads nothing. wasm-pack only takes
# a PATH wasm-bindgen whose version equals Cargo.lock's `wasm-bindgen`,
# so this pin moves with the lock; CI checks that it does.
wasmBindgenCli = pkgs.buildWasmBindgenCli rec {
src = pkgs.fetchCrate {
pname = "wasm-bindgen-cli";
version = "0.2.128";
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
};
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
inherit (src) pname version;
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
};
};
# Everything CI runs, and nothing else: `devShells.ci` is exactly this,
# and the default shell is this plus editor and release tooling, so a
# local `nix develop` runs the toolchain the checks run.
ciInputs = [
rustToolchain
pkgs.pkg-config
# `scripts/kb` and the CI knowledge-base check both run trealla. It
# was reached through `nix shell nixpkgs#trealla` per invocation,
# which cost more than the work: the tool's first real use took past
# two minutes for a write that takes half a second on PATH.
pkgs.trealla
# `web/build.sh` — and so the Pages job's local dry run — needs this.
# The `wasm` CI job only runs `cargo check`, which does not, so the
# shell was missing the one tool that turns a passing check into a
# loadable page (improvise-d31). Must match WASM_PACK_VERSION in
# scripts/web-toolchain-versions.env; CI checks that it does.
pkgs.wasm-pack
wasmBindgenCli
pkgs.binaryen
# NODE_VERSION's major, same check. The js and browser jobs ran 22
# while `build` ran the pinned 24; one shell means one node.
pkgs.nodejs_24
pkgs.python3
pkgs.jq
];
# Playwright's downloaded browsers are generic-Linux builds that cannot
# start on NixOS, so nixpkgs' build is used. harness/package.json pins
# @playwright/test to this playwright-driver's version: they must match.
craneLib = (crane.mkLib pkgs).overrideToolchain (_: rustToolchain);
ci = import ./nix/ci.nix {
inherit pkgs craneLib wasmBindgenCli;
src = self;
# What the site's cache-bust token names; CI checks out a clean tree.
rev = self.rev or "dirty";
};
playwrightEnv = {
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true";
RUST_BACKTRACE = "1";
};
in {
inherit cargoNix;
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
devShells.ci = pkgs.mkShell (playwrightEnv // {nativeBuildInputs = ciInputs;});
devShells.default = pkgs.mkShell (playwrightEnv // {
nativeBuildInputs = ciInputs ++ [
pkgs.rust-analyzer
crate2nix.packages.${system}.default
pkgs.cargo-expand
@@ -50,18 +116,6 @@
pkgs.universal-ctags
beads.packages.${system}.default
# `scripts/kb` and the CI knowledge-base check both run trealla. It
# was reached through `nix shell nixpkgs#trealla` per invocation,
# which cost more than the work: the tool's first real use took past
# two minutes for a write that takes half a second on PATH.
pkgs.trealla
# `web/build.sh` — and so the Pages job's local dry run — needs this.
# The `wasm` CI job only runs `cargo check`, which does not, so the
# shell was missing the one tool that turns a passing check into a
# loadable page (improvise-d31).
pkgs.wasm-pack
# Demo recording and release tooling
pkgs.asciinema
pkgs.vhs
@@ -69,12 +123,21 @@
# nixpkgs cargo-dist installs as "dist"; alias so `cargo dist` works
(pkgs.writeShellScriptBin "cargo-dist" ''exec ${pkgs.cargo-dist}/bin/dist "$@"'')
];
RUST_BACKTRACE = "1";
};
});
packages = {
improvise = cargoNix.workspaceMembers.improvise.build;
default = self.packages.${system}.improvise;
};
packages =
ci.packages
// {
improvise = cargoNix.workspaceMembers.improvise.build;
default = self.packages.${system}.improvise;
};
# `nix flake check` builds every CI build: crane's checks plus the
# crate2nix package and the bundles.
checks =
ci.checks
// {
inherit (self.packages.${system}) improvise web;
};
});
}
+15
View File
@@ -9,10 +9,25 @@
# half is the *deployed* arrangement rather than `web/` itself, because a
# harness that runs against a different tree from the one that ships is
# testing something nobody deploys (improvise-vvl3).
#
# Re-run THIS script, never `web/assemble.sh harness/serve` alone: assembling
# starts with `rm -rf` of its output, which deletes opfs/ and the fixtures
# (improvise-mkbp). opfs.spec.js says so if it finds opfs/ missing.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
serve="$root/harness/serve"
# A stale opfs-contract/Cargo.lock fails HERE rather than being rewritten in
# every agent's tree (improvise-mkbp). wasm-pack cannot enforce this: it
# re-resolves the lock itself before any `-- --locked` reaches cargo build.
# Fix a failure with `cargo update -w` in harness/opfs-contract, and commit it.
cargo metadata --locked --format-version 1 \
--manifest-path "$root/harness/opfs-contract/Cargo.toml" >/dev/null || {
echo "harness/build.sh: harness/opfs-contract/Cargo.lock is stale. Do not remove --locked." >&2
echo " Run: cargo update -w --manifest-path harness/opfs-contract/Cargo.toml, and commit the lock." >&2
exit 1
}
"$root/web/build.sh" >/dev/null
"$root/web/assemble.sh" "$serve" harness >/dev/null
+8 -10
View File
@@ -228,8 +228,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
@@ -308,9 +306,9 @@ dependencies = [
[[package]]
name = "pest"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
dependencies = [
"memchr",
"ucd-trie",
@@ -318,9 +316,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
dependencies = [
"pest",
"pest_generator",
@@ -328,9 +326,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
dependencies = [
"pest",
"pest_meta",
@@ -341,9 +339,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
dependencies = [
"pest",
]
+15 -33
View File
@@ -6,70 +6,52 @@
"": {
"name": "improvise-harness",
"devDependencies": {
"@playwright/test": "1.56.1"
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.1"
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
}
+1 -1
View File
@@ -8,6 +8,6 @@
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.56.1"
"@playwright/test": "1.63.0"
}
}
+124 -6
View File
@@ -9,6 +9,19 @@ import { test, expect } from "@playwright/test";
// the same with the handshake window).
import { NOTICE_KEY } from "../../web/shell.js";
/** Every string the page posts to its worker, kept on `window.__posted`. */
function recordPosts() {
window.__posted = [];
const post = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (message, ...rest) {
window.__posted.push(String(message));
return post.call(this, message, ...rest);
};
}
const setCells = (page) =>
page.evaluate(() => window.__posted.filter((m) => m.includes("SetCell")).length);
async function openApp(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
@@ -126,8 +139,9 @@ test.describe("every field the render model projects reaches the screen", () =>
const rowHeaders = page.locator("#grid tbody th");
await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
// Tile select opens on the first regular category, which in the demo is
// Product (improvise-oplp), so no arrow key is needed to reach it.
await page.keyboard.press("Shift+T");
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
await expect(product).toHaveClass(/selected/);
await page.keyboard.press("c");
@@ -250,15 +264,16 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
await expect(page.locator("#views li.active")).toContainText("Season");
await expect(page.locator("#views li.selected")).toContainText("Season");
// A formula row: navigation only, because FormulaPanel binds no Enter and
// a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md.
// `#grid input` toHaveCount(0) belongs here and is deliberately absent:
// it cannot fail, measured by injection (improvise-qf57, clients.md).
// A formula row: FormulaPanel's Enter edits the formula (improvise-km71)
// and a click mirrors Enter, so the click opens the editor on that row.
// `#grid input` toHaveCount(0) is deliberately absent (improvise-qf57).
await page.keyboard.press("Shift+F");
await expect(page.locator("#formulas li").first()).toBeVisible();
await page.keyboard.press("Escape");
const formula = await page.locator("#formulas li").first().innerText();
await page.locator("#formulas li").first().click();
await expect(page.locator("#mode")).toHaveText("FORMULAS");
await expect(page.locator("#mode")).toHaveText("FORMULA");
await expect(page.locator("#minibuffer")).toContainText(formula.trim());
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
});
@@ -287,6 +302,109 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// improvise-ixxx. The editor hands wasm the seed it captured at open, and
// only wasm decides an untouched commit writes nothing — JS must pass it.
test("an untouched mouse editor sends no write, and a touched one does", async ({
page,
}) => {
await page.addInitScript(() => {
window.__posted = [];
const post = Worker.prototype.postMessage;
Worker.prototype.postMessage = function (message, ...rest) {
window.__posted.push(String(message));
return post.call(this, message, ...rest);
};
});
await openApp(page);
const cells = page.locator("#grid tbody td");
const writes = () =>
page.evaluate(() => window.__posted.filter((m) => m.includes("SetCell")).length);
await cells.nth(4).click();
await expect(cells.nth(4).locator("input")).toHaveCount(1);
await page.keyboard.press("Enter");
await expect(page.locator("#grid input")).toHaveCount(0);
await page.waitForTimeout(300);
expect(await writes()).toBe(0);
// The control: without it a page that never sends anything passes above.
await cells.nth(4).click();
await expect(cells.nth(4).locator("input")).toHaveCount(1);
await page.keyboard.type("1");
await page.keyboard.press("Enter");
await expect.poll(writes).toBe(1);
});
// improvise-jibp. The page reports its first edit, so retyping the seed over
// the selection is a write, not an untouched editor.
test("a mouse editor typed back to its own seed still writes", async ({ page }) => {
await page.addInitScript(recordPosts);
await openApp(page);
const cells = page.locator("#grid tbody td");
await cells.nth(4).click();
const input = cells.nth(4).locator("input");
await expect(input).toHaveCount(1);
const seed = await input.inputValue();
expect(seed, "the fixture cell has no seed to retype").not.toBe("");
await page.keyboard.type(seed);
await expect(input).toHaveValue(seed);
await page.keyboard.press("Enter");
await expect.poll(() => setCells(page)).toBe(1);
});
// improvise-ugj4. A frame that lands while an editor is open changes the
// cache but not the paint; the next editor must open on what it captured.
test("an editor opened after an unpainted write shows and commits what it captured", async ({
page,
}) => {
await page.addInitScript(recordPosts);
await page.addInitScript(() => {
window.__frames = [];
const add = Worker.prototype.addEventListener;
Worker.prototype.addEventListener = function (type, listener, ...rest) {
if (type !== "message") return add.call(this, type, listener, ...rest);
window.__deliver = (data) => listener.call(this, new MessageEvent("message", { data }));
const recorded = (event) => {
if (typeof event.data === "string") window.__frames.push(event.data);
return listener.call(this, event);
};
return add.call(this, type, recorded, ...rest);
};
});
await openApp(page);
const cells = page.locator("#grid tbody td");
const target = cells.nth(4);
const where = await target.evaluate((td) => [Number(td.dataset.row), Number(td.dataset.col)]);
await cells.nth(0).click();
await expect(cells.nth(0).locator("input")).toHaveCount(1);
// Another client's write to cell 4, delivered while cell 0's editor is open.
await page.evaluate(([row, col]) => {
let latest = null;
const walk = (node) => {
if (Array.isArray(node)) node.forEach(walk);
else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
if (key === "Cells" && Array.isArray(value)) {
for (const [[r, c], cell] of value) if (r === row && c === col) latest = cell;
} else walk(value);
}
}
};
window.__frames.forEach((f) => walk(JSON.parse(f)));
const moved = { ...latest, text: "777", edit_text: "777", value: { Number: 777 } };
window.__deliver(JSON.stringify({ Commands: [{ Project: { Grid: { Cells: [[[row, col], moved]] } } }] }));
}, where);
await target.click();
const input = target.locator("input");
await expect(input).toHaveCount(1);
await expect(input, "the editor opened on the stale paint").toHaveValue("777");
await page.keyboard.press("Enter");
await page.waitForTimeout(300);
expect(await setCells(page), "an untouched editor wrote the stale text").toBe(0);
});
// improvise-b8ot moved these five surfaces onto one handler per stable root.
// The three tests below are the properties a naive delegation loses, each of
// which is silent: the page still paints, and only the pointer stops working.
+26
View File
@@ -132,4 +132,30 @@ test.describe("the help overlay draws the shared content", () => {
await expect(rows_.last().locator("td.selected")).toHaveCount(1);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// improvise-1c5m. Below the width at which the app bar wraps, `#help` covers
// `#hint`, and Welcome itself says nothing about paging. The contract: the
// mode's own hint is UNCOVERED somewhere while help is open.
test("a narrow window still shows how to page through help", async ({ page }) => {
await page.setViewportSize({ width: 600, height: 800 });
await openHelp(page);
// The string is the page's own `#hint`, i.e. `hint_line(Help)`; this spec
// spells no keys. Asserting it names paging keeps an empty hint out.
const hint = (await page.locator("#hint").textContent()).trim();
expect(hint).toContain("next-page");
// An element showing exactly that string, topmost at its own centre: the
// overlay covering `#hint` is precisely what `elementFromPoint` sees.
const uncovered = await page.evaluate((text) =>
[...document.querySelectorAll("body *")].some((node) => {
if (node.children.length > 0 || node.textContent.trim() !== text) return false;
const r = node.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return top !== null && (top === node || node.contains(top));
}),
hint);
expect(uncovered, "the paging hint is under the help overlay").toBe(true);
});
});
+5 -1
View File
@@ -23,7 +23,11 @@ const EXPECTED = [
test.describe("OpfsFs in a real browser", () => {
test.beforeEach(async ({ page, browserName }, testInfo) => {
await page.goto("/opfs/index.html");
const served = await page.goto("/opfs/index.html");
expect(
served?.ok(),
"harness/serve/opfs is missing: re-run harness/build.sh, not web/assemble.sh alone",
).toBe(true);
// The skip is conditional on the PROBE, not on the browser name, so it
// cannot quietly swallow a real regression. See clients.md.
+29
View File
@@ -187,6 +187,21 @@ test.describe("the standalone deployment", () => {
await expect(page.locator("#grid td.editable").first()).toBeVisible();
});
// improvise-i0v9: "in-page worker · <doc>" was written at 13ms, before the
// worker had answered anything, and read as success over a blank page.
test("the worker is not called up until its first frame has arrived", async ({ page }) => {
const doc = `first-frame-${Date.now()}.improv`;
await page.addInitScript(logStatus);
await page.goto(`/app/index.html?doc=${doc}`);
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(status(page)).toHaveText(`in-page worker · ${doc}`);
const log = await page.evaluate(() => window.__statusLog);
expect(log, `#status said: ${json(log)}`).toEqual([
`starting the worker · ${doc}…`,
`in-page worker · ${doc}`,
]);
});
// improvise-9cma: a `?doc=` this backend cannot hold used to reach the
// generic watchdog 8s later. The REASON now crosses the port (improvise-1wft).
test("an invalid ?doc= is named on screen, and not blamed on the browser", async ({
@@ -235,6 +250,7 @@ test.describe("the standalone deployment", () => {
const second = await context.newPage();
await second.clock.install();
await second.addInitScript(logStatus);
await second.goto(`/app/index.html?doc=${doc}`);
// It PAINTS. That is the whole point: a locked document is read-only, not
@@ -242,6 +258,15 @@ test.describe("the standalone deployment", () => {
await expect(second.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(second.locator("#status")).toContainText("read-only");
await expect(second.locator("#status")).toContainText("another tab");
// The page's clock has not been walked, so a reason that arrived AFTER the
// watchdog would have been logged behind "did not answer". Nor was success
// ever claimed: the Snapshot that follows the notice must not overwrite it.
const early = await second.evaluate(() => window.__statusLog);
expect(early.at(-1), `#status said: ${json(early)}`).toContain("read-only");
expect(
early.filter((t) => t.includes("did not answer") || t === `in-page worker · ${doc}`),
`#status said: ${json(early)}`,
).toEqual([]);
// And the reason STANDS: it is not replaced 8s later by a watchdog
// blaming the browser for something the code knew precisely.
@@ -252,6 +277,10 @@ test.describe("the standalone deployment", () => {
await second.clock.runFor(HANDSHAKE_TIMEOUT_MS + 2_000);
await expect(second.locator("#status")).toContainText("read-only");
await expect(second.locator("#status")).not.toContainText("did not answer");
const late = await second.evaluate(() => window.__statusLog);
expect(late.some((t) => t.includes("did not answer")), `#status said: ${json(late)}`).toBe(
false,
);
await second.close();
await first.close();
+184
View File
@@ -0,0 +1,184 @@
# Everything CI *builds*, as Nix derivations (crane). The script-only checks
# (node tests, kb, the checkers, the comment and CI-graph gates, fmt) stay dev
# shell steps in ci.yml: they compile nothing, and their `#!/usr/bin/env bash`
# shebangs -- which scripts/checkers.sh itself enforces -- cannot run in the
# build sandbox, which has no /usr/bin/env.
#
# Why Nix: the self-hosted runners substitute from titan's harmonia first, and
# titan builds gh/main's outputs hourly. Dependencies are separate derivations
# (buildDepsOnly, keyed on the Cargo.lock), so a PR that leaves the lock alone
# rebuilds only the workspace.
{
pkgs,
craneLib,
wasmBindgenCli,
src,
rev,
}: let
inherit (pkgs) lib;
# Two lockfiles: harness/opfs-contract is its own workspace root.
cargoVendorDir = craneLib.vendorMultipleCargoDeps {
cargoLockList = [../Cargo.lock ../harness/opfs-contract/Cargo.lock];
};
common = {
inherit src cargoVendorDir;
pname = "improvise";
version = "0";
strictDeps = true;
# The `test` job ran the dev/test profiles, not crane's default release:
# debug assertions and overflow checks are part of what it tested.
CARGO_PROFILE = "";
};
wasmTarget = "wasm32-unknown-unknown";
# The crates in the browser bundle, checked for wasm32 one at a time: a
# single `cargo check -p a -p b` unifies features across them and can hide
# a crate that only builds with a sibling's features (improvise-ywd).
wasmCrates = [
"improvise-core"
"improvise-formula"
"improvise-protocol"
"improvise-io"
"improvise-command"
"improvise-wasm-client"
"improvise-worker-server"
"improvise-gp"
];
pFlags = crates: lib.concatMapStringsSep " " (c: "-p ${c}") crates;
nativeDeps = craneLib.buildDepsOnly (common
// {
pname = "improvise-deps";
cargoExtraArgs = "--locked --workspace";
});
wasmCheckDeps = craneLib.buildDepsOnly (common
// {
pname = "improvise-wasm-check-deps";
cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags wasmCrates}";
cargoCheckExtraArgs = "";
doCheck = false;
});
# What wasm-pack's `cargo build --release --target wasm32` needs.
bundleDeps = craneLib.buildDepsOnly (common
// {
pname = "improvise-bundle-deps";
CARGO_PROFILE = "release";
cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags ["improvise-wasm-client" "improvise-worker-server"]}";
cargoCheckCommand = "true";
doCheck = false;
});
# A derivation that runs cargo commands and only has to succeed.
cargoCheck = name: args:
craneLib.mkCargoDerivation (common
// {
pname = "improvise-${name}";
doInstallCargoArtifacts = false;
installPhaseCommand = "mkdir -p $out";
}
// args);
in rec {
checks = {
clippy = craneLib.cargoClippy (common
// {
pname = "improvise-clippy";
cargoArtifacts = nativeDeps;
cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings";
});
# The evidence gate runs `cargo test --workspace -- --list`, so it runs
# here, where the tests are already compiled, not as a separate step.
test = craneLib.cargoTest (common
// {
pname = "improvise-test";
cargoArtifacts = nativeDeps;
cargoTestExtraArgs = "--workspace";
nativeBuildInputs = [pkgs.python3 pkgs.git];
# tests/shipped_documents_load.rs walks the git index (`git ls-files`)
# and makes scratch repos. The flake source is exactly the tracked
# files, so force-adding all of it (but crane's target/) rebuilds the
# checkout's index, ignored-but-tracked documents included.
preBuild = ''
export HOME=$TMPDIR
export GIT_AUTHOR_NAME=nix GIT_AUTHOR_EMAIL=nix@localhost
export GIT_COMMITTER_NAME=nix GIT_COMMITTER_EMAIL=nix@localhost
git init -q .
git add --all --force -- . ':!target'
'';
postBuild = ''
python3 scripts/check-comment-runs.py --check-evidence
'';
});
wasm-check = cargoCheck "wasm-check" {
cargoArtifacts = wasmCheckDeps;
buildPhaseCargoCommand = lib.concatMapStringsSep "\n" (c: ''
echo "== ${c}"
cargo check --locked -p ${c} --target ${wasmTarget}
'')
wasmCrates;
};
# harness/opfs-contract is its own workspace, so the root clippy never
# reaches it; `-p` keeps cargo fmt to this one crate (see ci.yml).
harness-lint = cargoCheck "harness-lint" {
cargoArtifacts = null;
buildPhaseCargoCommand = ''
cd harness/opfs-contract
cargo fmt -p opfs-contract -- --check
cargo clippy --locked --target ${wasmTarget} --all-targets -- -D warnings
'';
};
};
packages = {
# The dependency builds on their own, for titan's cache timer to keep as a
# GC root: the checks' outputs do not reference them, so rooting only the
# checks would let the part a PR actually reuses be collected.
ci-deps = pkgs.linkFarmFromDrvs "improvise-ci-deps" [nativeDeps wasmCheckDeps bundleDeps];
# harness/build.sh, unchanged: the three wasm bundles and harness/serve.
# No network: wasm-bindgen and wasm-opt come from PATH, so wasm-pack
# downloads nothing (see wasmBindgenCli in flake.nix).
web = craneLib.mkCargoDerivation (common
// {
pname = "improvise-web";
cargoArtifacts = bundleDeps;
doInstallCargoArtifacts = false;
nativeBuildInputs = [pkgs.wasm-pack wasmBindgenCli pkgs.binaryen pkgs.nodejs_24];
# web/assemble.sh calls scripts/assemble-stamp.sh, whose
# `#!/usr/bin/env bash` has no interpreter in the sandbox. Patching
# this private copy is harmless: no checker runs here.
postPatch = "patchShebangs scripts web harness";
buildPhaseCargoCommand = ''
export HOME=$TMPDIR
./harness/build.sh
'';
installPhaseCommand = ''
mkdir -p $out
cp -r harness/serve $out/harness-serve
cp -r web/pkg web/worker-pkg $out/
'';
});
# The Pages tree, stamped with the commit it was built from. Separate from
# `web` so the stamp changing every commit does not rebuild the bundles.
site =
pkgs.runCommand "improvise-site" {
nativeBuildInputs = [pkgs.nodejs_24];
} ''
cp -r ${src} tree
chmod -R u+w tree
patchShebangs tree/scripts tree/web
cp -r ${packages.web}/pkg ${packages.web}/worker-pkg tree/web/
chmod -R u+w tree/web
cd tree
./web/assemble.sh $out ${rev}
'';
};
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# The dead-ID audit: every improvise-<id> the tree cites must be a ticket bd
# knows. See AGENTS.md, "Cite a ticket ID you have read back" (improvise-52eq).
set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage: bd-audit-ids
Run from anywhere inside the repository. Takes no arguments.
exit 0 every cited id is a ticket bd lists
exit 1 at least one cited id is not; each is printed
exit 3 bd could not answer: missing, failing, or listing nothing
exit 4 usage error, or the file scan itself failed
The comparison is POSITIVE: a cited id passes only by appearing in what
`bd list --all` returns. The recipe this replaced looked for bd's "not found"
lines and reported "all resolve" when there were none, so a bd that never ran
read as a clean audit.
THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same
pipeline or `;` list.
EOF
exit 4
}
[ $# -eq 0 ] || usage
BD="${BD_AUDIT_BD:-bd}"
ROOTS=(context README.md AGENTS.md CLAUDE.md crates src tests examples web
harness docs .agents .claude)
# Crate, package, artifact and file names that are shaped like ids. Anchored,
# because an unanchored `gp` would silently exclude any real ticket id starting with it.
EXCLUDE='^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|report|side|web)$'
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
cd "$(git rev-parse --show-toplevel)"
git ls-files -z --cached --others --exclude-standard -- "${ROOTS[@]}" > "$work/files"
# xargs exits 123 when one grep batch matched nothing, which is not an error.
set +e
xargs -0 grep -d skip -ohE 'improvise-[a-z0-9]{3,8}\b' < "$work/files" > "$work/raw"
rc=$?
set -e
[ "$rc" -eq 0 ] || [ "$rc" -eq 123 ] || { echo "bd-audit-ids: grep failed ($rc)" >&2; exit 4; }
sort -u "$work/raw" | { grep -vE "$EXCLUDE" || true; } > "$work/cited"
if ! command -v "$BD" >/dev/null 2>&1; then
echo "bd-audit-ids: '$BD' is not on PATH, so nothing was checked." >&2
exit 3
fi
if ! "$BD" list --all --limit 0 --json > "$work/known.json" 2> "$work/bd.err"; then
echo "bd-audit-ids: '$BD list' failed, so nothing was checked:" >&2
cat "$work/bd.err" >&2
exit 3
fi
if ! jq -r '.[].id' "$work/known.json" 2>/dev/null | sort -u > "$work/known"; then
echo "bd-audit-ids: '$BD list' did not print a JSON list of issues." >&2
exit 3
fi
if [ ! -s "$work/known" ]; then
echo "bd-audit-ids: '$BD list' returned no issues; refusing to call that a pass." >&2
exit 3
fi
comm -23 "$work/cited" "$work/known" > "$work/dead"
if [ -s "$work/dead" ]; then
echo "bd-audit-ids: cited but not a ticket bd knows:"
sed 's/^/ /' "$work/dead"
exit 1
fi
printf 'bd-audit-ids: all %d cited ids resolve (bd knows %d)\n' \
"$(wc -l < "$work/cited" | tr -d ' ')" "$(wc -l < "$work/known" | tr -d ' ')"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# check_the_checker for scripts/bd-audit-ids. A throwaway git repo and a fake
# bd; no network, no real tracker. See context/test-evidence/tooling.md.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AUDIT="$here/bd-audit-ids"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
# The fake bd answers `list` from $FAKE_BD_OUT and exits $FAKE_BD_RC; any
# other subcommand is an error, so the audit cannot pass by calling `show`.
mkdir -p "$work/bin"
cat > "$work/bin/bd" <<'EOF'
#!/usr/bin/env bash
[ "${1:-}" = list ] || { echo "fake bd: unexpected '$*'" >&2; exit 2; }
[ -f "${FAKE_BD_OUT:-}" ] && cat "$FAKE_BD_OUT"
exit "${FAKE_BD_RC:-0}"
EOF
chmod +x "$work/bin/bd"
repo="$work/repo"
mkdir -p "$repo/context" "$repo/harness/serve"
git -C "$repo" init -q
printf 'serve/\n' > "$repo/harness/.gitignore"
# A live id, and a crate name the exclusion list must drop: without the
# exclusion, improvise-core would be reported dead and the first case fails.
printf 'See improvise-ab12 in improvise-core.\n' > "$repo/context/notes.md"
# Ignored build output with a dead id. The audit reads git's file list, so
# this must never be reported (improvise-8haj).
printf 'improvise-gone9\n' > "$repo/harness/serve/copy.md"
printf '[{"id":"improvise-ab12"},{"id":"improvise-cd34"}]\n' > "$work/known.json"
printf '' > "$work/empty"
printf '[]\n' > "$work/none.json"
# $1 name, $2 expected exit, $3 grep -E pattern the output must contain;
# the caller sets FAKE_BD_* and BD_AUDIT_BD in the environment.
run_case() {
local name="$1" want="$2" pat="$3"
(cd "$repo" && "$AUDIT") > "$work/out" 2>&1
local got=$?
if [ "$got" != "$want" ]; then bad "$name" "exit $got, wanted $want: $(head -c 200 "$work/out")"
elif ! grep -Eq "$pat" "$work/out"; then bad "$name" "no /$pat/ in: $(head -c 200 "$work/out")"
else ok "$name"; fi
}
export BD_AUDIT_BD="$work/bin/bd"
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=0 \
run_case "every cited id known is a pass" 0 'all 1 cited ids resolve'
printf 'Also improvise-zzzq.\n' > "$repo/context/dead.md"
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=0 \
run_case "a dead id is exit 1, and named" 1 '^ improvise-zzzq$'
rm -f "$repo/context/dead.md"
# The three shapes of "bd never answered". The recipe this replaced reported
# "all resolve" for every one of them (improvise-52eq).
FAKE_BD_OUT="$work/empty" FAKE_BD_RC=0 \
run_case "a bd that prints nothing is not a pass" 3 'did not print a JSON list|returned no issues'
FAKE_BD_OUT="$work/none.json" FAKE_BD_RC=0 \
run_case "a bd that lists no issues is not a pass" 3 'returned no issues'
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=1 \
run_case "a bd that fails is not a pass" 3 'list. failed'
BD_AUDIT_BD="$work/bin/no-such-bd" \
run_case "a missing bd is not a pass" 3 'is not on PATH'
# The live repo, with the fake bd listing nothing: if this passed, a real
# audit whose bd fell silent would pass too.
(cd "$here/.." && FAKE_BD_OUT="$work/none.json" "$AUDIT") > "$work/out" 2>&1
got=$?
[ "$got" = 3 ] && ok "the real tree does not pass against a silent bd" \
|| bad "the real tree does not pass against a silent bd" "exit $got"
"$AUDIT" --help > "$work/out" 2>&1
got=$?
[ "$got" = 4 ] && grep -q 'POSITIVE' "$work/out" \
&& ok "--help is exit 4 and states the positive comparison" \
|| bad "--help is exit 4 and states the positive comparison" "exit $got"
printf '\nbd-audit-ids-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+9 -3
View File
@@ -133,9 +133,15 @@ EOF
chmod +x "$work/fakebin/bash"
# The runner is invoked through a REAL bash, so the fake one below is only
# what its own `command -v bash` probe finds -- not its interpreter.
# /usr/bin:/bin carries the utilities the runner itself needs and carries no
# nix, so the fake bash is the only candidate and the nix fallback is absent.
env PATH="$work/fakebin:/usr/bin:/bin" HOME="$HOME" \
# utilbin carries only the utilities the runner itself needs, and no nix, so
# the fake bash is the only candidate and the nix fallback is absent. Linked
# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env`
# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3.
mkdir -p "$work/utilbin"
for tool in dirname; do
ln -sf "$(command -v "$tool")" "$work/utilbin/$tool"
done
env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \
"${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1
got=$?
[ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \
+6 -2
View File
@@ -9,7 +9,8 @@ usage() {
usage: pr-reviews <pr-number> [--wait <sec>] [--interval <sec>]
--wait <sec> wait up to this long for a review or an inline review
comment whose commit is the current head
comment whose commit is the current head; any --wait
above 0 polls at least once more after the first read
(default 0 = print what exists and exit)
--interval <sec> seconds between polls (default 30)
@@ -157,8 +158,10 @@ report "$json"
if has_head_review "$json"; then exit 0; fi
# The deadline is checked AFTER each poll: checked first, a second ticking over
# between two reads of the clock skipped the only re-poll (improvise-qnpi).
deadline=$(( $(date +%s) + WAIT ))
while [ "$(date +%s)" -lt "$deadline" ]; do
while :; do
sleep "$INTERVAL"
json=$(fetch)
if has_head_review "$json"; then
@@ -166,6 +169,7 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
report "$json"
exit 0
fi
[ "$(date +%s)" -lt "$deadline" ] || break
done
printf '\npr-reviews: waited %ss; nothing against the current head.\n' "$WAIT"
+53
View File
@@ -197,6 +197,59 @@ mk "$d" last.issue.json <<'EOF'
EOF
run_case "an inline comment arriving mid-wait is seen" 0 "$d" --wait 1 --interval 1
# The same fixture on a clock that ticks one second per read: a loaded machine,
# compressed. A second passing between two reads must not cost the only poll.
clock="$work/clock"
mkdir -p "$clock"
cat > "$clock/date" <<'EOF'
#!/usr/bin/env bash
[ "${1:-}" = "+%s" ] || exec /bin/date "$@"
n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$CLOCK_TICKS"
echo $(( 1000 + n ))
EOF
printf '#!/usr/bin/env bash\nexit 0\n' > "$clock/sleep"
chmod +x "$clock/date" "$clock/sleep"
rm -f "$d/.poll" "$work/ticks"
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$d" \
"$REVIEWS" 1 --wait 1 --interval 1 > "$work/out" 2>&1
got=$?
[ "$got" = 0 ] && ok "a second passing before the first re-poll does not skip it" \
|| bad "a second passing before the first re-poll does not skip it" "exit $got, wanted 0"
# Every fixture above resolves by poll 2, so a loop that stops after one
# re-poll passed them all (improvise-7epn). This one resolves on poll 3.
d3="$work/arrives-third"
for n in 1 2; do
mk "$d3" "$n.pr.json" <<EOF
{"headRefOid":"$HEAD","reviews":[]}
EOF
mk "$d3" "$n.inline.json" <<'EOF'
[]
EOF
mk "$d3" "$n.issue.json" <<'EOF'
[]
EOF
done
cp -f "$d/last.pr.json" "$d/last.inline.json" "$d/last.issue.json" "$d3/"
rm -f "$d3/.poll" "$work/ticks"
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$d3" \
"$REVIEWS" 1 --wait 10 --interval 1 > "$work/out" 2>&1
got=$?
polls=$(cat "$d3/.poll" 2>/dev/null || echo 0)
[ "$got" = 0 ] && [ "$polls" = 3 ] && ok "a review arriving on the third poll is seen" \
|| bad "a review arriving on the third poll is seen" "exit $got after $polls polls, wanted 0 after 3"
# The timeout path: nothing ever arrives, so --wait 5 on a clock that ticks
# once per read must keep polling until the deadline, then report exit 1.
rm -f "$work/empty/.poll" "$work/ticks"
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$work/empty" \
"$REVIEWS" 1 --wait 5 --interval 1 > "$work/out" 2>&1
got=$?
polls=$(cat "$work/empty/.poll" 2>/dev/null || echo 0)
[ "$got" = 1 ] && [ "$polls" -ge 5 ] && ok "--wait keeps polling until its deadline" \
|| bad "--wait keeps polling until its deadline" "exit $got after $polls polls, wanted 1 after 5 or more"
# ---------------------------------------------------------------------------
# `gh api --paginate` emits one array PER PAGE, so a thread past the page size
# arrives as several concatenated documents. Passing that straight to
+27 -15
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Waits for a PR's checks to finish and says what happened. Every rule in here
# is one this repo got wrong first; see context/code/pr-watch.md.
# is one this repo got wrong first; see context/code/tooling.md section 3.
set -euo pipefail
usage() {
@@ -11,10 +11,12 @@ usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>
--timeout <sec> give up after this long (default 1800)
--interval <sec> seconds between polls (default 20)
exit 0 every check COMPLETED and none failed
exit 1 a check failed, or the PR is not mergeable
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
state of CLEAN, UNSTABLE or HAS_HOOKS
exit 1 a check failed, or the PR is not mergeable (any other known state)
exit 2 the head moved during the wait, or is not --expect
exit 3 timed out with checks still running
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
or still BLOCKED with every registered check green
exit 4 usage or environment error
EOF
exit 4
@@ -86,13 +88,28 @@ while :; do
total="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]?] | length')"
running="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length')"
failed="$(printf '%s' "$json" | jqf \
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
# UNKNOWN and green-but-BLOCKED are waited on: see context/code/tooling.md,
# "pr-watch and mergeability" (improvise-edpl, improvise-zlj2).
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
break
if [ "$failed" -gt 0 ] || [ "$mergeable" = CONFLICTING ]; then break; fi
case "$mergeable/$state" in
UNKNOWN/*|*/UNKNOWN|MERGEABLE/BLOCKED) ;;
*) break ;;
esac
fi
now=$(date +%s)
if [ $((now - started)) -ge "$TIMEOUT" ]; then
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
echo "pr-watch: timed out after ${TIMEOUT}s with mergeability $mergeable/$state" >&2
else
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
fi
exit 3
fi
sleep "$INTERVAL"
@@ -100,15 +117,10 @@ done
printf '%s' "$json" | jqf '.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | " \(.conclusion) \(.name)"'
failed="$(printf '%s' "$json" | jqf \
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
[ "$failed" -eq 0 ] || exit 1
# UNSTABLE means a non-required check is unhappy; it is not a merge blocker on
# its own, so only an outright non-mergeable state fails here.
[ "$mergeable" != "CONFLICTING" ] || exit 1
exit 0
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
[ "$mergeable" = MERGEABLE ] || exit 1
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
+91
View File
@@ -73,5 +73,96 @@ run_case "a head that moves mid-wait is not a pass" 2 "$work/moved" --timeout 60
run_case "--expect refuses a different head" 2 "$work/green" --expect bbbbbbbb
run_case "--expect accepts a prefix of the head" 0 "$work/green" --expect aaaa
# improvise-edpl. GitHub computes mergeability lazily: for a while after the
# base moves it reports UNKNOWN, and PRs #172 and #179 both exited 0 there.
# This is the shape both had: every check green, mergeability unresolved.
green_rollup='[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]'
red_rollup='[{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]'
# $1 dir, $2 file, $3 mergeable, $4 mergeStateStatus, $5 rollup (JSON values)
state() {
mkdir -p "$1"
printf '{"headRefOid":"aaaaaaaaaaaa","mergeable":%s,"mergeStateStatus":%s,"statusCheckRollup":%s}\n' \
"$3" "$4" "$5" > "$1/$2"
}
state "$work/unk-conflict" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
state "$work/unk-conflict" last.json '"CONFLICTING"' '"DIRTY"' "$green_rollup"
run_case "UNKNOWN that resolves to CONFLICTING fails" 1 "$work/unk-conflict" --timeout 60
state "$work/unk-clean" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
state "$work/unk-clean" 2.json '"MERGEABLE"' '"UNKNOWN"' "$green_rollup"
state "$work/unk-clean" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
run_case "UNKNOWN that resolves to MERGEABLE/CLEAN passes" 0 "$work/unk-clean" --timeout 60
state "$work/unk-forever" last.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
run_case "UNKNOWN until the timeout is a timeout, not a pass" 3 "$work/unk-forever"
# A failed check fails whatever mergeability says; waiting on it would turn a
# known red into a timeout.
state "$work/unk-red" last.json '"UNKNOWN"' '"UNKNOWN"' "$red_rollup"
run_case "a failed check fails without waiting on mergeability" 1 "$work/unk-red" --timeout 0
# The whole table, enumerated rather than hand-picked, every check green. The
# rule it states is in context/code/tooling.md, "pr-watch and mergeability".
for m in MERGEABLE CONFLICTING UNKNOWN; do
for s in BEHIND BLOCKED CLEAN DIRTY DRAFT HAS_HOOKS UNKNOWN UNSTABLE SOMETHING_NEW null; do
if [ "$m" = CONFLICTING ]; then want=1
elif [ "$m" = UNKNOWN ] || [ "$s" = UNKNOWN ]; then want=3
else case "$s" in CLEAN|UNSTABLE|HAS_HOOKS) want=0 ;; BLOCKED) want=3 ;; *) want=1 ;; esac
fi
sv="\"$s\""; [ "$s" = null ] && sv=null
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
done
done
# improvise-zlj2. The required "Browser harness" check comes from a job that
# `needs: build`, so its check run does not exist until build finishes. In that
# gap every registered check is green and GitHub says MERGEABLE/BLOCKED. The
# rollup below is PR #189's, verbatim; the gap states drop or rewind entries.
cat > "$work/rollup.jsonl" <<'EOF'
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:53Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406461","name":"Build all bundles","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047406233","name":"plan","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"Release"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482378","name":"build-local-artifacts (${{ join(matrix.targets, ', ') }})","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:42:22Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406506","name":"Test and lint (host)","startedAt":"2026-09-23T04:40:16Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482733","name":"build-global-artifacts","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:40Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406409","name":"wasm32 check (browser bundle crates)","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482801","name":"host","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:25Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406415","name":"Browser JS unit tests (node --test)","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047483313","name":"announce","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406264","name":"Knowledge base consistency","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:39Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406373","name":"Lint the harness crate","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047753280","name":"Browser harness (OPFS contract + shell)","startedAt":"2026-09-23T04:41:56Z","status":"COMPLETED","workflowName":"CI"}
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:18Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107048259684","name":"Deploy to GitHub Pages","startedAt":"2026-09-23T04:44:18Z","status":"COMPLETED","workflowName":"CI"}
EOF
harness='Browser harness (OPFS contract + shell)'
deploy='Deploy to GitHub Pages'
# $1 dir, $2 file, $3 mergeable, $4 state, $5 jq filter over the rollup array
gap_state() {
state "$1" "$2" "\"$3\"" "\"$4\"" "$(jq -cs "$5" "$work/rollup.jsonl")"
}
build_running='map(if .name == "Build all bundles" then .status = "IN_PROGRESS" | .conclusion = "" else . end)'
not_yet="map(select(.name != \"$harness\" and .name != \"$deploy\"))"
harness_running="map(if .name == \"$harness\" then .status = \"IN_PROGRESS\" | .conclusion = \"\" else . end) | map(select(.name != \"$deploy\"))"
gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
run_case "a required check that never registers is a timeout" 3 "$work/gap-never"
# improvise-h3ni. The head must stay pinned across the UNKNOWN wait, or a
# verdict about b is reported as a verdict about a.
state "$work/unk-moved" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
state "$work/unk-moved" 2.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
state "$work/unk-moved" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
for f in 2.json last.json; do
sed 's/aaaaaaaaaaaa/bbbbbbbbbbbb/' "$work/unk-moved/$f" > "$work/unk-moved/$f.new"
mv -f "$work/unk-moved/$f.new" "$work/unk-moved/$f"
done
run_case "a head that moves during the UNKNOWN wait is not a pass" 2 "$work/unk-moved" --timeout 60
printf '\npr-watch-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+50 -2
View File
@@ -79,7 +79,7 @@ pub fn run_tui(
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
app.handle_key(convert_key_event(key))?;
press(&mut app, convert_key_event(key))?;
}
Event::Resize(w, h) => {
app.term_width = w;
@@ -99,6 +99,13 @@ pub fn run_tui(
Ok(())
}
/// One keypress in the TUI. A message lasts until the next key, so it is drawn
/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`).
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
app.handle_key(key)
}
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
// The only place crossterm's key types and `command::key` may both appear.
@@ -566,7 +573,8 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
#[cfg(test)]
mod bottom_bar_tests {
use super::{draw_bottom_bar, minibuffer_line};
use super::{draw_bottom_bar, minibuffer_line, press};
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::ui::app::{App, AppMode};
use crate::workbook::Workbook;
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
@@ -587,6 +595,46 @@ mod bottom_bar_tests {
.collect()
}
/// A message lasts until the next key, then the derived hint is back
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
#[test]
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let mut app = App::new(Workbook::new("T"), None);
app.view_state.status_msg = "Saved to x.improv".to_string();
press(&mut app, key(KeyCode::Char('T'))).unwrap();
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
let row = bottom_row(&app, 240);
assert!(
!row.contains("Saved to"),
"a stale message outlived a key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint in TileSelect: {row:?}"
);
press(&mut app, key(KeyCode::Char(' '))).unwrap();
let said = app.view_state.status_msg.clone();
assert!(!said.is_empty(), "the axis cycle must say what it did");
assert!(
bottom_row(&app, 240).contains(&said),
"a message was wiped before it was drawn"
);
press(&mut app, key(KeyCode::Esc)).unwrap();
let row = bottom_row(&app, 240);
assert!(
!row.contains(&said),
"the axis message outlived the next key: {row:?}"
);
assert!(
row.contains(&app.hint_text()),
"no hint back in Normal: {row:?}"
);
}
/// The wizard overlay draws correct per-step hints; the status bar stacked a
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
#[test]

Some files were not shown because too many files have changed in this diff Show More