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
This commit is contained in:
Edward Langley
2026-09-22 23:33:52 -07:00
32 changed files with 2315 additions and 416 deletions
+9 -3
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:
@@ -77,10 +78,15 @@ defect is a test that passes.
- 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.
- An ADR PR merges on the user's approval, not on a review:
`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
+51 -15
View File
@@ -29,12 +29,12 @@ 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 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).
## Commit early: a worktree is not durable storage
@@ -47,14 +47,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 +89,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
@@ -165,9 +191,19 @@ 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.
- **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
+116 -46
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,21 @@ 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. 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 +158,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
@@ -250,9 +253,20 @@ 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
`AGENTS.md` says merges over rebases, and a PR's commits are individually
meaningful.
### ADR pull requests
An ADR (`context/adr/`) merges on the user's approval, not on a review. 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"**.
### Never hand-edit `.beads/issues.jsonl`
bd manipulates that file and auto-exports to it. Editing it — or even
@@ -269,15 +283,37 @@ 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' \
git ls-files -z --cached --others --exclude-standard -- \
context README.md AGENTS.md CLAUDE.md crates src tests examples web harness docs \
.agents .claude \
| xargs -0 grep -d skip -ohE 'improvise-[a-z0-9]{3,8}\b' \
| sort -u \
| grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|side|web)$' \
| grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|report|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"
| grep -E '^(Issue [^ ]+ not found|[Nn]o such issue)' || echo "all resolve"
```
`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 three 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.
- **The skills twice.** `.claude/skills/*` are symlinks to directories under
`.agents/skills/`. `-d skip` passes over them, so the skills are read once,
through `.agents`.
Plain `git grep` is not a substitute: on macOS its `-E` silently ignores `\b`
and finds a fraction of the ids.
The last line matches at the start of a line because `bd show` prints each
ticket's body indented, and a body can itself contain the words "not found".
Three things the earlier version of this recipe got wrong, each of which let a
real defect through:
@@ -293,7 +329,8 @@ real defect through:
- **The exclusion list named only `core` and `wasm`**, so every other crate
name would have been reported missing. The exclusions above 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
@@ -327,8 +364,32 @@ 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"). 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 +405,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 -->
+3 -1
View File
@@ -40,7 +40,9 @@ 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"] }
+2 -1
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.
+170 -4
View File
@@ -935,6 +935,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
@@ -2629,8 +2675,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
@@ -2874,6 +2922,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,
@@ -3150,8 +3235,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.
@@ -3225,3 +3310,84 @@ 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`.
## 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. The
test
also asserts that the widest candidate it actually passes matches at least four
records. That guard catches a demo with too little data, and it catches the
derived drills being dropped from the argument list. It does **not** catch the
comparison losing its wide drills some other way, because it measures what the
argument lists match, not what the built commands ordered (improvise-b9f2). The
review showed this with a rival: a `drill-into-cell` parser that refuses fewer
than two arguments sends every one-coordinate drill to the one-record cursor
fallback. The guard still reports four records, and with the store's sort
removed the law passed 38 of 300 runs. With the store's sort removed and the
parser intact, the law fails 400 of 400 runs.
**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.
+77 -27
View File
@@ -756,28 +756,54 @@ 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 crate that parses a frame**: `improvise-ws-server`
(`transport.rs`), `improvise-worker-server` (`worker.rs`) and
`improvise-wasm-client` (`socket.rs`). Each crate's own manifest names it,
because each browser bundle is built from one crate alone. The `wasm` CI job
and `wasm-pack` both 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.
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.
That is also why 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.
`every_build_that_parses_a_frame_enables_float_roundtrip` asks `cargo tree`
per crate over normal edges instead. A new crate that parses frames belongs in
its list.
**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, `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.
Not covered, and filed separately rather than widened into this change
(improvise-6yvg): `improvise-io`'s JSON import and `improvise-command`'s JSON
reads also parse numbers with the default parser. They are file formats, not
frames. In the TUI binary nothing enables the feature. In the worker bundle
unification happens to reach them, so one import can give different numbers
in the two front ends.
**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
@@ -2064,14 +2090,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
+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.
+144 -19
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
@@ -2071,6 +2082,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
@@ -3341,9 +3396,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
@@ -4522,21 +4590,78 @@ before #178 too, as the review noted.
**Fault injected:** the same pre-fix tree. Fails: the host stored
`4598108823619341467` in South/Cost.
### `every_build_that_parses_a_frame_enables_float_roundtrip`
**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.
The two tests above run in **one** feature-unified test build, which includes
`improvise-ws-server` and `improvise-command` as dev-dependencies. So they pass
whenever **any** crate in that build enables the feature. The shipped browser
bundle is built from one crate alone, so it would still be lossy. That was
demonstrated: with the feature removed from `improvise-wasm-client` only, both
tests stayed green while `cargo tree` showed no `float_roundtrip` in the wasm
bundle. This test asks cargo instead, once per crate that parses a frame:
`cargo tree --locked --edges normal,features --invert serde_json --package
<crate>`, with `--target wasm32-unknown-unknown` for the two bundles. It uses
normal edges only, because a dev edge would unify the feature back in
(`context/repo-map.md`, `## Wasm`).
### resolve::tests::every_grid_move_lands_where_its_binding_says
**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.
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.
**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.
+161
View File
@@ -5314,6 +5314,167 @@ before `refuse_and_stop` was applied to its arms. For `load`, the injected fault
is removing its abort (`refuse_and_stop` back to `app.refuse`), and the test
then fails with "`load absent.improv . q` quit having loaded nothing".
### ui::effect::tests::a_drill_rows_measure_edits_follow_its_rename
improvise-uo2p. `ApplyAndClearDrill` walked `pending_edits`, a `HashMap`, and
applied each edit alone against the model as the previous one left it. A rename
moves the row's cells. A measure edit then wrote `row.cell_key(measure)`, the
snapshot's old key. So when the rename came first, a ghost record appeared at the
old coordinates carrying the new value, and the moved record kept the stale one.
The reviewer saw this in 16 of 40 fresh Apps. The fixture stages two measure edits
and a rename on drill row 1, so a wrong answer needs only one of the three orders
that put the rename last. Each assertion runs over 32 fresh Apps and compares the
**whole** store, so the ghost and the stale value are both visible. Before the
fix it failed on five of five runs. Rival 3 (the rewrite addresses measure edits
at the unrenamed key) fails it.
### ui::effect::tests::a_drill_row_renamed_twice_and_edited_lands_as_one_record
improvise-uo2p. This is the case that fails **every** time, not with some
probability. Each rename read the snapshot's old keys. The second rename found
nothing there, because the first had moved it, so one rename was always dropped
and the row split into a `Clothing/Feb` record and a `Drink/Jan` record. Rival 1
is the ticket's proposed fix direction: measures before renames, each edit
applied alone. It passes the ticket's own case and fails here. So "a fixed
order" is not a fix; the renames have to be composed.
### ui::effect::tests::a_drill_that_swaps_two_records_coordinates_swaps_them
improvise-uo2p. Row 0 is renamed onto row 1's coordinates and row 1 onto row 0's.
If the rows are applied in sequence, the first move overwrites the other record
and the second move carries the overwritten values back, so one record's data
is lost in either order. Rival 2 composes each row correctly but writes it
before reading the next row, and it fails here and nowhere else. That is why
`RecordRewrite::merged` reads every row off the model first, then clears every
old key, then writes.
### ui::effect::tests::a_drill_edit_addresses_its_own_record_not_the_one_renamed_onto_its_coordinates
improvise-uo2p. Row 1's staged `Cost` belongs to row 1's record, even though row
0 is being renamed away from coordinates and row 1 onto them. Before the fix,
some hash orders left `Clothing/Jan/Cost = 999` as a ghost. Rival 3 fails it.
### ui::effect::tests::two_drill_rows_renamed_onto_one_record_resolve_to_the_later_row
improvise-uo2p. Two rows renamed onto the same coordinates collide per cell. The
rule chosen is that the later snapshot row wins, which is what plain records
mode gives when the same renames are made top to bottom. Before the fix the
winner was the hash order's choice. Rival 4 reverses the row order in `merged`
and fails here. The rule is a choice, and it still loses data silently. See
`context/code/command.md`, `RecordRewrite`.
### ui::effect::tests::a_record_rewrite_is_the_same_under_every_order_of_its_edits
improvise-uo2p. The composition is order-independent **by construction**, so it
is asserted over all 24 orders of four edits on one row: two renames, a measure
write and a measure clear. The test pins one value as well as the equality, so
a rewrite that is the same wrong answer in every order still fails. Rival 3
fails it.
## `command/cmd/tile.rs` — the tile bar's offered set, by name (improvise-jgzw)
### the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included
This test pins a decision, not a bug. The argument is in `context/code/command.md`
under `cmd/tile.rs`. Before this test, no tile assertion named the whole offered
set. The two older ones named only the category at cursor 0, so a filter that
changed what the bar offers past the cursor was invisible. The fixture puts
`_Measure` on **Column**, as every shipped document does. The test then walks the
cursor past the end and asserts the exact list
`[_Index, _Dim, _Measure, Type, Month]`.
Rivals, injected, full workspace run:
- **`regular_category_names()` in `TileAxisOp`**, the obvious "no virtual
categories in pickers" fix, which drops `_Measure` too. It fails this test
(`["Type", "Month"]`), the two characterization tests above it, and
`lockstep.rs::a_locally_resolved_tile_axis_change_agrees_with_the_host`.
- **`View::tileable_cats` filtering `is_index_or_dim`**, the ticket's option (b).
With the list asserted this test fails by construction, so the point is what
*else* fails: `renders_each_tile_with_its_axis_label` (TUI) and
`the_tile_bar_route_into_records_mode_does_not_strand_the_user`. The second one
is improvise-bd9o's own test of the route this decision keeps.
## `command/cmd/determinism.rs` — the determinism law over every command (improvise-hzjo)
The reasoning is in `context/code/command.md`, "A command is a function of its
context". This entry records what each test can see.
### every_command_gives_equal_effects_in_independently_built_equal_contexts
It runs all 89 registrations, 1500+ (command, args) pairs, in four contexts built
from four separately parsed copies of `web/demo.improv`, and requires equal
effect `Debug` text. It passed at `ae96b8c` with no exemptions. It is not a
regression test for a live bug. It restores a law, and the evidence is which
rivals it catches:
- **`command-mode-backspace` reading `ctx.buffers.values().next()`** instead of
the `command` buffer: a real command following hash order. Injected, full
workspace run: this test is the **only** failure, because every
`text_buffer.rs` test builds a one-entry buffer map. With the comparison
switched to one reused context (`compare(&a, from_ref(&a))`) the same rival
**passes**. That is the half the ticket required: the distinct construction
is what does the work.
- **`DataStore::matching_positions` without its sort**: gotcha 18 re-shipped.
This entry first said the test catches it through `drill-into-cell` and
`fit-measure`'s record order. That was wrong (improvise-e1sm). `fit-measure`
never diverged, in 60 failing runs in review and in 354 here. And the catch
was a matter of luck. The only real drill was `Product/Latte, Quarter/Q2`,
which is one record with two cells, so each other context agrees with
probability 1/2. With the sort removed and the test binary run in a loop, the
law **passed 46 of 400 runs** at `fc0b13c`, and `drill-into-cell
["Product/Latte", "Quarter/Q2"]` was the only pair that ever diverged. The
arguments now include every item of every regular category as a
one-coordinate drill, read off the parsed document (four records, eight cells
each in the demo). With the same injection the law **passed 0 of 400**, and
all eight new drills diverged in about 1195 of 1200 pairwise comparisons each.
Without the injection it passed 400 of 400. The three `cell.rs` order tests
still pin the store directly.
- **The derived drills dropped from the argument list** (the
`all.extend(one_coordinate_drills(model))` line commented out): the test
fails before comparing anything, with "the widest drill orders 1 records".
That is the only way of losing the wide drills the guard can see. It measures
what the argument lists match, not what the built commands ordered, so it can
pass while the comparison has lost its wide drills (improvise-b9f2). Review
rival: `drill-into-cell`'s parser changed to refuse fewer than two arguments,
which sends every one-coordinate drill to the one-record cursor fallback. The
guard stays silent and, with gotcha 18 re-injected, the law passed 38 of 300
runs.
- **A stale exemption**: `("quit", "stale")` listed in `OUTSIDE_THE_CONTEXT`.
It fails as "listed … but did not".
**A vacuity this test itself had on its first version.** The fixture's filler
buffers held `v0`…`v15`. The `command-mode-backspace` rival pops a character,
so every value collapsed to `v` (or `v1`), and the rival passed across two
distinct contexts. This is the "two derivations the fixture makes agree" shape.
The values are now `value {i} of 16`. The comparison was also widened from one
other instance to three, because a take-the-first rival agrees across two seeds
about one time in 21.
### only_distinct_instances_can_see_a_command_that_follows_hash_order
The check-the-checker behind the law. A test-only `EmitsInHashOrder` emits one
`SetBuffer` per `ctx.buffers` entry in iteration order. It must equal itself on
one context, which is why the old single-context law was vacuous, and differ
across two independently built ones. If a future refactor made the fixture
share one map, or share one `RandomState`, the second assertion fails. Without
this test the law would then pass everything and look healthy.
## JSON import numbers (improvise-6yvg)
### `a_json_import_stores_its_numbers_bit_for_bit`
`ImportJsonHeadless` imports `[{"Region":"East","Revenue":0.24815714285714285}]`
and the test reads Region=East, _Measure=Revenue back by `f64::to_bits`.
`StartImportWizard` parses through the same `serde_json::from_slice` call, so
one test covers the crate's feature.
**Red at `1158c23`** in `cargo test -p improvise-command`: stored
`0.24815714285714283`. That reproduces improvise-6yvg. **Limitation:** this test
cannot see the feature removed from `improvise-command` alone, because
`improvise-io`, a normal dependency, enables it in the same build. That case is
held by `every_crate_that_can_parse_json_enables_float_roundtrip` (`tooling.md`).
### the_grid_and_a_csv_export_agree_on_a_fold_row
The projection-level pin PR #203's review asked to keep. The review's own
+23
View File
@@ -1362,6 +1362,29 @@ fold is the row `z` names the group from. Note the narrowing this implies: an
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.
### 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`
+14
View File
@@ -1434,3 +1434,17 @@ 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 numbers (improvise-6yvg)
### `a_legacy_json_workbook_loads_its_numbers_bit_for_bit`
A workbook with Food/Jan holding `0.24815714285714285` is serialized with
`serde_json` and loaded through `load_from`. A leading `{` sends it down the
legacy JSON path. The test first asserts that the text holds the exact decimal,
so what it measures is the parse. It compares `f64::to_bits`. Found by the
review of PR #190, not by the ticket.
**Red at `1158c23`** in `cargo test -p improvise-io`: loaded
`0.24815714285714283`. Green once `improvise-io`'s own manifest enables
`float_roundtrip`, and red again with only that line reverted.
+91
View File
@@ -1100,3 +1100,94 @@ 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.
+38
View File
@@ -681,3 +681,41 @@ reproduction.
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)`, and so does
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit`.
`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.
+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).
@@ -1,8 +1,8 @@
use crate::model::category::MEASURE_CATEGORY;
use crate::model::cell::{CellKey, CellValue};
use crate::model::cell::CellKey;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use crate::view::{RecordCol, RecordRow};
use crate::view::RecordCol;
use super::core::{Cmd, CmdContext, read_buffer};
use super::grid::AddRecordRow;
@@ -14,6 +14,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 {
@@ -622,47 +623,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,
@@ -674,11 +636,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),
@@ -0,0 +1,233 @@
//! 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, HashSet};
use super::core::{Cmd, CmdContext};
use super::registry::tests::candidate_args;
use crate::command::key::{KeyCode, KeyModifiers};
use crate::model::Model;
use crate::model::cell::{CellKey, CellValue};
use crate::model::record::record_of;
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
}
/// How many distinct records the widest all-`Cat/Item` candidate matches.
fn widest_drill(model: &Model, candidates: &[Vec<String>]) -> usize {
let coordinates = |args: &Vec<String>| -> Option<Vec<(String, String)>> {
args.iter()
.map(|a| {
a.split_once('/')
.map(|(c, i)| (c.to_string(), i.to_string()))
})
.collect()
};
candidates
.iter()
.filter(|args| !args.is_empty())
.filter_map(coordinates)
.map(|partial| {
let cells = model.data.matching_cells(&partial);
let records: HashSet<CellKey> = cells.iter().map(|(k, _)| record_of(k)).collect();
records.len()
})
.max()
.unwrap_or(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<String> {
let cmd = ctx.registry.interactive(name, args, ctx).ok()?;
Some(format!("{:?}", 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,
}
fn compare(a: &CmdContext, others: &[CmdContext]) -> Comparison {
let mut found = Comparison::default();
let candidates = args_the_document_names(a.model);
for name in a.registry.names().collect::<Vec<_>>() {
let before = found.compared;
for args in &candidates {
let Some(left) = effects_of(name, args, a) else {
continue;
};
found.compared += 1;
for b in others {
let right = effects_of(name, args, b).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 model = contexts[0].model;
let widest = widest_drill(model, &args_the_document_names(model));
assert!(
widest >= 4,
"the widest drill orders {widest} records, so the store's order is nearly a coin toss"
);
let found = compare(&contexts[0], &contexts[1..]);
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"
);
}
@@ -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;
@@ -498,13 +498,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"],
@@ -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();
+353 -59
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::fmt::Debug;
use std::path::PathBuf;
@@ -663,24 +664,19 @@ impl Effect for ApplyAndClearDrill {
let Some(drill) = app.drill_state.take() else {
return;
};
if drill.pending_edits.is_empty() {
return;
}
let mut rows: BTreeMap<usize, Vec<(&RecordCol, &str)>> = BTreeMap::new();
for ((record_idx, col), new_value) in &drill.pending_edits {
let Some(row) = drill.records.get(*record_idx) else {
continue;
};
match col {
RecordCol::Measure(measure) => {
apply_measure_edit(app, &row.cell_key(measure), new_value);
}
RecordCol::Category(category) => {
apply_coordinate_rename(app, row, category, new_value);
}
// Never staged: `commit_cell_value` applies a new measure or
// category immediately — a schema change is not a cell edit.
RecordCol::NewMeasure | RecordCol::NewCategory => {}
}
rows.entry(*record_idx)
.or_default()
.push((col, new_value.as_str()));
}
let model = &app.model_state.workbook.model;
let rewrite = RecordRewrite::merged(rows.iter().filter_map(|(record_idx, edits)| {
let row = drill.records.get(*record_idx)?;
Some(RecordRewrite::of(model, row, edits.iter().copied()))
}));
for effect in rewrite.effects() {
effect.apply(app);
}
// No `dirty = true` here: this declares `Model(CellData)` and
// `App::apply_effects` raises the flag off that (improvise-gv9r).
@@ -690,51 +686,114 @@ impl Effect for ApplyAndClearDrill {
}
}
/// Write one measure cell, or clear it when the edit is blank.
fn apply_measure_edit(app: &mut App, key: &CellKey, new_value: &str) {
let model = &mut app.model_state.workbook.model;
match new_value {
"" => {
model.clear_cell(key);
}
v => {
let value = v
.parse::<f64>()
.map(CellValue::Number)
.unwrap_or_else(|_| CellValue::Text(v.to_string()));
model.set_cell(key.clone(), value);
}
}
/// A records row's edits composed into the one record they mean, read off the model
/// before anything is written. Why composed: `context/code/command.md`, `RecordRewrite`.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct RecordRewrite {
refused: bool,
items: Vec<(String, String)>,
clears: Vec<CellKey>,
sets: Vec<(CellKey, CellValue)>,
}
/// Rename one coordinate of a records row. The row is a group of cells —
/// one per measure — so every one of them moves (improvise-c5v).
fn apply_coordinate_rename(app: &mut App, row: &RecordRow, category: &str, new_value: &str) {
if new_value.is_empty() {
app.refuse(RECORD_COORDS_CANNOT_BE_EMPTY);
return;
}
let keys: Vec<CellKey> = if row.values.is_empty() {
vec![row.coords.clone()]
} else {
row.values
.iter()
.map(|(measure, _)| row.cell_key(measure))
.collect()
};
let _ = app
.model_state
.workbook
.add_item(category, new_value.to_string());
for old_key in keys {
let Some(value) = app.model_state.workbook.model.get_cell(&old_key).cloned() else {
continue;
impl RecordRewrite {
/// `row`'s edits, each naming a distinct column. The result does not depend on
/// their order: every coordinate is renamed before any cell is placed.
pub(crate) fn of<'a>(
model: &crate::model::Model,
row: &RecordRow,
edits: impl IntoIterator<Item = (&'a RecordCol, &'a str)>,
) -> Self {
let mut refused = false;
let mut renames: BTreeMap<&str, &str> = BTreeMap::new();
let mut measures: BTreeMap<&str, Option<CellValue>> = BTreeMap::new();
for (col, value) in edits {
match col {
RecordCol::Category(_) if value.is_empty() => refused = true,
RecordCol::Category(category) => {
renames.insert(category.as_str(), value);
}
RecordCol::Measure(measure) => {
measures.insert(measure.as_str(), improvise_protocol::typed(value));
}
// Never staged: a new measure or category is a schema change,
// applied at once by `commit_cell_value`.
RecordCol::NewMeasure | RecordCol::NewCategory => {}
}
}
let renamed = |key: &CellKey| {
renames.iter().fold(key.clone(), |key, (category, item)| {
key.with(*category, *item)
})
};
app.model_state.workbook.model.clear_cell(&old_key);
app.model_state
.workbook
.model
.set_cell(old_key.with(category, new_value), value);
let moved: Vec<(Option<&str>, CellKey)> = match (renames.is_empty(), row.values.is_empty())
{
(true, _) => Vec::new(),
(false, true) => vec![(None, row.coords.clone())],
(false, false) => row
.values
.iter()
.map(|(measure, _)| (Some(measure.as_str()), row.cell_key(measure)))
.collect(),
};
let mut rewrite = RecordRewrite {
refused,
items: renames
.iter()
.map(|(category, item)| (category.to_string(), item.to_string()))
.collect(),
..Self::default()
};
for (measure, old) in &moved {
let value = match measure.and_then(|m| measures.remove(m)) {
Some(edit) => edit,
None => model.get_cell(old).cloned(),
};
rewrite.clears.push(old.clone());
rewrite.sets.extend(value.map(|v| (renamed(old), v)));
}
for (measure, edit) in measures {
let key = renamed(&row.cell_key(measure));
match edit {
Some(value) => rewrite.sets.push((key, value)),
None => rewrite.clears.push(key),
}
}
rewrite
}
/// Several rows' rewrites as one: every clear lands before any write, so a
/// swap is a swap, and where two rows land on one cell the later row wins.
pub(crate) fn merged(rewrites: impl IntoIterator<Item = Self>) -> Self {
rewrites.into_iter().fold(Self::default(), |mut all, one| {
all.refused |= one.refused;
all.items.extend(one.items);
all.clears.extend(one.clears);
all.sets.extend(one.sets);
all
})
}
pub(crate) fn effects(self) -> Vec<Box<dyn Effect>> {
let refusal = self.refused.then(|| refuse(RECORD_COORDS_CANNOT_BE_EMPTY));
let items = self
.items
.into_iter()
.map(|(category, item)| Box::new(AddItem { category, item }) as Box<dyn Effect>);
let clears = self
.clears
.into_iter()
.map(|key| Box::new(ClearCell(key)) as Box<dyn Effect>);
let sets = self
.sets
.into_iter()
.map(|(key, value)| Box::new(SetCell(key, value)) as Box<dyn Effect>);
refusal
.into_iter()
.chain(items)
.chain(clears)
.chain(sets)
.collect()
}
}
@@ -3071,6 +3130,209 @@ mod tests {
assert_eq!(app.model_state.workbook.model.get_cell(&key), None);
}
// ── Composing a drill's staged edits (improvise-uo2p) ───────────────
fn drill_cell(ty: &str, month: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Type".into(), ty.into()),
("Month".into(), month.into()),
("_Measure".into(), measure.into()),
])
}
/// Two records, each carrying Revenue and Cost: row 0 is Food/Jan (1, 2),
/// row 1 is Clothing/Jan (42, 17). Every edit below is staged on row 1 at least.
fn two_record_drill() -> App {
let mut app = test_app();
let cells = [
(drill_cell("Food", "Jan", "Revenue"), CellValue::Number(1.0)),
(drill_cell("Food", "Jan", "Cost"), CellValue::Number(2.0)),
(
drill_cell("Clothing", "Jan", "Revenue"),
CellValue::Number(42.0),
),
(
drill_cell("Clothing", "Jan", "Cost"),
CellValue::Number(17.0),
),
];
for (key, value) in cells.clone() {
app.model_state.workbook.model.set_cell(key, value);
}
let records = RecordRow::group(cells);
assert_eq!(records.len(), 2, "one row per coordinate tuple");
assert_eq!(records[1].coords.get("Type"), Some("Clothing"));
StartDrill(records).apply(&mut app);
app
}
fn stage(app: &mut App, record_idx: usize, col: RecordCol, value: &str) {
SetDrillPendingEdit {
record_idx,
col,
new_value: value.to_string(),
}
.apply(app);
}
/// The whole store, as `(Type, Month, measure) = n`, so a ghost cannot hide.
fn drill_store(app: &App) -> std::collections::BTreeSet<String> {
app.model_state
.workbook
.model
.data
.iter_cells()
.map(|(key, value)| {
let coord = |c: &str| key.get(c).unwrap_or("-").to_string();
let n = value.as_f64().expect("every drill cell here is a number");
format!(
"{}/{}/{} = {n}",
coord("Type"),
coord("Month"),
coord("_Measure")
)
})
.collect()
}
fn category(name: &str) -> RecordCol {
RecordCol::Category(name.into())
}
fn measure(name: &str) -> RecordCol {
RecordCol::Measure(name.into())
}
/// Stage `edits` and leave the drill, in 32 fresh Apps: each `HashMap` gets its
/// own `RandomState`, and one map iterates the same way twice.
fn assert_drill_commits_to(edits: &[(usize, RecordCol, &str)], expected: &[&str]) {
let expected: std::collections::BTreeSet<String> =
expected.iter().map(|s| s.to_string()).collect();
for attempt in 0..32 {
let mut app = two_record_drill();
for (row, col, value) in edits {
stage(&mut app, *row, col.clone(), value);
}
app.apply_effects(vec![Box::new(ApplyAndClearDrill)]);
assert_eq!(drill_store(&app), expected, "fresh App #{attempt}");
}
}
#[test]
fn a_drill_row_renamed_twice_and_edited_lands_as_one_record() {
assert_drill_commits_to(
&[
(1, measure("Cost"), "999"),
(1, category("Type"), "Drink"),
(1, category("Month"), "Feb"),
],
&[
"Food/Jan/Revenue = 1",
"Food/Jan/Cost = 2",
"Drink/Feb/Revenue = 42",
"Drink/Feb/Cost = 999",
],
);
}
#[test]
fn a_drill_rows_measure_edits_follow_its_rename() {
assert_drill_commits_to(
&[
(1, measure("Revenue"), "100"),
(1, measure("Cost"), "999"),
(1, category("Type"), "Drink"),
],
&[
"Food/Jan/Revenue = 1",
"Food/Jan/Cost = 2",
"Drink/Jan/Revenue = 100",
"Drink/Jan/Cost = 999",
],
);
}
#[test]
fn a_drill_that_swaps_two_records_coordinates_swaps_them() {
assert_drill_commits_to(
&[
(0, category("Type"), "Clothing"),
(1, category("Type"), "Food"),
],
&[
"Clothing/Jan/Revenue = 1",
"Clothing/Jan/Cost = 2",
"Food/Jan/Revenue = 42",
"Food/Jan/Cost = 17",
],
);
}
#[test]
fn a_drill_edit_addresses_its_own_record_not_the_one_renamed_onto_its_coordinates() {
assert_drill_commits_to(
&[
(0, category("Type"), "Drink"),
(1, category("Type"), "Food"),
(1, measure("Cost"), "999"),
],
&[
"Drink/Jan/Revenue = 1",
"Drink/Jan/Cost = 2",
"Food/Jan/Revenue = 42",
"Food/Jan/Cost = 999",
],
);
}
#[test]
fn a_record_rewrite_is_the_same_under_every_order_of_its_edits() {
let app = two_record_drill();
let row = app.drill_state.as_ref().unwrap().records[1].clone();
let edits = [
(measure("Cost"), "999"),
(category("Type"), "Drink"),
(measure("Revenue"), ""),
(category("Month"), "Feb"),
];
let model = &app.model_state.workbook.model;
let mut seen = Vec::new();
for a in 0..4 {
for b in (0..4).filter(|&b| b != a) {
for c in (0..4).filter(|&c| c != a && c != b) {
let d = 6 - a - b - c;
let order = [a, b, c, d].map(|i| (&edits[i].0, edits[i].1));
seen.push(RecordRewrite::of(model, &row, order));
}
}
}
assert_eq!(seen.len(), 24);
let first = &seen[0];
assert_eq!(
first.clears,
vec![
drill_cell("Clothing", "Jan", "Revenue"),
drill_cell("Clothing", "Jan", "Cost")
]
);
assert_eq!(
first.sets,
vec![(drill_cell("Drink", "Feb", "Cost"), CellValue::Number(999.0))]
);
assert!(seen.iter().all(|rewrite| rewrite == first));
}
#[test]
fn two_drill_rows_renamed_onto_one_record_resolve_to_the_later_row() {
assert_drill_commits_to(
&[
(0, category("Type"), "Drink"),
(1, category("Type"), "Drink"),
],
&["Drink/Jan/Revenue = 42", "Drink/Jan/Cost = 17"],
);
}
// ── Toggle effects ──────────────────────────────────────────────────
#[test]
@@ -3283,6 +3545,38 @@ mod tests {
assert_eq!(app.view_state.status_msg, "Imported successfully");
}
#[test]
fn a_json_import_stores_its_numbers_bit_for_bit() {
const LOSSY: f64 = 0.248_157_142_857_142_85;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.json");
std::fs::write(
&path,
r#"[{"Region":"East","Revenue":0.24815714285714285}]"#,
)
.unwrap();
let mut app = test_app();
ImportJsonHeadless {
path,
model_name: None,
array_path: None,
}
.apply(&mut app);
let key = CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
let Some(CellValue::Number(n)) = app.model_state.workbook.model.get_cell(&key) else {
panic!(
"the import must store the number; status {:?}",
app.view_state.status_msg
);
};
assert_eq!(n.to_bits(), LOSSY.to_bits(), "stored {n:?}, not {LOSSY:?}");
}
// ── json_import_records (improvise-oaq helper) ──────────────────────
#[test]
+62
View File
@@ -1927,4 +1927,66 @@ mod tests {
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);
}
}
+3 -1
View File
@@ -17,7 +17,9 @@ indexmap = { version = "2", features = ["serde"] }
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).
@@ -205,6 +205,28 @@ mod tests {
assert_eq!(m2.model.name, "Budget");
}
#[test]
fn a_legacy_json_workbook_loads_its_numbers_bit_for_bit() {
use crate::storage::{MemoryFs, Storage};
const LOSSY: f64 = 0.248_157_142_857_142_85;
let key = coord(&[("Type", "Food"), ("Month", "Jan")]);
let mut wb = two_cat_model();
wb.model.set_cell(key.clone(), CellValue::Number(LOSSY));
let json = serde_json::to_string(&wb).unwrap();
assert!(
json.contains("0.24815714285714285"),
"the file holds the exact text"
);
let fs = MemoryFs::new();
let path = std::path::Path::new("legacy.improv");
fs.write(path, json.as_bytes()).unwrap();
let loaded = super::load_from(&fs, path).unwrap();
let Some(CellValue::Number(n)) = loaded.model.get_cell(&key) else {
panic!("the cell must load as a number");
};
assert_eq!(n.to_bits(), LOSSY.to_bits(), "loaded {n:?}, not {LOSSY:?}");
}
// ── save/load roundtrip via file ────────────────────────────────────
#[test]
+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
+270 -91
View File
@@ -304,14 +304,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 +323,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 +453,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 +531,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 +547,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 +614,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 +657,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 +695,7 @@ fn editing() -> Vec<HelpLine> {
.collect()
}
fn panels() -> Vec<HelpLine> {
fn panels(keys: KeysRow) -> Vec<HelpLine> {
let n = &ModeKey::Normal;
[
Some(HelpLine::Blank),
@@ -741,14 +843,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 +865,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 +971,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 +987,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 +1135,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 +1167,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 +1203,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, ..
+26
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
+340 -49
View File
@@ -619,6 +619,11 @@ enum GridMove {
Page(i32),
}
/// Rows one `page-scroll` step moves: three quarters of the window, at least one.
fn page_rows(window_rows: usize) -> i32 {
(window_rows as i32 * 3 / 4).max(1)
}
fn grid_move(state: &ClientState, kind: GridMove) -> Option<Vec<Command>> {
let GridExtent { rows, cols } = state.grid.extent;
let (window_rows, window_cols) = state.window;
@@ -641,7 +646,7 @@ fn grid_move(state: &ClientState, kind: GridMove) -> Option<Vec<Command>> {
GridMove::ToEnd(true) => (row_max as usize, cursor.selected.1),
GridMove::ToEnd(false) => (cursor.selected.0, col_max as usize),
GridMove::Page(dir) => {
let delta = (window_rows as i32 * 3 / 4).max(1) * dir;
let delta = page_rows(window_rows) * dir;
((row + delta).clamp(0, row_max) as usize, cursor.selected.1)
}
};
@@ -2044,18 +2049,60 @@ mod tests {
// ── what is bound and what is not ───────────────────────────────
/// A three-by-three grid with `id` at (1,1) and the cursor on it: no move
/// from there is clamped by an edge (improvise-6562).
fn centred_on(id: CellId) -> ClientState {
let mut centre = CellDisplay::number("1200");
centre.id = Some(id);
let mut cells: Vec<_> = (0..3)
.flat_map(|r| (0..3).map(move |c| ((r, c), CellDisplay::number("7"))))
.collect();
cells[4].1 = centre;
let mut client = ClientState::default();
client.bootstrap(Snapshot {
view: ViewState::default(),
seed: vec![
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
"North", "South", "East",
]))),
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
"Revenue", "Cost", "Profit",
]))),
Projection::Grid(GridUpdate::Cells(cells)),
Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 3 })),
Projection::Basis(published()),
],
});
client.window = (3, 3);
client.view.cursor_mut().selected = (1, 1);
client
}
/// A client whose every widget has something in it, so an `Upstream` names
/// an **unhandled command** and never a state that said no (improvise-rmvx).
fn furnished(mode: AppMode) -> ClientState {
let mut state = client_on(record_cell());
furnished_on(mode, record_cell())
}
/// `furnished`, with `cell` under the cursor. A history in both directions
/// and a yanked value, so no `Cmd` is refused by an empty stack (3t5x).
fn furnished_on(mode: AppMode, cell: CellId) -> ClientState {
let mut state = centred_on(cell);
let frame = |name: &str| crate::ViewFrame {
view_name: name.into(),
mode: AppMode::Normal,
};
state.view.view_back_stack = vec![frame("Second")];
state.view.view_forward_stack = vec![frame("Third")];
state.view.yanked = Some(CellValue::Number(5.0));
// A records MODE over a non-records GRID is a state no host can
// produce, so the fixture projects both (improvise-e5pf).
crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records()));
state.view.mode = mode;
state.grid.page_coords = vec![crate::PageAxis {
cat: "Quarter".into(),
selected: "Q1".into(),
items: Some(vec!["Q1".into(), "Q2".into()]),
selected: "Q2".into(),
items: Some(vec!["Q1".into(), "Q2".into(), "Q3".into()]),
}];
// THREE of everything, every panel cursor seated in the MIDDLE: an edge
// makes `move-panel-cursor` answer `Ignored` for the wrong reason.
@@ -2087,10 +2134,15 @@ mod tests {
axes: Vec::new(),
})
.collect();
state.tiles.tiles = vec![crate::TileEntry {
category: "Region".into(),
axis: Axis::Row,
}];
state.tiles.tiles = ["Region", "Product", "Quarter"]
.into_iter()
.map(|category| crate::TileEntry {
category: category.into(),
axis: Axis::Row,
})
.collect();
state.view.tile_cat_idx = 1;
state.view.help_page = 1;
state.view.cat_panel_cursor = 1;
state.view.formula_cursor = 1;
state.view.view_panel_cursor = 1;
@@ -2107,59 +2159,298 @@ mod tests {
state
}
/// improvise-rmvx. An advertised key that resolves upstream is a black hole
/// (gotcha 47). See `context/test-evidence/clients.md`.
/// What a user sees an advertised key do. A status alone is `Declines`:
/// honest, but not what the caption promised (improvise-3t5x).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Heard {
Acts,
Declines,
Silent,
}
/// Judged by what the fold MOVED, never by a command's type: a clamped move
/// or a lone disarm changes nothing (improvise-6562). `Model` is sent.
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
let commands = match outcome {
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
KeyOutcome::Request(_) => return Heard::Acts,
KeyOutcome::Local(commands) => commands,
};
let quiet = |view: &ViewState| ViewState {
status_msg: String::new(),
transient_prefix: None,
..view.clone()
};
let before = quiet(&state.view);
commands.iter().for_each(|c| state.fold(c));
let sent = commands.iter().any(|c| matches!(c, Command::Model { .. }));
let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_)));
match (sent || quiet(&state.view) != before, said) {
(true, _) => Heard::Acts,
(false, true) => Heard::Declines,
(false, false) => Heard::Silent,
}
}
/// Every `(mode, search, command, args)` either table advertises: the
/// status-bar hint and the help pages (improvise-wk18).
fn advertised_everywhere() -> Vec<(AppMode, bool, &'static str, Option<Vec<String>>)> {
let mut rows = Vec::new();
for mode in AppMode::all() {
for (command, args, _) in crate::help::advertised(&mode) {
rows.push((mode.clone(), false, command, args));
}
}
for (key, command, args, _) in crate::help::help_bindings() {
let (mode, search) = match key {
ModeKey::SearchMode => (AppMode::Normal, true),
key => match AppMode::all()
.into_iter()
.find(|m| ModeKey::from_app_mode(m, false).as_ref() == Some(&key))
{
Some(mode) => (mode, false),
None => panic!("a help row names {key:?}, which no mode reaches"),
},
};
rows.push((mode, search, command, args));
}
rows.sort_by_key(|(m, s, c, a)| (crate::mode_label(m), *s, *c, a.clone()));
rows.dedup();
rows
}
/// Every key sequence that runs `command` in `keymap`: one key, or a
/// prefix and the key after it.
fn sequences_running(
keymap: &Keymap,
command: &str,
args: Option<&[String]>,
) -> Vec<Vec<KeyEvent>> {
let one = keymap
.keys_running(command, args)
.into_iter()
.map(|(code, mods)| vec![KeyEvent::new(code, mods)]);
let two = keymap
.prefixed_keys_running(command, args)
.into_iter()
.map(|pair| pair.map(|(code, mods)| KeyEvent::new(code, mods)).to_vec());
one.chain(two).collect()
}
/// What pressing `sequence` in `state` does, arming any prefix first.
fn press(mut state: ClientState, sequence: &[KeyEvent]) -> Heard {
let (last, prefix) = sequence.split_last().expect("a sequence has a key");
for key in prefix {
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
commands.iter().for_each(|c| state.fold(c));
}
}
let outcome = resolve(&state, *last);
heard(&mut state, outcome)
}
/// improvise-rmvx, wk18, 3t5x. Every advertised key does what it says, or
/// is named below saying why not. See `context/test-evidence/clients.md`.
#[test]
fn every_advertised_command_answers_in_its_own_mode() {
let mut unanswered = Vec::new();
for mode in AppMode::all() {
let Some(mode_key) = ModeKey::from_app_mode(&mode, false) else {
continue;
let aggregate = CellId::Aggregate(
serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(),
);
let derived = CellId::Derived {
target: "Profit".into(),
};
// (mode, cell, command, heard) -> the keys that heard it.
let mut short: std::collections::BTreeMap<(String, &str, Heard), Vec<String>> =
Default::default();
for (mode, search, command, args) in advertised_everywhere() {
let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode");
let keymap = Keymaps::shared().get(&key).expect("a keymap");
let at = |cell: &CellId| {
let mut state = furnished_on(mode.clone(), cell.clone());
state.view.search_mode = search;
state
};
let Some(keymap) = Keymaps::shared().get(&mode_key) else {
continue;
};
let state = furnished(mode.clone());
for (command, args, caption) in crate::help::advertised(&mode) {
for (code, mods) in keymap.keys_running(command, args.as_deref()) {
let event = KeyEvent::new(code, mods);
// BOTH silent variants. To a user `Ignored` is the same
// black hole as `Upstream` — nothing is on the screen.
let answered = !matches!(
resolve(&state, event),
KeyOutcome::Upstream(_) | KeyOutcome::Ignored
);
if !answered {
unanswered.push(format!(
"{}: {:?} → `{command} {args:?}` ({caption})",
crate::mode_label(&mode),
code
));
let label = crate::mode_label(&mode).to_string() + if search { " SEARCH" } else { "" };
for sequence in sequences_running(keymap, command, args.as_deref()) {
let keys: String = sequence
.iter()
.map(|k| crate::keymap::key_label(&k.code, k.modifiers))
.collect();
let on_record = press(at(&record_cell()), &sequence);
let mut report = vec![(label.clone(), on_record)];
// Only a grid mode stands on another kind of cell, and a cell
// matters only where it changes the answer.
if mode == AppMode::Normal {
for (on, cell) in [
(" on an aggregate", &aggregate),
(" on a derived cell", &derived),
] {
let got = press(at(cell), &sequence);
if got != on_record {
report.push((label.clone() + on, got));
}
}
}
for (where_, got) in report.into_iter().filter(|(_, got)| *got != Heard::Acts) {
short
.entry((where_, command, got))
.or_default()
.push(keys.clone());
}
}
}
unanswered.sort();
unanswered.dedup();
// The five that remain, each with the reason it is not a black hole.
// Closing one has to come here and say so; opening a sixth fails.
let expected = [
let short: Vec<String> = short
.into_iter()
.map(|((at, command, got), mut keys)| {
keys.sort();
keys.dedup();
format!("{at}: {} {command} {got:?}", keys.join("/"))
})
.collect();
// Closing one has to come here and say so; adding one fails. A caption
// is a promise, and each line here is a promise a browser does not keep.
let expected: &[&str] = &[
// Reached only from a mode `declined` refuses at the door, so a
// browser is never standing in front of this hint.
"COMMAND: Enter → `execute-command None` (run)",
"EXPORT: Enter → `commit-export None` (export)",
"SAVE AS: Enter → `commit-save None` (save)",
// browser is never standing in front of these.
"COMMAND: Enter execute-command Silent",
"EXPORT: Enter commit-export Silent",
"SAVE AS: Enter commit-save Silent",
"NORMAL SEARCH: Enter/Esc exit-search-mode Silent",
// Commands the browser has no arm for: upstream, so a black hole
// (improvise-uln5). Each is also in the host-only list below.
"NORMAL: Enter enter-advance Silent",
"NORMAL: H hide-selected-row-item Silent",
"NORMAL: / search Silent",
"NORMAL: n search-navigate Silent",
"NORMAL: z toggle-group-under-cursor Silent",
// Honestly unavailable, and saying so in `declined`'s words.
"FORMULAS: Del/d delete-formula-at-cursor Declines",
"NORMAL: > drill-into-cell Declines",
"NORMAL: : enter-mode Declines",
"NORMAL: P toggle-prune-empty Declines",
"NORMAL: R toggle-records-mode Declines",
"NORMAL: ZZ wq Declines",
"RECORDS: o add-record-row Declines",
"RECORDS: a/i edit-or-drill Declines",
"RECORDS: : enter-mode Declines",
"RECORDS: P toggle-prune-empty Declines",
"RECORDS: R toggle-records-mode Declines",
// `i` on an aggregate IS drill, which the browser lacks (s8h7); on a
// derived cell the browser names the formula instead (ir7l).
"NORMAL on an aggregate: a/i edit-or-drill Declines",
"NORMAL on a derived cell: a/i edit-or-drill Declines",
// No record under the cursor to write. The TUI writes onto the
// aggregate's partial key, which is its own bug (improvise-l4mp).
"NORMAL on an aggregate: p paste Silent",
"NORMAL on an aggregate: x clear-cell Silent",
"NORMAL on a derived cell: p paste Silent",
"NORMAL on a derived cell: x clear-cell Silent",
// A records cell commit needs `record_col` (gotcha 1); only the two
// creating columns resolve, and this fixture is on a record cell.
"RECORDS INSERT: Enter → `commit-cell-edit None` (commit)",
"RECORDS INSERT: Tab → `commit-and-advance-right None` (commit+right)",
"RECORDS INSERT: Tab commit-and-advance-right Silent",
"RECORDS INSERT: Enter commit-cell-edit Silent",
];
let mut expected: Vec<String> = expected.iter().map(|s| s.to_string()).collect();
expected.sort();
let mut short = short;
short.sort();
assert_eq!(
unanswered, expected,
"advertised and unanswerable: anything here that is not in the \
named list above reads as broken rather than as unavailable"
short,
expected,
"advertised keys that do not do what they say, in full:\n{}",
short.join("\n")
);
}
/// improvise-6562: "it moved" cannot see a move the WRONG way, so every grid
/// key is pressed from the centre and must land where it says. Evidence file.
#[test]
fn every_grid_move_lands_where_its_binding_says() {
let keymap = Keymaps::shared().get(&ModeKey::Normal).expect("Normal");
let mut bound = Vec::new();
bound_commands(keymap, &mut bound);
bound.sort();
bound.dedup();
// Far from every edge of a grid far larger than any step, so a landing
// shows magnitude as well as sign (improvise-5kdr); a jump's is its name.
const START: (usize, usize) = (10, 10);
const WINDOW: (usize, usize) = (8, 8);
let d = |a: &String| a.parse::<isize>().expect("a delta");
let at = |base: usize, by: isize| base.checked_add_signed(by).expect("inside the grid");
let page = page_rows(WINDOW.0) as isize;
let destination = |name: &str, args: &[String]| match name {
"move-selection" => Some((at(START.0, d(&args[0])), at(START.1, d(&args[1])))),
"scroll-rows" => Some((at(START.0, d(&args[0])), START.1)),
"page-scroll" => Some((at(START.0, page * d(&args[0])), START.1)),
"jump-first-row" => Some((0, START.1)),
"jump-last-row" => Some((20, START.1)),
"jump-first-col" => Some((START.0, 0)),
"jump-last-col" => Some((START.0, 20)),
_ => None,
};
let mut unstated = Vec::new();
let mut unpressed = Vec::new();
let mut pressed = std::collections::BTreeSet::new();
for (name, args) in &bound {
let to = destination(name, args);
let sequences = sequences_running(keymap, name, Some(args.as_slice()));
if to.is_some() && sequences.is_empty() {
unpressed.push(format!("{name} {args:?}"));
}
for sequence in sequences {
let mut state = furnished(AppMode::Normal);
state.grid.extent = GridExtent { rows: 21, cols: 21 };
state.window = WINDOW;
*state.view.cursor_mut() = crate::ViewCursor {
selected: START,
row_offset: 5,
col_offset: 5,
};
let view = state.view.active_view.clone();
for key in &sequence {
if let KeyOutcome::Local(commands) = resolve(&state, *key) {
commands.iter().for_each(|c| state.fold(c));
}
}
let landed = state.view.cursor().selected;
if to.is_some() {
pressed.insert(name.as_str());
}
match to {
Some(to) => {
assert_eq!(
landed, to,
"{sequence:?} runs `{name} {args:?}` from {START:?}"
)
}
None if state.view.active_view == view && landed != START => {
unstated.push(format!("{name} {args:?} -> {landed:?}"))
}
None => {}
}
}
}
assert!(
unpressed.is_empty(),
"stated and never pressed: {unpressed:?}"
);
assert!(
unstated.is_empty(),
"these move the cursor and nothing states where to: {unstated:?}"
);
let every = [
"jump-first-col",
"jump-first-row",
"jump-last-col",
"jump-last-row",
"move-selection",
"page-scroll",
"scroll-rows",
];
assert_eq!(
pressed.into_iter().collect::<Vec<_>>(),
every,
"each kind is pressed"
);
}
@@ -2431,8 +2722,8 @@ mod tests {
not vacuous over a table that binds everything or nothing"
);
assert_eq!(
composed(&state, ev(KeyCode::Char(',')), "view-forward", &[]),
Some(vec![Command::ViewForward]),
resolve(&state, ev(KeyCode::Char(','))),
KeyOutcome::Local(vec![Command::ViewForward]),
"and the key is `,` — the unshifted twin of `<`, so both halves of \
the history sit on one physical key"
);
+3 -3
View File
@@ -427,9 +427,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 {
@@ -707,44 +707,6 @@ fn a_yanked_number_crosses_the_serialized_wire_exactly() {
);
}
/// The two tests around this one share a feature-unified build, so neither can
/// see ONE frame-parsing crate lacking `float_roundtrip`. Evidence file.
#[test]
fn every_build_that_parses_a_frame_enables_float_roundtrip() {
for (krate, target) in [
("improvise-wasm-client", Some("wasm32-unknown-unknown")),
("improvise-worker-server", Some("wasm32-unknown-unknown")),
("improvise-ws-server", None),
] {
let mut cargo = std::process::Command::new(env!("CARGO"));
cargo.current_dir(env!("CARGO_MANIFEST_DIR")).args([
"tree",
"--locked",
"--edges",
"normal,features",
"--invert",
"serde_json",
"--package",
krate,
]);
if let Some(target) = target {
cargo.args(["--target", target]);
}
let out = cargo.output().expect("cargo tree must run");
assert!(
out.status.success(),
"{krate}: cargo tree failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let tree = String::from_utf8(out.stdout).expect("cargo tree prints UTF-8");
assert!(
tree.contains(r#"serde_json feature "float_roundtrip""#),
"{krate} parses frames without float_roundtrip, so its numbers land \
one ULP off:\n{tree}"
);
}
}
/// A typed number is parsed exactly in the client and re-parsed from JSON by
/// the host. Evidence file.
#[test]
+3 -3
View File
@@ -69,9 +69,9 @@ fn page_content(page: usize) -> Vec<Row> {
HelpLine::Heading(text) => plain(text, s.heading),
HelpLine::Text(text) => plain(text, s.normal),
HelpLine::Accent(text) => plain(text, s.accent),
HelpLine::Keys { keys, description } => Row {
key: format!(" {}", keys.join(" ")),
desc: description,
HelpLine::Keys(row) => Row {
key: format!(" {}", row.keys().join(" ")),
desc: row.description(),
style: s.key,
},
HelpLine::Literal {
+61
View File
@@ -0,0 +1,61 @@
//! A number read through serde_json must be the number written (improvise-6yvg,
//! improvise-dz8c). The rule and its derivation: `context/code/protocol.md`.
use serde_json::Value;
/// Every NORMAL serde_json dependency in the workspace, and those lacking
/// `float_roundtrip`, each spelled `package (target)`.
fn lossy_json_dependencies(metadata: &Value) -> (Vec<String>, Vec<String>) {
let mut checked = vec![];
let mut lossy = vec![];
let packages = metadata["packages"]
.as_array()
.expect("metadata lists packages");
for package in packages {
let name = package["name"].as_str().expect("a package has a name");
let deps = package["dependencies"]
.as_array()
.expect("a package lists deps");
for dep in deps {
if dep["name"] != "serde_json" || !dep["kind"].is_null() {
continue;
}
let target = dep["target"].as_str().unwrap_or("all targets");
let spelled = format!("{name} ({target})");
let features = dep["features"].as_array().expect("a dep lists features");
if !features.iter().any(|f| f == "float_roundtrip") {
lossy.push(spelled.clone());
}
checked.push(spelled);
}
}
(checked, lossy)
}
/// Derived from `cargo metadata`, not listed: a new crate that can parse JSON
/// is covered the day it declares the dependency. Evidence file.
#[test]
fn every_crate_that_can_parse_json_enables_float_roundtrip() {
let out = std::process::Command::new(env!("CARGO"))
.current_dir(env!("CARGO_MANIFEST_DIR"))
.args(["metadata", "--no-deps", "--format-version", "1"])
.output()
.expect("cargo metadata must run");
assert!(
out.status.success(),
"cargo metadata failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let metadata: Value = serde_json::from_slice(&out.stdout).expect("metadata is JSON");
let (checked, lossy) = lossy_json_dependencies(&metadata);
assert!(
checked.len() >= 2,
"the derivation found {checked:?}: too few to be reading the metadata right"
);
assert!(
lossy.is_empty(),
"these parse JSON numbers with serde_json's lossy default, one ULP off \
for roughly one decimal in seven; add features = [\"float_roundtrip\"] \
to each crate's own manifest: {lossy:?}"
);
}