Compare commits
310 Commits
da2e5ab9e5
...
433c2d79e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 433c2d79e0 | |||
| 1790764f76 | |||
| 91fee245b8 | |||
| 171f982de3 | |||
| af44653f92 | |||
| c5e8d7d922 | |||
| 202e7eac63 | |||
| 0ff17c0f0e | |||
| 799a81aed6 | |||
| b8a4849622 | |||
| d3f03f8c01 | |||
| c26f016968 | |||
| 4502ed2b11 | |||
| 228f130e61 | |||
| a634b3f356 | |||
| 6dceaad1c7 | |||
| 4e52d06838 | |||
| 35013b6bda | |||
| 463bef149d | |||
| 6d5b4e328a | |||
| fa3735b068 | |||
| 3a4b0eac69 | |||
| f14a7f5f7a | |||
| 5932ad749f | |||
| 47bab86a50 | |||
| b4779edb0b | |||
| fa0af07988 | |||
| ca2d1b19ef | |||
| 6096c03caa | |||
| 37a64445e0 | |||
| c6de77dbde | |||
| aa96cd6404 | |||
| 6c181a9c11 | |||
| 5c19fdd4be | |||
| 7b045567c6 | |||
| b060d4cc65 | |||
| 961b0c01f8 | |||
| 5960fe8f0a | |||
| bb5dfe5c04 | |||
| cc9bc41092 | |||
| 2f7d9c8c5a | |||
| 8747074e52 | |||
| bd49de6b48 | |||
| e5b18d3499 | |||
| c4877456a4 | |||
| d24dc8ec61 | |||
| 872f9bc07b | |||
| 91c80cb7e6 | |||
| e406edc8e8 | |||
| 5cd00770cf | |||
| 4d2252373e | |||
| 0ec808f7a5 | |||
| 194d8f529c | |||
| 52fa3976bc | |||
| 66359a2e04 | |||
| d9ea594604 | |||
| 0051e57341 | |||
| 8c797720ba | |||
| 1c78e71322 | |||
| 5eaafbae1e | |||
| 38ec60616c | |||
| 705c92d9ff | |||
| 21e239dca5 | |||
| 968f65afe0 | |||
| 0a4f906b1c | |||
| c6c2de96b2 | |||
| cb60166a84 | |||
| f85fddbb2f | |||
| c7e48546c3 | |||
| 993418f143 | |||
| 6026768ceb | |||
| 2383c37f2f | |||
| 27cbe2c1dd | |||
| c312431154 | |||
| 6e021699d4 | |||
| 93c455db19 | |||
| ab1c981829 | |||
| a1a9be33f0 | |||
| f3fb83ee45 | |||
| 77a3825576 | |||
| 1f541e0fb2 | |||
| 5bcea582f2 | |||
| 168a28d2c5 | |||
| 7da905cdbb | |||
| 33cf496927 | |||
| 82bbc74c13 | |||
| 531cf4954c | |||
| ae281a58a8 | |||
| bf2de4995b | |||
| 345a35895e | |||
| cd55944e98 | |||
| e62a289cc3 | |||
| 56f8d37e95 | |||
| 41aa4073f1 | |||
| 5e37973fc0 | |||
| ec548862ac | |||
| 7fb6960cf8 | |||
| ebf3dd2ba2 | |||
| 5690903d60 | |||
| 12c81dd3d6 | |||
| f176c2b8ca | |||
| bc060f6760 | |||
| 46e75c415f | |||
| a8737f3fa5 | |||
| 08b87edd49 | |||
| 6ccad4b80d | |||
| 7aff3e7717 | |||
| 52c6a05369 | |||
| 104c10f570 | |||
| 3929196096 | |||
| 092a4020a3 | |||
| 77f059c852 | |||
| a91db1d857 | |||
| f3a0ef45e6 | |||
| 6f04819d40 | |||
| 74dda4c5c7 | |||
| 0e846b8e41 | |||
| e311d4174c | |||
| ea119bd9d1 | |||
| b3379d1a17 | |||
| a00fffc94e | |||
| 087aa854b5 | |||
| 1550d390cb | |||
| 7767cee9b6 | |||
| 1499296586 | |||
| 83c3c7d392 | |||
| de7037365e | |||
| 7e1e8afba2 | |||
| e2b132f594 | |||
| 2a528884a7 | |||
| 72f0c63fff | |||
| fb20d1c73a | |||
| e131ae99ff | |||
| 1114cce532 | |||
| 1dbf6de936 | |||
| 57fea26aa6 | |||
| 3b8b186f67 | |||
| 0f3d06afa2 | |||
| ef743e926d | |||
| 94e8f0f08e | |||
| 4e451199af | |||
| 0ecb1d89a9 | |||
| 141be9fca6 | |||
| 689c7665da | |||
| fa8c8b61a2 | |||
| 8ba64c8ef9 | |||
| 7407abbca6 | |||
| 7716d823d2 | |||
| 32d5d8f7f3 | |||
| 775f02b4e4 | |||
| 70c33bd3f3 | |||
| 42bb8a26a7 | |||
| 4b880846b9 | |||
| 345bb8cdb6 | |||
| 4e5299f6d7 | |||
| 8c3b013c48 | |||
| daa09e1f4f | |||
| 7dfa90f138 | |||
| ccb517c610 | |||
| 966637cdb6 | |||
| ab17674811 | |||
| 13bccf772e | |||
| 5d56196f56 | |||
| e829b66487 | |||
| 13b998d7f6 | |||
| aadf5ddd35 | |||
| 6b3b9f03de | |||
| afb3e471c9 | |||
| 74d329d15f | |||
| 9b9b1798a8 | |||
| b4f58e9c35 | |||
| bcbb5591f9 | |||
| 3cad9c2936 | |||
| 5c50bf8780 | |||
| 5be1306281 | |||
| 288db9b269 | |||
| 691c6c69d1 | |||
| 170d94807b | |||
| 98d10ca591 | |||
| 8775267865 | |||
| cfcc9bff97 | |||
| de49716a41 | |||
| b22f6e5389 | |||
| 09a5cdfa03 | |||
| fdc8e91383 | |||
| f75b58af91 | |||
| 31fd2f5480 | |||
| 9cb7741c4b | |||
| 561cff479e | |||
| 9512a2db1f | |||
| a9a5b3075a | |||
| 5df8ed2ce5 | |||
| 2a4f35d76f | |||
| 097533e50c | |||
| a2f3c8d3e5 | |||
| e5721b9d94 | |||
| 66398f163f | |||
| 30187208d7 | |||
| 3390ab0394 | |||
| 7628770d79 | |||
| 92eefeec4b | |||
| bc08a13038 | |||
| 856122604a | |||
| 95bd593dbe | |||
| c1932094d0 | |||
| 0825f9984d | |||
| 20390cf65f | |||
| d1c4884399 | |||
| b59af2dfe0 | |||
| 9d3fe197df | |||
| 14acf46d77 | |||
| f3932c1ca5 | |||
| 79175bbb58 | |||
| 4c45166b8a | |||
| f24944b437 | |||
| c70c428c47 | |||
| e3aa2ccb4b | |||
| c8db3ee2dd | |||
| feefe17d53 | |||
| 95a35e8cce | |||
| b535245a21 | |||
| a5fbd84f73 | |||
| 31b5acdb6e | |||
| 608ba6a6c8 | |||
| 59e67df3bb | |||
| bd95044a6e | |||
| 810077a0d0 | |||
| 4ada5211a2 | |||
| aeb88e3eac | |||
| d5854bc11c | |||
| a1b86001db | |||
| c960713f8c | |||
| a164726fbd | |||
| 8772db2e7b | |||
| 1c4a27243d | |||
| b76d8d6ef8 | |||
| 1a6b877482 | |||
| 2619d1710d | |||
| 5bcc3cacdd | |||
| 8fb5f6b14a | |||
| aa959343a2 | |||
| 1158c23d7b | |||
| f8f4dd5fa0 | |||
| 54ef80d960 | |||
| 90b93d4874 | |||
| 6b3dd8ca3e | |||
| f24882766e | |||
| 23a3c4d269 | |||
| 48f52e7da1 | |||
| 25a06d52ff | |||
| 7935b006eb | |||
| 04230a7548 | |||
| a4fa3ae174 | |||
| 43ba5e6151 | |||
| 44a183aa26 | |||
| bb1a05674c | |||
| 3bcd8774cb | |||
| 0680f65da8 | |||
| 9907a20a62 | |||
| a9346a0be2 | |||
| 54d9cc85de | |||
| 141e41ec4b | |||
| 0a6e940dc5 | |||
| 300bf613fc | |||
| 12171fb335 | |||
| f44767c9a8 | |||
| ffd3ec80be | |||
| 99046f7c63 | |||
| 77893d830c | |||
| 544744cfd2 | |||
| c246dc1df6 | |||
| 327fa176ad | |||
| f8345ea8c6 | |||
| ea1f2d313b | |||
| 07d9732b7a | |||
| 8fe55e0b4a | |||
| 3c6001ac7b | |||
| e6d7d13e90 | |||
| 128a2a093e | |||
| ddf463e366 | |||
| fc0b13c465 | |||
| 384e04acbc | |||
| 60dbe970b0 | |||
| c5e72e889f | |||
| da8c67109a | |||
| 2db69d927a | |||
| d1a9ef9add | |||
| 473ac9622e | |||
| e0550fc51f | |||
| c831e5ea4a | |||
| 34ef2802d2 | |||
| a4910eb4c1 | |||
| 4277cf6f84 | |||
| 1dd9bba452 | |||
| a6e36511b5 | |||
| 7e1bf86260 | |||
| d4b60d7979 | |||
| ddc0bf4ce2 | |||
| 949810cd30 | |||
| 3d03d9ff8f | |||
| 4f6e033a35 | |||
| 26abc3b9a1 | |||
| 6e3e51b0ac | |||
| bdb01b8763 | |||
| 3cf3718764 | |||
| 299ade52d4 | |||
| 740511e0f9 | |||
| 97d17b60f2 | |||
| 6bdd3ad7cf | |||
| a06f50560d |
@@ -28,6 +28,7 @@ them here would let the copy drift:
|
||||
| `context/repo-map.md` | The "where", plus **numbered gotchas** — read the ones your diff touches |
|
||||
| `prolog-sessions/kb.pl` | Executable knowledge: `invariant/2`, `doctrine/2`, `forbidden/2`, `decision/3`, and prior `work_done/3` |
|
||||
| `AGENTS.md` | Working rules: bug-first tests, `Option`/`Result`/`Box` at the edges, merges over rebases |
|
||||
| `.agents/skills/wave/agent-rules.md`, "GitHub" | The reviewer's rule: what to read before your first comment, how to answer each existing review, and what every review you post must state. Read it before reviewing a PR |
|
||||
|
||||
`kb.pl` is the highest-value and least-obvious of these. Grep it before
|
||||
reviewing: `grep -n "invariant(\|doctrine(\|decision(" prolog-sessions/kb.pl`.
|
||||
|
||||
@@ -10,26 +10,40 @@ scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec
|
||||
```
|
||||
|
||||
```
|
||||
exit 0 every check COMPLETED and none failed
|
||||
exit 1 a check failed, or the PR is CONFLICTING
|
||||
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 1 a check failed, or the PR is not mergeable (CONFLICTING, BLOCKED,
|
||||
DIRTY, BEHIND, DRAFT, or a state it does not know)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running
|
||||
exit 3 timed out with checks still running, mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
```
|
||||
|
||||
**Exit 0 now means mergeable, not only green.** GitHub computes mergeability
|
||||
lazily and reports `UNKNOWN` for a while after the base moves. It also reports
|
||||
`BLOCKED` for the moment before a required check that `needs:` another job has
|
||||
registered. The watch waits out both, so you do not need to re-check
|
||||
`mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required
|
||||
check that never ran. Why each
|
||||
state counts the way it does: `context/code/tooling.md`, "pr-watch and
|
||||
mergeability" (improvise-edpl).
|
||||
|
||||
**Pass `--expect <sha>` whenever you know which commit you are asking about.**
|
||||
It is the difference between "this PR is green" and "the thing I pushed is
|
||||
green", and a PR here was once merged at a head that predated its own fix.
|
||||
|
||||
## Do not write the loop yourself
|
||||
|
||||
Three signals look like "finished" and are not:
|
||||
Four signals look like "finished" and are not:
|
||||
|
||||
- **"nothing pending"** — also true when no check has registered yet. Require a
|
||||
**non-zero total** as well.
|
||||
- **`conclusion == null`** — `conclusion` is a *string*, empty while running.
|
||||
Test `status == "COMPLETED"`.
|
||||
- **a verdict from before the head moved** — re-check the head, or `--expect`.
|
||||
- **`mergeable: UNKNOWN`** — GitHub has not computed it yet. It is not a
|
||||
pass, and PRs #172 and #179 both went green on it.
|
||||
|
||||
`gh` is shell-aliased to a different tool in these shells; `pr-watch` locates a
|
||||
real one itself. Do not reintroduce a bare `gh` call in a script here.
|
||||
|
||||
@@ -37,7 +37,8 @@ it must cross. Two agents adding a workspace member will still collide in
|
||||
**Point the agent at [`agent-rules.md`](agent-rules.md) and tell it to read it in
|
||||
full first.** That file is the agent's standing rules: base and push mechanics,
|
||||
the gate list, the claim, the vacuity shapes, evidence, the files it must not
|
||||
write, GitHub conventions, the hand-back and disk cleanup. Do not restate them
|
||||
write, GitHub conventions (including how authors and reviewers answer reviews
|
||||
on the PR thread), the hand-back and disk cleanup. Do not restate them
|
||||
in the brief. A restated rule drifts from its source, and the brief is the copy
|
||||
nobody corrects. The brief adds only what varies per wave:
|
||||
|
||||
@@ -73,14 +74,20 @@ defect is a test that passes.
|
||||
## Shepherding
|
||||
|
||||
- Wait on checks with `scripts/pr-watch <pr> --expect <sha>`. Never hand-roll a
|
||||
poll; see the `pr-watch` skill for the three ways they report green while wrong.
|
||||
poll; see the `pr-watch` skill for the four ways they report green while wrong.
|
||||
- Review before merging. Copilot's quota may be exhausted and its request is a
|
||||
**silent no-op**; an absent review and an unrequested one both read as
|
||||
"pending". Dispatch an adversarial reviewer and give it the head SHA, the base,
|
||||
and a test-merge, so it reviews the tree that will exist.
|
||||
and a test-merge, so it reviews the tree that will exist. Point it at
|
||||
`agent-rules.md`, "GitHub", for what a reviewer reads and answers first.
|
||||
- Only the user's explicit approval accepts an ADR, and lets its PR merge. The
|
||||
review gates still apply, and none of them is approval:
|
||||
`AGENTS.md`, "ADR pull requests".
|
||||
- A re-review is of the **delta**, at a named SHA. A review of a superseded
|
||||
commit reads as a pass.
|
||||
- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`.
|
||||
- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`. Close
|
||||
the bead only once the merge has landed. That is when the work is done:
|
||||
`AGENTS.md`, "Repository policy over the Beads blocks".
|
||||
- Anything posted to GitHub goes out under the owner's account: open every
|
||||
comment with a line naming the model and saying an agent wrote it. That
|
||||
wording is agent-neutral because this one file serves Claude and Codex
|
||||
|
||||
@@ -29,12 +29,20 @@ not in a second copy.
|
||||
5. **Before pushing a fix to an existing PR, check `gh pr view <n> --json
|
||||
state`.** GitHub deletes a merged PR's branch. A push to it then prints
|
||||
`* [new branch]` and lands nowhere. Why: `AGENTS.md`, "Shipping a wave".
|
||||
6. **The "Session Completion" block near the end of `AGENTS.md` does not apply
|
||||
to you.** It says `git merge origin/main` and then a bare `git push`. On a
|
||||
wave branch the first is the #145 trap and the second is refused (rule 4).
|
||||
Rules 1–5 replace it. The block sits between bd's generated
|
||||
`BEGIN/END BEADS INTEGRATION` markers, so it is not edited by hand
|
||||
(improvise-x1uk).
|
||||
6. **The Beads "Session Completion" block near the end of `AGENTS.md` does not
|
||||
set your git policy.** This repository does not opt in to the block's
|
||||
"team-maintainer" profile. On a wave branch that profile's pull and bare
|
||||
`git push` would be refused (rule 4), or worse. Rules 1–5 and 7 are your
|
||||
git policy. The block is bd's and bd regenerates it whole, so it is not
|
||||
edited by hand: `AGENTS.md`, "Repository policy over the Beads blocks" (improvise-x1uk).
|
||||
7. **Pin `gh/main` to a literal SHA when the task starts.** Run
|
||||
`git rev-parse gh/main` once. Use that SHA, not the ref name, for every
|
||||
reset, merge-base and diff. Remote refs are shared by every worktree of the
|
||||
repository, so `gh/main` moves whenever another session fetches. An agent
|
||||
ran `git reset --soft gh/main` mid-task and staged a reverse diff of #196.
|
||||
It noticed only because it read `git status` (improvise-diho). Re-resolve
|
||||
only on purpose, for example when you mean to merge a newer main. Rule 3's
|
||||
check before a PR is such a time.
|
||||
|
||||
## Commit early: a worktree is not durable storage
|
||||
|
||||
@@ -47,14 +55,33 @@ deleted worktree. Tidying history before the PR is fine; losing it is not.
|
||||
## Gates
|
||||
|
||||
```
|
||||
direnv exec . cargo test --workspace --no-fail-fast
|
||||
direnv exec . cargo clippy --workspace --all-targets -- -D warnings
|
||||
direnv exec . ./scripts/fmt-workspaces.sh # NOT cargo fmt --all; exit 2 = the gate did not run
|
||||
direnv exec . python3 scripts/check-comment-runs.py --check-evidence
|
||||
direnv exec . ./scripts/checkers.sh
|
||||
direnv exec . cargo clippy --workspace --all-targets -- -D warnings
|
||||
direnv exec . cargo test --workspace --no-fail-fast
|
||||
direnv exec . python3 scripts/check-comment-runs.py --self-test
|
||||
direnv exec . python3 scripts/check-comment-runs.py --check-evidence # also answers the comment cap
|
||||
direnv exec . python3 scripts/check-ci-graph.py --self-test
|
||||
direnv exec . python3 scripts/check-ci-graph.py
|
||||
direnv exec . ./scripts/checkers.sh # every suite in scripts/, its own self-test included
|
||||
direnv exec . tpl prolog-sessions/kb.pl -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
|
||||
direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
|
||||
```
|
||||
|
||||
This list is the `test`, `js` and `kb` jobs of `.github/workflows/ci.yml`,
|
||||
step for step, and this file is the one place it is written down. Change it in
|
||||
the same PR that changes CI. The other CI jobs are conditional, below, or left
|
||||
out on purpose:
|
||||
|
||||
- **`wasm`**: when you touch a crate it checks (next bullet).
|
||||
- **`harness-lint`**: when you touch `harness/opfs-contract`, run
|
||||
`cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings`
|
||||
from that directory. Its format half is already `fmt-workspaces.sh`.
|
||||
- **`build` and `browser`**: when you touch `web/`, `harness/`, or the surface
|
||||
the page calls in `improvise-wasm-client` or `improvise-worker-server`
|
||||
(third bullet).
|
||||
- **`pages-deploy`** is left out. It deploys, and it runs only on a push to
|
||||
`main`. What guards it locally is `check-ci-graph.py`, which is in the list.
|
||||
|
||||
- **Never `cargo fmt --all`.** It reaches only **one** of this repo's two
|
||||
workspace roots, because `harness/opfs-contract` declares its own
|
||||
`[workspace]`. The obvious repair is worse: `cargo fmt -p opfs-contract` from
|
||||
@@ -70,10 +97,17 @@ direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
|
||||
- **`--no-fail-fast`**, because plain `cargo test --workspace` stops at the
|
||||
first failing binary. Use it for any claim about blast radius.
|
||||
- If you touch a browser-bundle crate (core, formula, io, protocol, command,
|
||||
wasm-client, worker-server), also run
|
||||
wasm-client, worker-server) or `improvise-gp`, also run
|
||||
`cargo check --target wasm32-unknown-unknown -p <crate>` **for each crate**.
|
||||
CI checks each one separately, so feature unification cannot hide a leak.
|
||||
- If you touch `web/` or `harness/`, run the Playwright suite in `harness/`.
|
||||
- If you touch `web/`, `harness/` or the page's wasm surface, run
|
||||
`./harness/build.sh`, then
|
||||
`./scripts/node-test.sh 'web/tests/bundle/*.test.js'`, then `npm ci` and
|
||||
`npx playwright test` in `harness/`. The bundle tests sit outside the
|
||||
`web/tests/*.test.js` glob and read the built `web/pkg`, so the standard list
|
||||
never runs them. They failed only in CI once. A fresh worktree has no
|
||||
`harness/node_modules`, and Playwright then reports a missing
|
||||
`@playwright/test`, which reads like a broken checkout.
|
||||
- **If you change the grid's row or column count, also grep
|
||||
`harness/fixtures/*.improv` and `harness/tests/*.spec.js`.** On PR #176 a
|
||||
Playwright spec pinned the old collapsed-group row count. `cargo test` cannot
|
||||
@@ -87,6 +121,12 @@ direnv exec . ./scripts/node-test.sh 'web/tests/*.test.js'
|
||||
check that no test *disappeared*. Diff test **names**, not counts: a
|
||||
textually clean merge can delete a test module with no conflict marker, and
|
||||
the other side's new tests mask the loss in a count.
|
||||
- **Build each side's test list in a fresh `CARGO_TARGET_DIR`, every time.** If
|
||||
a parent's tree comes from `git archive`, a reused target dir gives a wrong
|
||||
answer, not just a slow one. Archive stamps every file with its commit time,
|
||||
so cargo judges the sources older than the last build and lists the previous
|
||||
tree's tests. On #188 that made three of the branch's own tests look
|
||||
merge-only.
|
||||
- **The shell guard refusing a compound command means NEITHER part ran.** Split
|
||||
the command. After a refusal, re-check the state it was meant to produce.
|
||||
- **Re-run the full suite after reverting an injected fault.** A revert applied
|
||||
@@ -165,9 +205,21 @@ text for any of these in your hand-back.
|
||||
They name the agent and the session, so this shared file cannot hold them.
|
||||
- Read reviews with `./scripts/pr-reviews <n>`, and do not pipe it. How to tell
|
||||
a ChatGPT Codex review from ours, and why Copilot is not coming: `AGENTS.md`,
|
||||
"Shipping a wave". Answer each Codex review on its thread.
|
||||
- **Do not merge, and do not close beads.** Avoid stacked PRs. If you must
|
||||
stack, never let the base branch be deleted.
|
||||
"Shipping a wave".
|
||||
- **As a PR's author, reply on the PR to every review finding you address.**
|
||||
Name the commit that fixes it, or say why you declined it. Putting it only in
|
||||
your hand-back to the orchestrator is not enough: the PR thread is where the
|
||||
next reader looks.
|
||||
- **As a reviewer, read the existing reviews first**, with
|
||||
`./scripts/pr-reviews <n>`, unpiped. Reply on its thread to every Codex
|
||||
review: fixed (name the commit), declined (say why), or already one of our
|
||||
tickets (name it). Every review you post states the head SHA it reviewed.
|
||||
- **Answer a review even if the PR has already merged.** Reviews arrive late,
|
||||
and the thread is still where the next reader looks.
|
||||
- **Do not merge, and do not close beads.** Your part ends at an open PR plus
|
||||
your hand-back. What "done" means is in `AGENTS.md`, "Repository policy over
|
||||
the Beads blocks". Avoid stacked PRs. If you must stack, never let the base
|
||||
branch be deleted.
|
||||
|
||||
## Hand-back, per ticket
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Labels the self-hosted NixOS microVM runners register with.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- nixos
|
||||
- microvm
|
||||
- audited
|
||||
+123
-169
@@ -12,6 +12,15 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
# Every job runs on the self-hosted NixOS microVM runners, and every `run:`
|
||||
# step inside the flake's `ci` dev shell. Generic-Linux downloads (rustup,
|
||||
# setup-node's node, Playwright's browsers) cannot run on NixOS, so the
|
||||
# toolchain comes from flake.nix, the same one `nix develop` gives locally.
|
||||
# Each step re-enters the shell; after the first, the eval cache makes it cheap.
|
||||
defaults:
|
||||
run:
|
||||
shell: nix develop .#ci --command bash --noprofile --norc -eo pipefail {0}
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# The one place wasm-pack runs (improvise CI dedup).
|
||||
@@ -35,7 +44,7 @@ jobs:
|
||||
# would serialise a 109s job behind a 126s one for no artifact at all.
|
||||
build:
|
||||
name: Build all bundles
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# First, because it needs nothing and takes a second: the gate that stops
|
||||
@@ -44,61 +53,52 @@ jobs:
|
||||
# below, so it fails under this job's name rather than another's.
|
||||
- name: The stamp gate refuses what it claims to
|
||||
run: ./scripts/checkers.sh assemble-stamp
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
# Two workspaces, because `harness/opfs-contract` carries an empty
|
||||
# `[workspace]` table and so has its own target dir, which the default
|
||||
# single-workspace configuration never cached (it recompiled its whole
|
||||
# dependency tree every run).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
harness/opfs-contract -> target
|
||||
# One home for the two pins, so the release capture
|
||||
# (scripts/build-web-release.sh, improvise-oujb) provisions the same
|
||||
# versions rather than a second copy free to go stale. The grep keeps a
|
||||
# comment or a blank line out of $GITHUB_ENV, which takes NAME=VALUE only.
|
||||
- name: Pinned web toolchain versions
|
||||
run: grep -E '^[A-Z_]+=' scripts/web-toolchain-versions.env >> "$GITHUB_ENV"
|
||||
|
||||
# Pinned release binary rather than `cargo install` (a three-minute
|
||||
# source build) or a third-party action. wasm-pack runs its own bundled
|
||||
# `wasm-opt`, which is why improvise-wasm-client's Cargo.toml names
|
||||
# `--enable-bulk-memory`: without it the optimizer rejects the
|
||||
# `memory.copy` LLVM emits regardless.
|
||||
# v0.15.0 is what `nix develop` supplies, and the bundle was built and
|
||||
# measured against it locally (346 KB, 120 KB gzipped). Pinning the same
|
||||
# version is what makes that local run evidence about this one.
|
||||
- name: Install wasm-pack
|
||||
# scripts/web-toolchain-versions.env stays the one home for the pins, so
|
||||
# the release capture (scripts/build-web-release.sh, improvise-oujb)
|
||||
# provisions the same versions. CI no longer installs them — the dev
|
||||
# shell supplies wasm-pack and node — so this checks the shell against
|
||||
# the pins instead: a nixpkgs bump that moves either fails here, by name.
|
||||
# wasm-pack runs its own bundled `wasm-opt`, which is why
|
||||
# improvise-wasm-client's Cargo.toml names `--enable-bulk-memory`.
|
||||
- name: The dev shell supplies the pinned web toolchain
|
||||
run: |
|
||||
set -eu
|
||||
url="https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl -sSfL "$url" | tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
"wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack"
|
||||
. scripts/web-toolchain-versions.env
|
||||
test "$(wasm-pack --version)" = "wasm-pack ${WASM_PACK_VERSION#v}"
|
||||
test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION"
|
||||
wasm-pack --version
|
||||
node --version
|
||||
# wasm-pack uses the shell's wasm-bindgen only at Cargo.lock's exact
|
||||
# version; otherwise it cargo-installs one (71s) — so a lock bump
|
||||
# must move flake.nix's wasmBindgenCli too, and this says so.
|
||||
lock="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, ""); print}' Cargo.lock)"
|
||||
test "$(wasm-bindgen --version)" = "wasm-bindgen $lock" || {
|
||||
echo "flake.nix wasmBindgenCli is $(wasm-bindgen --version); Cargo.lock wants $lock" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Both assemblies below generate the prose site with node. The runner
|
||||
# image ships one, but pinning it is what makes a local run evidence.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
# The same script a developer runs locally, so there is one answer to
|
||||
# "how is the bundle built" rather than two that can drift. It builds all
|
||||
# three wasm bundles (client, worker, opfs-contract) and assembles
|
||||
# `harness/serve` — the deployed arrangement plus /opfs/.
|
||||
- name: Build the bundles and the harness tree
|
||||
run: ./harness/build.sh
|
||||
|
||||
# Re-assembles from the pkg dirs the step above already produced — no
|
||||
# wasm-pack runs here. A second assembly rather than a copy of
|
||||
# `harness/serve` because the cache-bust token differs: the harness tree
|
||||
# is stamped `harness` and the published site the commit SHA, which is
|
||||
# what makes a returning visitor pick up a fresh bundle (improvise-9x6).
|
||||
- name: Assemble the site
|
||||
run: ./web/assemble.sh site "$GITHUB_SHA"
|
||||
# Built by Nix (packages.web and packages.site in nix/ci.nix), which runs
|
||||
# the same harness/build.sh and web/assemble.sh a developer runs, so there
|
||||
# is still one answer to "how is the bundle built". `web` is the three
|
||||
# wasm bundles plus `harness/serve`; `site` re-assembles from web's pkg
|
||||
# dirs with the commit SHA as the cache-bust token (improvise-9x6), where
|
||||
# the harness tree is stamped `harness`. Dependencies are their own
|
||||
# derivations, substituted from titan's cache when Cargo.lock is unchanged.
|
||||
- name: Build the bundles, the harness tree and the site
|
||||
shell: bash
|
||||
run: |
|
||||
nix build --print-build-logs --out-link result-web .#web
|
||||
nix build --print-build-logs --out-link result-site .#site
|
||||
# The upload steps below read these paths. Copied rather than linked:
|
||||
# an artifact of a symlink into /nix/store is a dangling link elsewhere.
|
||||
- name: Lay the outputs out where the uploads read them
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf harness/serve web/pkg web/worker-pkg site
|
||||
cp -rL result-web/harness-serve harness/serve
|
||||
cp -rL result-web/pkg web/pkg
|
||||
cp -rL result-web/worker-pkg web/worker-pkg
|
||||
cp -rL result-site site
|
||||
chmod -R u+w harness/serve web/pkg web/worker-pkg site
|
||||
|
||||
- run: find site -type f | sort
|
||||
- run: du -sh site
|
||||
@@ -133,27 +133,29 @@ jobs:
|
||||
|
||||
test:
|
||||
name: Test and lint (host)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# NOT `cargo fmt --all`, which reaches only ONE of the two workspace
|
||||
# roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks).
|
||||
- name: Every workspace root is formatted
|
||||
run: ./scripts/fmt-workspaces.sh
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- run: cargo test --workspace
|
||||
# checks.clippy and checks.test in nix/ci.nix: the same cargo commands,
|
||||
# on the dev/test profiles, with dependencies as a cached derivation.
|
||||
- name: cargo clippy --workspace --all-targets -- -D warnings
|
||||
shell: bash
|
||||
run: nix build --print-build-logs .#checks.x86_64-linux.clippy
|
||||
# Also runs the test-evidence gate (check-comment-runs.py
|
||||
# --check-evidence), which lists the tests this derivation compiled.
|
||||
- name: cargo test --workspace, and test-evidence headings name real tests
|
||||
shell: bash
|
||||
run: nix build --print-build-logs .#checks.x86_64-linux.test
|
||||
# The comment cap and the test-evidence keys (improvise-9c8c); without
|
||||
# this step the rule is advisory. See context/design-principles.md §6.
|
||||
- name: The comment checker catches what it has been fixed for
|
||||
run: python3 scripts/check-comment-runs.py --self-test
|
||||
- name: Comment runs capped at two lines
|
||||
run: python3 scripts/check-comment-runs.py
|
||||
- name: Test-evidence headings name real tests
|
||||
run: python3 scripts/check-comment-runs.py --check-evidence
|
||||
# `pages-deploy` needs every other job, and saying so was not enough: a
|
||||
# job added during review of PR #83 was ungated and nothing noticed.
|
||||
- name: The deploy gate refuses what it claims to
|
||||
@@ -161,83 +163,47 @@ jobs:
|
||||
- name: Every check job gates the deploy
|
||||
run: python3 scripts/check-ci-graph.py
|
||||
|
||||
# Too slow on GitHub Actions
|
||||
#
|
||||
# nix-test:
|
||||
# name: Nix Test
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Free up disk space
|
||||
# run: |
|
||||
# sudo rm -rf /usr/share/dotnet \
|
||||
# /usr/local/lib/android
|
||||
# sudo df -h
|
||||
|
||||
# - uses: actions/checkout@v7
|
||||
# - name: Install Nix
|
||||
# uses: DeterminateSystems/nix-installer-action@v14
|
||||
|
||||
# - name: Magic Nix Cache
|
||||
# uses: DeterminateSystems/magic-nix-cache-action@v8
|
||||
|
||||
# - name: Check Nix health
|
||||
# run: nix doctor
|
||||
|
||||
# - run: nix build
|
||||
# ---------------------------------------------------------------------------
|
||||
# The crate2nix build (Cargo.nix), which `cargo` never exercises. It was off
|
||||
# because GitHub-hosted runners start with an empty store and rebuilt every
|
||||
# dependency crate each run. The NixOS runners substitute from titan's
|
||||
# harmonia first, and titan builds gh/main's `#improvise` hourly, so a PR that
|
||||
# leaves Cargo.lock alone rebuilds only the workspace crates it changed.
|
||||
nix-build:
|
||||
name: Nix build (crate2nix)
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Plain bash, not the dev shell: this step needs nix and nothing else.
|
||||
- name: nix build .#improvise
|
||||
shell: bash
|
||||
run: nix build --print-build-logs .#improvise
|
||||
|
||||
wasm:
|
||||
name: wasm32 check (browser bundle crates)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# Only the crates that go into the browser bundle. The root `improvise`
|
||||
# crate is the terminal binary and depends on crossterm, which does not
|
||||
# build for wasm — that is correct, not a gap (improvise-ywd).
|
||||
- name: improvise-core
|
||||
run: cargo check -p improvise-core --target wasm32-unknown-unknown
|
||||
- name: improvise-formula
|
||||
run: cargo check -p improvise-formula --target wasm32-unknown-unknown
|
||||
- name: improvise-protocol
|
||||
run: cargo check -p improvise-protocol --target wasm32-unknown-unknown
|
||||
- name: improvise-io
|
||||
run: cargo check -p improvise-io --target wasm32-unknown-unknown
|
||||
- name: improvise-command
|
||||
run: cargo check -p improvise-command --target wasm32-unknown-unknown
|
||||
# The browser client itself. This is the one crate for which a wasm
|
||||
# build is the *point* rather than a property worth keeping, so a
|
||||
# failure here is not a portability regression but a broken deliverable
|
||||
# (improvise-gsw).
|
||||
- name: improvise-wasm-client
|
||||
run: cargo check -p improvise-wasm-client --target wasm32-unknown-unknown
|
||||
# The worker half of the standalone deployment (improvise-9x6). Same
|
||||
# terms as the client: a wasm build is the point, so a failure here is a
|
||||
# broken deliverable rather than a portability regression.
|
||||
- name: improvise-worker-server
|
||||
run: cargo check -p improvise-worker-server --target wasm32-unknown-unknown
|
||||
# Not in the browser bundle yet — nothing depends on improvise-gp. Gated
|
||||
# anyway because it is a candidate (fitting a tree in the browser) and
|
||||
# staying wasm-clean is free now, expensive to retrofit (improvise-z5eg).
|
||||
- name: improvise-gp
|
||||
run: cargo check -p improvise-gp --target wasm32-unknown-unknown
|
||||
# checks.wasm-check in nix/ci.nix: `cargo check --target wasm32` for each
|
||||
# crate in the browser bundle, one at a time, so features are not unified
|
||||
# across them. The root `improvise` crate is the terminal binary and
|
||||
# depends on crossterm, which does not build for wasm — that is correct,
|
||||
# not a gap (improvise-ywd). The log names each crate as it is checked.
|
||||
- name: cargo check --target wasm32-unknown-unknown, per bundle crate
|
||||
shell: bash
|
||||
run: nix build --print-build-logs .#checks.x86_64-linux.wasm-check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The browser JavaScript's unit tests. Its own job, deliberately: a failure
|
||||
# here is a decision `web/shell.js` or `web/serial.js` got wrong, and
|
||||
# reporting that as "Browser harness" would put it behind an 80 MB browser
|
||||
# download and two wasm-pack builds. It needs neither — `node --test` is in
|
||||
# the runtime, and this job installs nothing at all.
|
||||
# the runtime, which the dev shell supplies; this job installs nothing.
|
||||
js:
|
||||
name: Browser JS unit tests (node --test)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The glob is the SCRIPT's, not the shell's and not node's. Under bash an
|
||||
# unmatched glob survives as a literal, node globs it, matches nothing and
|
||||
# exits 0 having run no test -- so this step gated nothing the moment
|
||||
@@ -252,23 +218,23 @@ jobs:
|
||||
|
||||
kb:
|
||||
name: Knowledge base consistency
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: cachix/install-nix-action@v31
|
||||
# kb.pl is executable knowledge, so it gets checked like code: rules
|
||||
# must reject what they should, domains must close, and functional keys
|
||||
# must not carry conflicting values. See kb_report/0 in the file.
|
||||
# trealla comes from the dev shell, as it does locally.
|
||||
- name: kb_consistent
|
||||
run: |
|
||||
nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \
|
||||
tpl prolog-sessions/kb.pl \
|
||||
-g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))"
|
||||
# scripts/kb is the only thing that writes kb.pl, so its refusals are as
|
||||
# load-bearing as kb_consistent itself. Without this the replacement
|
||||
# writer could regress green -- the review that found the directive
|
||||
# bypass also found that nothing ran this suite.
|
||||
- name: The kb tool refuses what it claims to
|
||||
run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb
|
||||
run: ./scripts/checkers.sh kb
|
||||
# Every suite goes through scripts/checkers.sh, which pins a bash >= 5
|
||||
# and scrubs the environment, so the shell and the variables a suite
|
||||
# passes under here are the ones it passes under locally (improvise-f9qf).
|
||||
@@ -280,8 +246,10 @@ jobs:
|
||||
# was blind to inline comments for as long as it existed (improvise-xgra).
|
||||
# fmt-workspaces is here because its own failure mode is the bug it
|
||||
# exists for: a usage error wearing a formatting failure's exit code.
|
||||
# bd-audit-ids is the dead-ID audit, whose recipe once read a bd that
|
||||
# never ran as a clean audit (improvise-52eq).
|
||||
- name: The remaining checkers refuse what they claim to
|
||||
run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces
|
||||
run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lints the one crate the `test` job cannot reach.
|
||||
@@ -299,20 +267,14 @@ jobs:
|
||||
# failed the *Browser harness* job. `-p opfs-contract` scopes it to 1 file.
|
||||
harness-lint:
|
||||
name: Lint the harness crate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: harness/opfs-contract -> target
|
||||
- working-directory: harness/opfs-contract
|
||||
run: |
|
||||
cargo fmt -p opfs-contract -- --check
|
||||
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
|
||||
# checks.harness-lint in nix/ci.nix, run from inside harness/opfs-contract:
|
||||
# `cargo fmt -p opfs-contract -- --check` and clippy for wasm32.
|
||||
- name: cargo fmt -p opfs-contract and clippy --target wasm32
|
||||
shell: bash
|
||||
run: nix build --print-build-logs .#checks.x86_64-linux.harness-lint
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The browser harness (improvise-vvl3, absorbing improvise-xf1).
|
||||
@@ -333,37 +295,25 @@ jobs:
|
||||
browser:
|
||||
name: Browser harness (OPFS contract + shell)
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install the harness
|
||||
working-directory: harness
|
||||
run: npm ci
|
||||
# Keyed on the resolved @playwright/test version, so a version bump is a
|
||||
# miss and anything else is a hit. The download was 50s on run
|
||||
# 35535225637 and is pure I/O — nothing about it depends on this commit.
|
||||
- name: Playwright version
|
||||
id: pw
|
||||
# No browser install step: the dev shell points PLAYWRIGHT_BROWSERS_PATH
|
||||
# at nixpkgs' Chromium, Firefox and WebKit, since Playwright's own
|
||||
# downloads cannot run on NixOS. All three, because improvise-xf1's
|
||||
# acceptance criterion names Chrome, Firefox and Safari and
|
||||
# `createSyncAccessHandle` is implemented separately by each engine.
|
||||
# WebKit's OPFS is absent today; the spec skips on a runtime probe.
|
||||
# @playwright/test must equal nixpkgs' playwright-driver version, or it
|
||||
# looks for browser builds that are not there; this says which.
|
||||
- name: Playwright matches the dev shell's browsers
|
||||
working-directory: harness
|
||||
run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v4
|
||||
id: pw-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.v }}
|
||||
# All three, because improvise-xf1's acceptance criterion names Chrome,
|
||||
# Firefox and Safari and `createSyncAccessHandle` is implemented
|
||||
# separately by each engine. WebKit is installed even though its OPFS is
|
||||
# absent today: the spec skips on a runtime probe, so the day that build
|
||||
# gains OPFS the check starts running without a CI change.
|
||||
# `--with-deps` installs apt packages OUTSIDE the cached directory, so it
|
||||
# runs on a cache hit too; only the browser download is skipped.
|
||||
- name: Install browsers
|
||||
working-directory: harness
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
run: |
|
||||
npx playwright --version
|
||||
ls "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
# The tree `build` assembled, not one this job rebuilds. Same bytes as
|
||||
# the Pages artifact, modulo the cache-bust token `web/assemble.sh`
|
||||
# stamps — the harness tree is stamped `harness` and the site the commit
|
||||
@@ -387,9 +337,13 @@ jobs:
|
||||
# renamed export is cheaper to read here than as a blank page.
|
||||
- name: The page's calls match the bundle it calls
|
||||
run: ./scripts/node-test.sh 'web/tests/bundle/*.test.js'
|
||||
# Chromium and Firefox only, for now. nixpkgs' WebKit (WPEWebProcess)
|
||||
# aborts on its first page inside the runner VMs, though not on the
|
||||
# host; its four specs skip on the OPFS probe anyway, so nothing gated
|
||||
# is lost. Put `--project=webkit` back once that abort is understood.
|
||||
- name: Run the harness
|
||||
working-directory: harness
|
||||
run: npx playwright test
|
||||
run: npx playwright test --project=chromium --project=firefox
|
||||
- if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -430,9 +384,9 @@ jobs:
|
||||
# exists to stop.
|
||||
pages-deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: [test, wasm, js, browser, harness-lint, kb, build]
|
||||
needs: [test, wasm, js, browser, harness-lint, kb, build, nix-build]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
|
||||
@@ -57,9 +57,9 @@ cp -rf source dest # NOT: cp -r source dest
|
||||
## The Wave Workflow
|
||||
|
||||
Most work here is done by **waves**: an orchestrator chunks the open backlog,
|
||||
fans several agents out into their own git worktrees, merges what comes back,
|
||||
reviews it, and pushes the wave as one PR. This section is what that costs to
|
||||
get right. Everything in it was learned by getting it wrong first.
|
||||
fans several agents out into their own git worktrees, and shepherds each
|
||||
agent's pull request through review to a merge on GitHub. This section is what
|
||||
that costs to get right. Everything in it was learned by getting it wrong first.
|
||||
|
||||
### Who owns what
|
||||
|
||||
@@ -75,11 +75,13 @@ badly:
|
||||
|
||||
Wave agents **read** all three freely (`bd show`, `bd ready`, grep `kb.pl`) and
|
||||
**write** none of them. They report proposed repo-map lines and `kb.pl` facts as
|
||||
*text* in their hand-back, and the orchestrator applies them — batched, at the
|
||||
end of the wave, after the review. **`kb.pl` is written by `scripts/kb add|replace|rm` and by nothing else** — not
|
||||
*text* in their hand-back, and the orchestrator applies them. Repo-map lines
|
||||
are batched at the end of the wave, after the review, because they conflict.
|
||||
`kb.pl` facts are not batched: the orchestrator writes each one when it arrives.
|
||||
**`kb.pl` is written by `scripts/kb add|replace|rm` and by nothing else** — not
|
||||
the Prolog MCP, not an editor. Every write is gated on `kb_consistent` against
|
||||
the candidate, so a bad delta leaves the file untouched. Facts do not need
|
||||
batching: write one when you learn it. There is a `kb` skill; read it before
|
||||
the candidate, so a bad delta leaves the file untouched, which is why a fact
|
||||
can be written the moment it is learned. There is a `kb` skill; read it before
|
||||
your first write. Reading is unaffected — `loadSession` at session start is
|
||||
still how a session gets the model in hand.
|
||||
|
||||
@@ -127,18 +129,22 @@ and watch the check fail.
|
||||
|
||||
### Shipping a wave
|
||||
|
||||
**Do not push `main` to `gh/main`.** Work reaches GitHub as a branch and a pull
|
||||
request, and lands on `main` by being merged there. The orchestrator still
|
||||
merges agent branches into its *local* `main` to integrate and run the gates —
|
||||
that is how conflicts get found early — but the push is a branch push, not a
|
||||
`main` push. `origin` and `gitea` are mirrors and take `main` directly.
|
||||
**Do not push `main` to `gh/main`.** Each agent's work reaches GitHub as its own
|
||||
branch and its own pull request, and lands on `main` by being merged there, once
|
||||
CI is green and an adversarial review agent has passed the PR's head SHA. An ADR
|
||||
PR also needs the user's explicit approval, and nothing else stands in for it:
|
||||
"ADR pull requests" below. The orchestrator does not integrate agent branches
|
||||
locally: its `main` only fast-forwards to `gh/main` after a merge. The procedure is
|
||||
`.agents/skills/wave/SKILL.md`, "Shepherding". `origin` and `gitea` are mirrors
|
||||
and take `main` directly.
|
||||
|
||||
**So `origin/main` runs ahead of `gh/main`, and a branch destined for a GitHub
|
||||
PR must merge `gh/main` — never `origin/main`.** This is a trap in this repo's
|
||||
own topology rather than a slip: the two remotes are both spelled `main`, and
|
||||
nothing about `git merge origin/main` looks wrong. The session-completion
|
||||
protocol below literally says `git merge origin/main`, which is right for
|
||||
`main` itself and wrong for a PR branch. It cost a review round on PR #145,
|
||||
**Because the mirrors take `main` directly, `origin/main` can run ahead of
|
||||
`gh/main`, and a branch destined for a GitHub PR must merge `gh/main` — never
|
||||
`origin/main`.** This is a trap in this repo's own topology rather than a slip:
|
||||
the two remotes are both spelled `main`, and nothing about
|
||||
`git merge origin/main` looks wrong. bd's generated Session Completion block
|
||||
once prescribed exactly that command (see "Repository policy over the Beads blocks" below).
|
||||
It cost a review round on PR #145,
|
||||
where four unmerged commits — including another PR's whole diff — were pulled
|
||||
into a branch based on `gh/main` and had to be rebuilt by cherry-picking.
|
||||
ChatGPT Codex caught it; nothing in CI could, because the result compiles and
|
||||
@@ -153,15 +159,13 @@ finishing. It happened in this repo: 27 commits across six agent branches —
|
||||
including a new crate, a new protocol type, and a type moving between crates —
|
||||
went straight to `main` unreviewed, while `AGENTS.md` already said not to. If
|
||||
you notice a batch has bypassed the loop, the cheap repair is a **review-only
|
||||
PR**: base a branch at the last PR merge, open a PR with `main` as the head, let
|
||||
Copilot review the diff, and land the findings as an ordinary PR afterwards. It
|
||||
PR**: base a branch at the last PR merge, open a PR with `main` as the head,
|
||||
have an adversarial review agent review the diff, and land the findings as an
|
||||
ordinary PR afterwards. It
|
||||
cannot be merged — the commits are already in — so close it once its findings
|
||||
are filed or fixed. Rewinding the default branch to fake a mergeable PR is not
|
||||
worth it on a repo with other collaborators.
|
||||
|
||||
One PR per wave, built by a **dedicated PR agent in its own worktree**, so waves
|
||||
pipeline against it: the next wave runs while the PR absorbs review fixes.
|
||||
|
||||
**Read the PR's reviews before merging it.** This is separate from CI and from
|
||||
the adversarial agent, and it has been missed: two ChatGPT Codex reviews landed
|
||||
on merged PRs and were not read until afterwards, one of them a live P2
|
||||
@@ -181,8 +185,8 @@ earlier head, and the finding may already be fixed — verify rather than assume
|
||||
in either direction.
|
||||
|
||||
Reviews arrive **as the owner's quota allows**, so absence is not evidence.
|
||||
Wait where a review is plausible, and when one is found, answer it on the
|
||||
thread even if the PR has already merged: that is where the next reader looks.
|
||||
Wait where a review is plausible. How a review is answered once found, by the
|
||||
author and by a reviewer, is `.agents/skills/wave/agent-rules.md`, "GitHub".
|
||||
|
||||
**Copilot is not reviewing this repository, and the request is a silent no-op.**
|
||||
Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61,
|
||||
@@ -228,8 +232,8 @@ normally.)
|
||||
|
||||
Across four PRs Copilot has found genuine bugs and no false positives, so its
|
||||
findings are worth the loop. **But judge each one against the repo's doctrine
|
||||
rather than applying it reflexively**, and when you decline, reply on the thread
|
||||
with the reason. The distinction that has mattered: a finding can be *correct*
|
||||
rather than applying it reflexively**. How a declined finding is answered is
|
||||
agent-rules, "GitHub". The distinction that has mattered: a finding can be *correct*
|
||||
and still be wrong to fix in that PR. On #12, a non-ASCII `truncate` panic was
|
||||
applied (byte-identical on ASCII, so it cost no evidence) while a hint-line
|
||||
padding bug in the same file was declined — fixing it would have rewritten the
|
||||
@@ -248,11 +252,31 @@ is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the
|
||||
stray re-creation. Check `gh pr view <n> --json state` *before* pushing a fix to
|
||||
a PR you have not looked at in a few minutes, rather than after.
|
||||
|
||||
Merge only once a review has passed — an adversarial agent's while Copilot is
|
||||
absent, and it must name the head SHA it reviewed. Prefer a **merge commit** —
|
||||
`AGENTS.md` says merges over rebases, and a wave's commits are individually
|
||||
Merge only once a review of the PR's current head has passed — an adversarial
|
||||
agent's while Copilot is absent. What a review must state is agent-rules,
|
||||
"GitHub". Prefer a **merge commit** —
|
||||
`AGENTS.md` says merges over rebases, and a PR's commits are individually
|
||||
meaningful.
|
||||
|
||||
### ADR pull requests
|
||||
|
||||
**Only the user's explicit approval accepts an ADR (`context/adr/`), and only
|
||||
it lets an ADR PR merge.** "An ADR isn't approved until I say it is" (the
|
||||
user). Green CI, every review answered and a passing adversarial review
|
||||
("Shipping a wave") are still required. They are gates, never approval: an ADR
|
||||
PR that passes all of them is still unapproved and does not merge. The steps,
|
||||
modelled on PR #122 (ADR 004):
|
||||
|
||||
1. The PR proposes the ADR with its **open questions numbered**.
|
||||
2. The user answers them on the PR.
|
||||
3. The answers are folded into the ADR, in a section headed **"What the user
|
||||
decided"**.
|
||||
4. The PR merges only after the user comments **"Approved"**, and only while
|
||||
the gates above still hold. Nobody else's comment or review stands in for
|
||||
it. "Read the PR's reviews before merging it" still
|
||||
applies after the approval. On #122 the user's own review landed as the PR
|
||||
merged.
|
||||
|
||||
### Never hand-edit `.beads/issues.jsonl`
|
||||
|
||||
bd manipulates that file and auto-exports to it. Editing it — or even
|
||||
@@ -269,15 +293,42 @@ session before an agent tried one and found nothing. Read the ID back after
|
||||
filing, and before pushing docs run the audit:
|
||||
|
||||
```bash
|
||||
grep -rohE 'improvise-[a-z0-9]{3,8}\b' \
|
||||
context README.md AGENTS.md CLAUDE.md crates src tests examples web harness docs \
|
||||
| sort -u \
|
||||
| grep -vE '^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|side|web)$' \
|
||||
> /tmp/bd-audit-ids.txt
|
||||
bd show $(cat /tmp/bd-audit-ids.txt) 2>&1 \
|
||||
| grep -iE 'not found|no such issue' || echo "all resolve"
|
||||
./scripts/bd-audit-ids
|
||||
```
|
||||
|
||||
It exits 0, printing `all N cited ids resolve`, only when every cited id is in
|
||||
what `bd list --all` returns. A dead id is exit 1, and each one is named. A bd
|
||||
that is missing, fails, or lists no issues is exit 3. The comparison is
|
||||
positive because the recipe the script replaced was not: it searched `bd show`
|
||||
for "not found" lines and printed "all resolve" when there were none, so a bd
|
||||
that never ran read as a clean audit (improvise-52eq). Its suite,
|
||||
`scripts/bd-audit-ids-test.sh`, runs in `checkers.sh` and checks both halves:
|
||||
a dead id, and a bd that does not answer.
|
||||
|
||||
In the script, `git ls-files` picks the files, and grep only reads them. The file list is the
|
||||
tracked files plus the untracked ones, minus anything git ignores. That leaves
|
||||
out two things a `grep -r` over the same roots reads:
|
||||
|
||||
- **Build output.** After `./harness/build.sh`, `harness/serve/` holds
|
||||
rendered copies of `context/`, and `grep -r` reported three dead ids from them
|
||||
that no tracked file contains (improvise-8haj).
|
||||
- **Other agents' worktrees.** In the orchestrator's checkout
|
||||
`.claude/worktrees/` holds every agent's worktree, dozens of copies of the
|
||||
repository with their unpushed work.
|
||||
|
||||
`-d skip` is there for the skills' symlinks, and it changes no answer. The
|
||||
`.claude/skills/*` entries are symlinks to directories under `.agents/skills/`,
|
||||
and `git ls-files` lists each one, so each reaches grep as a command-line
|
||||
operand. The grep has no `-r`, so it never reads a directory operand. It prints
|
||||
`grep: <path>: Is a directory` on stderr instead, and `-d skip` only silences
|
||||
those lines. The output goes through `sort -u`, so a duplicate could not change
|
||||
the answer anyway. Measured with BSD grep 2.6.0 and GNU grep 3.12: 3092 matches
|
||||
with or without the flag, 462 distinct ids. Do not add `-r`: GNU grep then
|
||||
follows those symlinks and reads the skills a second time (3107 matches).
|
||||
|
||||
Plain `git grep` is not a substitute: on macOS its `-E` silently ignores `\b`
|
||||
and finds a fraction of the ids.
|
||||
|
||||
Three things the earlier version of this recipe got wrong, each of which let a
|
||||
real defect through:
|
||||
|
||||
@@ -291,9 +342,10 @@ real defect through:
|
||||
to match. An ID-shaped non-ID is worse than a dead ID, because the
|
||||
diagnosis is identical and the repair is not.
|
||||
- **The exclusion list named only `core` and `wasm`**, so every other crate
|
||||
name would have been reported missing. The exclusions above also cover the
|
||||
name would have been reported missing. The script's exclusions also cover the
|
||||
non-ticket tokens this repo really contains: a package name, a temp file
|
||||
name, and the prose word "improvise-side". **They are anchored `^…$` for the
|
||||
name, the prose word "improvise-side", and the example crate name in the
|
||||
`kb` skill's `crate(...)` fact. **They are anchored `^…$` for the
|
||||
same reason the recipe is read as code**: unanchored, the `gp` alternative is
|
||||
a *prefix* match, so a future real ticket whose id happens to begin `gp` is
|
||||
silently excluded and the audit reports nothing while skipping it. An
|
||||
@@ -303,20 +355,23 @@ real defect through:
|
||||
- **A release artifact's name is ID-shaped, and `improvise-installer.sh`
|
||||
escaped only by being ten characters long.** `improvise-web.tar.gz`
|
||||
(improvise-oujb) is three, so it matches `{3,8}` and resolves to nothing;
|
||||
`web` is therefore excluded above. The general point is that the length
|
||||
`web` is therefore excluded in the script. The general point is that the length
|
||||
bound, not any rule, is what has been keeping `dist`'s artifact names out of
|
||||
this audit — so **a new artifact whose name is a short lowercase word must be
|
||||
added to the exclusion list in the same commit**, or it arrives as a dead id
|
||||
that no amount of `bd` archaeology explains.
|
||||
|
||||
Note the recipe's own prose deliberately spells no live or dead id: a document
|
||||
that lists ids as examples makes itself a source of audit noise.
|
||||
The recipe's own prose spells no id as an *example*, because a document that
|
||||
lists example ids makes itself a source of audit noise. The ids it does cite,
|
||||
improvise-8haj and improvise-oujb, are the tickets behind two of the rules
|
||||
above. They resolve, and the audit checks them like any other.
|
||||
|
||||
One environment note: in a worktree-isolated agent the shell guard refuses a
|
||||
loop that passes a runtime-computed argument to `bd`, so the recipe above
|
||||
materialises the id list into a file and hands `bd show` the whole list in one
|
||||
call rather than looping. If `bd show` in your build does not take several ids,
|
||||
run the ids through `xargs -n1 -I{} bd show {}` from a plain shell instead.
|
||||
One environment note: run the audit by its name. Which `bd` command forms a
|
||||
worktree-isolated agent's shell guard allows has varied between sessions. In
|
||||
one session it refused the old recipe's `bd show $(cat …)`, and also
|
||||
`xargs bd show < file` and `sh file`. In another, both `xargs` forms ran.
|
||||
`./scripts/bd-audit-ids` passes `bd` no argument computed at runtime, and it
|
||||
ran in the session that refused the other three.
|
||||
|
||||
A dead ID is worse than no ID: it reads as a promise that the follow-up exists.
|
||||
|
||||
@@ -327,8 +382,33 @@ in progress. Two duplicates were filed in this repo within minutes of the
|
||||
hand-backs that prompted them. `bd search` the core concept *and* scan
|
||||
in-progress issues first.
|
||||
|
||||
## Repository policy over the Beads blocks
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||
Everything between a `BEGIN BEADS …` marker and its `END` is generated by bd,
|
||||
and bd replaces it whole. `bd init` and `bd setup factory|opencode|mux` rewrite
|
||||
the integration block, and the last three call it "stale" and invite the
|
||||
rewrite whenever bd's template has moved on. `bd setup codex` rewrites the
|
||||
Codex block. So a hand edit inside the markers is silently reverted by the next
|
||||
regeneration. Repository policy lives here, outside them:
|
||||
|
||||
- **Work is not done until it is in a pull request and that pull request has
|
||||
merged.** A pushed branch is not done, and neither is a green one. In a wave,
|
||||
the merge is the orchestrator's, after an adversarial review passes the head
|
||||
SHA ("Shipping a wave"). An ADR PR merges only on the user's explicit
|
||||
approval as well ("ADR pull requests"). So a wave agent's part ends at an
|
||||
open PR plus its hand-back, and the ticket is done, and its bead closed, only
|
||||
when the merge lands.
|
||||
- **This repository does not opt in to the Beads "team-maintainer" profile.**
|
||||
So the pull (`git pull`, or `git pull --rebase` in bd's "full" profile) and
|
||||
the bare `git push` that the block lists for that profile are not
|
||||
instructions here. Work reaches `main` as "Shipping a wave" describes,
|
||||
and a wave agent pushes with the explicit refspec in
|
||||
`.agents/skills/wave/agent-rules.md`, "Base, branch, push".
|
||||
- **The no-markdown-TODO and no-`MEMORY.md` rules have one exception:**
|
||||
`context/design-principles.md` and `context/repo-map.md` are kept by hand
|
||||
(the repo map by the orchestrator only; see "Who owns what").
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:46cd31e7 -->
|
||||
## Beads Issue Tracker
|
||||
|
||||
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||
@@ -344,35 +424,44 @@ bd close <id> # Complete work
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
|
||||
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||
- Run `bd prime` for detailed command reference and session close protocol
|
||||
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files (the exception being that you must maintain context/design-principles.md and context/repo-map.md)
|
||||
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||
|
||||
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/core-concepts/sync-concepts.md for details and anti-patterns.
|
||||
|
||||
## Agent Context Profiles
|
||||
|
||||
The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions.
|
||||
|
||||
- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands.
|
||||
- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise.
|
||||
- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins.
|
||||
|
||||
## Session Completion
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
1. **File issues for remaining work** - Create beads for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
4. **Handle git/sync by active profile**:
|
||||
```bash
|
||||
git merge origin/main
|
||||
# Conservative/minimal/default: report status and proposed commands; wait for approval.
|
||||
git status
|
||||
|
||||
# Team-maintainer opt-in only, unless current instructions forbid it:
|
||||
git pull
|
||||
bd dolt push
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
git status
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
**Critical rules:**
|
||||
- Explicit user or orchestrator instructions override this Beads block.
|
||||
- Do not commit or push without clear authority from the active profile or the current user request.
|
||||
- If a required sync or push is blocked, stop and report the exact command and error.
|
||||
<!-- END BEADS INTEGRATION -->
|
||||
|
||||
<!-- BEGIN BEADS CODEX SETUP: generated by bd setup codex -->
|
||||
|
||||
Generated
-2
@@ -997,8 +997,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -2640,7 +2640,6 @@ rec {
|
||||
{
|
||||
name = "indexmap";
|
||||
packageId = "indexmap";
|
||||
features = [ "serde" ];
|
||||
}
|
||||
{
|
||||
name = "ratatui";
|
||||
@@ -2654,6 +2653,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
@@ -2673,6 +2673,10 @@ rec {
|
||||
name = "improvise-wasm-client";
|
||||
packageId = "improvise-wasm-client";
|
||||
}
|
||||
{
|
||||
name = "libc";
|
||||
packageId = "libc";
|
||||
}
|
||||
{
|
||||
name = "pest";
|
||||
packageId = "pest";
|
||||
@@ -2735,6 +2739,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "unicode-width";
|
||||
@@ -2776,7 +2781,6 @@ rec {
|
||||
{
|
||||
name = "indexmap";
|
||||
packageId = "indexmap";
|
||||
features = [ "serde" ];
|
||||
}
|
||||
{
|
||||
name = "serde";
|
||||
@@ -2898,7 +2902,6 @@ rec {
|
||||
{
|
||||
name = "indexmap";
|
||||
packageId = "indexmap";
|
||||
features = [ "serde" ];
|
||||
}
|
||||
{
|
||||
name = "js-sys";
|
||||
@@ -2921,6 +2924,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "wasm-bindgen";
|
||||
@@ -2975,6 +2979,7 @@ rec {
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
optional = true;
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "unicode-width";
|
||||
@@ -3011,6 +3016,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "wasm-bindgen";
|
||||
@@ -3068,6 +3074,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "wasm-bindgen";
|
||||
@@ -3135,6 +3142,7 @@ rec {
|
||||
{
|
||||
name = "serde_json";
|
||||
packageId = "serde_json";
|
||||
features = [ "float_roundtrip" ];
|
||||
}
|
||||
{
|
||||
name = "tokio";
|
||||
@@ -3171,27 +3179,6 @@ rec {
|
||||
packageId = "hashbrown 0.17.1";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
{
|
||||
name = "serde";
|
||||
packageId = "serde";
|
||||
optional = true;
|
||||
usesDefaultFeatures = false;
|
||||
target = { target, features }: false;
|
||||
}
|
||||
{
|
||||
name = "serde_core";
|
||||
packageId = "serde_core";
|
||||
optional = true;
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
name = "serde";
|
||||
packageId = "serde";
|
||||
usesDefaultFeatures = false;
|
||||
features = [ "derive" ];
|
||||
}
|
||||
];
|
||||
features = {
|
||||
"arbitrary" = [ "dep:arbitrary" ];
|
||||
@@ -3202,7 +3189,7 @@ rec {
|
||||
"serde" = [ "dep:serde_core" "dep:serde" ];
|
||||
"sval" = [ "dep:sval" ];
|
||||
};
|
||||
resolvedDefaultFeatures = [ "default" "serde" "std" ];
|
||||
resolvedDefaultFeatures = [ "default" "std" ];
|
||||
};
|
||||
"indoc" = rec {
|
||||
crateName = "indoc";
|
||||
@@ -5655,7 +5642,7 @@ rec {
|
||||
"preserve_order" = [ "indexmap" "std" ];
|
||||
"std" = [ "memchr/std" "serde_core/std" ];
|
||||
};
|
||||
resolvedDefaultFeatures = [ "default" "std" ];
|
||||
resolvedDefaultFeatures = [ "default" "float_roundtrip" "std" ];
|
||||
};
|
||||
"sha1" = rec {
|
||||
crateName = "sha1";
|
||||
@@ -8871,7 +8858,15 @@ rec {
|
||||
)
|
||||
crateConfigs;
|
||||
target = makeTarget pkgs.stdenv.hostPlatform;
|
||||
build = mkBuiltByPackageIdByPkgs pkgs.buildPackages;
|
||||
# Build-time dependency graph (for proc-macros and build
|
||||
# dependencies). When not cross-compiling it equals the host
|
||||
# graph, so reuse `self`; otherwise build it for
|
||||
# `pkgs.buildPackages`.
|
||||
build =
|
||||
if pkgs.stdenv.buildPlatform.config == pkgs.stdenv.hostPlatform.config then
|
||||
self
|
||||
else
|
||||
mkBuiltByPackageIdByPkgs pkgs.buildPackages;
|
||||
};
|
||||
in
|
||||
self;
|
||||
@@ -8887,38 +8882,35 @@ rec {
|
||||
devDependencies = lib.optionals (runTests && packageId == rootPackageId) (
|
||||
crateConfig'.devDependencies or [ ]
|
||||
);
|
||||
dependencies = dependencyDerivations {
|
||||
# Enabled (platform- and feature-filtered) dependency lists, reused
|
||||
# for both derivation wiring and the crate renames below.
|
||||
enabledDependencies = filterEnabledDependencies {
|
||||
inherit features;
|
||||
inherit (self) target;
|
||||
buildByPackageId =
|
||||
depPackageId:
|
||||
# proc_macro crates must be compiled for the build architecture
|
||||
if crateConfigs.${depPackageId}.procMacro or false then
|
||||
self.build.crates.${depPackageId}
|
||||
else
|
||||
self.crates.${depPackageId};
|
||||
dependencies = (crateConfig.dependencies or [ ]) ++ devDependencies;
|
||||
};
|
||||
buildDependencies = dependencyDerivations {
|
||||
enabledBuildDependencies = filterEnabledDependencies {
|
||||
inherit features;
|
||||
inherit (self.build) target;
|
||||
buildByPackageId = depPackageId: self.build.crates.${depPackageId};
|
||||
dependencies = crateConfig.buildDependencies or [ ];
|
||||
};
|
||||
dependencies = map
|
||||
(
|
||||
dependency:
|
||||
# proc_macro crates must be compiled for the build architecture
|
||||
if crateConfigs.${dependency.packageId}.procMacro or false then
|
||||
self.build.crates.${dependency.packageId}
|
||||
else
|
||||
self.crates.${dependency.packageId}
|
||||
)
|
||||
enabledDependencies;
|
||||
buildDependencies = map
|
||||
(dependency: self.build.crates.${dependency.packageId})
|
||||
enabledBuildDependencies;
|
||||
# Order (build dependencies, then normal dependencies) feeds the
|
||||
# crateRenames grouping below.
|
||||
dependenciesWithRenames =
|
||||
let
|
||||
buildDeps = filterEnabledDependencies {
|
||||
inherit features;
|
||||
inherit (self) target;
|
||||
dependencies = crateConfig.dependencies or [ ] ++ devDependencies;
|
||||
};
|
||||
hostDeps = filterEnabledDependencies {
|
||||
inherit features;
|
||||
inherit (self.build) target;
|
||||
dependencies = crateConfig.buildDependencies or [ ];
|
||||
};
|
||||
in
|
||||
lib.filter (d: d ? "rename") (hostDeps ++ buildDeps);
|
||||
lib.filter (d: d ? "rename") (enabledBuildDependencies ++ enabledDependencies);
|
||||
# Crate renames have the form:
|
||||
#
|
||||
# {
|
||||
@@ -9090,6 +9082,17 @@ rec {
|
||||
corresponding feature sets are merged. Features in rust are additive.
|
||||
*/
|
||||
mergePackageFeatures =
|
||||
args: builtins.mapAttrs (_packageId: builtins.attrNames) (mergePackageFeaturesImpl args);
|
||||
|
||||
/*
|
||||
Core of the feature-resolution fixpoint. The cache (`featuresByPackageId`)
|
||||
maps each packageId to a feature *set* (an attrset `feature -> 1`) rather
|
||||
than a sorted list, so the fold merges with `//` and detects convergence
|
||||
with attrset equality instead of re-concatenating and re-sorting the
|
||||
accumulated feature list on every step. `mergePackageFeatures` projects the
|
||||
result back to canonical sorted lists.
|
||||
*/
|
||||
mergePackageFeaturesImpl =
|
||||
{ crateConfigs ? crates
|
||||
, packageId
|
||||
, rootPackageId ? packageId
|
||||
@@ -9138,14 +9141,15 @@ rec {
|
||||
cache:
|
||||
{ packageId, features }:
|
||||
let
|
||||
cacheFeatures = cache.${packageId} or [ ];
|
||||
combinedFeatures = sortedUnique (cacheFeatures ++ features);
|
||||
cacheFeatures = cache.${packageId} or { };
|
||||
# `features` is the (small) incoming list; merge it into the set.
|
||||
combinedFeatures = cacheFeatures // listToSet features;
|
||||
in
|
||||
if cache ? ${packageId} && cache.${packageId} == combinedFeatures then
|
||||
if cache ? ${packageId} && cacheFeatures == combinedFeatures then
|
||||
cache
|
||||
else
|
||||
mergePackageFeatures {
|
||||
features = combinedFeatures;
|
||||
mergePackageFeaturesImpl {
|
||||
features = builtins.attrNames combinedFeatures;
|
||||
featuresByPackageId = cache;
|
||||
inherit
|
||||
crateConfigs
|
||||
@@ -9158,8 +9162,8 @@ rec {
|
||||
);
|
||||
cacheWithSelf =
|
||||
let
|
||||
cacheFeatures = featuresByPackageId.${packageId} or [ ];
|
||||
combinedFeatures = sortedUnique (cacheFeatures ++ enabledFeatures);
|
||||
cacheFeatures = featuresByPackageId.${packageId} or { };
|
||||
combinedFeatures = cacheFeatures // listToSet enabledFeatures;
|
||||
in
|
||||
featuresByPackageId
|
||||
// {
|
||||
@@ -9186,27 +9190,31 @@ rec {
|
||||
assert (builtins.isList features);
|
||||
assert (builtins.isAttrs target);
|
||||
|
||||
let
|
||||
# Identical for every dep in this call; build the predicate arg once.
|
||||
targetArgs = { inherit features target; };
|
||||
in
|
||||
lib.filter
|
||||
(
|
||||
dep:
|
||||
let
|
||||
targetFunc = dep.target or (features: true);
|
||||
in
|
||||
targetFunc { inherit features target; }
|
||||
(dep.target or (features: true)) targetArgs
|
||||
&& (!(dep.optional or false) || builtins.any (doesFeatureEnableDependency dep) features)
|
||||
)
|
||||
dependencies;
|
||||
|
||||
# Returns whether the given feature should enable the given dependency.
|
||||
doesFeatureEnableDependency =
|
||||
dependency: feature:
|
||||
dependency:
|
||||
# Callers partially apply this once per dependency, then test every feature,
|
||||
# so hoist the dep-invariant strings out of the per-feature comparison.
|
||||
let
|
||||
name = dependency.rename or dependency.name;
|
||||
depName = "dep:" + name;
|
||||
prefix = "${name}/";
|
||||
len = builtins.stringLength prefix;
|
||||
startsWithPrefix = builtins.substring 0 len feature == prefix;
|
||||
in
|
||||
feature == name || feature == "dep:" + name || startsWithPrefix;
|
||||
feature:
|
||||
feature == name || feature == depName || builtins.substring 0 len feature == prefix;
|
||||
|
||||
/*
|
||||
Returns the expanded features for the given inputFeatures by applying the
|
||||
@@ -9220,29 +9228,24 @@ rec {
|
||||
assert (builtins.isAttrs featureMap);
|
||||
assert (builtins.isList inputFeatures);
|
||||
let
|
||||
# Transitive closure of `inputFeatures` under `featureMap`. `seen`
|
||||
# tracks already-expanded features so each is visited at most once;
|
||||
# this also breaks feature cycles (issue #209).
|
||||
expandFeaturesNoCycle =
|
||||
oldSeen: inputFeatures:
|
||||
if inputFeatures != [ ] then
|
||||
let
|
||||
# The feature we're currently expanding.
|
||||
feature = builtins.head inputFeatures;
|
||||
# All the features we've seen/expanded so far, including the one
|
||||
# we're currently processing.
|
||||
seen = oldSeen // {
|
||||
${feature} = 1;
|
||||
};
|
||||
# Expand the feature but be careful to not re-introduce a feature
|
||||
# that we've already seen: this can easily cause a cycle, see issue
|
||||
# #209.
|
||||
enables = builtins.filter (f: !(seen ? "${f}")) (featureMap."${feature}" or [ ]);
|
||||
in
|
||||
[ feature ] ++ (expandFeaturesNoCycle seen (builtins.tail inputFeatures ++ enables))
|
||||
# No more features left, nothing to expand to.
|
||||
else
|
||||
[ ];
|
||||
outFeatures = expandFeaturesNoCycle { } inputFeatures;
|
||||
seen: features:
|
||||
builtins.foldl'
|
||||
(
|
||||
acc: feature:
|
||||
if acc ? ${feature} then
|
||||
acc
|
||||
else
|
||||
expandFeaturesNoCycle (acc // { ${feature} = 1; }) (featureMap.${feature} or [ ])
|
||||
)
|
||||
seen
|
||||
features;
|
||||
seen = expandFeaturesNoCycle { } inputFeatures;
|
||||
in
|
||||
sortedUnique outFeatures;
|
||||
sortedUnique (builtins.attrNames seen);
|
||||
|
||||
/*
|
||||
This function adds optional dependencies as features if they are enabled
|
||||
@@ -9301,16 +9304,19 @@ rec {
|
||||
in
|
||||
defaultOrNil ++ explicitFeatures ++ additionalDependencyFeatures;
|
||||
|
||||
# Builds a feature set (attrset `feature -> 1`) from a list of feature names.
|
||||
listToSet =
|
||||
features:
|
||||
builtins.listToAttrs (map (feature: { name = feature; value = 1; }) features);
|
||||
|
||||
# Sorts and removes duplicates from a list of strings.
|
||||
sortedUnique =
|
||||
features:
|
||||
assert (builtins.isList features);
|
||||
assert (builtins.all builtins.isString features);
|
||||
let
|
||||
outFeaturesSet = lib.foldl (set: feature: set // { "${feature}" = 1; }) { } features;
|
||||
outFeaturesUnique = builtins.attrNames outFeaturesSet;
|
||||
in
|
||||
builtins.sort (a: b: a < b) outFeaturesUnique;
|
||||
# attrNames returns keys in ascending string order, so the result is
|
||||
# sorted and deduplicated. The feature-merge fixpoint relies on this
|
||||
# canonical order to detect convergence.
|
||||
builtins.attrNames (listToSet features);
|
||||
|
||||
deprecationWarning =
|
||||
message: value:
|
||||
|
||||
+4
-2
@@ -40,10 +40,12 @@ improvise-command = { path = "crates/improvise-command" }
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Parses JSON a user's numbers cross, so it must read back the f64 written: per crate,
|
||||
# because each build stands alone (improvise-6yvg, context/code/protocol.md).
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
indexmap = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
unicode-width = "^0.2"
|
||||
|
||||
@@ -169,8 +169,9 @@ Measure: column
|
||||
format: ,.2f
|
||||
```
|
||||
|
||||
Gzip-compressed `.improv.gz` is also supported. Legacy JSON is auto-detected
|
||||
for backward compatibility.
|
||||
Gzip-compressed `.improv.gz` is also supported. `.improv` text is the only
|
||||
native document format: the old JSON document format is no longer read, and
|
||||
opening one fails with an error that says so. JSON *import* is unaffected.
|
||||
|
||||
## Import and scripting
|
||||
|
||||
|
||||
@@ -226,3 +226,9 @@ learns no coordinate space, and an invalid operation is unconstructible rather
|
||||
than rejected. What the extension adds is that the enum is now **four** answers
|
||||
to "what may be done here", so `None` finally means only what this ADR always
|
||||
said it meant — no projected cell at all.
|
||||
|
||||
**A fifth kind (2026-09-22, improvise-exdg).** The records-mode coordinate cell
|
||||
was the last `None` that named a visible cell. It is now `CellId::Coordinate`.
|
||||
The variant carries no operand, because no client composes a coordinate rename.
|
||||
The previous paragraph's "four answers" is therefore five. `None` now means
|
||||
only a padded or blanked slot. Reasoning: `context/code/protocol.md` §3.
|
||||
|
||||
+61
-23
@@ -98,7 +98,8 @@ The message states the **shape** rather than the arity, because
|
||||
## 6 — the help's ex-commands were the unguarded surface
|
||||
|
||||
`help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose
|
||||
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` and
|
||||
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` (an
|
||||
ex-command only, since improvise-wk18: built by `ex!`) and
|
||||
`HelpLine::Accent` rows are hand-written prose and had nothing checking them —
|
||||
which is how all three documented spellings of `:formula` came to be ones the
|
||||
parser refuses.
|
||||
@@ -161,7 +162,7 @@ limitation that is not new: `ExecuteCommand` returns one effect batch, so the
|
||||
segments of `:a . b` all see the pre-batch cursor, exactly as a
|
||||
`Binding::Sequence` does (gotchas 21 and 23).
|
||||
|
||||
## 8 — the interactive arm is `parse`, then context; it is never a second parser
|
||||
## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser
|
||||
|
||||
#151 fixed three instances of *a registry with two construction arms demotes
|
||||
whichever caller reaches the weaker one*, one patch each. A post-merge review
|
||||
@@ -175,31 +176,59 @@ and the key from the cursor. So a script that wrote 5 to `Region/East`
|
||||
The shape rather than the instances is what changed. `CmdRegistry::register`
|
||||
used to take two free functions, `parse(args)` and `interactive(args, ctx)`,
|
||||
and nothing related them; each command re-derived its arguments twice and every
|
||||
disagreement was silent. It now takes three pieces, and the interactive arm is
|
||||
**built from `parse`**, not beside it:
|
||||
disagreement was silent. The interactive arm is now **built from `parse`**, not
|
||||
beside it:
|
||||
|
||||
- `parse(args) -> C` — the only function that reads arguments a caller supplied.
|
||||
- `contextualize(C, &CmdContext) -> C` — fills the fields that are context and
|
||||
never argument (a `Move`'s origin cursor, a `MovePanelCursor`'s bound). It is
|
||||
handed the built command and **not the args**, so it cannot discard them.
|
||||
- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused.
|
||||
This is where "no coordinates means the cursor" lives: `clear-cell` bare, and
|
||||
`commit-cell-edit <mode>` bare, which is what the keymap binds.
|
||||
- For `register_at_cursor` only, the registry itself then sets the command's
|
||||
**cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the
|
||||
live one. That is the whole of what the interactive arm adds to a parsed
|
||||
command, and no registration supplies it.
|
||||
|
||||
Two consequences hold by construction rather than by test. **Whatever `parse`
|
||||
accepts, the interactive arm accepts** — `contextualize` is infallible — so the
|
||||
§7 gate cannot pass a segment the loop then refuses. And **an accepted argument
|
||||
list cannot be overridden by the cursor**, because the only function that sees
|
||||
both args and context never runs on an accepted list. `register_pure` and
|
||||
`register_nullary` already had one arm; they are the `contextualize = identity,
|
||||
fallback = None` case.
|
||||
Three consequences hold by construction rather than by test. **Whatever `parse`
|
||||
accepts, the interactive arm accepts** — nothing after `parse` can fail — so the
|
||||
§7 gate cannot pass a segment the loop then refuses. **An accepted argument list
|
||||
cannot be overridden by the cursor**, because the only function that sees both
|
||||
args and context never runs on an accepted list. And **no field `parse` set can
|
||||
be overwritten from context**, because nothing a registration writes can name
|
||||
one: the cursor slot is typed `&mut CursorState`, and no command parses a
|
||||
`CursorState` out of its arguments. `register_pure` and `register_nullary`
|
||||
already had one arm; they are `register` with no fallback.
|
||||
|
||||
That last one is improvise-yf7p, and it is why the registry lost a parameter.
|
||||
#171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the
|
||||
built command and not the args, and stated honestly that it could still
|
||||
**overwrite** a parsed field — naming the field was "a visible line in review".
|
||||
Review then injected five such overwrites on commands other than the one with a
|
||||
test (yank, paste and drill-into-cell taking their key from the cursor over the
|
||||
parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor
|
||||
clamping its parsed delta to ±1) and the whole workspace stayed green. A hole
|
||||
guarded by review discipline plus one command's test is not guarded. Every
|
||||
contextualizer in the tree fell into one of two shapes, and each had an answer
|
||||
that removes the function rather than checking it:
|
||||
|
||||
- **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one
|
||||
`CursorState` field, always `CursorState::from_ctx`. So the registry does it,
|
||||
through a slot whose *type* is the guard: an `AtCursor` impl pointing at a
|
||||
`CellKey` or a delta is a compile error, which was tried.
|
||||
- **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s
|
||||
`currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s
|
||||
cursor and bound). These copied context into the command only to read it back;
|
||||
`execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus`
|
||||
got below. Those three are `register`/`register_nullary` now, so their
|
||||
interactive arm is exactly `parse`.
|
||||
|
||||
What `execute` does with a parsed field is still the command's own logic and is
|
||||
tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and
|
||||
`move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or
|
||||
the delta is ignored there. The difference is that the registry is no longer a
|
||||
second place such a bug can be written.
|
||||
|
||||
What is still representable, stated rather than hidden:
|
||||
|
||||
- `contextualize` can overwrite a field `parse` set. It would have to name the
|
||||
field, which is a visible line in review, where the old `|_args, ctx|` was an
|
||||
invisible omission. `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`
|
||||
fails if `commit-cell-edit`'s does.
|
||||
- A `fallback` sees the args `parse` refused and could ignore some of them.
|
||||
`commit-cell-edit`'s refuses anything but a lone mode, so the malformed
|
||||
`commit-cell-edit editing 5` is an error on the `:` line — which has no pure
|
||||
@@ -207,9 +236,12 @@ What is still representable, stated rather than hidden:
|
||||
- The gate is **stricter** than the loop wherever a fallback exists: a bare
|
||||
`clear-cell` is refused in a script. That is the safe direction — the whole
|
||||
line is refused — and it is unchanged from before #151.
|
||||
- A new command could declare a *second* `CursorState` field and parse it from
|
||||
args; `cursor_mut` could then point at the wrong one. No command has one, and
|
||||
a parsed cursor would be a new idea deserving its own argument.
|
||||
|
||||
`toggle-panel-and-focus` did not fit `contextualize`, because its pure arm
|
||||
defaulted the omitted flags to `true` and a contextualizer cannot tell an
|
||||
`toggle-panel-and-focus` did not fit #171's `contextualize` either, because its
|
||||
pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an
|
||||
omitted flag from an explicit one. So the omission is now in the type —
|
||||
`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is
|
||||
resolved in `execute` against the same context. That is the honest home: the
|
||||
@@ -281,9 +313,15 @@ still possible later, and would want the severity *on the message*; that is a
|
||||
separate change with a separate staleness problem.
|
||||
|
||||
**The classification is per writer, and it is not total.** Every refusal in
|
||||
`ui/effect.rs` and in `cmd/{commit,fit,grid,mode,search,text_buffer}.rs` goes
|
||||
through `refuse`. Those still written with `set_status` exit 0 as before, and
|
||||
are listed in improvise-i3o5. `q` on a dirty document is deliberately left a
|
||||
`ui/effect.rs` and in `cmd/{cell,commit,fit,grid,mode,search,text_buffer}.rs`
|
||||
goes through `refuse`. `EditOrDrill`'s collapsed-group arm and the three
|
||||
spelled-paste declines in `cell.rs` (aggregate, coordinate column, inside a
|
||||
drill) joined them with improvise-i3o5. Those still written with `set_status`
|
||||
exit 0 as before, and are listed on that bead: three in `cmd/panel.rs`, and
|
||||
`command/keymap.rs::dispatch`'s arg-error arm. The sweeps that pin refusals
|
||||
revert `refuse(` calls, so they **cannot see a refusal written as
|
||||
`set_status`**. Finding one takes a read of every `set_status` on a decline
|
||||
path, and that is how these were found. `q` on a dirty document is deliberately left a
|
||||
report: headless, its text is false — the CLI saves anyway (improvise-ep5o).
|
||||
The two rivals udt2 named both stay green: a successful `:w` and a successful
|
||||
`fit-measure` report through `set_status` and exit 0.
|
||||
|
||||
+146
-23
@@ -950,7 +950,8 @@ gotcha 47 asks for — closing e24v has to come to that line and delete it.
|
||||
aggregate, which is true in both front ends because the browser's refusal
|
||||
names drill. Delivering drill is `improvise-obtu`'s, not this bullet's.
|
||||
- **The commit's write is not spelled again here.** It is
|
||||
`Client::typed_cell`'s, the same one click-to-edit uses, so a browser keyboard
|
||||
`typed_cell_command`'s, the same one click-to-edit reaches through
|
||||
`typed_target_frame`, so a browser keyboard
|
||||
commit and a browser mouse commit cannot disagree about what `""` or `"42"`
|
||||
means.
|
||||
- **`cell_at_cursor` returns `None` when the selection is scrolled out of the
|
||||
@@ -995,20 +996,39 @@ folded when the resolver runs — the trap that reads as "the cursor lands on
|
||||
`Plan` and the document switches to `Default`", silent and wrong in the half a
|
||||
user does not watch.
|
||||
|
||||
The second clause of rule 1 is what makes the formula panel look inconsistent
|
||||
and is in fact the rule holding. `ModeKey::ViewPanel` binds `Enter` to
|
||||
`switch-view-at-cursor`; `ModeKey::FormulaPanel` binds `a`/`n`/`o` and
|
||||
`d`/`Delete` and **no `Enter` at all**. So a view-row click switches and a
|
||||
formula-row click only navigates. Opening the editor on a formula click would
|
||||
be an activation *invented for the pointer that the keyboard does not have* —
|
||||
a second derivation of what a formula row means, which is the disease §5 names.
|
||||
**The guard for this must be aimed at the keymap, not at the click**: asserting
|
||||
that the click emits no `FormulaEdit` only restates the author's reading, and a
|
||||
reviewer demonstrated the gap by adding `Enter -> enter-mode formula-edit` to
|
||||
`FormulaPanel` and watching the whole workspace stay green while the click and
|
||||
the keyboard disagreed. `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`
|
||||
asserts the binding's absence and names `click_formula_row` as what must change
|
||||
if it ever appears.
|
||||
The second clause of rule 1 is why a formula-row click opens the editor. Until
|
||||
improvise-km71, `ModeKey::FormulaPanel` bound `a`/`n`/`o` and `d`/`Delete` and
|
||||
**no `Enter` at all**, so a formula-row click only navigated, and opening the
|
||||
editor on a click would have been an activation *invented for the pointer that
|
||||
the keyboard did not have*. km71 bound `Enter` (and `e`) to
|
||||
`edit-formula-at-cursor`, so the rule now yields the editor on the clicked row,
|
||||
and `click_formula_row` ends with `edit_formula_at(index)` exactly as
|
||||
`click_view_row` ends with `switch_view_at(index)`. **The guard is aimed at what
|
||||
`Enter` resolves to, not at the keymap and not at the click**:
|
||||
`a_formula_row_click_ends_with_what_enter_does_on_that_row` resolves `Enter` at
|
||||
cursor 1 and asserts the click on row 1, made with the cursor at 0, *ends with*
|
||||
those commands. The earlier guard asserted only that `Enter` was unbound, which
|
||||
tied the two by a comment; this one fails if either side changes without the
|
||||
other, and it separates "reads the clicked index" from "reads the cursor",
|
||||
because the two point at different rows. The superseded test was
|
||||
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
|
||||
|
||||
Editing needs the client to name *which* formula, and `FormulaCache` used to
|
||||
carry only the raw text, from which neither the target nor its category is
|
||||
recoverable without reimplementing `parse_formula` on the client — the gap
|
||||
`decision(improvise-e24v)` recorded for `delete-formula-at-cursor`. km71 closed
|
||||
it the gotcha 47 way, by **enriching the projection**: `FormulaCache::formulas`
|
||||
is `Vec<FormulaEntry { id: FormulaId, raw }>`. The id is opaque on purpose
|
||||
(`context/code/core.md` §14). `delete-formula-at-cursor` is still declined in
|
||||
the browser; it now needs only its resolver.
|
||||
|
||||
One divergence is pinned rather than closed: a rename. The host's
|
||||
`ReplaceFormula` rewrites the status to say what was renamed and which formulas
|
||||
still name the old target; the client cannot know the old target (the id is
|
||||
opaque) and folds the optimistic "Formula updated".
|
||||
`renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host` asserts
|
||||
both sides of that, as `a_formula_the_client_cannot_parse_diverges_on_the_status_line`
|
||||
does for a parse error.
|
||||
|
||||
**The browser assertion that PR #60 dropped stays dropped, and improvise-qf57
|
||||
is the decision rather than the oversight.** `#grid input` `toHaveCount(0)` was
|
||||
@@ -1025,6 +1045,9 @@ running the spec: `#grid input` `toHaveCount(0)` **passes**, and the run fails
|
||||
on the `#mode` assertion below it. Restoring it would add an assertion that
|
||||
cannot fail — the same defect this file already records one line down, where an
|
||||
explanatory `assert!` was removed for never being able to be the failing one.
|
||||
Since improvise-km71 the activation is real rather than invented, so the spec's
|
||||
`#mode` assertion reads `"FORMULA"` and it also asserts that `#minibuffer` holds
|
||||
the clicked formula's text; the argument about `#grid input` is unchanged.
|
||||
|
||||
**Rule 2 — a click is all of its intents or none of them** (improvise-hkr8).
|
||||
Each `click_*` returns `Option<Vec<Command>>` and `click` does the single
|
||||
@@ -1411,12 +1434,81 @@ than a check; or this client has not been told the document's basis and so has
|
||||
nothing truthful to say about what it composed against (`BasisCache::claim`
|
||||
withholds rather than invents).
|
||||
|
||||
A **fifth** `None` is not a failure: the text is the seed the editor captured
|
||||
when it opened (improvise-ixxx). The seed is part of `EditTarget`, captured by
|
||||
`edit_target_at` from the cache's `edit_text` beside the record and its
|
||||
version, so the page passes no seed and cannot pass a wrong one.
|
||||
`typed_target_frame` asks `resolve::untouched`, the one home beside `typed()`,
|
||||
before anything else. The argument is in `protocol.md` §3, "An untouched editor
|
||||
writes nothing".
|
||||
|
||||
**Nothing is folded there.** The new value comes back as a `GridUpdate::Cells`
|
||||
from the host, which is the side that formats it; guessing it locally is how the
|
||||
two ends drift apart.
|
||||
|
||||
**The page commits by identity, never by position (improvise-2wu3).** While an
|
||||
inline editor is open, `repaint` returns early, so the screen is frozen. But
|
||||
`on_frame` goes on folding, so the cache moves while the screen does not. The
|
||||
page used to keep the editor's viewport `(row, col)` and resolve it against the
|
||||
**current** cache at commit. If another client had hidden a row above, that
|
||||
position now held a different record. `basis.claim` was satisfied too, because
|
||||
the fresh `Projection::Basis` had been folded along with the rows. So the write
|
||||
landed on a row the user never looked at, and it was reported accepted. The
|
||||
basis was current *for the client*; the stale thing was the screen, and
|
||||
nothing measures that.
|
||||
|
||||
So the JS surface is now two calls. `edit_target(row, col)` returns an
|
||||
`EditTarget` as an opaque string, and the page takes it when the editor opens.
|
||||
`typed_cell(target, text)` composes the write against it
|
||||
(`typed_target_frame`). The position-taking composer is gone from the page's
|
||||
surface, so the confusion cannot be written in the page. `typed_cell_frame(row,
|
||||
col, …)` survives as Rust-only sugar over `edit_target_at` and
|
||||
`typed_target_frame`, for tests that compose at the moment they read.
|
||||
|
||||
**The target carries the version the user saw, not only the identity
|
||||
(improvise-0j0f).** The first version captured the `RecordCell` at open but
|
||||
claimed the basis at commit. The fold keeps running while the editor is open,
|
||||
so another client's write to *that same record* bumped the counter in the
|
||||
client's `BasisCache`. The commit then claimed the fresh counter and was
|
||||
accepted, and the other write was silently overwritten. If the other client
|
||||
cleared the cell instead, the commit re-created it. An `EditTarget` is
|
||||
`{ document_generation, cell, version, opened }`: the document and the record's
|
||||
counter as the client had them when the editor opened, and the seed it opened
|
||||
on (improvise-ixxx, above). `BasisCache::claim_as_read`
|
||||
states both beside the *current* `layout_version`.
|
||||
|
||||
**The two document tiers are not alike, and only the layout stays current.**
|
||||
The rows renumbering under the editor, which was 2wu3's case, moves
|
||||
`layout_version`. Claiming that at open would refuse every such commit as
|
||||
`LayoutMoved`, although the identity already makes the commit safe. The
|
||||
generation is the opposite case (improvise-vndi). A `RecordHash` is derived
|
||||
from coordinates, so after another client's `LoadModel` the same record can
|
||||
resolve in the incoming document, and a fresh `DataStore`'s counter can equal
|
||||
the one the editor read. Claiming the current generation then got the commit
|
||||
**accepted**: the loaded document's value, which this user never saw, was
|
||||
overwritten. Nothing composed against the outgoing document names anything in
|
||||
the incoming one, which is `Rejection::DocumentReplaced`'s own contract, so the
|
||||
generation is captured at open and the swap refuses the commit. So the result is:
|
||||
- the document was replaced: `DocumentReplaced`;
|
||||
- the record moved: `RecordMoved`;
|
||||
- the record is gone: `RecordGone`;
|
||||
- only the rows moved: the commit is accepted.
|
||||
|
||||
Clearing the cell under an open editor is `RecordMoved`, **not** `RecordGone`
|
||||
(improvise-4img). The store no longer holds the record, but
|
||||
`App::resolve_record` falls back to the layout, and the grid still draws the
|
||||
row. So the record resolves, and its counter has moved. `RecordGone` needs a
|
||||
record that neither the store nor the layout resolves.
|
||||
|
||||
**`EditTarget` is the one object an editor captures at open, and it is a
|
||||
struct so it can grow.** Two fields have joined `cell` and `version` since:
|
||||
the seed as `opened` (improvise-ixxx: a commit whose buffer equals the seed
|
||||
writes nothing) and the generation (improvise-vndi). The page did not change
|
||||
for either, because the target is opaque to it. `typed_target_frame` asks the
|
||||
two in that order: an untouched editor writes nothing, so it is never refused.
|
||||
|
||||
`typed_cell_command` is the one composition shared by the mouse path
|
||||
(`typed_cell_frame`) and the keyboard path (`keys::commit`). Writing it twice is
|
||||
(`typed_target_frame`) and the keyboard path (`keys::commit`). Writing it twice is
|
||||
how a browser commit by Enter and a browser commit by click would come to
|
||||
disagree about what an empty string means.
|
||||
|
||||
@@ -1514,8 +1606,10 @@ Four smaller decisions that would be re-derived wrongly:
|
||||
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
|
||||
**above** it, so the prompt is neither covered nor displaced.
|
||||
|
||||
`RenderCell::edit_text` is the stored value, not the formatted one: seeding an
|
||||
editor from `text` and committing turns `1200` into the string `"1,200"`. The
|
||||
An editor is seeded from `edit_seed(target)`, the stored value the target
|
||||
captured, never from `text`: seeding from the formatted text and committing
|
||||
turns `1200` into the string `"1,200"`. `RenderCell` carries no `edit_text`
|
||||
since improvise-ugj4, because a paint can be stale while an editor is open. The
|
||||
`numeric`/`error` flags are the server's answers, echoed — the client must not
|
||||
re-derive them from the text, which is how the two ends drift.
|
||||
|
||||
@@ -1558,14 +1652,17 @@ The browser half of improvise-cr3, kept as thin as it can be made, now talking
|
||||
to either of two peers (improvise-djm). **The boundary is JSON strings in and
|
||||
out, so this file holds no protocol knowledge at all**: it never inspects a
|
||||
`Command`, never builds a `CellId`, never decides whether a cell is editable or
|
||||
whether a typed string is a number. Five calls are the whole harness:
|
||||
whether a typed string is a number. These calls are the whole harness:
|
||||
|
||||
| call | what it is |
|
||||
|---|---|
|
||||
| `client.set_viewport(rows, cols)` | the handshake frame, and every resize; also records the window so keys resolve here |
|
||||
| `client.on_frame(json)` | fold one frame, in arrival order |
|
||||
| `client.render_model()` | everything to paint, already decided |
|
||||
| `client.typed_cell(r, c, s)` | the frame for "the user typed s here" |
|
||||
| `client.edit_target(r, c)` | what an editor opened here edits (the record, the version seen, and the seed it opened on): an opaque string the page holds, or `null` |
|
||||
| `client.edit_seed(target)` | the text the target's editor opens on: the seed it captured, never the paint (improvise-ugj4) |
|
||||
| `client.edited_target(target)` | the same target marked edited, so retyping the seed still writes (improvise-jibp) |
|
||||
| `client.typed_cell(target, s)` | the frame for "the user typed s into that cell", by identity (improvise-2wu3), or none if `s` is still the target's seed (improvise-ixxx) |
|
||||
| `client.on_key_event(k,c,a,s,m)` | the frame for a keypress, folded locally first where this client may answer it |
|
||||
|
||||
**The two rules this file must not break are ordering and framing**: frames are
|
||||
@@ -1700,6 +1797,31 @@ watchdog must be DISARMED and not merely pre-empted**: it fires later and would
|
||||
overwrite the better message with "did not answer" (PR #13 review). The watchdog
|
||||
is for silence only, and `setShellFailure` clears the timer.
|
||||
|
||||
**Success is claimed by the first frame, and only if nothing failed first**
|
||||
(improvise-i0v9). The worker transport used to write `in-page worker · <doc>`
|
||||
the moment it spawned the worker — 13ms after navigation, before anything had
|
||||
answered — so a worker that could not start showed a success-looking status
|
||||
over a blank page. It now writes `starting the worker · <doc>…`, and
|
||||
`createShell().arm(message, { answer })` holds the claim until `sawFrame`. The
|
||||
rule the claim obeys is **the watchdog's own state**: it is written only if the
|
||||
watchdog is still armed when the first frame lands. That is not a convenience.
|
||||
`worker.js` posts a `ReadOnly` (or `SeedMissing`) notice straight after
|
||||
`start()` and **before** the Snapshot that answers the handshake, so an
|
||||
unconditional "first frame ⇒ say we are up" would overwrite the precise reason
|
||||
the notice just wrote. `fail` already disarms the watchdog, so "armed" answers
|
||||
"has anything been said since arming" without a second flag to keep in step.
|
||||
`fail` disarms **directly** rather than through `sawFrame`, or it would
|
||||
briefly flash the success text on its way to the failure. The starting text
|
||||
deliberately does not contain `in-page worker`, so the older specs asserting
|
||||
that substring now need the first frame to pass. The socket path passes no
|
||||
`answer`: `open` is already a real fact, and its status was never premature.
|
||||
|
||||
One consequence changes what the watchdog guards. In the read-only case a
|
||||
Snapshot always follows the notice, and `sawFrame` disarms the watchdog either
|
||||
way, so a notice that forgot to disarm would **not** be overwritten eight
|
||||
seconds later. It is overwritten **at once**, by the claim. The eight-second
|
||||
overwrite survives only where no frame follows: `BootFailed`.
|
||||
|
||||
**Did this socket ever open?** The page has to record that itself, because
|
||||
nothing on the close event carries it: `readyState` is already CLOSED by the time
|
||||
a close listener runs, whichever happened, and a refused connection and a
|
||||
@@ -1747,9 +1869,10 @@ Neither arm leaves one armed.
|
||||
- **`editable` is the server's answer relayed through Rust**: a stored cell. An
|
||||
aggregate is a drill target and drill is not on the wire yet, so it is simply
|
||||
not offered.
|
||||
- The inline editor seeds from `RenderCell::edit_text` — the STORED value, not
|
||||
the drawn one: `td.textContent` has been through the number format, so a stored
|
||||
1200 reads `"1,200"` and committing that stores the *string* `"1,200"`. Enter
|
||||
- The inline editor seeds from `client.edit_seed(target)` — the STORED value the
|
||||
target captured, not the drawn one: `td.textContent` has been through the number
|
||||
format, so a stored 1200 reads `"1,200"` and committing that stores the *string*
|
||||
`"1,200"`. Enter
|
||||
removes the focused `<input>`, which fires `blur`, which calls `close` again:
|
||||
harmless, but only by accident.
|
||||
- **The title bar is three fields and one joining.** `doc_name`, `doc_dirty` and
|
||||
|
||||
+549
-23
@@ -553,6 +553,106 @@ status line.
|
||||
one, and the limit is checked in exactly one place — `Model::add_category` — so
|
||||
nothing in the effect restates it.
|
||||
|
||||
### A model effect that cannot do what it names refuses: `App::refuse`, `on_view` and `refuse_and_stop` (improvise-bok4, cz8d)
|
||||
|
||||
`AddCategory` was the only wire-reachable model effect that aborted when it
|
||||
failed. The others did one of two things:
|
||||
|
||||
- **Narrated.** `AddItem`, `AddItemInGroup`, `AddFormula`, `LoadModel` and
|
||||
`ImportJsonHeadless` wrote a sentence to the *host's* `status_msg` and
|
||||
carried on.
|
||||
- **Swallowed.** Every view-addressed effect (`SetAxis`, `SetPageSelection`,
|
||||
`ToggleGroup`, `HideItem`, `ShowItem`, `TransposeAxes`, `CycleAxis`,
|
||||
`SetNumberFormat`, `TogglePruneEmpty`) did nothing when `view_mut_of` found
|
||||
no such view. `DeleteView` discarded "cannot delete the last view" with
|
||||
`let _`, and answered `Ok` for a view the document did not have
|
||||
(improvise-40j4). `AddFormula` stored a formula for a category that was gone,
|
||||
through `let _ = add_item(…)` (improvise-40j4).
|
||||
|
||||
Either way the batch ran to its end, and `Host::mutating_frame` reported the
|
||||
frame **accepted**. That is clause 4's partial-and-silent shape, one layer below
|
||||
the record tier that `broken_chain` guards. The reviewer measured two cases.
|
||||
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]` was accepted with
|
||||
half of it a no-op. `[RemoveCategory("Region"), AddItem { category: "Region" }]`
|
||||
was accepted too, and its reason went only to the host's own status line, so
|
||||
the client was told nothing at all.
|
||||
|
||||
**One writer, and the abort is separate (improvise-cz8d).** improvise-c5ip
|
||||
(PR #185) had meanwhile added `App::refuse`, which writes the reason, sets
|
||||
`batch_refused` and does **not** abort. The combination is now:
|
||||
|
||||
- **`App::refuse` is the only writer** of a refusal. bok4's own `refused()`
|
||||
helper, which aborted, is deleted.
|
||||
- **`on_view(app, target, write)`** writes the named view, or calls
|
||||
`App::refuse` naming the missing one.
|
||||
- **`Host::mutating_frame` rejects on `last_batch_refused() ||
|
||||
last_batch_aborted()`.** That is sound because each `ModelCommand` maps to a
|
||||
one-effect batch, so either flag belongs to the mutation that raised it.
|
||||
- **Aborting is an explicit extra step**, taken only where a later effect in a
|
||||
key-path batch assumes the failed one happened. `refuse_and_stop` does it for
|
||||
`LoadModel` and every `ImportJsonHeadless` arm, so `load x . q` and
|
||||
`import-json x . q` do not quit having loaded nothing. `AddCategory` keeps its
|
||||
own inline abort (gotcha 21). The item and view effects, and `AddFormula`'s
|
||||
missing-category arm, only refuse: the frame is rolled back by the refusal
|
||||
alone. On the key path, a failed `AddItem` or view edit no longer stops the
|
||||
rest of its batch, which is what it did before bok4.
|
||||
- **`AddFormula`'s parse-error arm also stops (improvise-v4hg).** It is the one
|
||||
item-or-formula failure a key-path batch was found to depend on. Enter in
|
||||
`FormulaEdit` is `[SetStatus, AddFormula, ChangeMode(FormulaPanel),
|
||||
ClearBuffer("formula")]`, and the last two assume the commit worked. Without
|
||||
the stop, `Total = (` was reported and then the editor closed and the typed
|
||||
text was cleared. With it, the user stays in `FormulaEdit` with the text, and
|
||||
the error is drawn beside the prompt. `fit-measure` emits `AddFormula` last, so
|
||||
there is nothing after it to stop. The missing-category arm is reachable only
|
||||
from a wire frame or a `:` line naming another category, and nothing in either
|
||||
depends on it, so it does not stop. PR #181's review searched for an
|
||||
`AddItem` case the same way and found none, so `AddItem` does not stop either.
|
||||
An abort is added where a later effect depends on the failed one, never for
|
||||
symmetry.
|
||||
|
||||
In a client's frame, any refusal becomes `Rejected(MutationRefused)`. The frame
|
||||
is undone whole, and the client is told, which is the channel the narrated
|
||||
reason never had.
|
||||
|
||||
**This takes the ticket's first option, scoped to the effects the wire can
|
||||
reach.** Failure is *reported* by the effect that knows it failed. The second
|
||||
option, growing `broken_chain` to ask whether every view, category and item
|
||||
target still resolves, would have been a second description of which targets
|
||||
each effect needs. That second copy would be free to disagree with the effect
|
||||
itself, which is gotcha 24's reason for observing rather than predicting.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **A failed load is refused**, where it used to be accepted with a host status
|
||||
line. So `a_load_that_fails_is_not_a_rebase` now witnesses the failure by the
|
||||
refusal, not by `status_msg`. The frame's rollback also rolls the host's
|
||||
status line back.
|
||||
- **Every refusal site has a test that fails when it is reverted** to a
|
||||
status-only write (improvise-9fpl, improvise-0v8l). Two tables carry most of
|
||||
them. `every_failing_model_command_refuses_its_whole_frame` covers the wire.
|
||||
`every_refusal_site_refuses_its_failure_and_only_its_failure` covers the key
|
||||
path and the `:` line, which is what the headless exit code reads. The wire
|
||||
table cannot see the refusal half of a `refuse_and_stop` site, because
|
||||
`Host::mutating_frame` rejects on `refused || aborted`, and the abort alone
|
||||
suffices. So each `refuse_and_stop` site needs a key-path assertion for its
|
||||
refusal as well as one for its abort. The abort is
|
||||
`a_failed_load_or_import_does_not_run_the_quit_after_it` (improvise-514o).
|
||||
Three sites are not in the key-path table (improvise-rtud). The quit test's
|
||||
"setup:" assertion holds the refusal of `load` and `import-json absent.json`.
|
||||
`a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text`
|
||||
holds `AddFormula`'s parse arm, which has no case in the quit test. The other
|
||||
five import arms are in
|
||||
`every_refusal_site_refuses_its_failure_and_only_its_failure`.
|
||||
Each case in that table has a succeeding twin, so a site that refused
|
||||
unconditionally would fail too. That matters because these are exactly the
|
||||
lines a merge conflict could silently drop.
|
||||
- **Not covered:** a no-op *inside* a view that does resolve. Examples are
|
||||
`SetAxis` on a category the view does not have, `HideItem` of an absent
|
||||
item, and `RemoveItem`/`RemoveCategory`/`RemoveFormula` of something already
|
||||
gone. The removals are idempotent by intent. The others are the remaining
|
||||
instances of this class, and each needs its own `View` method to say whether
|
||||
it changed anything.
|
||||
|
||||
### `AddFormula` and non-`_Measure` targets
|
||||
|
||||
For non-`_Measure` targets the item is added to the category so it appears in the
|
||||
@@ -721,12 +821,45 @@ is gone rather than joined by a second one. A guard spelled here as
|
||||
copy of `switch_to_view`'s own condition, free to disagree with it the day that
|
||||
condition grows a clause.
|
||||
|
||||
Not fixed, and reachable only by construction: `ViewBack` and `ViewForward` call
|
||||
`switch_to_view` just as unguardedly, pushing the current view onto the opposite
|
||||
stack before the switch they cannot be sure of. They are safe **because
|
||||
`ViewState::forget_view` purges both stacks when a view is deleted**, so a popped
|
||||
frame always names a live view — a guard living one level away from the hazard,
|
||||
which is the shape that keeps returning here.
|
||||
`ViewBack` and `ViewForward` have the same shape now (improvise-8nd9). They used to
|
||||
push the current view onto the opposite stack **before** a switch they could not be
|
||||
sure of, and were safe only because `ViewState::forget_view` purges both stacks when
|
||||
a view is deleted — a guard living one level away from the hazard. improvise-lrrx
|
||||
proved the guard incomplete: `App::install_workbook` (`LoadModel`,
|
||||
`AcceptWizardModel`) cleared `cursors` and not the history, so `<` after a load
|
||||
walked the **outgoing** document's frames, landing on a view of the same name in the
|
||||
incoming one that this client had never visited, or on a refused switch that still
|
||||
left a phantom frame behind. Both halves are fixed, so neither relies on the other:
|
||||
`install_workbook` clears both stacks beside `cursors`, for the same reason, and the
|
||||
two steps go through `stepped_to`, which switches, observes whether the document
|
||||
**accepted** the view (`App::switch_to_view` now returns that), and only then
|
||||
restores the frame's mode and hands back the frame just left. A frame the document
|
||||
refuses is consumed and leaves nothing.
|
||||
|
||||
The observation is *accepted*, not *moved*, and that is the one place the sibling
|
||||
shapes differ. `SwitchView` asks "did `active_view` change" because asking for the
|
||||
view you are on is not a move. A history frame is a **place**, meaning a view and a
|
||||
mode, so a live frame naming the view you are on still restores its mode. Such
|
||||
frames exist: `forget_view` collapses `[A, B, A]` to `[A, A]`. The client's
|
||||
`reduce_view::stepped` also steps onto them unconditionally. A "moved" test
|
||||
would therefore drop a frame the client keeps, and the host and the client
|
||||
would disagree. The first version of this fix made exactly that mistake.
|
||||
`colon_view_back_respects_whether_history_exists` caught it, because its
|
||||
fixture is such a frame. `reduce_view`'s `stepped` is unchanged, because the
|
||||
client holds no `Workbook` to observe against. It is safe only while no client
|
||||
stack holds a frame the document would refuse, which rests on three paths:
|
||||
`ForgetView` purges a deleted view from every mirror, a load arrives as a fresh
|
||||
`Snapshot`, and that snapshot's history starts **empty**. The third was false
|
||||
until improvise-lxg0. `project.rs::fresh_view` used to start from
|
||||
`ViewState::default()` and fold `SwitchView(active)`, which recorded a visit to
|
||||
the literal `Default`. So every connected or rebased client carried a phantom
|
||||
`{Default, Normal}` back-frame, whether or not the document had such a view.
|
||||
It now builds the slice pointed at the document's active view and folds
|
||||
nothing, as `App::with_storage` does. The guard is still one level away from
|
||||
the hazard. A new path that seeds or replaces a client's history without
|
||||
emptying it reopens the divergence, and nothing but
|
||||
`a_fresh_session_has_no_history_to_step_back_through` and its rebase twin
|
||||
would notice.
|
||||
|
||||
### `ReturnToNormalUnlessMoved` — observation, not prediction (improvise-toc)
|
||||
|
||||
@@ -835,6 +968,52 @@ There is deliberately **no `dirty = true`** in it: it declares
|
||||
of `MarkDirty` — harmless, and a second home for the one thing that commit set
|
||||
out to give exactly one.
|
||||
|
||||
### `RecordRewrite` — a row's edits composed, not replayed (improvise-uo2p)
|
||||
|
||||
A staged drill edit is a statement about the record **as the snapshot showed
|
||||
it**. It is not a statement about the model as the previous edit left it. The
|
||||
edits in one row do not commute when they are replayed one at a time. A rename
|
||||
moves every cell in the row (gotcha 1). A measure edit replayed after it writes
|
||||
the old key, which leaves a ghost record there and a stale value in the moved
|
||||
record. A second rename replayed after the first finds nothing at the old keys,
|
||||
so it is dropped. `ApplyAndClearDrill` replayed them in `HashMap` order, so the
|
||||
outcome was different from one App to the next. That is gotcha 18's class of
|
||||
bug, found in an effect rather than an index.
|
||||
|
||||
**Sorting is not the fix.** "Measures before renames" repairs the ticket's own
|
||||
case and still drops a second rename every time (rival 1 in the evidence). Any
|
||||
fixed order makes the corruption reproducible instead of removing it. So the
|
||||
rule is **build each row's final record once, then write it**:
|
||||
|
||||
- `RecordRewrite::of(model, row, edits)` collects the row's renames and measure
|
||||
edits into maps keyed by column, and reads each unedited value off the model
|
||||
**before anything is written**. It then states the rewrite: clear every old
|
||||
key, and write every cell at the key with *all* renames applied. A measure
|
||||
edit that is staged replaces the carried value. The result does not depend on
|
||||
the order of the edits. A drill stages at most one edit per `(row, column)`,
|
||||
so no two edits in a row name the same column. A blank rename is refused, and
|
||||
the row's other edits still land at the unrenamed coordinates, as before.
|
||||
- `RecordRewrite::merged` concatenates the rows' rewrites **by phase**: every
|
||||
refusal, then every item added, then every clear, then every write. The clears
|
||||
come first so that a swap is a swap. Rows are taken in snapshot row order
|
||||
(`BTreeMap`), so where two final records land on one cell, **the later row
|
||||
wins**. That is the answer plain records mode gives for the same renames made
|
||||
top to bottom.
|
||||
|
||||
The collision rule is a **choice**, and it still loses the earlier row's values
|
||||
silently. Plain records mode loses them the same way when a rename lands on an
|
||||
existing record. Refusing the colliding rows instead would be a change to both
|
||||
paths.
|
||||
|
||||
**One copy.** The plain records path (`commit.rs::commit_plain_records_edit`)
|
||||
calls the same `RecordRewrite::of` with its one edit, so `rename_record_coordinate`
|
||||
and the drill's `apply_coordinate_rename`/`apply_measure_edit` are gone. This is
|
||||
the consolidation improvise-fdk1 asked for, "regardless" of its wire half. For a
|
||||
single edit the output is the effects the plain path used to emit, reordered from
|
||||
`clear, set` per cell to all clears then all sets, which touches distinct keys.
|
||||
One behaviour moved on the drill side: it used to discard `add_item`'s error, and
|
||||
now goes through `effect::AddItem`, which refuses when the category has gone.
|
||||
|
||||
### `Save` / `SaveAs` abort the batch (improvise-8tk / improvise-lae)
|
||||
|
||||
A failed save aborts the rest of the batch, so the `Quit` that `wq`/`ZZ` chains
|
||||
@@ -1112,7 +1291,9 @@ emit `RebuildLayout`) first (gotcha 23).
|
||||
|
||||
`display_value_of` is the value an editor opened on `key` starts with —
|
||||
unformatted, and so **not** `CellDisplay::text` (design-principles §4: display
|
||||
rounding is view-only and must not feed back into the model). Crate-visible so
|
||||
rounding is view-only and must not feed back into the model). It is also **not**
|
||||
`CellValue`'s `Display`, which rounds to four places: both arms spell the value
|
||||
with `CellValue::edit_text` (improvise-umi4, `core.md` §17). Crate-visible so
|
||||
`project::cell_display` can put it on the wire as `CellDisplay::edit_text`; a
|
||||
browser that un-formatted the drawn text instead would be a second derivation, and
|
||||
a lossy one.
|
||||
@@ -2099,9 +2280,31 @@ would be sent and every client would stay stale *permanently* rather than until
|
||||
next scroll: a partial fix strictly worse than none. Broadcasting only would leave
|
||||
the server projecting each client a window it is no longer looking at.
|
||||
|
||||
The audience is `Broadcast::ToAll`, and the asker is not an exception. Its mirror
|
||||
was reset here like everyone else's, and it folded nothing optimistically, because
|
||||
a `Command::Model` is not something a client can apply to itself.
|
||||
The audience is every session on a reframed view, each addressed with
|
||||
`Broadcast::To` (improvise-nynk), and the asker is not an exception: a transpose
|
||||
invalidates its own position too. What an earlier version of this paragraph got
|
||||
wrong is the claim that the asker "folded nothing optimistically". A
|
||||
`Command::Model` is not something a client can apply to itself, but the **rest of
|
||||
its frame** is, and a browser frame routinely places the cursor *after* the
|
||||
mutation. The `+cat` commit is `[ChangeMode, AddCategory, SetSelected]`. The
|
||||
mirror folds that in order: reset, then placed, which is exactly the order the TUI
|
||||
applies the same batch in. The client folded all of it **before** hearing the
|
||||
reset, so the relayed `ResetCursor` arrived last and threw the placement away.
|
||||
The client sat at the origin while its mirror, and every projection built from
|
||||
it, sat at the placement (improvise-wdmp).
|
||||
|
||||
So the host answers the asker in the order the mirror saw. Once the asker has been
|
||||
told `ResetCursor`, `Host::mutating_frame` relays back to it every **later**
|
||||
cursor-placing command of the same frame (`SetSelected`, `SetRowOffset`,
|
||||
`SetColOffset`), ahead of that command's projections. All three are absolute, so
|
||||
folding them twice is idempotent. Excluding the asker from the reset, which the
|
||||
ticket proposed, was rejected. For a frame that places nothing after the reframe
|
||||
it is gotcha 24's own "strictly worse than nothing": the mirror is reset and the
|
||||
client never hears it. `a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`
|
||||
runs both shapes. One residue is stated rather than handled: a frame that
|
||||
reframes and then *switches views* before placing would reset the view the
|
||||
client ends on rather than the one it reframed. No resolver composes that frame
|
||||
today.
|
||||
|
||||
What is *not* broadcast is a position: another client's transpose must not move
|
||||
where I am looking — that is UI and it is mine (improvise-g0f). It may only declare
|
||||
@@ -2492,12 +2695,12 @@ available for the rare command that needs to enumerate all views, such as the vi
|
||||
panel.
|
||||
|
||||
The interactive arm (keymap dispatch, scripts, the `:` line) is built **from**
|
||||
`parse`: `register` takes `parse`, a `contextualize` that fills context-only
|
||||
fields of the command `parse` built without seeing the args, and an optional
|
||||
`fallback` reached only for argument lists `parse` refused. So an accepted
|
||||
argument list is never overridden by context (improvise-2bac; the argument is
|
||||
`context/code/cli.md` §8). `register_pure` is the case with nothing to
|
||||
contextualize and no fallback: both arms are `parse`.
|
||||
`parse`: `register` takes `parse` and an optional `fallback` reached only for
|
||||
argument lists `parse` refused, and `register_at_cursor` additionally has the
|
||||
registry set the command's `CursorState` to the live cursor. No registration
|
||||
writes any other field, so an accepted argument list is never overridden by
|
||||
context (improvise-2bac, improvise-yf7p; the argument is `context/code/cli.md`
|
||||
§8). `register_pure` is the case with no fallback: both arms are `parse`.
|
||||
|
||||
`write_buffer` is the effect that writes a named buffer — the mirror of
|
||||
`read_buffer`, and the only place that knows "search" is not an entry in the buffers
|
||||
@@ -2527,8 +2730,10 @@ At bottom-right `EnterAdvance` has no place to go, so it emits a single `AbortCh
|
||||
stages in drill pending edits while in drill mode, or applies directly in plain
|
||||
records mode; for real keys it writes to the model.
|
||||
|
||||
`rename_record_coordinate` renames one coordinate of a records row. In wide format a
|
||||
row is a group of cells — one per measure — so the rename has to move **every** one
|
||||
`commit_plain_records_edit` hands a coordinate or measure edit to
|
||||
`effect::RecordRewrite::of`, the one home it shares with the drill (improvise-uo2p;
|
||||
see `RecordRewrite` above). It replaced `rename_record_coordinate`. In wide format a
|
||||
row is a group of cells — one per measure — so a rename has to move **every** one
|
||||
of them, not just the cell under the cursor (improvise-c5v, gotcha 1). Every cell in
|
||||
the row moves, and a row seeded by `add-record-row` has no measure cells yet: its
|
||||
coordinates live on a lone measure-less cell, which must move too or the row loses
|
||||
@@ -2772,6 +2977,43 @@ flag: tidy-up goes where an abort cannot reach it.
|
||||
stays in `TileSelect` mode so the user can adjust several tiles. `axis: None` cycles;
|
||||
`axis: Some(a)` sets.
|
||||
|
||||
**The tile bar offers `_Index` and `_Dim`, and that is deliberate (improvise-jgzw).**
|
||||
Every tile call site passes `Model::category_names()`, virtual categories included,
|
||||
so tile cursor 0 is `_Index`. The ticket asked whether that was a leak. It is not,
|
||||
for three reasons, each checked rather than argued:
|
||||
|
||||
- **It is a decision the user already made.** improvise-kos: "`_Dim` and `_Index`
|
||||
should work as pivot axes outside Records/Drill view". A category that works as a
|
||||
pivot axis is one the axis picker offers.
|
||||
- **The tile bar is the only way out of a records layout that was saved.**
|
||||
improvise-jgzw's "check what it does" found that hand-assigning `_Index` → Row and
|
||||
`_Dim` → Column in a real view (tile bar or `:set-axis`) is **written** to the
|
||||
`.improv`, because a real view persists every axis, and it reopens as records mode.
|
||||
(`VIRTUAL_VIEW_NAMES`' "never-persisted `_Index`/`_Dim` on axes" is true only of
|
||||
`_Records`/`_Drill`.) In that document, `R` (`toggle-records-mode`) emits
|
||||
`ViewBack` on an empty back stack, so it leaves the layout as it was and sets the
|
||||
status to "Pivot mode". That is filed as improvise-gj5p. Moving the two axes back through
|
||||
the tile bar is what gets out. In the browser, which declines
|
||||
`toggle-records-mode`, the tile bar is also the only way in, and improvise-bd9o
|
||||
already made that route safe instead of closing it
|
||||
(`the_tile_bar_route_into_records_mode_does_not_strand_the_user`).
|
||||
- **What filtering would cost was measured.** With `View::tileable_cats` filtering
|
||||
on `CategoryKind::is_index_or_dim`, that bd9o test and the TUI's
|
||||
`renders_each_tile_with_its_axis_label` fail, as well as the two characterization
|
||||
tests here.
|
||||
|
||||
What the default cursor does cost, stated: one cycle key on tile 0 puts `_Index`
|
||||
on Row alone, and in a pivot view that empties the grid (0 rows on
|
||||
`web/demo.improv`). The status line says `_Index → Row`, and three more presses
|
||||
undo it. If that turns out to matter, the fix is the order the tile bar lists
|
||||
categories in, not taking entries out of it.
|
||||
|
||||
**`_Measure` must stay tileable.** It is on an axis in every shipped document, so
|
||||
`regular_category_names()`, the advice for *prompts*, is the wrong filter here.
|
||||
`the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included`
|
||||
names the whole offered list with `_Measure` on a column, and fails if either
|
||||
decision changes.
|
||||
|
||||
### `cmd/effect_cmds.rs`
|
||||
|
||||
The parseable model-mutation commands are thin `Cmd` wrappers around effects,
|
||||
@@ -3048,8 +3290,8 @@ the cursor**. The result was silent corruption of an unrelated record: appending
|
||||
a row changes `view_frame`'s hash, the frame observation inside `apply_effects`
|
||||
invalidates the cursor to the origin *mid-batch* (gotcha 24), and
|
||||
`EnterEditAtCursor` then pre-fills the editor with row 0's category coordinate.
|
||||
Enter commits through `rename_record_coordinate`, which by gotcha 1 moves every
|
||||
measure cell of that row.
|
||||
Enter commits through `rename_record_coordinate` (since improvise-uo2p,
|
||||
`RecordRewrite::of`), which by gotcha 1 moves every measure cell of that row.
|
||||
|
||||
Two things are worth carrying past this fix.
|
||||
|
||||
@@ -3120,6 +3362,290 @@ so `paste Region=West _Measure=Revenue Product=Gadget` from a script still
|
||||
writes where the current view happens to aggregate `Product` away: a key that
|
||||
names every coordinate is a record whatever the view shows. For the cursor's
|
||||
key the two questions agree, because a cursor key never carries a None-axis
|
||||
category. The **collapsed-group fold** half of improvise-l4mp is not covered:
|
||||
its key carries the group's name as an ordinary coordinate, so no question about
|
||||
the key alone can see it — it needs `GridLayout::folds_a_group`.
|
||||
category. The **collapsed-group fold** half of improvise-l4mp cannot be seen by
|
||||
any question about the key alone, because the key carries the group's name as an
|
||||
ordinary coordinate. It is answered at the cursor instead, by the next section.
|
||||
**The first version of that section wrote a false sentence here**: that the
|
||||
checks in `paste_into` were reached "only by a key a script spells out". The
|
||||
cursor path handed `PasteCell` the resolved record key, and `paste_into` ran
|
||||
`aggregates` over it **again**. That is a second derivation beside `cell_id`, and
|
||||
it reads a record lacking a later category (every record, once `+cat` adds one)
|
||||
as an aggregate, so `p` refused where gh/main had pasted and the browser still
|
||||
wrote (improvise-q9eg). The distinction is now a type: `PasteAt::Resolved`
|
||||
carries a key the door answered and is written as it stands, and
|
||||
`PasteAt::Spelled` carries a key a script typed, which only its shape can vet.
|
||||
Nothing is asked twice.
|
||||
|
||||
## The cursor's cell has three doors (improvise-gw18, lx7w, 54g1, yk2s)
|
||||
|
||||
**The lesson of improvise-gw18 is that guarding one command did not close the
|
||||
path.** PR #176 taught `EditOrDrill` to refuse a collapsed group's fold, and `>`
|
||||
still drilled into it. The drill was empty, and `o` inside it wrote
|
||||
`Month=Q1, _Measure=Revenue`, a coordinate that is not an item, into `## Data`.
|
||||
The same shape turned up three more times, each in a command nobody had
|
||||
listed. `p` wrote onto the fold (the fold half of improvise-l4mp). `x` in records
|
||||
mode cleared the synthetic `(_Index, _Dim)` key, a silent no-op, while the
|
||||
browser cleared the real record (improvise-lx7w). `p` onto a formula's cell
|
||||
stored a value under the formula that survived in `## Data`, while the browser
|
||||
declined (improvise-54g1). Each fix was one more guard in one more command. So
|
||||
the answer is the **accessor**, not the commands: a command gets the cursor's
|
||||
cell through a door that matches what it does with it.
|
||||
|
||||
Every command that takes the cursor's cell, and its door:
|
||||
|
||||
| command | door | on a fold | on a formula's cell | in records mode |
|
||||
|---|---|---|---|---|
|
||||
| `yank` | `cell_key` (reads) | yanks the fold's value | yanks the result | `YankCell` resolves the synthetic key |
|
||||
| `paste`, `clear-cell` | `value_cell_key` | refused | refused | resolved to the real record; a coordinate or creating column and a drill are refused |
|
||||
| `commit-cell-edit`, `commit-and-advance-right` | `committable_cell_key` | refused | refused | the synthetic key, which `commit_cell_value` resolves itself (gotcha 19) |
|
||||
| `drill-into-cell` | `unfolded_cell_key` | refused | drills | unchanged |
|
||||
| `edit-or-drill` | its own check, `folds_a_group` then `owning_formula` | refused | refused | unchanged |
|
||||
| `hide-selected-row-item` (`H`) | `GridLayout::row_folds_a_group` | refused | n/a (hides a row item) | n/a |
|
||||
| `add-record-row` (`o`) | none, it takes no cell | n/a | n/a | closed by the drill door: no drill of a fold exists to add a row in |
|
||||
|
||||
**`value_cell_key` asks the browser's own question.** It calls
|
||||
`project::cell_id`, the function that fills `CellDisplay::id` for the browser
|
||||
(ADR 002), and writes only where the answer is `CellId::Record`. The browser's
|
||||
`record_cell_at_cursor` is that same match on the projected id. The two ends
|
||||
therefore agree because they read one answer, not because two derivations
|
||||
happen to match. `tests/lockstep.rs` holds them together for all three tickets.
|
||||
`cell_id` took `&App` only to reach the model, so it now takes `&Model`. That is
|
||||
what let `CmdContext` ask it.
|
||||
|
||||
**Commit has a door of its own**, and not `value_cell_key`, because commit is
|
||||
the one writer that must see the synthetic key: renaming a coordinate column,
|
||||
creating a column and staging a drill edit all resolve the records position
|
||||
inside `commit_cell_value`. Its door refuses the two arms `i` refuses, an
|
||||
aggregate (a fold included) and a formula's result. It needed one because
|
||||
`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opens it at the
|
||||
new cursor with no guard. `i` declines to edit `Profit`, yet `Tab` from `Cost`
|
||||
landed the editor on it, and the next commit stored a value under the formula.
|
||||
That is a fourth door nobody had listed. **The entry half is still open**
|
||||
(improvise-enz6): the editor still opens on a fold or a formula's cell. Only the
|
||||
write is refused, out loud, at commit. Closing the entry belongs in `ui/effect.rs`.
|
||||
|
||||
**Drill keeps the narrowest door** because drilling into an aggregate is its
|
||||
purpose. Only a fold is refused, since no record carries a group's name. The
|
||||
ticket's alternative, expanding the fold into its members' records, would be the
|
||||
more useful drill and is not built.
|
||||
|
||||
**A text-path key is not covered.** `paste Month=Q1 _Measure=Revenue` typed as
|
||||
coordinates still reaches `PasteCell` with a group name. The doors are about the
|
||||
**cursor**, and a key a user spells out is a claim about the document that no
|
||||
layout checked. The same is true of `set-cell`.
|
||||
|
||||
## A command is a function of its context — the determinism law (improvise-hzjo)
|
||||
|
||||
`cmd/determinism.rs` holds the law the old `law_toggle_group_involution` claimed
|
||||
and could not state. For **every** registered command and every argument list
|
||||
that builds it, the effects `execute` returns must be equal, by `Debug` text,
|
||||
across contexts built **independently from equal data**: four `App`s, each
|
||||
parsed from `web/demo.improv` and given the same buffers, expanded categories,
|
||||
yank, panels and cursor.
|
||||
|
||||
**Why the instances must be distinct.** The old test called `execute` twice
|
||||
against one `CmdContext`. Every `HashMap` it reached was the same instance with
|
||||
the same `RandomState`, so it iterated the same way both times: `f(x) == f(x)`,
|
||||
which nothing can fail. Separately built `App`s give every map its own seed. So a
|
||||
command whose effects follow hash iteration order disagrees with itself. Hash
|
||||
order is the one kind of nondeterminism this repo has actually shipped (gotcha
|
||||
18). This law can see it where a command's `execute` builds it, as the drill
|
||||
snapshot's record order was. It cannot see the other shipped instance, the
|
||||
analyzer's `HashSet`: `analyze_records` is reached only from inside
|
||||
`Effect::apply`, which is outside this law (see below). The companion test
|
||||
`only_distinct_instances_can_see_a_command_that_follows_hash_order` keeps that
|
||||
property checked: a test-only command that emits `ctx.buffers` in iteration
|
||||
order must agree with itself on one context and disagree across two.
|
||||
|
||||
**Four instances, not two.** A rival that takes *the first* entry of a map, not
|
||||
all of them, agrees across two seeds whenever both happen to put the same entry
|
||||
first. With 21 buffers that is roughly one chance in 21, and three comparisons
|
||||
make it negligible. The fixture's values are also chosen to stay distinct under
|
||||
the edits commands make. A first version used `v0`…`v15`, which `pop()` collapses
|
||||
to `v`, and so it let `command-mode-backspace` reading an arbitrary buffer pass.
|
||||
|
||||
**The store needs several records to order, so the drills come from the
|
||||
document (improvise-e1sm).** The registry's `candidate_args` are extended with
|
||||
names the demo really has, because coordinates that match nothing never reach
|
||||
the records a drill or a fit would order. A hand-picked name is not enough. The
|
||||
first version's only real drill was `Product/Latte, Quarter/Q2`: one record, two
|
||||
cells. With the store's sort removed, two contexts put those two cells in the
|
||||
same order about half the time, and with three comparisons the law passed about
|
||||
one run in eight (measured: 46 of 400). So the arguments now include **every
|
||||
item of every regular category as a one-coordinate drill**, read off the parsed
|
||||
document. A one-coordinate drill spans every other category whole, so it matches
|
||||
the whole slice of records under that item, not one record picked by name. In
|
||||
the demo that is four records and eight cells for each of the eight drills. With
|
||||
the store's sort removed, the law fails 400 of 400 runs.
|
||||
|
||||
**The width guard measures what the comparison compared (improvise-b9f2,
|
||||
improvise-btyw).** The test asserts that some `drill-into-cell` the comparison
|
||||
actually built and compared froze at least four records. For each one it applies
|
||||
those very effects to one scratch `App` and counts the records in its
|
||||
`drill_state`. It counts them only when their `Debug` text, which carries their
|
||||
order, appears in the text the comparison compared. The comparison reads `Debug`
|
||||
while the scratch `App` reads the applied state, and those are two
|
||||
representations. The review showed they can disagree: give `StartDrill` a
|
||||
`Debug` that prints only a row count, and the frozen records are still four
|
||||
while the compared text no longer holds their order. With the store's sort
|
||||
removed the law then passed 300 of 300 runs, with the guard green. Requiring
|
||||
the ordered records in the compared text ties the guard to what the comparison
|
||||
could actually see. A first
|
||||
version measured what the *argument lists* match in the store instead. That was
|
||||
a parallel derivation, and the review beat it with a `drill-into-cell` parser
|
||||
that refuses fewer than two arguments. Every one-coordinate drill then went to
|
||||
the one-record cursor fallback, the argument lists still matched four records,
|
||||
and with the store's sort removed the law passed 38 of 300 runs.
|
||||
|
||||
Counting frozen records is not enough by itself. A drill over no coordinates
|
||||
(any candidate with no `Cat/Item` argument, such as `["1", "1"]`) freezes
|
||||
**every** record, and it reads them through `iter_cells` in insertion order,
|
||||
never through the narrowing index where gotcha 18 lives. Under the parser rival
|
||||
that whole-store drill still froze 16 records, and a guard counting it stayed
|
||||
silent. So only a drill that **narrowed** the store counts: one that froze fewer
|
||||
records than the store holds. With the parser rival in place, the guard fires on
|
||||
every run ("froze 1 records"), and it fires on every run under the `Debug` rival
|
||||
too ("froze 0 records").
|
||||
|
||||
What the guard still cannot see is a narrowing drill whose records are frozen in
|
||||
an order that does not come from the store. It checks that the law had a wide
|
||||
drill to compare, not why the drill's order would differ.
|
||||
|
||||
**What it cannot see.** Only commands and arguments the fixture reaches. A
|
||||
command whose nondeterminism needs some other state, such as records mode, a
|
||||
wizard or a drill in progress, is outside this fixture. And `Debug` equality is
|
||||
text equality: two effects that differ only in something their `Debug` omits are
|
||||
equal here.
|
||||
|
||||
**Its scope is what `execute` returns, not what `Effect::apply` does.** The law
|
||||
compares the effects a command hands back. Many effects are unit structs that
|
||||
decide all their work inside `apply`: `ApplyAndClearDrill`,
|
||||
`SortData`, `CleanEmptyRecords`, `Save`, `RebuildLayout`, `AcceptWizardModel`.
|
||||
Hash-order iteration inside an `apply` is therefore invisible here, however
|
||||
often the law runs. improvise-uo2p is the worked example.
|
||||
`ApplyAndClearDrill::apply` walks `drill.pending_edits`, a `HashMap`, in hash
|
||||
order. A measure edit and a category rename staged on one row can then write a
|
||||
ghost record. The command that emits it passes this law, because the effect's
|
||||
`Debug` is just its name. So "the one kind of nondeterminism this repo has
|
||||
shipped" is guarded here only on the `execute` side. The `apply` side needs its
|
||||
own test per effect, or a law over `App` state after `apply_effects`.
|
||||
|
||||
**The exemption list.** `OUTSIDE_THE_CONTEXT` names commands that legitimately
|
||||
depend on something outside the context, such as a clock or an entropy source,
|
||||
each with its reason. It is empty: `fit-measure`'s seed is an argument with a
|
||||
fixed default, not a clock read. The list is checked in both directions. An
|
||||
unlisted command that diverges fails the test, and so does a listed one that
|
||||
does not diverge. A stale entry therefore cannot linger, and a new registration
|
||||
is covered the day it lands.
|
||||
|
||||
## Editing a formula: the capture is on the mode, read in `execute` (improvise-km71)
|
||||
|
||||
`edit-formula-at-cursor` (Enter or `e` in the formula panel) opens
|
||||
`AppMode::FormulaEdit` with `slot: FormulaSlot::Existing { id, was }` and seeds
|
||||
the `formula` buffer with the formula's `raw`. That slot is the capture: which
|
||||
formula, and what it read when the editor opened. Nothing at commit time looks
|
||||
at the cursor again.
|
||||
|
||||
**Where `commit-buffer formula` reads the slot matters, and the registry is the
|
||||
wrong place.** The obvious move was to resolve it in `parse_commit_kind`, the
|
||||
way `Item` takes its category from `AppMode::ItemAdd`. That does not work, and
|
||||
it fails silently: the interactive arm is built **from `parse`**
|
||||
(`context/code/cli.md` §8), and `parse` accepts `formula` under
|
||||
`AppMode::Normal`, so the mode-reading fallback is never reached. `Item` only
|
||||
works that way because `parse` *refuses* `item`. The first build of km71 did
|
||||
exactly this, and every edit became an add. `add_formula` upserts by target, so
|
||||
an edit that kept its target still landed in place, and only the rename tests
|
||||
and the concurrency tests showed it. Making the interactive arm read the mode
|
||||
first would break improvise-yf7p's guarantee that an accepted argument list is
|
||||
never overridden by context. So `CommitBuffer::execute` asks
|
||||
`CommitKind::in_mode(ctx.mode)`. That is a read of context in `execute`, which
|
||||
is the answer yf7p gave to `toggle-panel-visibility`. A script line stays an
|
||||
add, and cannot name an edit, because the slot does not exist outside the
|
||||
editor.
|
||||
|
||||
**`effect::ReplaceFormula` refuses with `refuse_and_stop`, not `refuse`.** The
|
||||
Enter binding is `[commit-buffer formula, clear-buffer formula]`, and
|
||||
`CommitBuffer` puts the change back to `FormulaPanel` *after* the mutation. A
|
||||
refusal that stops the batch therefore leaves the editor open with the user's
|
||||
text and the reason in the minibuffer line (`draw_bottom_bar` draws the status
|
||||
beside the minibuffer). That covers all four refusals: the formula is gone, it
|
||||
was rewritten since the editor opened (`was`), the new text does not parse, or
|
||||
the new target belongs to another formula, or the rename is of a formula
|
||||
outside `_Measure` (improvise-kb1d, below). `AddFormula`'s parse error stops the
|
||||
batch the same way since improvise-v4hg (#213), so a bad *new* formula also
|
||||
leaves the editor open with its text; adding and editing now agree.
|
||||
|
||||
**A target change outside `_Measure` is refused (improvise-kb1d).** A regular
|
||||
category's formula target is a real item of that category, not a derived one.
|
||||
`replace_formula` added the new target item and left the old one, so renaming
|
||||
`Total = East + West [Region]` to `Sum` left `Total` as an empty item and
|
||||
appended `Sum` at the end. Renaming the item in place was the alternative and
|
||||
was not taken: an item's name is its identity in cells, views, hidden lists and
|
||||
groups, so that rename is the whole of improvise-sboz, not a small fix. Editing
|
||||
such a formula without changing its target still works.
|
||||
|
||||
On success the command's optimistic "Formula updated" stands, except on a
|
||||
rename, where the effect rewrites the status to name the old and new targets
|
||||
and any formula that still mentions the old one (`context/code/core.md` §18).
|
||||
|
||||
## A wire write reaches only a cell the host projects as a Record (improvise-ciw8)
|
||||
|
||||
`App::resolve_record` answers "which record does this surrogate name". Its
|
||||
fallback, `record_in_layout`, accepts any key the current layout names, and that
|
||||
includes a collapsed group's fold and a key the None axis aggregates over. It
|
||||
has to stay that permissive: it is also the basis check's question, and a record
|
||||
nobody has stored yet (the empty cell a client is about to fill) is found only
|
||||
there. But resolving is not the same as being writable. A hand-rolled frame
|
||||
`SetCell { record: RecordHash::of({Month=Q2}), .. }` was answered `Accepted` and
|
||||
stored a cell at `Month=Q2`, while the host's own projection called that
|
||||
position `CellId::Aggregate`. A write under a formula (`Derived`) was accepted
|
||||
the same way.
|
||||
|
||||
So `model_effects` asks `App::unwritable` before a wire `SetCell` or `ClearCell`
|
||||
builds its effect, and refuses through `effect::refuse`. That is the #181 path:
|
||||
the refusal marks the batch, `Host::mutating_frame` undoes the frame, the asker
|
||||
gets `Rejected(MutationRefused)`, and the reason is on the host's status line.
|
||||
The question it asks is the projection's own, never a second derivation of it:
|
||||
|
||||
- A coordinate that names a **group** rather than an item is refused, in any
|
||||
view (improvise-b114). A group has no cell of its own, so writing one stores a
|
||||
non-item coordinate. This rule asks the document, not the layout, and runs
|
||||
first, because a fold in the measure half hides from both rules below. Take
|
||||
`{Month=Feb, _Measure=Money}`, where `Money` groups Revenue and Cost. Its
|
||||
record `{Month=Feb}` is stored, so the second rule below accepts it and never
|
||||
asks the projection. And with `Money` expanded the host draws no such cell at
|
||||
all, so the third rule has no position to ask. A fold on the row axis is
|
||||
refused here too, so the `Aggregate` arm below now guards only the None-axis
|
||||
aggregate. It also closes improvise-xur5: a fold's record `{Month=Q2}` written
|
||||
with a measure the host draws nowhere (hidden, unknown or absent) has no
|
||||
position for the third rule to ask, and was accepted.
|
||||
- A cell a formula owns is refused. That is `cell_id`'s own `Derived` rule,
|
||||
`formula_for(&key)`. It is asked directly because a stored record may sit on
|
||||
no position in the host's current view.
|
||||
- A record found **in the store** is a record, whatever view anyone is on.
|
||||
`RecordHash` is document-scoped (gotcha 44).
|
||||
- A record found **only through the layout** is asked of `project::cell_id` at
|
||||
the position of **its cell**: the one whose key is the whole key the write
|
||||
names, record and measure together. `Aggregate`, a fold or a sum over a
|
||||
None-axis category, is refused. It must be the whole key, not the first
|
||||
position carrying the record (improvise-sjdb). `RecordHash` drops `_Measure`,
|
||||
so every column of a row carries the same record, and with a collapsed
|
||||
`_Measure` group the first of them can be the fold. That refused the empty
|
||||
`Units` cell beside a `Money` fold, which the host itself projects as a
|
||||
`Record`. A write whose whole key the host's grid does not draw is not
|
||||
refused here, as it was not before this rule existed.
|
||||
|
||||
**Why the `vec![]` arm stays.** A reviewer asked whether a `SetCell` whose cell
|
||||
does not resolve at all, which `model_effects` turns into `vec![]` and so
|
||||
applies nothing, should also be a `Rejection`. Through a `Host`, it already is,
|
||||
before that arm is reached. A lone write naming a record that never existed gets
|
||||
`RecordGone` up front, whether or not the basis claims it, because the create
|
||||
exemption covers only a *later* command in a frame
|
||||
(`a_wire_write_to_a_record_that_never_existed_is_rejected`). A later write whose
|
||||
record nothing in the frame made resolvable gets `ChainBroken` from
|
||||
`broken_chain`
|
||||
(`a_frame_whose_write_would_land_nowhere_is_refused_rather_than_dropped`). So
|
||||
the arm is unreachable from the wire. Making it a refusal would add a second
|
||||
answer to "does this resolve" beside `broken_chain`, and that answer could only
|
||||
ever fire after the first had already refused. It is left as the mutation
|
||||
path's own behaviour for callers that do not go through the session door.
|
||||
|
||||
+160
-14
@@ -869,11 +869,14 @@ an involution without anything being taught about involution, and neither
|
||||
|
||||
### The cell
|
||||
|
||||
`Model::evaluate_aggregated` expands, at its head, any coordinate that names a
|
||||
group rather than an item into one key per member (a cross product where several
|
||||
coordinates do). Doing it in the **model** is what makes `display_text`, prune,
|
||||
column totals, `grid_of`, search, yank and CSV export all follow with no change.
|
||||
Three decisions:
|
||||
`Model::evaluate_covering` expands, at its head, any coordinate that names a group
|
||||
rather than an item into one key per covered member (a cross product where
|
||||
several coordinates do). The grid reads every pivot key through
|
||||
`GridLayout::evaluate`, which passes the members it draws. Since
|
||||
improvise-eccl/f2oo, display text, pruning, column totals, `grid_of`, search,
|
||||
yank and CSV export all go through that one evaluation, rather than each calling
|
||||
`Model::evaluate_aggregated`. That remains the view-less answer, and it covers
|
||||
every member. Five decisions:
|
||||
|
||||
- **Membership is read off each item's own `group` field, never off
|
||||
`Category::groups`.** `add_item_in_group` registers no `Group`; that list is
|
||||
@@ -882,14 +885,67 @@ Three decisions:
|
||||
- **An item with the same name as the group wins.** `items.contains_key`
|
||||
short-circuits the expansion, so an existing document with an item `Q1` reads
|
||||
exactly as before.
|
||||
- **Stored values fold once over every record the group covers; a derived value
|
||||
folds its members' results.** Folding the members' *aggregates* is right for
|
||||
`Sum` and wrong for `Avg`/`Count` under `Axis::None` — `avg(avg(2,4), 9) = 6`
|
||||
where the group's average is `5`. A formula target has no stored records to
|
||||
pool, so its members' evaluated results are folded with the measure's
|
||||
aggregation; for a ratio formula that is the usual pivot-table limitation, not
|
||||
a new one. Hidden items are included in the fold, as on `Axis::None`: hiding
|
||||
decides what is drawn, not what an aggregate covers.
|
||||
- **Stored values fold once over every record the group covers.** Folding the
|
||||
members' *aggregates* is right for `Sum` and wrong for `Avg`/`Count` under
|
||||
`Axis::None`: `avg(avg(2,4), 9) = 6` where the group's average is `5`.
|
||||
- **A formula is evaluated over the fold, never folded itself** (improvise-eccl).
|
||||
This bullet used to say a formula target "has no stored records to pool", so
|
||||
its members' results were folded with the measure's aggregation, and it called
|
||||
that "the usual pivot-table limitation". **The premise was wrong.** A formula's
|
||||
*inputs* are stored and can be pooled, and the model already evaluated a
|
||||
formula over pooled inputs whenever a category sat on `Axis::None`:
|
||||
`recompute_formulas` strips the None categories from the stems. Folding the
|
||||
members' results summed ratios instead, so `Margin = (Revenue - Cost) / Revenue`
|
||||
at 0.5 and 0.9 showed **1.4** on the Q1 row where Month-on-None showed **0.8**.
|
||||
Spreadsheet calculated fields do not have that limitation either. The formula
|
||||
is now evaluated at the group key, with each reference read through the fold
|
||||
(`Reach::Fold` in `model/types.rs`):
|
||||
- a raw measure reads the stored fold;
|
||||
- another formula target is evaluated over the fold in turn;
|
||||
- an aggregate inside the formula (`SUM`, `MAX`, …) pools every covered
|
||||
member's records. `MAX(Revenue)` had been *summing* the members' maxima.
|
||||
|
||||
The collapsed row now agrees with the None-axis answer. **Test with a ratio**:
|
||||
a sum agrees under both rules, which is how this was missed.
|
||||
- **The folded category reads as absent** (improvise-lnkb, the orchestrator's
|
||||
ruling). `Month IS Q1` is false on the Q1 fold, and a formula's own
|
||||
`WHERE Month = "Q1"` does not apply there. Both answers are what the None axis
|
||||
gives, and what an inline `SUM(… WHERE Month = "Q1")` already gave. This
|
||||
PR's first version said `IS` saw the group's name and called that "stated
|
||||
rather than hidden". That made the fold row the one place a formula could
|
||||
observe collapse state, breaking this section's own "fold = None over the
|
||||
drawn members" rule. `Model::read_coordinate` is the one reading of a
|
||||
coordinate by name at a fold.
|
||||
- A formula with its own `WHERE Month = "Jan"` shows **empty** on the Q1 fold,
|
||||
because the fold is not Jan. The None axis gives the same.
|
||||
- **A fold evaluates each formula once** (improvise-o1y0). A reference to
|
||||
another formula target is evaluated over the fold in turn, so without memory
|
||||
a doubling chain re-evaluates exponentially. The first version did exactly
|
||||
that: a chain of eight took 511 formula evaluations for one cell, and the
|
||||
reviewer measured 9.83 s on an 18k-cell grid. `evaluate_covering` now carries
|
||||
a memo for one evaluation, and a key met while still in progress is reported
|
||||
`circular`. Measured on an Apple M4 Max in release, on 100 products × 12
|
||||
months folded to quarters with the F0…F8 chain (4,000 cells): 0.94–1.04 s
|
||||
without the memo, about 60 ms with it, and about 10 ms on gh/main. gh/main
|
||||
was cheap because it read the members out of the formula cache, which is
|
||||
what gave the wrong ratios. The remaining ~6× is each column's cell
|
||||
re-deriving the chain below it (45 evaluations per row where 9 would do). A
|
||||
memo scoped to the whole grid would remove it; it is not built.
|
||||
- **A fold covers the members the grid draws** (improvise-f2oo). Hidden members
|
||||
are not in it. So a fold is exactly the rows it replaces, the total of a
|
||||
**stored** measure under Sum or Count does not move on `z`, and a group whose
|
||||
every member is hidden has no row. Under Avg, Min or Max a stored measure's
|
||||
total does move, and so does a **formula** column's (PR #203's review measured
|
||||
Margin 1.90 expanded, 1.30 collapsed). The strip sums the rows' values rather
|
||||
than folding the column the way a fold does, so it sums a Max row's maximum
|
||||
and a formula row's result. That is improvise-wm20, and it is not this rule. That last
|
||||
point was already true, and it contradicted the old rule, under which a hidden
|
||||
value appeared only while a sibling was visible. This deliberately **differs
|
||||
from `Axis::None`**, where hidden items still count, because the two replace
|
||||
different things. A fold replaces *rows the user sees*. A None axis replaces a
|
||||
*dimension*, and hiding an item on it hides nothing that was drawn. The view's
|
||||
hidden set reaches the model as the `covers` predicate `GridLayout::evaluate`
|
||||
passes, since the model knows no view.
|
||||
|
||||
### Settability and drill
|
||||
|
||||
@@ -900,7 +956,11 @@ regular on None projected the fold as `CellId::Record`, offered it for editing,
|
||||
and a `SetCell` would have written the coordinate `Month=Q1`, which is not an
|
||||
item. `render.rs::editable` is untouched (ADR 002: the variant decides).
|
||||
`EditOrDrill` asks the same predicate and declines out loud: drilling would filter
|
||||
on `Month=Q1`, which no record carries, and show an empty list.
|
||||
on `Month=Q1`, which no record carries, and show an empty list. Guarding that one
|
||||
command left `>`, `p`, `H` and an `Enter` advanced onto the fold all still writing
|
||||
or drilling there (improvise-gw18, yk2s). Every command that takes the cursor's
|
||||
cell now goes through a door that asks this predicate, or asks `cell_id`, which
|
||||
does: `context/code/command.md`, "The cursor's cell has three doors".
|
||||
|
||||
### The cursor, and why the order is the fix
|
||||
|
||||
@@ -1200,3 +1260,89 @@ off the `## Data` line and off `serde_json` and requires them equal. A second
|
||||
home that happens to agree is harmless; one that drifts fails there, and
|
||||
re-copying the tokens into `format.rs` with one letter changed was injected to
|
||||
prove it does.
|
||||
|
||||
## 17. An editor opens on the value, not on its `Display` (improvise-umi4)
|
||||
|
||||
`CellValue`'s `Display` is a **screen** spelling: a non-whole number is written
|
||||
`{:.4}`, so `1.23456789` reads `1.2346`. Every editor seed used to be that
|
||||
string (`display_value_in`'s pivot arm through `get_cell().to_string()`, its
|
||||
records arm through `records_display`, and `project::cell_display`'s records
|
||||
arm spelling it again), so pressing `i` and then `Enter` with nothing typed
|
||||
stored `1.2346`. The screen looked the same before and after, which is what
|
||||
made it silent data loss rather than a visible bug. The browser seeds from the
|
||||
projected `CellDisplay::edit_text`, which came from the same call, so it had
|
||||
the same bug.
|
||||
|
||||
`CellValue::edit_text` is the one home of the seed. A number is written as the
|
||||
**shortest decimal that parses back to the same `f64`**, which is what Rust's
|
||||
`f64` `Display` already guarantees (no precision argument, no exponent). The
|
||||
brief pointed at improvise-gp's `number_text` as the implementation to reuse.
|
||||
It is not a second implementation of this one: it takes an `f32` and searches
|
||||
fixed precisions for the shortest *f64* expansion of it, because a threshold
|
||||
must route the same under f32 and f64 evaluation. For an `f64` the standard
|
||||
library's spelling is the same answer with no search. A non-finite number takes
|
||||
§16's token (`inf`, `-inf`, `nan`), which `typed()` also reads back, so the
|
||||
editor and the `.improv` format spell it one way.
|
||||
|
||||
Text and errors keep `Display`. Text is written unchanged, and an error cell has
|
||||
no edit text on the wire anyway. Display itself is untouched: the grid still
|
||||
rounds (gotcha 8), only the seed changed.
|
||||
|
||||
What this does **not** fix: a text cell whose text reads as a number. `"007"`
|
||||
seeds `"007"`, and `typed()` classifies that as `Number(7.0)`. The seed is
|
||||
exact, but the classifier has no way to know the cell was text. That is
|
||||
improvise-ixxx.
|
||||
|
||||
## 18. Which formula an edit means (improvise-km71)
|
||||
|
||||
A `Formula` has no stored id. It lives in `Model::formulas: Vec<Formula>` and is
|
||||
identified, in practice and by `add_formula`'s upsert, by `(target,
|
||||
target_category)`. **`FormulaId` is that pair behind one opaque type**: its
|
||||
fields are private, `Formula::id()` is its only constructor, and nothing
|
||||
outside `improvise-formula` can read a name out of it. That is deliberate. The
|
||||
stable-identity work (improvise-sboz, ADR 005) will make a formula's identity
|
||||
something other than its names, and every signature that already says
|
||||
`FormulaId` — `Model::formula`, `Model::replace_formula`,
|
||||
`ModelCommand::ReplaceFormula`, `FormulaSlot::Existing`, `FormulaEntry` — then
|
||||
changes by changing this one struct. A name pair spread across those signatures
|
||||
would have made the swap a sweep.
|
||||
|
||||
**The index is not a candidate.** A position is what `formula_cursor` is, and
|
||||
the editor must not recompute its target from the cursor at commit
|
||||
(improvise-ixxx/2wu3's lesson): another client deleting an earlier formula
|
||||
shifts every index after it, and the commit would land on a neighbour. It would
|
||||
also put a position on the wire, which `every_model_command_field_is_position_free`
|
||||
makes a compile error.
|
||||
|
||||
**An edit is its own operation, never remove-then-add and never
|
||||
`add_formula`.** `add_formula` upserts on `(target, target_category)`: fed an
|
||||
edit that changes the target, it appends a second formula and leaves the old
|
||||
one; fed an edit onto another formula's target, it silently replaces *that*
|
||||
formula. `Model::replace_formula(id, formula)` writes into the slot `id` names,
|
||||
so the position (and with it `_Measure`'s item order, since
|
||||
`measure_item_names` appends formula targets in formula order) is kept, and it
|
||||
**refuses** when the new target belongs to a different formula.
|
||||
|
||||
**A target change is a rename of the same formula.** The id travels with the
|
||||
edit, so `Profit = …` edited to `Gross = …` is the formula that was `Profit`,
|
||||
in the same place. That is the least surprising reading of "edit this row", and
|
||||
it is what ADR 005 proposes. What a rename cannot carry under name identity is
|
||||
everything else that names the old target: other formulas' references, a
|
||||
view's hidden list, a `measure_agg` entry. Those cannot follow without rewriting
|
||||
`raw` text the parser does not print back, so the edit says so out loud:
|
||||
`ReplaceFormula` reports which formulas still mention the old name
|
||||
(`Formula::mentions`, over the expression and its `WHERE`), and their cells
|
||||
show the `ref` error rather than a silent wrong number. Under a stable id those
|
||||
references are what sboz makes follow. **This holds for `_Measure` targets only.**
|
||||
Outside `_Measure` a target is a real item, and a rename is refused until sboz
|
||||
(improvise-kb1d, `context/code/command.md`). A rename also leaves stored cells under
|
||||
the old `_Measure` name in `## Data`, invisible because the formula shadowed them.
|
||||
|
||||
**Concurrency is checked twice.** `FormulaSlot::Existing { id, was }` is
|
||||
captured when the editor opens: the id must still resolve, and the formula must
|
||||
still read `was`. Either failing means another client removed, renamed or
|
||||
rewrote it meanwhile, and the edit is refused with the batch stopped, so the
|
||||
editor stays open over the user's text (gotcha 21). `was` is not redundant
|
||||
with a stable id: a concurrent *replace* keeps the id and changes the text,
|
||||
which only `was` can see.
|
||||
|
||||
|
||||
@@ -471,8 +471,74 @@ document that decompressed perfectly well. Decoding once, afterwards, also
|
||||
leaves **one** answer to "is this UTF-8" rather than one per branch.
|
||||
|
||||
The `.gz` suffix selects gzip regardless of backend: that is a property of the
|
||||
document name, not of the filesystem (improvise-6mq). Legacy JSON is detected by
|
||||
a leading `{` and is never written.
|
||||
document name, not of the filesystem (improvise-6mq).
|
||||
|
||||
**`.improv` text is the only native format (improvise-tt22).** A legacy JSON
|
||||
workbook used to be detected by a leading `{` and loaded read-only through
|
||||
`Workbook`'s serde derives. That path is gone, on the user's direction on PR #210.
|
||||
A document that still starts with `{` is refused with an error that names the
|
||||
format and says it is no longer supported. The test is
|
||||
`a_legacy_json_workbook_is_refused_by_name`. The error is decided rather than
|
||||
defaulted, because otherwise `parse_md` would report a grammar error at line 1,
|
||||
which diagnoses the wrong thing: the file is not malformed `.improv`. The check
|
||||
is on the decompressed text, so `.improv.gz` is covered too.
|
||||
|
||||
The serde derives that existed only for that path went with it: `Workbook`,
|
||||
`Model`, `View`, `Category`, `Item`, `Group`, `CategoryKind`, `CellKey`,
|
||||
`DataStore`'s manual impls and the formula AST. So did indexmap's `serde` feature
|
||||
in `improvise-core`, `improvise-io` and the root crate, and `improvise-formula`'s
|
||||
serde dependency. Each was removed and the whole workspace rebuilt, so the
|
||||
compiler named every other consumer. Two remained, and both are the wire:
|
||||
`Axis` (whose `rename_all = "lowercase"` is its wire spelling) and `PageAxis`.
|
||||
A third arrived with improvise-km71 by the same rule: `FormulaId`, which crosses
|
||||
the wire in `ModelCommand::ReplaceFormula`, `FormulaSlot` and `FormulaEntry`.
|
||||
So `improvise-formula` depends on serde again, for that one type. No derive went
|
||||
back on the formula AST.
|
||||
`CellValue` was never a candidate, since it is carried on the wire too. JSON
|
||||
*import* (`StartImportWizard`, `ImportJsonHeadless`) is a separate feature and is
|
||||
untouched.
|
||||
|
||||
One test consumer had to move: the effect-classification tests fingerprinted the
|
||||
model and the views through `serde_json`. They now use `Debug`. `format_md` was
|
||||
rejected as the replacement, because it skips the virtual views and sorts the
|
||||
data, so it would have been a weaker fingerprint.
|
||||
|
||||
The first version of this paragraph said `Debug` was "no weaker" because it
|
||||
renders every field, including the derived `formula_cache` the JSON skipped.
|
||||
**That was wrong (improvise-pc3b).** A derived `Debug` prints a `HashMap` in hash
|
||||
order, and `recompute_formulas` clears and refills the cache. So a correct
|
||||
`ViewOnly` effect such as `RebuildLayout` changed the fingerprint in 90 of 200
|
||||
runs. The suite passed only because the fixture had no formulas, so the cache
|
||||
was empty. `Model` now has a hand-written `Debug` that renders `formula_cache`
|
||||
and `measure_agg` in key order. The fingerprint keeps every field, including the
|
||||
derived one, and a refill in a different hash order is no longer read as a
|
||||
change. `recomputing_formulas_leaves_the_model_fingerprint_alone` pins this.
|
||||
`view_only_effects_leave_shared_state_alone` now runs over a formula-bearing
|
||||
fixture, for the same reason.
|
||||
|
||||
**That fix was canonical within one instance, not across two (improvise-djcn).**
|
||||
It fixed `Model`'s own maps but not the maps inside its fields. `DataStore` has
|
||||
`index`, `records` and the symbol table, and `View` has `page_selections`,
|
||||
`hidden_items` and `collapsed_groups`. All still printed through a derived
|
||||
`Debug`, in hash order. Each `HashMap` gets its own `RandomState`, so two equal
|
||||
documents built separately printed differently in 32 of 32 runs.
|
||||
- The effect-classification tests compare one instance with itself before and
|
||||
after, so they were unaffected.
|
||||
- `a_model_command_lands_where_its_keypress_lands` compares **two** `App`s. It
|
||||
passed only because its fixture had no cells and no multi-entry view maps.
|
||||
|
||||
Now:
|
||||
- `DataStore`, `SymbolTable` and `View` have hand-written `Debug` impls. Each
|
||||
sorts wherever its storage is a hash map.
|
||||
- `DataStore` resolves symbols to their strings. A `Symbol`'s number is interning
|
||||
order, not content, so printing it would make two equal stores differ again.
|
||||
- `SymbolTable` prints its interned strings, sorted.
|
||||
- `cells` keeps its `IndexMap` order, because that order is part of the document
|
||||
(gotcha 18).
|
||||
- Every hand-written `Debug`, `Model`'s included, **destructures** `self`. A field
|
||||
added later is then a compile error there, where a `debug_struct` that names
|
||||
fields one by one would skip it silently. The pc3b version of `Model`'s impl
|
||||
had exactly that hole.
|
||||
|
||||
`parse_number_cell` returns `CellValue::Error` naming the offending text rather
|
||||
than a fabricated `0.0`. No token the current grammar admits actually fails
|
||||
|
||||
+313
-34
@@ -84,6 +84,20 @@ naming it would produce a mode with nothing to add to. `Quit` and
|
||||
`start-import-wizard` — which do work beyond changing the mode, so
|
||||
`enter-mode` would be a way to reach half of each.
|
||||
|
||||
### `AppMode::FormulaEdit` carries what it will commit to (improvise-km71)
|
||||
|
||||
`FormulaEdit { minibuf, slot }` is one mode for two editors. `slot` is
|
||||
`FormulaSlot::New` for `a`/`n`/`o` (`enter-mode formula-edit`, which
|
||||
`from_name` still builds) or `FormulaSlot::Existing { id, was }` for
|
||||
`edit-formula-at-cursor`. It is one variant and not a second `AppMode` because
|
||||
the keys, the buffer and the panel are identical: a second variant would add a
|
||||
`ModeKey`, a keymap and a hint for no difference in what the keys do. The slot
|
||||
is `ItemAdd`'s `category` again, a capture made when the mode is entered. Like
|
||||
`ItemAdd` it is not nameable from `from_name`, since a script line has no
|
||||
formula to open. It must be portable and snapshot-safe (§1 above). `FormulaId`
|
||||
is plain serde data, and a fresh client that inherits an open edit inherits
|
||||
exactly the claim the editor made. The commit re-checks that claim.
|
||||
|
||||
### `Panel`
|
||||
|
||||
`Panel::ALL`/`name()`/`key()` drive the toggle keys for Normal *and* all
|
||||
@@ -164,9 +178,12 @@ attempt in a fourth place is the disease.
|
||||
- The **model half** is `ModelCommand`: every `EffectKind::Model(..)` effect,
|
||||
one variant apiece, carrying that effect's own fields (improvise-kmgs).
|
||||
|
||||
The count is 29 model effects, minus three: `SwitchView`, which `Command`
|
||||
already names and routes, and `ApplyAndClearDrill`/`AcceptWizardModel`, which
|
||||
are **not addressable** (improvise-3x3u). That leaves **26**. A variant with
|
||||
The count is 27 model effects, minus two: `ApplyAndClearDrill` and
|
||||
`AcceptWizardModel`, which are **not addressable** (improvise-3x3u). That
|
||||
leaves **25**. `SwitchView`, `ViewBack` and `ViewForward` are no longer model
|
||||
effects at all (improvise-jytm), so they are not subtracted. This paragraph
|
||||
said "29 minus three, 26" until improvise-km71 added `ReplaceFormula`, and
|
||||
that figure was already stale: the enum had 24. A variant with
|
||||
no data mirrors a nullary effect whose operand is document state the
|
||||
authoritative `App` already holds.
|
||||
|
||||
@@ -230,11 +247,33 @@ guard below (improvise-c7mu). It removes three things a scan must not see.
|
||||
composers, so no verdict was wrong yet. It also had a mirror: a comment
|
||||
explaining that a `NOT_COMPOSED` variant is held back would have raised a
|
||||
false "stale entry" alarm.
|
||||
- **Test items inside a file.** A line scan that relies on rustfmt putting an
|
||||
item's closing brace at the attribute's own indentation. It treats
|
||||
`cfg(any(test, …))` as test code and `cfg(not(test))` as production.
|
||||
Over-stripping **fails** the test rather than passing it, which is the safe
|
||||
direction.
|
||||
- **Test items inside a file.** Each test-only attribute's item is bounded by
|
||||
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
|
||||
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
|
||||
follows. The first version was a line scan that relied on rustfmt putting an
|
||||
item's closing brace at the attribute's own indentation. The #172 review
|
||||
found three shapes it never bounded:
|
||||
- an item ending in `];` or `);`;
|
||||
- a `#[cfg(test)]` statement spanning lines;
|
||||
- a one-line `#[cfg(test)] use x;`.
|
||||
|
||||
In each case the scan read on to the end of the file and **hid every line of
|
||||
production code after it**, which is the silent direction for the forward
|
||||
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
|
||||
|
||||
**Which `cfg` is test-only is evaluated, not substring-matched.** The
|
||||
predicate is read off the file's *text*, because a `feature` value is a
|
||||
literal and blanked in the code. It is evaluated in three-valued logic for a
|
||||
build without `test`: `test` is false, and so is `feature = "test-support"`,
|
||||
which this repo never ships (improvise-26dm). Everything else a scan cannot
|
||||
know, such as a target or an ordinary feature, is unknown. An item is
|
||||
test-only when its predicate is **false**. So
|
||||
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
|
||||
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
|
||||
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
|
||||
heading a file makes the whole file test code. One appearing anywhere else
|
||||
is a panic, because this scan cannot bound an inline module's inner
|
||||
attribute.
|
||||
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
|
||||
`x/` test code, even though nothing inside the file says so. Before
|
||||
improvise-c7mu, this checker's own file and `improvise-io`'s
|
||||
@@ -592,14 +631,37 @@ broader than its reason — improvise-gcvb, which also records the two things
|
||||
that make it more than a one-liner: an inserted `+cat` column means the TUI
|
||||
does **not** advance, and `AddCategory` can abort.
|
||||
|
||||
### `CellId::Coordinate` — a drawn coordinate is not "no cell" (improvise-exdg)
|
||||
|
||||
A records-mode coordinate cell (the `Region` column holding `North`) used to
|
||||
project `None`. That was a **third** meaning of `None`, next to the padded slot
|
||||
and the blanked position: a visible cell that holds text and whose write
|
||||
belongs to the host. So absence was encoding a permission, the same conflation
|
||||
`Create` removed for the creating columns (improvise-poia). The pinning test,
|
||||
`a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is
|
||||
gone.
|
||||
|
||||
`Coordinate` carries **nothing**. Editing a coordinate renames it across every
|
||||
measure cell in the row (gotcha 1), and no client composes that operation: it
|
||||
needs `record_col`, and the browser declines every records-mode edit before
|
||||
reaching a cell. Giving the variant a `RecordHash` would hand a client a
|
||||
surrogate with nothing to spend it on. The operand belongs with the command
|
||||
that consumes it, whenever that command exists. Nothing a client can do has
|
||||
changed. `render.rs::editable` is now an exhaustive `match` rather than a
|
||||
`matches!`, so the next variant has to be classified. `typed_cell_command`,
|
||||
`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it.
|
||||
So paste onto a coordinate is still a silent `Upstream` in the browser. The
|
||||
TUI has declined it out loud since PR #184. That divergence is
|
||||
improvise-kkcy, and a named kind is what lets the browser decline deliberately
|
||||
now.
|
||||
|
||||
### `CellDisplay::id` — `None` is not "aggregate"
|
||||
|
||||
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
|
||||
of the enum. `None` means the slot holds **no projected cell**: a `GridCache`
|
||||
position padded by `fit_to_labels`, blanked by a reframing, or a
|
||||
records-mode column that is not a cell to write into at all (a category column
|
||||
renames a coordinate across the whole row, gotcha 1, which is a different
|
||||
intent from `SetCell`). Conflating the two either hides a cell or offers a
|
||||
position padded by `fit_to_labels`, or one blanked by a reframing. Every
|
||||
records-mode column is some kind of cell (`Record`, `Derived`, `Create` or
|
||||
`Coordinate`). Conflating the two either hides a cell or offers a
|
||||
write the type system exists to make unconstructible.
|
||||
|
||||
### `CellDisplay::edit_text` — the difference corrupts data
|
||||
@@ -615,13 +677,104 @@ and an editor's seed is exactly a feedback path.
|
||||
It is the projection of `App::display_value_at_cursor`, which is what
|
||||
`EditOrDrill` seeds the TUI's buffer from, so both front ends start from one
|
||||
string rather than each deriving one. `None` where there is nothing to edit:
|
||||
the same set of positions `id` is `None` for, plus cells whose value is
|
||||
absent. An error cell carries none, because its text is a diagnostic and not
|
||||
positions whose `id` is `None`, plus pivot cells with no key. A records-mode
|
||||
cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not
|
||||
a value.
|
||||
|
||||
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
|
||||
client over the same grid, and they disagreed by a comma.
|
||||
|
||||
**It is the value's exact spelling, not its `Display`** (improvise-umi4).
|
||||
`CellValue`'s `Display` writes a non-whole number `{:.4}`, so this field
|
||||
carried `1.2346` for a stored `1.23456789`, and `i` then `Enter` with nothing
|
||||
typed stored the rounded number in both front ends. Every arm that seeds an
|
||||
editor now goes through `CellValue::edit_text` (`core.md` §17). That is the
|
||||
shortest decimal that parses back to the same `f64`.
|
||||
|
||||
### An untouched editor writes nothing (improvise-ixxx)
|
||||
|
||||
An exact seed is not enough, because the commit reclassifies it. `typed()`
|
||||
reads text, not the cell it came from, so `i` then `Enter` on `Text("007")`
|
||||
stored `Number(7)`. A raw `CellValue::Error` has the edit text `ERR:msg`, and
|
||||
the same keys stored it as `Text("ERR:msg")`. After umi4 the seed differs from
|
||||
the stored value only when `typed(edit_text(v)) != v`. In every such case the
|
||||
right answer is to keep `v`, which is what an untouched commit means.
|
||||
|
||||
So **a commit whose text is the seed the editor captured as it opened writes
|
||||
nothing**. `resolve::untouched(Option<&OpenedEditor>, &str)` is the one home
|
||||
for that, beside `typed()`. Every commit path reaches it:
|
||||
|
||||
- **TUI, pivot and records.** `CommitAndAdvance` carries `opened`, which
|
||||
`commit_at_cursor` takes from `CmdContext::opened_editor`. It skips only
|
||||
`commit_cell_value`: the mode change, the cursor restore, the advance and the
|
||||
re-entry all still run, so records Tab still advances. The script form
|
||||
(`commit-cell-edit <mode> <value> <coords>`) carries `None` and always
|
||||
writes, since no editor was opened. The drill's staging goes through the same
|
||||
`commit_cell_value`, so it is covered too.
|
||||
- **Browser keyboard.** `resolve::commit` → `written`, which returns an empty
|
||||
write for an untouched buffer and leaves `ChangeMode` and `advanced` alone.
|
||||
- **Browser mouse.** The seed is a field of #188's `EditTarget` (`opened`),
|
||||
captured by `edit_target_at` from the cache's `edit_text` beside the record
|
||||
and its version. `typed_target_frame` starts with the `untouched` check, and
|
||||
the page passes no seed (`typed_cell(target, text)`). The `<input>` shows
|
||||
`edit_seed(target)`, so what the page shows and what it compares against are
|
||||
one capture (improvise-ugj4).
|
||||
|
||||
**The seed is captured at open, never recomputed at commit.** Where the editor
|
||||
opened is where `OpenedEditor` is written. For the TUI that is `EditOrDrill`
|
||||
and `EnterEditAtCursor`. For the browser it is `resolve::opened_on`, which
|
||||
`edit_or_drill` and `advanced` share, and `ClientState::settle_editor` for an
|
||||
editor that arrived without its buffer. For the mouse it is `edit_target_at`.
|
||||
The reason is concurrency. If another client writes the cell while this editor
|
||||
is open, a seed recomputed at commit has moved to the new value. The untouched buffer then differs from it, so the
|
||||
stale text gets written over the other client's value. The browser's basis
|
||||
claim would not stop that write, because it is taken when the commit is
|
||||
composed. Comparing against the captured seed avoids that lost update as well.
|
||||
|
||||
`OpenedEditor` lives on `ViewState` (`opened_editor`) and travels as
|
||||
`Command::SetOpenedEditor`, with the `ViewOnly` effect of the same name. It is
|
||||
a struct on purpose. What else an open editor must capture, namely which record
|
||||
it edits (improvise-2wu3) and at what version (improvise-0j0f), belongs in the
|
||||
same object. `EnterMode` into an editing mode writes `None`, because an editor
|
||||
opened that way has no seed and must not inherit an earlier one.
|
||||
|
||||
**Untouched means not edited, not merely equal** (improvise-2054). Comparing
|
||||
content alone dropped a deliberate write. Open on `7`, let another client write
|
||||
`5`, then press Backspace and `7`: the buffer spelled the seed again, so the
|
||||
commit wrote nothing and the user was not told. `OpenedEditor` therefore
|
||||
carries `touched`, and `untouched` requires `!touched && text == seed`. The
|
||||
edited write is then composed, and if the cell moved, the basis check
|
||||
(improvise-0j0f) refuses it loudly rather than dropping it silently.
|
||||
|
||||
`resolve::edited(opened, buffer)` is the one rule for what marks an editor. It
|
||||
is an edit of **the editor's own buffer**, `edit`, by `append-char` or
|
||||
`pop-char`. `AppendChar`/`PopChar` in the TUI and the browser's two arms each
|
||||
call it and emit `SetOpenedEditor` once, on the first edit. Two things
|
||||
deliberately do **not** mark it:
|
||||
|
||||
- **A key into another buffer.** Typing `:enter-mode editing` goes into
|
||||
`command`. If that marked the stale editor, it would hide the stale-seed case
|
||||
the `EnterMode` guard exists for, and no test could see the guard.
|
||||
- **`clear-buffer`.** Esc and the post-commit clear are not the user's edits.
|
||||
If they marked the editor, the guard above would again be redundant and
|
||||
unpinnable.
|
||||
|
||||
The comparison stays exact: whitespace is content, and nothing trims.
|
||||
|
||||
**The mouse editor reports its first edit.** Its edits happen in a DOM
|
||||
`<input>` that Rust never sees, so the page's `input` listener, registered
|
||||
`{ once: true }`, swaps its target for `edited_target(target)`, which is
|
||||
`EditTarget::edited`: the same capture with `touched` set (improvise-jibp).
|
||||
Before that, the mouse path compared content alone, so the 2054 case survived
|
||||
there. The `<input>` used to be seeded from the **painted** `editText` while
|
||||
`EditTarget` captured the **cache**. `repaint` returns early while an editor is
|
||||
open, so a paint that went stale then showed old text against a newer seed and
|
||||
version (improvise-ugj4). The page now seeds from `edit_seed(target)`, and
|
||||
`RenderCell` no longer carries `edit_text`, so the paint cannot be asked.
|
||||
|
||||
This does **not** change classification. `007` typed fresh is still
|
||||
`Number(7)`, and `a_touched_browser_commit_still_writes` pins that.
|
||||
|
||||
`numeric` and `error` are decided by the server for the same reason: it is
|
||||
the side that knows which it was, and re-deriving client-side is how the two
|
||||
ends drift apart. `CellDisplay` is deliberately not *drawn from* a `CellValue`
|
||||
@@ -636,8 +789,9 @@ that `text` was formatted from: `evaluate_aggregated` over the view's
|
||||
because the browser's `yy` has to copy what the TUI's `YankCell` copies, and
|
||||
neither string on the cell can be turned back into it:
|
||||
|
||||
- `edit_text` is `CellValue::to_string()`, which writes a non-whole number
|
||||
`{:.4}` — `1.23456789` becomes `1.2346`.
|
||||
- `edit_text` was `CellValue::to_string()`, which writes a non-whole number
|
||||
`{:.4}`, so `1.23456789` became `1.2346`. improvise-umi4 has since made it
|
||||
exact, but the next bullet still holds.
|
||||
- Re-parsing any text with `typed()` turns number-like text into a number —
|
||||
`Text("007")` becomes `Number(7.0)`.
|
||||
- In a pivot grid `edit_text` is the *stored* cell (`get_cell`), so an
|
||||
@@ -647,10 +801,8 @@ So the value is **projected**, not re-derived (gotcha 47): a client that
|
||||
wants the value asks for the value. It is kept separate from `edit_text`
|
||||
rather than replacing it, because the two answer different questions — what
|
||||
an editor starts with is a *string*, deliberately the one the TUI's buffer
|
||||
holds, and changing it would change what the TUI's own editor shows.
|
||||
|
||||
It cost `Eq`: `CellValue` holds an `f64`, so `CellDisplay`, `GridCache` and
|
||||
`GridUpdate` are `PartialEq` only. Nothing hashed or `Eq`-bounded them.
|
||||
holds. (That string was itself lossy until improvise-umi4. The fix changed what
|
||||
the TUI's own editor shows, which was the point.)
|
||||
|
||||
In a records grid the TUI does **not** yank this value: `YankCell` and
|
||||
`PasteCell` resolve the synthetic `(_Index, _Dim)` key, so the TUI yanks
|
||||
@@ -658,6 +810,102 @@ In a records grid the TUI does **not** yank this value: `YankCell` and
|
||||
is right there and the TUI is wrong, which is why the lockstep yank test stays
|
||||
on a pivot grid.
|
||||
|
||||
### `CellDisplay`'s equality is the wire's, not `f64`'s (improvise-4w09)
|
||||
|
||||
`CellDisplay`'s `==` has one job: the grid diff (`project::grid_updates`) and
|
||||
the seed property (`GridCache::folded(&g.seed()) == g`, gotcha 32) run on it.
|
||||
The question it answers is therefore **"could a client tell these apart?"**,
|
||||
not "are these the same number?".
|
||||
|
||||
A derived `PartialEq` answered the second question. `value` holds an `f64`, and
|
||||
`NaN != NaN`. So once #178 added `value`, a cell holding NaN, which `.improv`
|
||||
carries as a first-class value (improvise-55v2), was unequal to itself. The
|
||||
diff resent it on every grid-dirty projection, and the seed property was false
|
||||
for exactly the documents that hold one. It also cost `Eq` on `CellDisplay`,
|
||||
`GridCache` and `GridUpdate`.
|
||||
|
||||
So `PartialEq` is written by hand and compares `value` with
|
||||
`same_on_the_wire`, which has two clauses:
|
||||
|
||||
- **Every NaN is equal**, whatever its bits. The wire spells them all `"nan"`
|
||||
(`model/cell.rs::wire_f64`), and they cannot all share a payload: `0.0 / 0.0`
|
||||
on x86 sets the sign bit, and a client that parsed `"nan"` holds `f64::NAN`.
|
||||
Bit equality alone would fix the self-diff and still call the host's NaN and
|
||||
the client's NaN different.
|
||||
- **Any other number is its bits**, so `0.0` and `-0.0` differ. serde_json
|
||||
writes them differently, and a change between them is one the client does
|
||||
not yet have.
|
||||
|
||||
That is an equivalence relation, so `Eq` is back on all three types.
|
||||
`CellValue`'s own `PartialEq` is unchanged: formula evaluation and model code
|
||||
keep IEEE semantics. Only the projection, whose equality means "nothing to
|
||||
send", adopts the wire's.
|
||||
|
||||
The hand-written impl destructures `CellDisplay` exhaustively, so adding a
|
||||
field fails to compile until it is compared. The dangerous failure for a diff
|
||||
is **under**-reporting (gotcha 35), and a field left out of `==` is exactly
|
||||
that. Evidence: `ui::project::tests::a_grid_holding_nan_quiesces` and
|
||||
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
|
||||
|
||||
### A number must read back as the number written (improvise-6oy4)
|
||||
|
||||
serde_json's default f64 parser is fast and best-effort: it does **not**
|
||||
guarantee reading back the `f64` that serialized the text. Its
|
||||
`float_roundtrip` feature does, at roughly twice the cost of float parsing.
|
||||
Without it, the review of PR #178 measured about one ordinary decimal in seven
|
||||
coming back one ULP off, both in `CellDisplay::value` (host to client, so `yy`
|
||||
copied a neighbour) and in a `SetCell` (client to host, so a typed or pasted
|
||||
number landed as a neighbour).
|
||||
|
||||
The feature is on for **every workspace crate with a normal dependency on
|
||||
serde_json** (improvise-dz8c). Each crate's own manifest names it, because each
|
||||
build is made from one crate alone: the `wasm` CI job and `wasm-pack` invoke
|
||||
per crate, and feature unification cannot reach across separate invocations
|
||||
(`context/repo-map.md`, `## Wasm`). Enabling it in one crate and relying on the
|
||||
others to inherit it works in `cargo test` and nowhere else.
|
||||
|
||||
**Why "can parse" and not "parses a frame".** The first version of the rule
|
||||
covered only the three crates that parse frames and listed them by hand.
|
||||
Frames are not the only place a user's number crosses a JSON parser.
|
||||
`improvise-io`'s legacy-JSON `.improv` load (removed since, improvise-tt22), `improvise-command`'s JSON import
|
||||
(`StartImportWizard`, `ImportJsonHeadless`) and the root binary's
|
||||
`improvise import` all parsed with the lossy default, and in the TUI binary
|
||||
nothing enabled the feature (improvise-6yvg, reproduced: `0.24815714285714285`
|
||||
stored as `…283`). The worker bundle happened to be exact through unification,
|
||||
so one import gave different numbers in the two front ends. "Parses JSON input"
|
||||
cannot be read off a manifest, but "has a normal serde_json dependency" can, and
|
||||
it over-approximates in the cheap direction: the feature changes only parsing,
|
||||
so a crate that only serializes pays nothing for it. Dev-dependencies are out,
|
||||
because a test build ships nowhere. That also leaves out `gp-tree`, whose only
|
||||
serde_json is a dev-dependency (its example CLI reads a saved model). Every
|
||||
number it persists is an `f32`, and 3M random `f32`s all read back exactly
|
||||
through the lossy parser, against about 30% of random `f64`s.
|
||||
|
||||
**Why a declaration and not the build graph.** The first check asked
|
||||
`cargo tree` whether each listed crate's *build* had the feature. That answer
|
||||
can be yes by accident. `improvise-command` depends on `improvise-io`, so with
|
||||
the feature removed from command's manifest, command's own test build still
|
||||
has it, and its import test stays green. The rule requires the crate's own
|
||||
manifest to say it, so a later change to a dependency cannot take it away.
|
||||
|
||||
**Why not enable it once, in `improvise-protocol`.** That removes the list, but
|
||||
it would give the protocol a normal serde_json dependency it has no use for
|
||||
(its repo-map entry says core and serde only). It would also reach only builds
|
||||
that include the protocol, and `improvise-io` does not depend on it. Under this
|
||||
rule the protocol's one serde_json dependency, the optional `test-support` one,
|
||||
carries the feature too. That is off in every shipped build, so it costs
|
||||
nothing.
|
||||
|
||||
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
|
||||
reads `cargo metadata --no-deps` and names every package, and every
|
||||
target-specific entry, whose normal serde_json dependency lacks the feature. A
|
||||
new crate is covered the day it declares the dependency. It does not reach
|
||||
`harness/opfs-contract`, which is its own workspace and only serializes.
|
||||
|
||||
The end-to-end tests cannot guard this alone. They run in one unified build
|
||||
with `improvise-ws-server` as a dev-dependency, so they stay green with the
|
||||
feature removed from the browser bundle.
|
||||
|
||||
### `AxisFrame` — the grid's frame, and why it is *one* type
|
||||
|
||||
`AxisFrame` (improvise-anr9/s1xw) replaced a `Vec<String>` of labels that
|
||||
@@ -687,9 +935,16 @@ one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to
|
||||
smuggle a multi-level axis in flattened.
|
||||
|
||||
`FrameGroup::collapsed` is on the wire rather than left to the client because
|
||||
it is a fact about the *view*, which only the server holds. `FrameGroup::cat`
|
||||
travels so a client can echo it back in `ToggleGroup` without ever working out
|
||||
which category a label came from — which slash-joined labels made impossible.
|
||||
it is a fact about the *view*, which only the server holds. There is no
|
||||
`FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a
|
||||
client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs`
|
||||
has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host
|
||||
list), and neither renderer read the field. A promise about an interaction no
|
||||
code performs is the stale-sibling defect, so the field went with it. It is
|
||||
not a loss for `z`. The TUI's `ToggleGroupAtCursor` also places the cursor on
|
||||
the layout the toggle *will produce*, which a thin client cannot compute. So
|
||||
composing `z` in a browser needs its own design, and the operand should come
|
||||
back with that design rather than wait for it.
|
||||
|
||||
### `AxisFrame`: four accessors that are **not for a draw loop**
|
||||
|
||||
@@ -1196,13 +1451,13 @@ frozen behaviour reads as decided afterwards and nothing says it was only frozen
|
||||
for the duration of the move.
|
||||
|
||||
**The ticket's own proposed fix does not work, and that is worth more than the
|
||||
fix.** h0iv suggested comparing the `(cat, name)` pair, `FrameGroup` carrying
|
||||
`cat` precisely so a client need not work out which category a label came from.
|
||||
fix.** h0iv suggested comparing the `(cat, name)` pair; `FrameGroup` carried
|
||||
`cat` then (improvise-7xyx has since removed it).
|
||||
But its own reachability case is *one* category: `Month` grouped into `Q1`,
|
||||
nested under `Region`, giving `North/Q1` and `South/Q1` adjacent — the two
|
||||
headers are `(Month, Q1)` and `(Month, Q1)`, **equal as values**, so the pair
|
||||
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them`
|
||||
pins that, so the next reader does not re-propose it. What separates them is
|
||||
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them`
|
||||
pins that as whole-value equality, so the next reader does not re-propose it. What separates them is
|
||||
that they are two different `FrameEntry::Group` entries, and `groups()` already
|
||||
borrows each from `self.entries` — so the comparison is `std::ptr::eq` on the
|
||||
header, which is the literal statement of "the same header opened both".
|
||||
@@ -1936,14 +2191,38 @@ nothing in the table to look up, and its hint is a hand-written tail. That is a
|
||||
real hole and it is where it is visible, not papered over: a keymap cannot
|
||||
describe keys a command resolves privately.
|
||||
|
||||
**`gg`, `yy`, `ZZ` and the ex-commands.** A prefix sequence is two addresses and
|
||||
not one binding (gotcha 13), and `:q`/`:w` are parsed text rather than keys.
|
||||
Both are `HelpLine::Literal`, which says out loud that the row is spelled rather
|
||||
than derived.
|
||||
**The ex-commands.** `:q`/`:w` are parsed text rather than keys, so they are
|
||||
`HelpLine::Literal` — built only by the `ex!` macro, whose leading colon is its
|
||||
own — and the parser, not the keymap, is what checks them
|
||||
(`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`).
|
||||
|
||||
**The captions.** `i:edit` where `i` entered records mode would still pass every
|
||||
test here. A caption is English beside a command name and only a reader judges
|
||||
it — the same limit gotcha 14 recorded for the old hint test.
|
||||
This paragraph used to say the same of `gg`, `yy` and `ZZ`: "a prefix sequence
|
||||
is two addresses and not one binding". Two addresses is true and was no reason
|
||||
to spell them by hand — `Keymap::prefixed_keys_running` looks the pair up the
|
||||
way `keys_running` looks up one key, and `keys()` asks both (improvise-wk18). A
|
||||
key string with no command name was also the one row no guard could reach.
|
||||
|
||||
**Every row the pages ask for is visible to a guard.** The page builders take
|
||||
the `keys` lookup as a parameter; `pages()` passes the real one, and
|
||||
`help_bindings()` (tests only) passes one that records `(mode, command, args,
|
||||
description)`. So a check over the help overlay reads the *same builders* the
|
||||
overlay is drawn from, never a list kept beside them. That premise is a type,
|
||||
not a convention (improvise-v29m): `HelpLine::Keys` carries a `KeyRow` whose
|
||||
only constructor does the lookup, so a builder cannot type a key row the
|
||||
recorder would never see; and `ex!` refuses at compile time any usage with a
|
||||
bare word after the command name, so an ex row cannot smuggle a key either.
|
||||
`resolve::tests::every_advertised_command_answers_in_its_own_mode` walks it
|
||||
beside `advertised`, which is how the browser's black holes on the help page
|
||||
became visible (improvise-uln5).
|
||||
|
||||
**The captions.** `i:edit` where `i` entered records mode would still pass most
|
||||
tests here: a caption is English beside a command name and only a reader judges
|
||||
it — the same limit gotcha 14 recorded for the old hint test. Two checks now
|
||||
bite at its edges. The browser guard requires an advertised key to *act*, so a
|
||||
key whose caption promises an action and which only refuses must be named
|
||||
(improvise-3t5x); and `only_the_edit_key_is_captioned_as_editing_the_cell` asks
|
||||
by command that no other Normal row claims to edit (improvise-jhdf). A false
|
||||
caption in words neither looks for still passes.
|
||||
|
||||
### The browser half
|
||||
|
||||
|
||||
@@ -259,6 +259,72 @@ the deploy's and pass. `README.md` therefore states the rule and deliberately
|
||||
**does not list the jobs**: a second copy with nothing holding it to the first
|
||||
is what went stale, twice.
|
||||
|
||||
### pr-watch and mergeability (improvise-edpl, improvise-zlj2)
|
||||
|
||||
`scripts/pr-watch` is what the orchestrator's merge procedure stands on, and
|
||||
until improvise-edpl its verdict failed only on `mergeable == CONFLICTING`.
|
||||
GitHub computes mergeability lazily: for a while after the base moves it reports
|
||||
`UNKNOWN` for both `mergeable` and `mergeStateStatus`. PRs #172 and #179 both
|
||||
exited 0 at `UNKNOWN/UNKNOWN … 0 failed`. A PR that has *just become*
|
||||
conflicting is in exactly that window, so it could pass the watch. It is the
|
||||
same shape as improvise-b4ks: an uncertain state read as success.
|
||||
|
||||
The rule now:
|
||||
|
||||
- **`UNKNOWN` in either field is not an answer, so it is waited on.** On
|
||||
timeout it exits 3, the same code as a check still running. It never exits 0.
|
||||
- **`MERGEABLE/BLOCKED` with no failed check is waited on too** (improvise-zlj2).
|
||||
A required check that never runs therefore times out at exit 3, and does not
|
||||
exit 1.
|
||||
- **A known answer ends the wait early**: a failed check (exit 1 whatever
|
||||
mergeability says, since waiting would turn a known red into a timeout), or
|
||||
`mergeable == CONFLICTING`.
|
||||
- **The pass condition is an allowlist**: `mergeable == MERGEABLE` *and*
|
||||
`mergeStateStatus` in `CLEAN`, `UNSTABLE` or `HAS_HOOKS`. Anything else exits
|
||||
1. That includes `null` and any state GitHub adds later, so the check fails
|
||||
closed.
|
||||
|
||||
Why each state is where it is. The `main` ruleset (`gh api
|
||||
repos/fiddlerwoaroof/improvise/rules/branches/main`, read 2026-09-22) requires
|
||||
three status checks. It requires no approvals, and its required checks are not
|
||||
strict.
|
||||
|
||||
- `BLOCKED` with a `MERGEABLE` flag means a required check has not passed. A
|
||||
failing one is already in the rollup, so the script has exited 1 before it
|
||||
reads the state. What is left is a required check that **has not registered
|
||||
yet**, and every healthy push passes through that state. The required
|
||||
`Browser harness (OPFS contract + shell)` check comes from the `browser` job,
|
||||
which `needs: build`. GitHub creates its check run only after `build`
|
||||
completes. For a second or three per push, then, every registered check is
|
||||
`COMPLETED/SUCCESS`, the required one does not exist, and GitHub reports
|
||||
`MERGEABLE/BLOCKED`. The first version of this rule refused `BLOCKED`, and so
|
||||
exited 1 on a healthy PR whenever a poll landed in that gap. The reviewer put
|
||||
that at 5–15% of watches at the 20s interval. An "unrun" required check and a
|
||||
"not yet registered" one look the same from the rollup, so the state is
|
||||
waited on. A check that genuinely never runs costs a timeout, exit 3, not a
|
||||
false exit 1. An earlier version of this section said that nothing else
|
||||
produces `BLOCKED` here. That was wrong: the `needs:` gap produces it.
|
||||
- `DIRTY` can wear `MERGEABLE` for a moment while the two fields settle. It is
|
||||
refused.
|
||||
- `BEHIND` needs strict required checks, which this repo does not set. `DRAFT`
|
||||
cannot be merged. Both are refused.
|
||||
- `UNSTABLE` means a non-required check is unhappy. It stays a pass, as it was
|
||||
before. It is safe because the loop only reads the state once every
|
||||
registered check is `COMPLETED`, and any failed one has already exited 1. So
|
||||
whatever `UNSTABLE` is reporting, it is not a check in the rollup that is
|
||||
still running or red.
|
||||
- `HAS_HOOKS` is GitHub Enterprise's "mergeable, with pre-receive hooks". It is
|
||||
harmless to allow.
|
||||
|
||||
The suite enumerates the whole `mergeable × mergeStateStatus` table, plus an
|
||||
unshipped value and `null`, rather than hand-picking cases. Why: improvise-90ea.
|
||||
|
||||
The suite also plays the `needs:` gap through, poll by poll: build running, then
|
||||
build done with no harness check, then the harness running, then `CLEAN`. That
|
||||
sequence must exit 0. The rollup it starts from is PR #189's, copied from
|
||||
`gh pr view --json`. Each earlier poll is derived from it with `jq`, by dropping
|
||||
entries or rewinding them to `IN_PROGRESS`.
|
||||
|
||||
### Why the Pages deploy fails on "Found 0 artifact(s)" (improvise-864a)
|
||||
|
||||
`actions/deploy-pages@v5` intermittently fails a push to `main` with *No
|
||||
|
||||
@@ -379,3 +379,48 @@ again.
|
||||
hint is width-constrained and the history lives on help page 5 under "View
|
||||
history" with both halves. That is the #149 lesson — a hint that does not fit is
|
||||
never drawn, so the test pinning the widget cannot see it.
|
||||
|
||||
## A status message lasts until the next key (improvise-cq3d)
|
||||
|
||||
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
|
||||
nothing in production ever cleared `status_msg`: it was only replaced by the next
|
||||
message. So after the first "Saved", "Loaded" or error of a session, the derived
|
||||
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
|
||||
any mode.
|
||||
|
||||
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
|
||||
applies `SetStatus("")` and then dispatches the key, so:
|
||||
|
||||
- a message a key produces is drawn after that key, whatever it is, errors
|
||||
included. It stays until the user presses something, which is the moment they
|
||||
have acted on it. An error is never wiped before it has been drawn.
|
||||
- a message that arrives outside a key, such as one from startup or an import,
|
||||
also stands until the next key.
|
||||
- the next key clears it *before* its own effects run. So a key that writes a
|
||||
new message shows that message, and a key that writes none shows the hint.
|
||||
|
||||
**Rejected alternatives.**
|
||||
|
||||
- *Expire on mode change.* This fails the ticket's own case: a message written in
|
||||
Normal would stand in Normal indefinitely.
|
||||
- *Expire on the next successful command.* Effects carry no notion of success,
|
||||
and a failed command would leave the stale message in place. That is exactly
|
||||
the case where the user most needs the hint back.
|
||||
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
|
||||
test's middle step exists to catch that.
|
||||
- *Compose message and hint on one line.* A long message still eats the hint, and
|
||||
the stale message still stands forever.
|
||||
|
||||
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
|
||||
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
|
||||
browser after every key. Expiring there without changing the browser's resolver
|
||||
too would make the two ends disagree. The expiry is also a presentation decision
|
||||
about the TUI's one-line status row, so it belongs to the TUI front end.
|
||||
|
||||
**The browser already behaves this way.** Its `status_msg` is state that is never
|
||||
cleared either, but `web/app.js` shows it in a toast that hides after
|
||||
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
|
||||
is always drawn. So a browser user sees a message briefly and never loses the
|
||||
hint. The TUI now does the same, on a keypress rather than a timer, because a
|
||||
terminal status row has one line to share and a keypress is the event the TUI
|
||||
already has.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -298,8 +298,9 @@ The unit-level form of the headless test, against `CmdRegistry::interactive`
|
||||
directly: cursor on `Type=Food, Month=Jan`, a decoy `edit` buffer, and an
|
||||
explicit `5` for `Type/Clothing Month/Feb`. It asserts the built command's key
|
||||
and value. Rival (a) above — a `contextualize` that overwrites the parsed
|
||||
fields — fails it; this is the one hole the new registry shape leaves
|
||||
representable, so it is the one that needs a test.
|
||||
fields — failed it. improvise-yf7p then removed `contextualize` altogether
|
||||
(`context/code/cli.md` §8), so rival (a) no longer compiles; the test stays as
|
||||
the behavioural statement of what the signature now guarantees.
|
||||
|
||||
### every_argument_list_the_gate_accepts_the_run_accepts_too
|
||||
|
||||
|
||||
@@ -1458,6 +1458,17 @@ is prose, and nothing checks it against the command it documents. That is a
|
||||
neighbour of improvise-wk18, not the same gap — wk18 is about `literal()` rows
|
||||
whose *keys* go unchecked; here the keys were right and the words were wrong.
|
||||
|
||||
**improvise-jhdf closed the paraphrase, and the paragraph above is what it
|
||||
found.** Rewording Enter's caption to "Edit the cell (same as i)" — the same lie
|
||||
in new words — passed both help tests. The test now asks by **command**, which
|
||||
wk18's `help::help_bindings()` made possible: every Normal-mode help row whose
|
||||
caption says "edit" in any case must run `edit-or-drill`. It fails on that
|
||||
rival naming `(Normal, "enter-advance", None, "Edit the cell (same as i)")`.
|
||||
Two controls stop it passing by looking away: `edit-or-drill` and
|
||||
`enter-advance` must each still have a Normal row, or the filter has nothing to
|
||||
catch. What it still cannot see is a false caption that avoids the word — "Opens
|
||||
the cell" — which is the honest limit of any check over prose.
|
||||
|
||||
### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked
|
||||
|
||||
improvise-iioi, and it is the **same trap** as the category click one level
|
||||
@@ -1474,39 +1485,29 @@ view, or with a click on the row already under the cursor, the bug is invisible
|
||||
— the same reason `a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row`
|
||||
clicks Product rather than `_Index`.
|
||||
|
||||
### a_formula_row_click_navigates_and_activates_nothing
|
||||
### a_formula_row_click_navigates_and_opens_the_editor_on_that_row
|
||||
|
||||
The negative arm, and it is a statement about the *rule* rather than about the
|
||||
formula panel. The rule is "go there, then do what `Enter` does there";
|
||||
`ModeKey::FormulaPanel`'s keymap binds `a`/`n`/`o` to `enter-mode formula-edit`
|
||||
and `d`/`Delete` to delete, and **no `Enter` at all**. So the rule yields
|
||||
navigation and nothing else, and that is the rule applying rather than the fix
|
||||
stopping half way.
|
||||
Formerly `a_formula_row_click_navigates_and_activates_nothing`. That was the
|
||||
negative arm of the rule "go there, then do what `Enter` does there", true while
|
||||
`ModeKey::FormulaPanel` bound no `Enter`. improvise-km71 bound `Enter` (and
|
||||
`e`) to `edit-formula-at-cursor`, so the same rule now yields the editor on the
|
||||
clicked row. The test asserts the exact command vector. The clicked row is
|
||||
`Margin` at index 1 and the cursor stays at 0, so a click that read the cursor
|
||||
would open `Profit`.
|
||||
|
||||
This test asserts the *exact* command vector, which is the whole of what it can
|
||||
say. It once carried a second, explanatory `assert!` that no `FormulaEdit` mode
|
||||
change appears — **which could never have been the failing assertion**, because
|
||||
the `assert_eq!` above it pins the same three commands and aborts first. A
|
||||
reviewer demonstrated it by injecting a `ChangeMode(FormulaEdit)` and watching
|
||||
the panic land on the `assert_eq!` with the explanatory message never printed.
|
||||
It was a comment wearing an `assert!`, and the argument now lives in
|
||||
`context/code/clients.md` where a comment run cannot hold it.
|
||||
The old version once carried an explanatory `assert!` that no `FormulaEdit`
|
||||
mode change appeared, which could never have been the failing assertion,
|
||||
because the `assert_eq!` above it pinned the same commands and aborted first.
|
||||
That lesson stands: an `assert!` after an exact `assert_eq!` on the same vector
|
||||
is a comment.
|
||||
|
||||
### the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does
|
||||
|
||||
The guard that the removed assertion was pretending to be. The asymmetry — a
|
||||
view-row click activates, a formula-row click does not — is a fact about the
|
||||
**keymap**, and nothing tied the click to it. The reviewer proved the gap by
|
||||
adding `Enter -> enter-mode formula-edit` to `ModeKey::FormulaPanel` and running
|
||||
the whole workspace: **1381 passing, nothing failed**, while `click_formula_row`
|
||||
still activated nothing. The click and the keyboard then disagree about what a
|
||||
formula row does, silently.
|
||||
|
||||
Injecting that same binding now fails this test and only this test. The failure
|
||||
message names `click_formula_row` as what must change, because the person who
|
||||
adds an `Enter` binding is not the person reading `resolve.rs`'s click arms. The
|
||||
positive half (`ViewPanel` *does* bind `Enter`) is there so the test cannot pass
|
||||
by asserting that no panel binds anything.
|
||||
The guard that tied the click to the keymap was
|
||||
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`. A reviewer
|
||||
showed that nothing else tied them: adding an `Enter` binding to `FormulaPanel`
|
||||
left 1381 tests passing while `click_formula_row` still activated nothing. That
|
||||
test is replaced by `a_formula_row_click_ends_with_what_enter_does_on_that_row`,
|
||||
below, which compares the click with what `Enter` actually resolves to rather
|
||||
than asserting a binding's absence.
|
||||
|
||||
#### The view and formula row clicks in the browser (improvise-iioi)
|
||||
|
||||
@@ -2071,6 +2072,50 @@ mode `declined` refuses at the door, so a browser never stands in front of the
|
||||
hint; the other two are improvise-gcvb's records-cell commit, which needs
|
||||
`record_col`.
|
||||
|
||||
**improvise-3t5x and improvise-wk18 widened it again, in two directions, and the
|
||||
list above is superseded by the one in the test.**
|
||||
|
||||
*What "answers" means.* It counted any `Local` as answered, so a lone
|
||||
`SetStatus` refusal passed — which is why improvise-s8h7's false "Edit current
|
||||
cell" stayed green. Injected to show it: make `edit_or_drill`'s `Record` arm
|
||||
return only `SetStatus("RIVAL")`, so `i` refuses on every cell — the old guard
|
||||
**passed**. The line drawn now: a caption is a promise, so an advertised key
|
||||
must *act*. A refusal is legitimate — a feature a browser honestly lacks should
|
||||
say so rather than go silent — but it is not what the caption says, so it has to
|
||||
be **named** in the list with its reason, like a black hole. The test therefore
|
||||
sorts every key into three outcomes, `Acts`, `Declines` (said something, did
|
||||
nothing) and `Silent`, and lists the last two. The same injection now fails
|
||||
naming `NORMAL: a/i edit-or-drill Declines`.
|
||||
|
||||
*Judged by what moved, not by the variant.* The first version of the new
|
||||
classifier called anything but an all-status `Local` an action, and a rival that
|
||||
unhooked `jump-first-row` from `composed` **passed** — because a chord's second
|
||||
key always returns `Local([SetTransientPrefix(None), ..])`, the disarm, even
|
||||
when nothing else happens. That is the laundering this entry already warns
|
||||
about, one level down: `KeyOutcome::of` launders an empty vec into `Ignored`,
|
||||
and `chord` launders a no-op into a `Local`. The disarm is now ignored when
|
||||
deciding whether a key did anything, and the same rival fails naming `NORMAL:
|
||||
gg jump-first-row Silent`.
|
||||
|
||||
*Which keys.* It read `help::advertised`, the status-bar table, and the help
|
||||
overlay is a second body of advertising the browser also paints
|
||||
(improvise-uln5). It now also reads `help::help_bindings()`, which records every
|
||||
keys row the page builders ask for — the same builders `pages()` renders, so the
|
||||
two cannot drift — including prefix sequences (`gg`, `yy`, `ZZ`), which it
|
||||
presses by arming the prefix first. That is what added the five host-only
|
||||
black holes (`/`, `n`, `H`, `z`, Normal's `Enter`) to the list: each was already
|
||||
in `the_command_names_that_still_need_the_host_are_these`, and the help page
|
||||
advertised it anyway.
|
||||
|
||||
*Which cells.* `furnished` stood on a record cell only, so `i` on an aggregate
|
||||
(s8h7's whole subject) was outside its reach. Normal mode is now pressed on a
|
||||
record, an aggregate and a derived cell, and a non-record cell is reported only
|
||||
where it changes the answer, so the list does not triple. `furnished_on` also
|
||||
carries a history in both directions and a yanked value, so `<`, `,` and `p`
|
||||
are not refused by an empty stack — state refusals are correct behaviour and
|
||||
not this test's subject, the same argument that gave `furnished` three of
|
||||
everything.
|
||||
|
||||
### a_locally_committed_creating_column_agrees_with_the_host
|
||||
|
||||
improvise-gcvb. `resolve`'s commit returned `None` for the whole of records
|
||||
@@ -3054,6 +3099,23 @@ The third is the gotcha 48 property, and it needs `timesArmed() > 0` first or it
|
||||
denies an unreachable event: the precise reason must still be on screen after
|
||||
the 8s window, not replaced by the watchdog. `page.clock` walks past it.
|
||||
|
||||
**Strengthened by improvise-i0v9's second pass.** Every assertion above read the
|
||||
*final* `#status`, so two failures could still pass it. A watchdog that fired
|
||||
first and was then replaced by a late notice passed. So did a success claim
|
||||
shown before the notice. The test now records every status with `logStatus` and
|
||||
asserts, **before** the page clock is walked, that the log ends in `read-only`
|
||||
and never contained `did not answer` or the exact `in-page worker · <doc>`. A
|
||||
reason that arrived after the watchdog would sit in that log behind "did not
|
||||
answer"; that is the "well before 8s" property, stated without a wall-clock
|
||||
bound. Measured failing against `gh/main` at `da2e5ab`, whose log was
|
||||
`["in-page worker · two-tab-….improv", "read-only — …"]`: the premature claim
|
||||
the ticket measured at 13ms is still there once 1wft's notice exists. The
|
||||
brief's rival, `onNotice` calling `setShell` instead of `fail` (the watchdog not
|
||||
disarmed), fails it at the first `toContainText("read-only")`. The Snapshot's
|
||||
claim replaced the reason at once, which is why the eight-second form of this
|
||||
rival is now exercised by the invalid-`?doc=` spec instead (see
|
||||
`context/code/clients.md`, "The watchdog").
|
||||
|
||||
It runs on Chromium and Firefox — the two engines with OPFS — rather than
|
||||
Chromium alone, because the exclusivity semantics are the engine's and the two
|
||||
word the refusal completely differently.
|
||||
@@ -3324,9 +3386,22 @@ over `Ctrl-R` is in `context/code/tui.md`), the test is renamed, and the second
|
||||
half now asserts the binding **exists** — with `view-back` kept as the control,
|
||||
which now guards the opposite failure: a table that binds everything, or a
|
||||
sweep that reports names it never read. A third assertion pins the key itself,
|
||||
`composed(&state, ev(Char(',')), "view-forward", &[]) == [Command::ViewForward]`,
|
||||
so the binding cannot drift onto some other key while the presence check stays
|
||||
happy.
|
||||
`resolve(&state, ev(Char(','))) == Local([Command::ViewForward])`, so the
|
||||
binding cannot drift onto some other key while the presence check stays happy.
|
||||
|
||||
**That third assertion was vacuous as first written, and the paragraph above
|
||||
said it was not** (improvise-3ij0). It called `composed(&state, ev(Char(',')),
|
||||
"view-forward", &[])`, and `composed` dispatches on the command **name**: the
|
||||
`view-forward` arm never reads the event, so the `,` was decoration and the
|
||||
assertion restated the first half of the test. Rebinding `view-forward` from `,`
|
||||
to `~` in `keymap.rs` left it green; the key was pinned only by
|
||||
`ui::app::tests::the_view_history_walks_both_ways_from_the_keyboard`, in another
|
||||
crate. It now goes through `resolve`, which looks the key up in the shared
|
||||
keymap before anything reads a name — so the rival fails **here**, at this
|
||||
assertion (resolve.rs, "and the key is `,`"), and the pre-fix code under the
|
||||
same rival was run and passes. The general shape: a helper that takes a key
|
||||
*and* a name, and dispatches on the name, cannot pin the key; only the lookup
|
||||
can.
|
||||
|
||||
**The rival: bind `,` to `view-back` instead** — the misspelling the original
|
||||
control was written against, and the one a copy-paste actually produces. It
|
||||
@@ -4332,6 +4407,15 @@ with no `SetBuffer`, then presses the keys: Enter must compose
|
||||
`an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell` fails,
|
||||
as it did against the same rival in `render.rs`.
|
||||
|
||||
**Amended by improvise-ixxx.** Its first `Enter` is untouched now, so it
|
||||
composes no write. The assertion that it "must not clear" is therefore
|
||||
`committed == None`, where `committed` is the first `Command::Model` of any
|
||||
kind. It still fails against the pre-ktlk tree:
|
||||
that tree's buffer is `""`, and `settle_editor` never ran, so no seed was
|
||||
captured and the empty buffer composes `ClearCell`. The typed-`5` half is
|
||||
unchanged. `settle_editor` now captures `OpenedEditor` beside the buffer,
|
||||
because the editor opens at that moment.
|
||||
|
||||
## improvise-dsxq — the browser's `yy` copies the value, not a string
|
||||
|
||||
PR #161 (improvise-lut7) made the browser's `yank` arm compose
|
||||
@@ -4392,6 +4476,73 @@ Records mode is deliberately not covered. There the TUI yanks `None` and its
|
||||
case there would assert the bug. Since improvise-6p8q's fix it is covered, by
|
||||
`yy_then_p_in_records_mode_writes_the_record_the_host_writes` below.
|
||||
|
||||
### `an_untouched_browser_edit_stores_the_identical_value`
|
||||
|
||||
**improvise-umi4**, the browser half. The client seeds `i` from the projected
|
||||
`CellDisplay::edit_text` (`resolve.rs::edit_or_drill`), which was
|
||||
`App::display_value_in` and so `CellValue`'s rounded `Display`. The client
|
||||
resolves `i` and `Enter` itself, and what it composed is applied to the host
|
||||
through `apply_on` (`Checked::against` plus `apply_command`). The stored value
|
||||
is then compared by `f64::to_bits`. The fixture puts the value at row 1,
|
||||
column 1 of a 2×2 pivot grid, with values that `Display` rounds (the list in
|
||||
`context/test-evidence/command.md`). A records grid is not driven, because the
|
||||
browser declines records editing (`edit_or_drill`).
|
||||
|
||||
Failed before the fix on `Number(1.23456789)` stored as `Number(1.2346)`.
|
||||
**Rival:** `CellValue::edit_text`'s number arm set back to `Display`. The test
|
||||
failed on the same case.
|
||||
|
||||
The test is in-process, so it cannot see the JSON wire. A float that does not
|
||||
survive `serde_json` is improvise-6oy4, a separate fix.
|
||||
|
||||
### `every_projected_edit_text_commits_back_to_its_value`
|
||||
|
||||
The projection contract behind the test above, for **both** grid kinds. Every
|
||||
`Record` cell's `edit_text`, fed through `typed()`, must give back the cell's
|
||||
`value`. It runs over the whole projected grid in pivot mode and in records
|
||||
mode (entered with `R`). A guard asserts that the precise value was projected
|
||||
at all. This test exists because `project::cell_display`'s records arm spelled
|
||||
`edit_text` itself, apart from `display_value_in`, and no key-path test reaches
|
||||
that arm from a browser. **Rival:** that arm restored to `Some(text.clone())`,
|
||||
the display text. It failed with `records=true: edit text "1.2346" does not
|
||||
commit back to Some(Number(1.23456789))`.
|
||||
|
||||
### an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber
|
||||
|
||||
improvise-2wu3. `web/app.js` freezes the screen while an inline editor is open
|
||||
(`repaint` returns early), but `on_frame` goes on folding. The editor kept its
|
||||
viewport `(row, col)`, and `typed_cell` resolved it against the *current*
|
||||
cache at commit.
|
||||
|
||||
The test follows the ticket's acceptance criteria against a real `Host`:
|
||||
1. Client A reads the North/Revenue record at `(0, 0)`.
|
||||
2. Client B's `HideItem("North")` is applied.
|
||||
3. Only the frames addressed to A are folded into A, with no repaint, just as
|
||||
the page does it.
|
||||
4. A precondition asserts the rows really renumbered.
|
||||
5. The write A composes must name the record A clicked.
|
||||
|
||||
**Red first**, at HEAD after improvise-ktlk (PR #169), which moved the editor's
|
||||
seed but not its commit. Written against the position API as it stood
|
||||
(`typed_cell(0, 0, "42")`), it failed. The write named `RecordHash(161215…)`,
|
||||
which is South's record now sitting at row 0, where North's `RecordHash(246296…)`
|
||||
was expected. So the ticket still reproduced. The frame would have passed the
|
||||
basis check, because A had folded the fresh `Projection::Basis` along with the
|
||||
rows.
|
||||
|
||||
**The fix makes the fault unwritable in the page.** The page's surface no
|
||||
longer takes a position at commit. `edit_target(row, col)` is read when the
|
||||
editor opens and returns the `RecordCell` as an opaque string, and
|
||||
`typed_cell(target, text)` composes against it. The same test, rewritten for
|
||||
that surface, passes. There is no position left at commit for a fault to
|
||||
re-introduce. Rust-side `typed_cell_frame(row, col, …)` remains for tests
|
||||
that compose at read time.
|
||||
|
||||
The page path is exercised end to end by `harness/tests/shell.spec.js`'s
|
||||
"committing an edit sends one frame and shows the host's value", which goes
|
||||
through `edit_target` and the new `typed_cell`. The whole harness passes
|
||||
locally (69 passed, 4 skipped).
|
||||
|
||||
### yy_then_p_in_records_mode_writes_the_record_the_host_writes
|
||||
|
||||
improvise-6p8q in lockstep. The yank test in this file deliberately stayed on a
|
||||
@@ -4405,3 +4556,564 @@ assertion. The tail yanks off the `Region` and `+` columns and requires the two
|
||||
registers to agree; a TUI-only blank filter (`Text("")` to `None`) fails it on
|
||||
`+`, which is why that filter was removed rather than kept. The evidence entries
|
||||
for the host-side tests are in `command.md`.
|
||||
|
||||
### x_in_records_mode_clears_the_record_the_browser_clears
|
||||
|
||||
improvise-lx7w, both ends. The browser composes `ClearCell` of the projected
|
||||
`CellId::Record`. Before the fix the host's `x` cleared the synthetic key and
|
||||
the documents diverged in `both_ends`. South carries **two** measures. With one,
|
||||
clearing it removes South's whole records row, the host resets its cursor on
|
||||
the reframe, and `both_ends` reports a cursor divergence that belongs to the
|
||||
fixture and not to `x`. The first version of the test did exactly that. The test
|
||||
also asserts that South/Revenue is gone and North is untouched, because two ends
|
||||
that both do nothing also agree.
|
||||
|
||||
### p_onto_a_formula_owned_cell_declines_on_both_ends
|
||||
|
||||
improvise-54g1, both ends, on `yank_host`'s `Profit` column (row 1, column 3).
|
||||
The client must not compose a `Command::Model`, and the host's document must not
|
||||
move and must say why. The control, Cost at column 2, must compose a write, so
|
||||
a client that never writes cannot pass. The fold case,
|
||||
`a_write_onto_a_fold_declines_on_both_ends`, runs the same helper for `p` and `x`
|
||||
at row 2. Rival for all three: the host's paste/clear door restored to
|
||||
`ctx.cell_key()`, which was `gh/main`'s code. All three fail.
|
||||
|
||||
### the worker is not called up until its first frame has arrived
|
||||
|
||||
improvise-i0v9. The worker transport wrote `in-page worker · <doc>` when it
|
||||
spawned the worker, before anything had answered. A worker that then failed
|
||||
therefore showed a success-looking status over a blank page. The spec records
|
||||
every `#status` with `logStatus` and requires the whole log to be exactly
|
||||
`["starting the worker · <doc>…", "in-page worker · <doc>"]`. It pins the order
|
||||
as well as the final text, since a final read cannot see a claim made too early.
|
||||
Measured failing against `gh/main` at `da2e5ab`. The rival, restoring the
|
||||
immediate `setShell(\`in-page worker · ${DOC}\`)` in `workerTransport`, fails it
|
||||
on the log's first entry. The final read is `toHaveText`, not `toContainText`.
|
||||
The older specs' `toContainText("in-page worker")` could not tell the starting
|
||||
text from the claim if the one contained the other, and the starting text
|
||||
deliberately does not.
|
||||
|
||||
### only the first frame claims the peer answered, and not before it
|
||||
|
||||
improvise-i0v9, the unit half of the spec above. `arm(message, { answer })`
|
||||
must write nothing until `sawFrame`, then write `answer` once, however many
|
||||
frames follow. Fails against `gh/main`'s `createShell`, which has no `answer`
|
||||
and so never writes the claim at all.
|
||||
|
||||
### a reason given before the first frame is not replaced by the frame's claim
|
||||
|
||||
The order `worker.js` really produces on a locked document: the `ReadOnly`
|
||||
notice is posted after `start()` and **before** the Snapshot. So `fail` then
|
||||
`sawFrame` must leave the notice's text as the only thing ever shown. The rival
|
||||
is the obvious fix, `sawFrame` writing `answer` unconditionally. It fails both
|
||||
this test and the one above: the first on its exact log, which is then written
|
||||
twice. This test passes against `gh/main` by construction, since no claim exists
|
||||
there. It is not evidence of the bug; it guards against the naive fix.
|
||||
|
||||
## Numbers across the serialized wire (improvise-6oy4)
|
||||
|
||||
No manifest enabled serde_json's `float_roundtrip`, so its default f64 parser
|
||||
could read a number back one ULP away from the one written. Found by the
|
||||
adversarial review of PR #178. That review measured 139,787 of 1M ordinary
|
||||
decimals and 296,493 of 1M random-bit f64s coming back wrong. Every test that
|
||||
passes `Command`s or caches **in-process**, which includes all of
|
||||
`tests/lockstep.rs`, never serializes, so none of them could see it.
|
||||
|
||||
`LOSSY = 0.24815714285714285` is a value the default parser reads back as
|
||||
`0.24815714285714283`. Both tests compare `f64::to_bits`, never `==`.
|
||||
|
||||
### `a_yanked_number_crosses_the_serialized_wire_exactly`
|
||||
|
||||
South/Revenue holds `LOSSY` at row 1, so the source is off index 0. The page
|
||||
presses `j y y l p` through `press`, which serializes each frame to a real
|
||||
`Host::handle` and folds the serialized replies. The value crosses the wire
|
||||
twice: down as `CellDisplay::value` in the projection (#178), then up as the
|
||||
`SetCell` the paste composes. Each hop has its own assertion, so a fix to one
|
||||
hop cannot hide the other. `set_viewport` is called first. Without it the
|
||||
client records no window and the cursor keys do nothing, which an early
|
||||
version of this test found out.
|
||||
|
||||
**Fault injected:** no `float_roundtrip` in any manifest, the pre-fix tree at
|
||||
`a6e3651`. Fails at the host-to-client assertion: yanked bits
|
||||
`4598108823619341467` against `…468`.
|
||||
|
||||
### `a_typed_number_crosses_the_serialized_wire_exactly`
|
||||
|
||||
`typed_cell(1, 1, "0.24815714285714285")`. The client parses exactly with
|
||||
`str::parse`, and the host re-parsed the JSON lossily. This path lost precision
|
||||
before #178 too, as the review noted.
|
||||
|
||||
**Fault injected:** the same pre-fix tree. Fails: the host stored
|
||||
`4598108823619341467` in South/Cost.
|
||||
|
||||
**Retired: `every_build_that_parses_a_frame_enables_float_roundtrip`.** It asked
|
||||
`cargo tree` about three crates listed by hand. improvise-dz8c replaced it with
|
||||
`every_crate_that_can_parse_json_enables_float_roundtrip`, whose entry is in
|
||||
`tooling.md`. The injections recorded for it still hold: with the feature
|
||||
removed from `improvise-wasm-client` alone, the two tests above stay green, so
|
||||
they cannot guard the bundle alone.
|
||||
|
||||
### resolve::tests::every_grid_move_lands_where_its_binding_says
|
||||
|
||||
improvise-6562, found reviewing PR #180 at 949810c, and it is the same
|
||||
laundering as the disarm, one level over. `heard()` said it judged by what moved
|
||||
and in fact judged by command **type**: any `Local` command besides a status or a
|
||||
disarm counted as `Acts`. The fixture sat at every edge — a 2x1 grid with the
|
||||
cursor at (0,0), one tile, help page 0 — so about twenty movement keys that
|
||||
clamp to nothing were counted as acting. Rebinding `jump-last-col` to
|
||||
`GridMove::ToStart(false)`, so `$` and `End` go to the *first* column, left the
|
||||
whole workspace green.
|
||||
|
||||
Two changes, because either alone is not enough. `heard()` now **folds** the
|
||||
outcome and compares the view slice before and after (status and disarm aside),
|
||||
counting a `Command::Model` as sent and a `Request` as an act; and `furnished_on`
|
||||
stands on a 3x3 grid with the cursor at (1,1), three tiles with the cursor on
|
||||
the middle one, a page axis on its middle item and help page 1, so no move is
|
||||
clamped by an edge. Injecting `jump-last-col` as a `SetSelected` to where the
|
||||
cursor already is now fails `every_advertised_command_answers_in_its_own_mode`
|
||||
naming `NORMAL: $/End jump-last-col Silent`.
|
||||
|
||||
**The reviewer's own rival still passes that guard, and should.** From (1,1),
|
||||
`ToStart(false)` moves to (1,0) — the view moved, so the key "acts". No
|
||||
did-anything-happen check can see *direction*; only an expected destination can.
|
||||
This test supplies one without a second table: a `move-selection` binding's
|
||||
expected landing is read off its own args (the deltas), and the four jumps are
|
||||
named by what they are called. Every key that runs one is pressed from the
|
||||
centre — fourteen of them, with a floor so a blind walk fails. Under the rival
|
||||
it fails naming `End` and `$`. The general point: the fixture that made the
|
||||
laundering invisible (the edge) is also the one that makes a wrong-direction
|
||||
jump visible, so moving off the edge fixed one blind spot and opened the other,
|
||||
and each needs its own question.
|
||||
|
||||
### a_formula_row_click_ends_with_what_enter_does_on_that_row
|
||||
|
||||
improvise-km71, replacing `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
|
||||
The old guard asserted that `Enter` was unbound, which tied the click to the
|
||||
keyboard only by the message in its `assert!`. This one resolves `Enter` at
|
||||
cursor 1 and asserts that the click on row 1, made with the cursor at 0, ends
|
||||
with the same commands. Rival: `click_formula_row` activates at
|
||||
`state.view.formula_cursor` instead of the clicked index. This test and
|
||||
`a_formula_row_click_navigates_and_opens_the_editor_on_that_row` both fail. The
|
||||
cursor and the index differ in the fixture, which is what lets them fail.
|
||||
|
||||
### renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host
|
||||
|
||||
improvise-km71. `editing_a_formula_from_the_panel_agrees_with_the_host` alone
|
||||
cannot separate a browser that composes `ReplaceFormula` from one that composes
|
||||
`AddFormula`: the upsert lands a same-target edit in place on the twin too.
|
||||
Rival: `CommitKind::of` ignores the slot. Both lockstep tests fail. The edit
|
||||
test fails only on the status line ("Formula added" against "Formula
|
||||
updated"). This test fails on the **document**, which is the property. It also
|
||||
pins a real divergence: on a rename the host says what was renamed and the
|
||||
client cannot, because `FormulaId` is opaque and it does not know the old
|
||||
target.
|
||||
|
||||
**improvise-w7qd, and the floor was the hole.** As first written the test
|
||||
covered `move-selection` and a four-entry jump table, behind `pressed >= 14`.
|
||||
Two things passed the whole workspace, both found in re-review of #180 at
|
||||
544744c: negating the `scroll-rows` and `page-scroll` arms, so `Ctrl-d`/`Ctrl-u`
|
||||
and `PgDn`/`PgUp` all scroll the wrong way (advertised, and still "acting" from
|
||||
the centre); and dropping the jump table *with* `$` sent to the first column,
|
||||
because the sixteen `move-selection` sequences met the floor on their own. The
|
||||
test now walks **every** command Normal binds. `scroll-rows` and `page-scroll`
|
||||
state their landing from their own argument exactly as `move-selection` does —
|
||||
from the centre of a 3x3 grid any step of at least one lands at `1 + d`
|
||||
clamped, so a sign is visible whatever the page size — and a command that moves
|
||||
the cursor (in the same view) without a stated landing **fails** rather than
|
||||
being skipped. So a dropped table entry is caught by derivation, not by a count:
|
||||
under the second rival it fails naming every jump as moving with nothing stating
|
||||
where. The first rival fails on `PgUp` (`page-scroll ["-1"]`) and, alone, on
|
||||
`Ctrl-u` (`scroll-rows ["-5"]`). A last assertion that each kind was pressed at
|
||||
least once guards the other direction — a binding vanishing — and is a named
|
||||
list on purpose, since that is the one thing no walk over bindings can see.
|
||||
|
||||
**improvise-5kdr: the 3x3 fixture saw only a sign.** From the centre of a 3x3
|
||||
grid every step of one or more clamps to an edge, so the w7qd version stated
|
||||
`page-scroll` as `1 + d` — one row, which is not what it does — and passed
|
||||
because the true landing clamped to the same edge. Measured on #198 at 8772db2:
|
||||
`page-scroll` ignoring the page size together with `scroll-rows` moving one row,
|
||||
or `move-selection` moving `2d`, left the test green. The walk now stands at
|
||||
(10,10) of a 21x21 grid with an 8x8 window, far from every edge, so a landing
|
||||
shows magnitude: `10+d` for `move-selection` and `scroll-rows`, `10 + page·d`
|
||||
for `page-scroll`, 0/20 for the jumps. The page size is not written into the
|
||||
test: `grid_move`'s page rule moved into `page_rows(window_rows)` and the test
|
||||
asks it, so the rule has one home. Rivals, each run: `move-selection` doubled
|
||||
fails on `Up` (`move-selection ["-1","0"]`); the page-size-and-one-row pair
|
||||
fails on `PgUp` (`page-scroll ["-1"]`); the one-row `scroll-rows` alone fails on
|
||||
`Ctrl-u` (`scroll-rows ["-5"]`); the jump-table rival still fails naming every
|
||||
jump. The residual, stated: because the test reads `page_rows`, a change to the
|
||||
page rule itself moves the test with it — whether that rule is the TUI's is
|
||||
`tests/lockstep.rs`'s question, not this one's.
|
||||
|
||||
### an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open
|
||||
|
||||
improvise-0j0f, found by the review of PR #188 at 48f52e7 and confirmed
|
||||
against a real `Host`. #188's first version captured the edited record's
|
||||
*identity* when the editor opened, but claimed its *basis* at commit. Because
|
||||
the fold runs on while the screen is frozen, the claim was the fresh counter.
|
||||
|
||||
The test runs the steps in this order:
|
||||
1. A opens an editor on North/Revenue, which shows 1200.
|
||||
2. B writes 999 to the same cell.
|
||||
3. A folds only its own frames and does not repaint.
|
||||
4. A commits "42".
|
||||
|
||||
It asserts that the host answers `Rejected(RecordMoved)` and still holds 999.
|
||||
|
||||
**Red first:** against the claim-at-commit version it failed, because the
|
||||
commit was accepted. **Rival injected after the fix:** `typed_target_frame`
|
||||
back to `basis.claim([record])`, which claims at commit. Only this test failed
|
||||
(`12 passed; 1 failed`), and the 2wu3 test beside it stayed green. That is
|
||||
right, because the two are separate properties: *which* record the commit
|
||||
names, and *which version* of it the claim states. Reverted.
|
||||
|
||||
An earlier version of this entry said the reviewer's clear variant would be
|
||||
`RecordGone`. It is not, and that claim was reasoning and not a test. The
|
||||
clear variant is now tested by
|
||||
`an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open`,
|
||||
below, and the answer is `RecordMoved` (improvise-4img).
|
||||
|
||||
### an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open
|
||||
|
||||
improvise-4img: the clear variant of the 0j0f race above. The steps are the
|
||||
same, except that in step 2 B *clears* North/Revenue instead of writing 999.
|
||||
The test asserts that A's commit of "42" is refused with
|
||||
`Rejected(RecordMoved)`, and that the cell is still absent afterwards.
|
||||
|
||||
**Why `RecordMoved` and not `RecordGone`.** After the clear, North has no cell
|
||||
left, so `DataStore::resolve_record` answers `None`. But `App::resolve_record`
|
||||
falls back to the layout, and the grid still draws the North row because
|
||||
`prune_empty` is off. So the record still resolves, and the counter A claimed
|
||||
at open has moved. `RecordGone` is for a record that nothing resolves at all;
|
||||
this one is still on screen.
|
||||
|
||||
**Rival injected:** `typed_target_frame` back to `basis.claim([record])`, which
|
||||
claims at commit. The commit was then *accepted*. With the refusal assertion
|
||||
disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the
|
||||
cell B cleared. Under that rival, the 0j0f test above failed as well. Both
|
||||
files were restored.
|
||||
|
||||
### tile_select_opens_on_a_regular_category_on_both_ends
|
||||
|
||||
**What broke (improvise-oplp).** `EnterTileSelect` and the browser's
|
||||
`enter-tile-select` resolver both set the tile cursor to 0, and tile 0 is
|
||||
`_Index`, because `Model::category_names()` lists the virtual categories first.
|
||||
In TileSelect, `Enter` and `Space` cycle the tile's axis and `r` sets it to
|
||||
Row, so two keystrokes (`T Enter`, read as "confirm", or `T r`) put `_Index`
|
||||
alone on Row and emptied a pivot grid. The browser worker persists every
|
||||
accepted model command, so there the empty grid became the saved document at
|
||||
once.
|
||||
|
||||
**The fix.** The cursor opens on the first regular category;
|
||||
`_Index`/`_Dim` stay in the tile list, as #192 decided. The rule is
|
||||
`improvise_protocol::opening_tile`, over `&[TileEntry]`, called by both ends.
|
||||
The browser cannot tell `_Index` from a user category without sniffing names
|
||||
(design-principles §2 forbids that), so `TileEntry` carries `regular`,
|
||||
filled host-side from `CategoryKind` in `project::tiles_of`. That is the
|
||||
"enrich the projection" shape of gotcha 47. The TUI's `EnterTileSelect` builds
|
||||
the same list through `tiles_of` and asks the same function.
|
||||
|
||||
**The case.** `furnished_host`'s tiles are `_Index, _Dim, _Measure, Region,
|
||||
Product`. Both ends press `T` then `Enter` through `both_ends`, which compares
|
||||
the documents, mode, status and every cursor. The case then asserts that
|
||||
`_Index` is still off-grid, that `Region` moved Row → Column, and that `_Index`
|
||||
and `_Dim` are still tiles to the left of where the cursor opened.
|
||||
|
||||
**Red, and two rivals.**
|
||||
- At base, both ends agreed and both put `_Index` on Row: "T Enter moved
|
||||
_Index" (left `Row`, right `None`).
|
||||
- The TUI alone reverted to opening at 0: fails at `T`, "client and host
|
||||
disagree about a cursor".
|
||||
- `regular` filled as `true` for every tile, so the rule opens on 0 on both
|
||||
ends and they agree: fails on the axis assertion, "T Enter moved _Index".
|
||||
The comparison alone cannot see this, which is why the case also pins which
|
||||
category moved.
|
||||
|
||||
`harness/tests/contract.spec.js`'s "a resolved change repaints the widget it
|
||||
moved" reached `Product` by pressing `ArrowRight` three times from tile 0,
|
||||
which hard-coded the old opening position. It now asserts that `T` alone
|
||||
selects `Product`, the demo's first regular category, so the same spec also
|
||||
checks the fix in a real browser. At base it fails: tile select opens on
|
||||
`_Index`.
|
||||
|
||||
### p_onto_a_sparse_record_writes_what_the_browser_writes
|
||||
|
||||
improvise-q9eg, both ends, on a **sparse** record: `Season` is added after
|
||||
entering records mode, so no record carries it. `yy` on North/Revenue, then `p`
|
||||
on South/Revenue (row 1), through `both_ends`, which compares the host's
|
||||
document with the one the client's composed commands build. The host refused
|
||||
while the browser composed `SetCell`, so the documents differed. The final
|
||||
assertion that South holds 1200 is the half that keeps the test from passing
|
||||
when both ends decline. Rival: `PasteAt::Resolved` back through `paste_into`.
|
||||
This test fails.
|
||||
|
||||
### a_locally_resolved_page_scroll_lands_where_the_host_would_put_it
|
||||
|
||||
**What was missing (improvise-5xbq).** `tests/lockstep.rs` compared only `j`
|
||||
and `G` against the host. The browser's page rule (`resolve::page_rows`) and
|
||||
the TUI's (`Move`'s `Page` arm, `visible_rows * 3 / 4`) were two copies, and
|
||||
two faults injected into the browser's copy left the whole workspace green:
|
||||
`page_rows` returning `window_rows / 2`, and `grid_move` paging by
|
||||
`window_cols`. The second was invisible for a further reason: the only window
|
||||
the page-rule test used was square, `(8, 8)`.
|
||||
|
||||
**The fixture.** 20 rows by 3 columns; the host sees 8 rows and the client
|
||||
announces `(8, 3)`. The three candidate rules then give three different
|
||||
steps: 6 (three quarters of the rows), 4 (half the rows) and 2 (three quarters
|
||||
of the columns). The walk starts off the edge (two `j`), then presses `PgDn`,
|
||||
`PgUp`, `Ctrl-d` and `Ctrl-u`, including a `PgDn` that clamps at row 19.
|
||||
|
||||
**Why it pins the rows as well as comparing the ends.** `page_rows` is now the
|
||||
one home (the TUI's `Move` calls it), so a wrong rule moves host and client
|
||||
together, and a comparison alone passes. Each press therefore also asserts the
|
||||
row the TUI's rule names.
|
||||
|
||||
**Injected, before and after the move to one home.**
|
||||
|
||||
| Fault | Two copies | One home |
|
||||
|---|---|---|
|
||||
| `page_rows` returns `window_rows / 2` | fails at press 2 (`PgDn`), "client and host disagree" | fails at press 2, "not the row the TUI's page rule names"; `app.rs`'s `page_down_scrolls_by_three_quarters_visible` and `page_up_scrolls_backward` fail too |
|
||||
| `grid_move` pages by `window_cols` | fails at press 2, "client and host disagree" | fails at press 2, "client and host disagree" |
|
||||
|
||||
`Ctrl-d`/`Ctrl-u` are `scroll-rows ±5`, not the page rule. They are in the walk
|
||||
because the ticket named them, and they move the start point of the next page
|
||||
turn to a row the fixed rules alone would not reach.
|
||||
|
||||
### a narrow window still shows how to page through help — `harness/tests/help.spec.js`
|
||||
|
||||
**What broke (improvise-1c5m).** Below the width at which the app bar wraps,
|
||||
`#help` (fixed, `inset: 2rem`, `z-index: 30`) covers `#hint`, and since PR #180
|
||||
Welcome carries no paging instruction of its own. So a new user on Welcome at
|
||||
phone width could not see how to reach page two.
|
||||
|
||||
**The fix is option (b) from the ticket.** The overlay draws `model.hint` in a
|
||||
footer. In Help mode that is already `hint_line(Help)`, the same string `#hint`
|
||||
carries, so it adds no derivation, and it sits outside `.help-body`, so
|
||||
"Welcome is prose" still holds. The footer is `position: sticky` because the
|
||||
overlay itself scrolls and Welcome is longer than a phone-height overlay.
|
||||
|
||||
**What the case asserts.** At 600x800 on Welcome, it reads the hint string from
|
||||
`#hint`, so the spec spells no keys. It then requires some element showing
|
||||
exactly that string to be the topmost element at its own centre, which is the
|
||||
`elementFromPoint` measurement the ticket made by hand.
|
||||
|
||||
**Red, then a rival.** At base it failed with "the paging hint is under the
|
||||
help overlay". With the fix and `position: sticky` changed to `static`, it
|
||||
fails the same way: the footer exists but sits below the fold of a scrolling
|
||||
overlay, so presence alone cannot satisfy the case.
|
||||
|
||||
**Faults injected:** the feature removed from `improvise-wasm-client` alone,
|
||||
which fails naming that crate while the two tests above pass. Then removed from
|
||||
`improvise-ws-server` alone, which fails naming it. The native path runs
|
||||
without `--target`, so the two arms are different invocations.
|
||||
|
||||
### `an_untouched_browser_commit_writes_nothing_and_still_advances`
|
||||
|
||||
**improvise-ixxx**, browser keyboard. The fixture is a 3×1 grid with the cursor
|
||||
on row 1. For each seed in `"007"`, `"1e3"`, `"inf"`, `"ERR:boom"` and
|
||||
`"1.23456789"`, the test folds `i`, checks the buffer is that seed, and resolves
|
||||
`Enter`. The composed commands must contain no `Command::Model`. They must also
|
||||
contain `SetSelected { row: 2, col: 0 }`, the re-entry
|
||||
`ChangeMode(editing)`, and `SetOpenedEditor` capturing `"800"`, the cell it
|
||||
landed on.
|
||||
|
||||
Failed before the fix: `"007"` composed `SetCell(Number(7.0))`. **Rival:**
|
||||
`resolve::commit` returning `[ChangeMode(Normal)]` when untouched, which skips
|
||||
the advance along with the write. This test fails alone.
|
||||
|
||||
### `an_untouched_browser_edit_does_not_overwrite_a_write_made_while_it_was_open`
|
||||
|
||||
The captured-seed constraint, in the browser. The test opens the editor on
|
||||
`"007"`, then folds a `GridUpdate::Cells` that moves the cell to `"5"`. That is
|
||||
the projection another client's write produces. `Enter` with nothing typed must
|
||||
compose no `Command::Model`.
|
||||
|
||||
**Rival:** `written` comparing against `edit_seed(state)`, which re-reads the
|
||||
cache at commit time, instead of `ViewState::opened_editor`. This test fails
|
||||
alone, composing `SetCell(Number(7.0))`. The basis claim is taken when the
|
||||
commit is composed, so it would not have refused that write.
|
||||
|
||||
### `a_touched_browser_commit_still_writes`
|
||||
|
||||
Scope guard. The reviewer's fourth constraint says classification is out of
|
||||
scope. `Backspace` over the seed `"7"` and then typing `007` must still compose
|
||||
`SetCell(Number(7.0))`. It passes before and after. It is here so that a fix
|
||||
which suppressed number-shaped text in general, rather than untouched text,
|
||||
fails.
|
||||
|
||||
### `an_untouched_browser_edit_of_a_misread_value_writes_nothing`
|
||||
|
||||
The same shape as `an_untouched_browser_edit_stores_the_identical_value`, with
|
||||
the values `typed()` misreads: `Text("007")`, `Text("1e3")`, `Text("inf")` and a
|
||||
raw `Error("boom")`. It runs through the resolver and into a real host. Both
|
||||
tests share `untouched_browser_edit`, which now also asserts that the buffer
|
||||
after `i` is `value.edit_text()`. That assertion keeps the umi4 test
|
||||
discriminating once untouched commits write nothing (see
|
||||
`command.md`, `an_untouched_pivot_edit_of_a_misread_value_writes_nothing`).
|
||||
With `project.rs` seeding from `to_string()`, both umi4 browser tests fail.
|
||||
**Rival:** `untouched` always `false`. This test fails.
|
||||
|
||||
### `a_mouse_commit_of_the_untouched_seed_composes_nothing`
|
||||
|
||||
**improvise-ixxx**, browser mouse. The page's click editor used to commit
|
||||
unconditionally, so an untouched click-edit re-sent the seed through `typed()`.
|
||||
Since the merge with #188, the seed is part of `EditTarget` (`opened`).
|
||||
`edit_target_at` captures it from the cache's `edit_text`, and
|
||||
`typed_target_frame` starts with `untouched`. The page passes no seed, so the
|
||||
JS rival of the first version (a page passing the wrong string) cannot be
|
||||
written any more.
|
||||
|
||||
For `"007"`, `"ERR:boom"` and `"1.23456789"`, the test opens a target on a
|
||||
record cell whose edit text is that value. It asserts the captured seed, then
|
||||
commits the same text, which must compose nothing. The cell's **drawn** text is
|
||||
different on purpose (`"drawn …"`). The control commits `"12000"` and `"1200 "`
|
||||
against the seed `"1200"`, and both must compose a write, so a
|
||||
`typed_target_frame` that never composes cannot pass, and neither can one that
|
||||
trims. **Rivals:**
|
||||
- `edit_target_at` capturing the drawn `text` instead of `edit_text` fails the
|
||||
seed assertion.
|
||||
- `typed_target_frame` without the `untouched` check fails this test and the
|
||||
next.
|
||||
- `seed.trim() == text.trim()` in `untouched` fails the `"1200 "` control.
|
||||
|
||||
### `a_mouse_commit_is_judged_against_the_seed_it_opened_with`
|
||||
|
||||
The captured-seed constraint, on the mouse path. The target is taken on
|
||||
`"1200"`. A projected write then moves the cell to `"5"`, and a guard asserts
|
||||
the cache really holds `"5"`. Committing that target with `"1200"` must compose
|
||||
nothing. **Rival:** `typed_target_frame` comparing against the cache's
|
||||
`edit_text` at commit instead of `target.opened`. This test fails alone.
|
||||
|
||||
The page half is `harness/tests/contract.spec.js`, "an untouched mouse editor
|
||||
sends no write, and a touched one does". It wraps `Worker.prototype.postMessage`,
|
||||
opens the editor on cell 4, and presses `Enter`. No `SetCell` may be posted.
|
||||
Then it types `1` and presses `Enter`, and exactly one must be.
|
||||
|
||||
Both gaps this entry used to list are closed: the stale-paint seed
|
||||
(improvise-ugj4) and the missing `touched` flag (improvise-jibp). See the
|
||||
entries that follow.
|
||||
|
||||
### `a_mouse_target_marked_edited_writes_its_own_seed`
|
||||
|
||||
**improvise-jibp**, the Rust half. A target on the seed `"1200"` commits
|
||||
`"1200"` and composes nothing: that is the control. `edited()` must leave the
|
||||
seed where it was, and the same text committed through the edited target must
|
||||
compose a write. **Rival:** `edited()` leaving `touched` false fails this test
|
||||
and `the_page_opens_its_editor_on_what_the_target_captured`, and nothing else.
|
||||
|
||||
### `the_page_opens_its_editor_on_what_the_target_captured`
|
||||
|
||||
**improvise-ugj4 and improvise-jibp**, through the wasm-bindgen surface the
|
||||
page calls. The cell is drawn `"1,200.50"` and stored `"1200.5"`, and
|
||||
`edit_seed` must return `"1200.5"`. A projected write then moves the cell to
|
||||
`"777"`. A target taken afterwards seeds `"777"`, and the first target still
|
||||
seeds `"1200.5"`, because a target is one capture. `typed_cell(later, "777")`
|
||||
composes nothing, and the same text through `edited_target(later)` composes a
|
||||
write. **Rival:** as above, `edited()` leaving `touched` false fails here. I
|
||||
wrote no rival for an `edit_seed` that re-reads the cache. `EditTarget` names a
|
||||
record, not a position, so that rival would need a lookup the client does not
|
||||
have.
|
||||
|
||||
### a mouse editor typed back to its own seed still writes — `harness/tests/contract.spec.js`
|
||||
|
||||
**improvise-jibp**, the page half. The editor opens on cell 4, and the test
|
||||
types the seed over the selection and presses Enter. Exactly one `SetCell` must
|
||||
be posted. Before the fix the page sent `typed_cell(target, seed)` with an
|
||||
untouched target, which composes nothing. **Rival:** delete the page's `input`
|
||||
listener. This test fails, and the rest of `contract.spec.js` passes (16).
|
||||
|
||||
### an editor opened after an unpainted write shows and commits what it captured — `harness/tests/contract.spec.js`
|
||||
|
||||
**improvise-ugj4.** The test opens an editor on cell 0, so `repaint` stops.
|
||||
While it is open, it delivers a synthetic frame that moves cell 4 to `777`. It
|
||||
builds that frame from the last `Cells` entry the page actually received for
|
||||
cell 4, changing only `text`, `edit_text` and `value`, so the record and the
|
||||
version are real. Then it opens cell 4. The `<input>` must show `777`, and
|
||||
Enter with no edit must post no `SetCell`. **Rival:** seed the `<input>` from the
|
||||
paint (`drawn.replaceAll(",", "")`). This test fails on the value, and so does
|
||||
"an untouched mouse editor sends no write, and a touched one does", because the
|
||||
drawn text is not the seed. The other 15 pass.
|
||||
|
||||
### `a_browser_editor_typed_back_to_its_seed_still_writes`
|
||||
|
||||
**improvise-2054**, the browser keyboard. The fixture is a 3×1 grid, cursor on
|
||||
row 1, with the cell's edit text `"7"`. The test folds `i`, then a projected
|
||||
write that moves the cell to `"5"`, then Backspace and `7`. Enter must compose
|
||||
`SetCell(Number(7.0))`. After the Backspace, `opened_editor.touched` must be
|
||||
set. **Rivals:**
|
||||
- `untouched` ignoring `touched` fails this test and
|
||||
`untouched_is_exact_and_honours_an_edit`.
|
||||
- The `pop-char` arm not marking fails this test.
|
||||
- The `append-char` arm not marking fails only
|
||||
`only_an_edit_of_the_editors_buffer_marks_it_touched`, because the pop has
|
||||
already marked the editor.
|
||||
|
||||
### `only_an_edit_of_the_editors_buffer_marks_it_touched`
|
||||
|
||||
`x` and Backspace, each on its own on a freshly opened editor, must each set
|
||||
`touched`. `typed_into` of the `command` buffer with an editor open must
|
||||
compose the `SetBuffer` and nothing else. **Rivals:**
|
||||
- The `append-char` arm not marking fails the `x` case.
|
||||
- `edited` without its `buffer == "edit"` check fails the last assertion.
|
||||
|
||||
That second rival is the one that matters. Without the buffer check, typing
|
||||
`:enter-mode …` would mark the stale editor. That would make the `EnterMode`
|
||||
guard redundant, and
|
||||
`a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed`
|
||||
could no longer see the guard.
|
||||
|
||||
### `a_browser_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed`
|
||||
|
||||
**improvise-wv3h**, the browser half. The steps:
|
||||
|
||||
1. `i` on a cell whose edit text is `""` captures the seed `""`.
|
||||
2. Esc.
|
||||
3. A projected write moves the cell to `"9"`.
|
||||
4. `composed(enter-mode editing)` is folded.
|
||||
5. Enter, with the buffer `""`.
|
||||
|
||||
Enter must compose `ClearCell`. **Rival:** the `SetOpenedEditor(None)` in
|
||||
`resolve::entered` deleted. This test fails alone, composing no write.
|
||||
|
||||
No browser key is bound to `enter-mode editing`, and `:` is declined in the
|
||||
browser (`stranding`). So `entered` is reached here through `composed` directly,
|
||||
and that guard is currently defence in depth.
|
||||
|
||||
### `untouched_is_exact_and_honours_an_edit`
|
||||
|
||||
The shared function's contract, stated once: `"7"` against its own untouched
|
||||
seed is untouched. `"7 "` and `" 7"` against the seed `"7"`, `"7"` against
|
||||
`" 7"`, and `"A"` against `"a"` are all touched, and so is a `touched` editor spelling its seed. **Rival:** comparing
|
||||
`seed.trim() == text.trim()`. This test and
|
||||
`socket::tests::a_mouse_commit_of_the_untouched_seed_composes_nothing` fail. On
|
||||
the keyboard paths a trimmed comparison cannot be seen through the keys, since
|
||||
any whitespace edit also sets `touched`, so this unit test and the mouse path
|
||||
are where it shows. The mouse test now also asserts that `"7 "` against the
|
||||
seed `"7"` is a write.
|
||||
|
||||
### `resolve::tests::the_editor_an_advance_reopens_starts_untouched`
|
||||
|
||||
**improvise-465t**, the browser half. The fixture is a 3×1 grid with the cursor
|
||||
on row 1. The test presses `i`, types `9`, and asserts the first editor really
|
||||
is touched. Enter must then land on (2, 0), and the folded `opened_editor` must
|
||||
be exactly `OpenedEditor::on("800")`. **Injection:** `resolve::advanced`
|
||||
carrying the previous editor's `touched` flag. This test fails alone.
|
||||
|
||||
### an_edit_opened_before_a_document_swap_is_refused_in_the_new_document
|
||||
|
||||
improvise-vndi, through a real `Host` over `MemoryFs`. A opens an inline editor
|
||||
on North/Revenue, which shows 1200. B sends `LoadModel` for a document that
|
||||
differs only in that cell, which holds 5555. Every name, the view included, is
|
||||
kept, per gotcha 39's fixture warning: a rename would relay a `SwitchView` and
|
||||
hide the bug. A folds the rebase `Snapshot` with the editor still open, as
|
||||
`web/app.js` does, and then commits "42". The `EditTarget` held `{cell,
|
||||
version}` and `claim_as_read` paired that version with the *current*
|
||||
generation. `RecordHash` is coordinate-derived, so the same record resolved
|
||||
in the new document at a matching counter, and the host **accepted** the
|
||||
commit. It failed at the `DocumentReplaced` assertion: the answer was
|
||||
`Accepted`, and 5555 had become 42.
|
||||
|
||||
Rival injected: `claim_as_read` ignoring the captured generation and claiming
|
||||
the current one. That is the pre-fix behaviour, restated after the fix. Only
|
||||
this test failed in the whole workspace. The layout tier staying current is
|
||||
still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`.
|
||||
|
||||
+1139
-18
File diff suppressed because it is too large
Load Diff
@@ -476,12 +476,6 @@ cell in the row).
|
||||
The pair `live_records` yields is not two facts that could drift apart: the
|
||||
surrogate is the record's own, so it is the one that resolves it back.
|
||||
|
||||
### a_deserialized_store_can_resolve_its_records
|
||||
|
||||
The record index is derived state and is `#[serde(skip)]`-equivalent — it must
|
||||
come back *with* the data, rebuilt through `set`, rather than being written
|
||||
alongside it.
|
||||
|
||||
## `model/record.rs`
|
||||
|
||||
### a_records_surrogate_is_pinned_to_a_literal
|
||||
@@ -939,7 +933,12 @@ the regression was live whenever `prune_empty` was on.
|
||||
The fix distinguishes the two emptinesses **without consulting the `View`**,
|
||||
which `GridLayout` has already dropped: a run that contained no `DataItem` at
|
||||
all was never expanded, i.e. collapsed, and its header survives; a run that
|
||||
contained items the predicate rejected is genuinely empty and goes. A
|
||||
contained items the predicate rejected is genuinely empty and goes. **Superseded
|
||||
twice** (see `an_outer_group_whose_rows_all_prune_loses_its_header`). Since
|
||||
improvise-6qet a collapsed group emits its fold as a `DataItem`, so no flat run
|
||||
is item-less. The one run that still was, an outer group's header followed
|
||||
directly by an inner group's header, was kept by that arm when it should have
|
||||
gone (improvise-b5zh). The arm is deleted. A
|
||||
`GroupEnd` is kept whenever a run is open in the output, so a re-opened earlier
|
||||
header is closed even though the header between them was pruned. That keeps
|
||||
gotcha 1's shape — still a predicate over the data index, no `&View`
|
||||
@@ -1341,8 +1340,8 @@ this fixture and the value is absent — this test, the next, and
|
||||
|
||||
Under `Axis::None` (`Store`), `Avg` and `Count` over `Jan/A=2, Jan/B=4, Feb/A=9`.
|
||||
The group's average is `5` and its count `3`; folding the members' aggregates gives
|
||||
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (the
|
||||
formula-target branch taken for stored values) — this test fails. `Sum` cannot see
|
||||
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (what the
|
||||
formula-target branch did before improvise-eccl) — this test fails. `Sum` cannot see
|
||||
the difference, which is why the fixture sets `measure_agg`.
|
||||
|
||||
### model::types::model_tests::an_item_named_like_a_group_is_read_as_the_item
|
||||
@@ -1361,3 +1360,154 @@ fold is the row `z` names the group from. Note the narrowing this implies: an
|
||||
**empty** collapsed group now has an empty fold row, which `prune_empty` drops, and
|
||||
its header goes with it. lhi4 kept that header because it was the only way back to
|
||||
the rows; the fold is now that way back.
|
||||
|
||||
### an_outer_group_whose_rows_all_prune_loses_its_header
|
||||
|
||||
improvise-b5zh asked whether `close_pending_run`'s "a header whose run saw no item
|
||||
is collapsed, and survives" arm was dead since improvise-6qet, and proposed
|
||||
deleting it. It was **not dead**, and where it ran it was wrong. With two grouped
|
||||
categories on one axis (`Region` with `North[Upper]`, `South[Lower]`, outside
|
||||
`Month` with `Jan`, `Feb` in `Q1`), `cross_product` puts each outer header
|
||||
directly before an inner one: `[Upper, Q1, North/Jan, …]`. The inner header
|
||||
closed the outer header's run as "saw no item", so `Upper` was kept even with
|
||||
every North row pruned. At `1a6b877` the pruned axis was
|
||||
`[Upper, GroupEnd, Lower, Q1, South/Jan, South/Feb]`, an orphan header drawn
|
||||
over nothing. The fix keeps the pending headers as a stack: a header closes the
|
||||
pending header of its **own** category and everything nested inside it, a kept
|
||||
item flushes the whole stack, and a `GroupEnd` drops the innermost. Rival: a
|
||||
header clears every pending header, which is the flat reading of "a header ends
|
||||
the previous run". Only this test fails, because `Lower` is lost when `Q1`
|
||||
arrives. The flat-case tests (lhi4, u6gc, the partly-pruned group) pass
|
||||
unchanged under both versions, which is why none of them could see this.
|
||||
|
||||
One pre-existing shape is left alone and stated: two consecutive `GroupEnd`s
|
||||
(an inner and an outer run ending together) keep only the first, because
|
||||
`run_open` is a single flag. That was true before this change, pruned or not.
|
||||
|
||||
### records_steps_are_the_spine_then_every_non_index_dim_category_on_none
|
||||
|
||||
improvise-t0ma. Before this, `records_steps` was guarded by one incidental test: the
|
||||
`is_index_or_dim() → is_measure()` rival failed only
|
||||
`save_with_unfinished_record_is_readable`, a persistence test that happens to enter
|
||||
records mode. This asserts the **steps**, not the effects `cmd/grid.rs` translates
|
||||
them into, because the steps are the model-side answer a browser will also consume.
|
||||
The expected list filters `_Index`/`_Dim` by **name**, not by `CategoryKind`, so it
|
||||
is not a second copy of the predicate under test, and the setup asserts `_Measure`
|
||||
and a regular category are both present, since a model with neither could not tell
|
||||
the two predicates apart. Rival: `is_measure()` in place of `is_index_or_dim()`.
|
||||
This test fails, because `_Measure` is dropped and `_Index`/`_Dim` are put on `None`
|
||||
after the spine that just placed them.
|
||||
|
||||
### drill_steps_filter_each_coordinate_and_free_the_rest
|
||||
|
||||
improvise-t0ma's companion for `drill_steps`. The fixture includes a `none_cat` that
|
||||
the key also fixes (`Region`) and one that is virtual (`_Index`), so both exclusions
|
||||
are exercised. It also lists the key's coordinates out of sorted order, which checks
|
||||
that the steps follow `CellKey`'s canonical order. Rival: `Axis::Page` in place of
|
||||
`Axis::Filter` for a fixed coordinate, which reopens improvise-dz8 (a drilled
|
||||
coordinate the user could page away from). This test fails.
|
||||
|
||||
### a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs
|
||||
|
||||
improvise-eccl. `Margin = (Revenue - Cost) / Revenue` with Jan `10/5` and Feb `30/3`
|
||||
under `Q1`, and Apr in `Q2` with no data, so the Q1 fold is row 1. The member ratios
|
||||
are 0.5 and 0.9, so the three candidate rules give three different answers: summed
|
||||
1.4, averaged 0.7, pooled `(40 - 8) / 40` 0.8. The expected value is the **pooled**
|
||||
one, and it is also asserted equal to the same document with Month on `Axis::None`,
|
||||
which is the ticket's criterion. The None-axis document works because Apr carries
|
||||
no data, so the None axis pools exactly Q1's months. At `a164726f` the fold read
|
||||
**1.4**. The ticket's own fixture (0.5 and 0.5) would have separated sum from
|
||||
pool, but not mean from pool. The second formula set chains
|
||||
(`Profit = Revenue - Cost`, `Margin = Profit / Revenue`), so a reference to
|
||||
another formula target is read over the fold as well. A rule that folds a formula
|
||||
ref's members while pooling raw refs fails there.
|
||||
|
||||
### a_folds_aggregate_formula_pools_its_members_records
|
||||
|
||||
The guard on the other side of improvise-eccl. Once a formula at a fold is
|
||||
evaluated at the group key, an aggregate inside it (`SUM`, `MAX`) must pool every
|
||||
covered member's records, because `Month=Q1` matches no record. `MAX(Revenue)` is
|
||||
the discriminating case. It **failed before the change too**: the old rule folded
|
||||
the members' `MAX`es with the measure's `Sum`, giving 40 where the answer is 30.
|
||||
`SUM` alone agrees under the old rule and the new one. Rival: `contexts_of` stops
|
||||
pooling at a fold (`Reach::Fold { .. } => Vec::new()`). This test alone fails.
|
||||
|
||||
### collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was
|
||||
|
||||
improvise-f2oo. `Q1 = {Jan 3, Feb 4}`, `Q2 = {Apr 100}`, Feb hidden. Expanded, the
|
||||
drawn rows total 103. At `a164726f` collapsing gave a Q1 fold of 7 and a total of
|
||||
**107**: `z` moved the total. The ruling: a fold covers the members the grid
|
||||
draws. The test asserts 103 both ways and a fold of 3. It then hides Jan too and
|
||||
asserts that there is no Q1 row and a total of 100. That half already held, and it
|
||||
is the inconsistency the ticket named: before, a hidden value appeared only while
|
||||
a sibling was visible. Rival: `GridLayout::evaluate` covering every member
|
||||
(`&|_, _| true`). This test alone fails, with 107.
|
||||
|
||||
### a_folds_aggregate_filtered_on_the_folded_category_counts_each_record_once
|
||||
|
||||
Found by the ChatGPT Codex review of PR #203, at head `14acf46d`. Pooling an
|
||||
aggregate over a fold's member contexts ran `agg_values` once per member, and an
|
||||
inline `WHERE` on the **folded** category overwrites that coordinate, as it does
|
||||
at a record. Every member therefore became the same record set.
|
||||
`SUM(Revenue WHERE Month = "Jan")` on the Q1 fold read **20**, Jan twice. The
|
||||
test asserts 10, and equality with the Month-on-None document. The fix dedupes
|
||||
the member contexts after the filter is applied, so each record set is pooled
|
||||
once. The rival is the pre-fix code, which read 20. `a_folds_aggregate_formula_pools_its_members_records`
|
||||
could not see this, because none of its aggregates carried a filter.
|
||||
|
||||
### a_fold_evaluates_each_formula_it_reaches_once
|
||||
|
||||
improvise-o1y0, from PR #203's review. A reference from a fold's formula to
|
||||
another formula target is evaluated over the fold in turn, with nothing
|
||||
remembered, so a doubling chain `F{i} = F{i-1} + F{i-1}` re-evaluated
|
||||
exponentially. The test **counts** formula evaluations rather than timing them,
|
||||
through a test-only thread-local counter. At `2a4f35d7` evaluating `F8` on one
|
||||
fold took **511** evaluations (2⁹ − 1); with the memo it takes 9. It also
|
||||
asserts the chain's value (7 × 256), so a memo that returned the wrong entry
|
||||
cannot pass. Rival: keep the in-progress marker but drop finished values from
|
||||
the memo (`.filter(Option::is_none)`). This test fails with 511.
|
||||
|
||||
### a_cycle_at_a_fold_is_still_circular
|
||||
|
||||
improvise-o1y0's other half. `P = Q + 1`, `Q = P + 1` at a fold must read
|
||||
`Error("circular")`. The memo's in-progress marker is what answers it now,
|
||||
because the depth bound the fold path used before is gone. Without the marker
|
||||
the evaluation recurses until the stack runs out, which is why the marker is
|
||||
there rather than being an optimisation.
|
||||
|
||||
### one_formula_at_two_fold_keys_is_evaluated_at_each
|
||||
|
||||
improvise-d7aj, found by the adversarial re-review of PR #203 at `b3379d1a`. The
|
||||
fold memo in `fold_formula_once` is keyed by the whole `CellKey`, and nothing
|
||||
pinned that: both tests above keep one key context throughout, so a memo keyed
|
||||
by the formula alone passed the whole workspace. A reference to an item of
|
||||
**another** category moves the key while `_Measure` names the same formula.
|
||||
Stores A (Jan 3, Feb 4) and B (Jan 100, Feb 200), `G = Revenue * 2`,
|
||||
`F = IF(Revenue > 50, G, G + B)`. At `(Q1, A, F)` Revenue is 7, so F is
|
||||
`G + B`: G at A is 14, and `B` reads F at `(Q1, B)`, where Revenue is 300, so
|
||||
that F is G at B, 600. The answer is **614**. Rival: the memo keyed by
|
||||
`formula.target` alone. F is still in progress at `(Q1, A)` when F at `(Q1, B)`
|
||||
is asked, so that key reads `circular`. Injected into a full workspace run, it
|
||||
fails this test and nothing else. The reviewer's probe, now committed.
|
||||
|
||||
### a_diamond_at_a_fold_is_not_circular
|
||||
|
||||
improvise-d7aj's second case, which passed at `b3379d1a` and was pinned by
|
||||
nothing. `A1 = B2 + C3`, `B2 = D * 2`, `C3 = D * 3`, `D = Revenue` reaches D by
|
||||
two paths in one fold evaluation, so the second reach must find D *finished*
|
||||
and not *in progress*. It asserts **35** over D = 7 and exactly four
|
||||
evaluations, so a memo that re-evaluates D passes neither. Rival: never store a
|
||||
finished value, so only the in-progress marker stays. This test fails with
|
||||
`circular`, and so does `a_fold_evaluates_each_formula_it_reaches_once`, whose
|
||||
doubling chain is also a diamond at every step. So this test is a named pin of
|
||||
that shape rather than the only guard against it.
|
||||
|
||||
### a_fold_answers_is_and_where_on_its_category_as_the_none_axis_does
|
||||
|
||||
improvise-lnkb, ruled by the orchestrator: on a fold, the folded category reads
|
||||
as absent. `Flag = Month IS "Q1"` and `Marked = Revenue * 2 WHERE Month = "Q1"`
|
||||
are each compared with the same document with Month on `Axis::None`. At
|
||||
`689c7665` `Flag` read **1** on the fold and 0 on None. The final assertion pins
|
||||
`Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell
|
||||
cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`).
|
||||
This test fails.
|
||||
|
||||
@@ -1434,3 +1434,35 @@ rather than defaulted: the alternative — expanding the group in the export —
|
||||
reintroduce the iot6 divergence between what the user sees and what they export.
|
||||
On the pre-6qet tree the body is empty (the group had no row, and pruning left
|
||||
nothing), so the test fails there.
|
||||
|
||||
## Legacy JSON documents (improvise-6yvg, improvise-tt22)
|
||||
|
||||
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit` (improvise-6yvg) pinned
|
||||
that the legacy JSON load path read numbers exactly. improvise-tt22 removed that
|
||||
path, so the test went with it. Its other role was to witness `improvise-io`'s own
|
||||
`float_roundtrip` declaration. The manifest rule in
|
||||
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
|
||||
still covers that, because `improvise-io` keeps a normal `serde_json`
|
||||
dependency for its import types (`context/test-evidence/tooling.md`).
|
||||
|
||||
### `a_legacy_json_workbook_is_refused_by_name`
|
||||
|
||||
improvise-tt22, on the user's direction on PR #210: `.improv` text is the only
|
||||
native format. A document starting with `{` used to be deserialized as a
|
||||
`Workbook`. The test writes a JSON workbook in the shape the old loader read,
|
||||
both plain and gzipped, and asserts the error names "legacy JSON" and says "no
|
||||
longer supported".
|
||||
|
||||
**Red at `52c6a05`**: `Failed to deserialize workbook: missing field
|
||||
`next_category_id``. That is a parse error, which is exactly what the ticket said
|
||||
the refusal must not be. A complete legacy document instead loaded successfully,
|
||||
which the since-deleted bit-for-bit test above demonstrated.
|
||||
|
||||
**Rivals, each run:**
|
||||
|
||||
- **Deleting the refusal.** The text falls through to `parse_md`, and the test
|
||||
fails on `legacy.improv` with "Parse error at line 1, column 1 (expected
|
||||
version_line)". A parse error does not name the format.
|
||||
- **Refusing only when the path is not gzipped.** The plain case passes and the
|
||||
test fails on `legacy.improv.gz` with the same grammar error. So the gzip
|
||||
case is what makes the check prove it runs on decompressed text.
|
||||
|
||||
@@ -557,7 +557,7 @@ the evidence that nothing was relying on the permissive parse.
|
||||
|
||||
### render::tests::two_runs_under_two_headers_of_one_name_are_two_runs
|
||||
|
||||
### render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them
|
||||
### render::tests::the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them
|
||||
|
||||
improvise-h0iv. `group_runs` coalesced adjacent positions by group **name**, so
|
||||
two runs under two different headers sharing a name read as one and both front
|
||||
@@ -571,10 +571,12 @@ and nothing ungrouped between the runs. Before the fix it returns
|
||||
at the precondition rather than pass with a single run.
|
||||
|
||||
The second test exists because the ticket proposed the wrong fix and a reader
|
||||
will propose it again. h0iv says `FrameGroup` carries `cat` and "comparing the
|
||||
pair would fix it" — but its own reachability case is a single category nested
|
||||
under an outer one, so both headers are `(Month, Q1)`, equal as values. The test
|
||||
asserts that equality directly. What actually separates them is that they are
|
||||
will propose it again. h0iv said `FrameGroup` carried `cat` and that "comparing
|
||||
the pair would fix it". But its own reachability case is a single category
|
||||
nested under an outer one, so both headers are `(Month, Q1)`, equal as values.
|
||||
The test asserts that equality directly. Since improvise-7xyx removed
|
||||
`FrameGroup::cat`, it asserts equality of the whole header, which is stronger:
|
||||
no field-wise comparison can separate two equal values. What actually separates them is that they are
|
||||
two distinct entries in `self.entries`, which `std::ptr::eq` on the borrowed
|
||||
header states exactly.
|
||||
|
||||
@@ -970,6 +972,27 @@ give `None` and fail. Asserting only that `"command"` survived would pass under
|
||||
either behaviour, which is what the old spelling amounted to for the cleared
|
||||
key.
|
||||
|
||||
### render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart
|
||||
|
||||
`CellDisplay`'s equality is what the grid diff and the seed property run on
|
||||
(improvise-4w09). It compares `value` by `same_on_the_wire`: bits, except that
|
||||
every NaN is equal. It pins both clauses, since `a_grid_holding_nan_quiesces`
|
||||
can see only one of them. That test compares a NaN with its own bits,
|
||||
in-process.
|
||||
|
||||
- A NaN with the sign bit set, the shape `0.0 / 0.0` produces on x86, equals
|
||||
`f64::NAN`, the shape `parse_nonfinite("nan")` gives a client. Both go out as
|
||||
the one token `"nan"`.
|
||||
- `0.0` differs from `-0.0`: serde_json writes them as `0.0` and `-0.0`, so a
|
||||
change between them is real and must be sent.
|
||||
- `Number(7.0)` differs from `Text("7")`, and a value differs from none.
|
||||
|
||||
**Faults injected:**
|
||||
- `f64 ==` (the pre-fix semantics). Fails at the NaN case.
|
||||
- Bit equality only. Fails at the NaN case, while `a_grid_holding_nan_quiesces`
|
||||
**passes**, which is why this test exists.
|
||||
- `f64 ==` plus the NaN clause. Fails at the `0.0`/`-0.0` case.
|
||||
|
||||
### vocabulary_is_composed::a_variant_named_only_in_a_comment_or_string_is_not_composed
|
||||
|
||||
improvise-c7mu, found by the post-merge review of PR #160. `constructed_in` ran
|
||||
@@ -1029,3 +1052,142 @@ Faults injected, one per half, each compiling only `improvise-protocol`:
|
||||
A field added to an existing variant is caught too, but not in isolation,
|
||||
because every constructor of that variant also fails. Reverted with
|
||||
`git restore`.
|
||||
|
||||
### source_scan::tests::a_test_only_item_hides_nothing_after_it
|
||||
|
||||
improvise-59q8, found by the PR #172 review. The scanner's line-based
|
||||
`strip_test_items` looked for `}` at the attribute's indentation, or for an item
|
||||
line ending in `;`. So three shapes were never bounded, and it dropped
|
||||
**everything to the end of the file**:
|
||||
- a test-only `const … = &[\n … \n];`;
|
||||
- a `#[cfg(test)] let x = foo(\n 1,\n);` statement;
|
||||
- a one-line `#[cfg(test)] use foo::bar;`, where the attribute's own line is
|
||||
also the item line.
|
||||
|
||||
In `vocabulary_is_complete` that silently hides every effect after such an
|
||||
item. Each shape is a case here, asserting that the production code after it
|
||||
(`Hidden1`, `Visible`) survives. Against the line scan all three failed; the
|
||||
first printed `"impl X {\n"` and nothing else. They now pass because items are
|
||||
bounded by balanced brackets on the blanked code.
|
||||
`an_unbounded_test_only_item_is_refused_not_swallowed` pins the other half of
|
||||
the acceptance criteria: an item that cannot be bounded panics instead of
|
||||
consuming the rest of the file. Against the line scan it failed, because
|
||||
nothing panicked.
|
||||
|
||||
### source_scan::tests::a_test_only_cfg_is_recognised_whatever_its_spelling
|
||||
|
||||
improvise-59q8, the other direction: test code counted as production, which
|
||||
makes `vocabulary_is_composed` weaker. `cfg(all(test, not(target_arch =
|
||||
"wasm32")))` was kept because the old rule rejected any line containing
|
||||
`not(`. A leading `#![cfg(test)]` was kept because only `#[cfg(` was
|
||||
recognised. An attribute split across lines was not seen at all. All three
|
||||
failed against the old scanner. The predicate is now parsed and evaluated in
|
||||
three-valued logic (`context/code/protocol.md` §2). The controls
|
||||
(`cfg(not(test))`, `cfg(any(test, feature = "serde"))` and `cfg(target_arch =
|
||||
…)`) must stay production, so a scanner that dropped every `cfg` item could
|
||||
not pass.
|
||||
|
||||
A consequence to know about: `cfg(any(test, feature = "x"))` for an ordinary
|
||||
feature is now production. Only `feature = "test-support"`, the one test
|
||||
feature this repo has, counts as a test build.
|
||||
`test_only_items_and_modules_are_not_production` was updated to that spelling.
|
||||
|
||||
### source_scan::tests::a_c_string_literal_is_blanked
|
||||
|
||||
improvise-59q8's minor case. In `cr#"x" Command::InRawC(2) "y"#` the `r` has a
|
||||
word character before it, so the literal was lexed as two ordinary strings,
|
||||
and the text between the inner quotes leaked. It failed as
|
||||
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
|
||||
this test used a C string with no inner quotes and passed against the bug,
|
||||
because the ordinary lexing happened to blank it too.
|
||||
|
||||
### help::tests::no_help_row_spells_a_key_by_hand
|
||||
|
||||
improvise-wk18. The help pages had one kind of row whose keys the keymap never
|
||||
saw: `literal("gg", …)`, `literal("yy", …)`, `literal("ZZ", …)`, and a dim tip
|
||||
reading "press Tab or l/n". Every other key row comes off `keys_for`, so a
|
||||
rebinding reaches it; these would have gone on saying `gg` after `g g` stopped
|
||||
meaning anything, and no guard could see them because a string carries no
|
||||
command name. At HEAD the test failed naming all four.
|
||||
|
||||
The fix is structural rather than a second scanner. The three prefix rows are
|
||||
ordinary `keys(..)` rows now — `keys` also asks `Keymap::prefixed_keys_running`
|
||||
for two-key sequences. The tip is **gone** rather than derived: made a `keys` row
|
||||
in `ModeKey::Help`, it gave the Welcome page its one row with a description,
|
||||
and `harness/tests/help.spec.js` ("? opens help, and its rows carry the keys and
|
||||
the words") failed in CI on `welcome.every(row => row.description === null)` —
|
||||
Welcome is prose by contract. The Help mode's own status hint already derives
|
||||
`next-page` from the keymap, so the literal was a second table (improvise-rr8n's
|
||||
rule: keep the one derived from the thing it describes).
|
||||
`literal()` and `dim()` are gone; the only way left to build a
|
||||
`HelpLine::Literal` is the `ex!` macro, which writes the leading colon itself, so
|
||||
what remains hand-written is ex-command text — checked against the parser by
|
||||
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`, which reads
|
||||
exactly the text a user would type. This test is what stops a direct
|
||||
`HelpLine::Literal { keys: "gg", .. }` coming back past the macro.
|
||||
|
||||
### help::tests::every_keys_row_the_pages_ask_for_finds_its_keys
|
||||
|
||||
A `keys` row whose lookup finds nothing is **dropped**, by design — a page never
|
||||
advertises a key that does not exist. The cost of that design is that a broken
|
||||
lookup looks like a shorter page: `every_help_page_key_row_names_keys_that_exist`
|
||||
only checks the rows that survived, plus a floor of twenty. So this asks the
|
||||
question from the other end, over `help_bindings()` — every row the builders
|
||||
*asked for* — and requires each to find a key.
|
||||
|
||||
The injection: comment out the `prefixed_labels` extension in `keys`. The three
|
||||
prefix rows vanish from the page, every other help test stays green, and this
|
||||
fails naming `Normal jump-first-row`, `Normal yank` and `Normal wq`.
|
||||
|
||||
### help::tests::an_ex_row_can_name_placeholders_and_nothing_else
|
||||
|
||||
improvise-v29m, found reviewing PR #180 at 949810c. `no_help_row_spells_a_key_by_hand`
|
||||
read only `HelpLine::Literal`, and its premise — that `keys()` is the only way to
|
||||
make a key row — was a convention: `HelpLine::Keys { keys: Vec<String>, .. }`
|
||||
was a public variant with public fields, and `help_bindings()` records only what
|
||||
the builders *ask* for, so `HelpLine::Keys { keys: vec!["Q"], description: "Quit
|
||||
instantly" }` typed straight into `panels()`, with `Q` bound to nothing, passed
|
||||
the whole workspace. So did `ex!("q or press Q", ..)`: the parser test splits on
|
||||
colons and `parse_line` accepts trailing words.
|
||||
|
||||
Both are now **compile errors**, which is why this test is not where the guard
|
||||
lives. `HelpLine::Keys` carries a `KeyRow` whose fields are private to a module
|
||||
whose one constructor, `KeyRow::looked_up`, does the keymap lookup; the injected
|
||||
row fails with E0559/E0451. `ex!` evaluates `ex_usage_is_placeholders` in a
|
||||
`const` item, and the injected usage fails with `evaluation panicked: assertion
|
||||
failed: ex_usage_is_placeholders("q or press Q")`. The rule cost one row:
|
||||
`:add-items <cat> a b c ...` had three bare words and is now
|
||||
`:add-items <cat> <item> ...`. This test pins the rule the macro asks, in both
|
||||
directions, including that shipped usage and the rival.
|
||||
|
||||
### help::tests::every_keys_row_drawn_is_one_the_builders_asked_for
|
||||
|
||||
improvise-ssoe, found in re-review of #180 at 544744c. `KeyRow` closed
|
||||
hand-typed keys, but `KeyRow::looked_up` was `pub(super)`, so a page builder
|
||||
could call it directly instead of through its `keys` parameter — and
|
||||
`help_bindings()` records only what goes through the parameter. Injected into
|
||||
`editing()`: `KeyRow::looked_up(n, "clear-cell", None, "Edit the current
|
||||
cell")`, which captions `x` as editing the cell — the exact lie `jhdf`'s test
|
||||
exists for — and the whole workspace stayed green, because both guards that
|
||||
would object read `help_bindings()`.
|
||||
|
||||
Two changes. `looked_up` is now private to the `key_row` module, whose one
|
||||
caller is the module's own `keys` — the lookup `pages()` hands every builder —
|
||||
so the reviewer's injection is `E0624: associated function looked_up is
|
||||
private`. That narrows the door without closing it: a builder can still reach
|
||||
the module-level `keys` by path (`self::keys(..)`) rather than through its
|
||||
shadowing parameter. So this test states the premise directly — the number of
|
||||
keys rows drawn equals the number of rows the builders asked for (every asked
|
||||
row finds its keys, by `every_keys_row_the_pages_ask_for_finds_its_keys`) —
|
||||
and the `self::keys` bypass fails it `51 != 50`.
|
||||
|
||||
**improvise-aszt: a count is not the premise.** The test above compared
|
||||
*counts*, so a bypass that also drops one real row keeps them equal. Injected in
|
||||
`editing()`: call the parameter `keys(e, "clear-buffer", …)` and discard it with
|
||||
`let _ =`, then return `self::keys(n, "clear-cell", None, "Edit the current
|
||||
cell")` in its place. The asked row is recorded and never drawn, the drawn row
|
||||
is never recorded, 50 = 50, and the whole workspace stayed green. The test now
|
||||
compares **content in order**: the drawn keys rows must equal
|
||||
`help_bindings()` looked up through the same `keys`, row for row. The same
|
||||
injection now fails. What still passes is a bypass that draws exactly what the
|
||||
builders asked for, in the order they asked — which is harmless by definition.
|
||||
|
||||
@@ -610,3 +610,240 @@ to git. A later `add` is excused. After `git checkout --` and a hand loss, the
|
||||
its `.base`, `.ledger`, `.removed` and `.stamped` siblings and `.lock`. `git
|
||||
status --untracked-files=all` and `git add -A --dry-run` both show none of
|
||||
them, and `git check-ignore -v` names `.gitignore:40` (`prolog-sessions/*`).
|
||||
|
||||
### The pr-watch mergeability cases — `scripts/pr-watch-test.sh`
|
||||
|
||||
improvise-edpl. The cases are injected faults in themselves. Like the other
|
||||
`scripts/` suites, none of their names is a Rust `fn`, so the case list is the
|
||||
evidence. The reasoning is in `context/code/tooling.md`, "pr-watch and
|
||||
mergeability".
|
||||
|
||||
**The bug, reproduced before the fix.** Against the script as it stood at
|
||||
`ae96b8c`, 19 of the 34 new cases failed and all 19 exited **0**.
|
||||
`UNKNOWN that resolves to CONFLICTING fails` is PR #172's shape exactly: every
|
||||
check green, `UNKNOWN/UNKNOWN` on the first poll, `CONFLICTING/DIRTY` on the
|
||||
second. The old script broke out of the loop on the first poll and passed. The
|
||||
fixtures are hand-built. They are the subset of the `gh pr view --json` shape
|
||||
that the script reads: `headRefOid`, the two mergeability fields, and the
|
||||
rollup's `status` and `conclusion`. They are not a captured payload. A stub that
|
||||
errors instead would have exited 4, a wider shape than the fault.
|
||||
|
||||
**Rival 1: treat `UNKNOWN` as an answer.** The wait condition was replaced with
|
||||
`if true`. 13 cases fail: every `UNKNOWN` row of the table,
|
||||
`MERGEABLE/UNKNOWN`, the timeout case, and
|
||||
`UNKNOWN that resolves to MERGEABLE/CLEAN passes`. That last one exits 1 under
|
||||
this rival, so the fix cannot pass by refusing everything uncertain. It has to
|
||||
wait. `UNKNOWN that resolves to CONFLICTING fails` still passes under this
|
||||
rival, because the allowlist refuses `UNKNOWN` as well. Rival 2 does not
|
||||
separate it either: its first poll is `UNKNOWN`, which rival 2 still waits on.
|
||||
What separates it is the original fault, which combined both rivals. An earlier
|
||||
version of this entry said rival 2 separated it. That was wrong.
|
||||
|
||||
**Rival 2: the old verdict.** The allowlist was replaced with `exit 0` after the
|
||||
`MERGEABLE` test. Five cases fail: `MERGEABLE/BEHIND`, `DIRTY`, `DRAFT`,
|
||||
`SOMETHING_NEW` and `null`. These are the states that look mergeable by the flag
|
||||
and are not. At `128a2a0` it was six, with `BLOCKED` among them. Since
|
||||
improvise-zlj2, `BLOCKED` is waited on, so this rival never reaches the verdict
|
||||
for it. The entry below covers that.
|
||||
|
||||
`a failed check fails without waiting on mergeability` runs at `--timeout 0`.
|
||||
A script that waited on `UNKNOWN` before reading the rollup would report that
|
||||
known red as exit 3.
|
||||
|
||||
### The pr-watch needs-gap and pinned-head cases — `scripts/pr-watch-test.sh`
|
||||
|
||||
improvise-zlj2 and improvise-h3ni. These were found by review of PR #186 at
|
||||
`128a2a0`, which was ruled do-not-merge.
|
||||
|
||||
**zlj2: a healthy PR exited 1.** The required `Browser harness` check run
|
||||
exists only after the `build` job completes (`needs: build`). In that gap
|
||||
GitHub reports `MERGEABLE/BLOCKED` while every registered check is green, and
|
||||
the #186 rule refused `BLOCKED`. The rollup in the new cases is PR #189's,
|
||||
captured verbatim with `gh pr view --json`. It has thirteen entries with every
|
||||
field GitHub returns. Each gap state drops or rewinds entries of that rollup
|
||||
with `jq`. Against `128a2a0` three cases failed, all with exit 1:
|
||||
|
||||
- `the needs-gap before the required check registers is not a failure`: the
|
||||
healthy sequence should exit 0.
|
||||
- `a required check that never registers is a timeout`: should exit 3.
|
||||
- `table: MERGEABLE/BLOCKED`: should exit 3, not 1.
|
||||
|
||||
**Rival for zlj2:** remove `MERGEABLE/BLOCKED` from the wait. The same three
|
||||
cases fail with exit 1. That rival is `128a2a0`'s code.
|
||||
|
||||
**h3ni: nothing pinned the head across the UNKNOWN wait.** The case is
|
||||
`a head that moves during the UNKNOWN wait is not a pass`. Its polls are
|
||||
UNKNOWN@a, then UNKNOWN@b, then CLEAN@b, and it must exit 2. It passed on the
|
||||
first run, so it is a guard for behaviour the script already has, not a
|
||||
reproduction.
|
||||
|
||||
**Rival for h3ni:** re-pin the head whenever `mergeable` is UNKNOWN. That case
|
||||
alone fails, exiting 0. All 44 others pass. That is the review's point: the
|
||||
older `a head that moves mid-wait is not a pass` starts from MERGEABLE, so it
|
||||
cannot see this rival.
|
||||
|
||||
## Every JSON parser reads numbers exactly (improvise-dz8c)
|
||||
|
||||
### `every_crate_that_can_parse_json_enables_float_roundtrip`
|
||||
|
||||
It replaces a check over three crates listed by hand. It reads
|
||||
`cargo metadata --no-deps` and fails naming every workspace package, and
|
||||
every target-specific entry, whose **normal** serde_json dependency lacks
|
||||
`float_roundtrip`. The rule and why it was chosen over the alternatives are in
|
||||
`context/code/protocol.md`. It is red at `1158c23`, naming `improvise`,
|
||||
`improvise-command`, `improvise-io` and `improvise-protocol`.
|
||||
|
||||
**Faults injected, each run:**
|
||||
|
||||
- **The feature removed from `improvise-io` alone.** The check fails naming
|
||||
`improvise-io (all targets)`. So did
|
||||
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit`, until improvise-tt22
|
||||
removed the legacy load path and that test with it.
|
||||
`a_json_import_stores_its_numbers_bit_for_bit` stays green, because
|
||||
`improvise-command` enables the feature and unification reaches io in its
|
||||
build.
|
||||
- **The feature removed from `improvise-command` alone.** The check fails naming
|
||||
`improvise-command (all targets)`. The command crate's own import test
|
||||
**stays green**: `cargo tree -p improvise-command` shows io's declaration
|
||||
switching the feature on. So the manifest check is the only guard that
|
||||
sees this case. It is also the case the retired build-graph check would
|
||||
have passed.
|
||||
- **A throwaway workspace member**, `zz-throwaway`, with normal dependencies on
|
||||
`improvise-protocol` and `serde_json` and no feature (the lockfile updated,
|
||||
as a real new crate would be). The check fails naming
|
||||
`zz-throwaway (all targets)`. With the unconditional dependency carrying the
|
||||
feature and a `cfg(target_arch = "wasm32")` one lacking it, the check fails
|
||||
naming `zz-throwaway (cfg(target_arch = "wasm32"))`. That is stricter than
|
||||
the build needs, since a wasm build unifies the two entries, and cheap to
|
||||
satisfy. The crate was then removed.
|
||||
|
||||
The `checked.len() >= 2` assertion is the positive signal. If a cargo change
|
||||
renamed the metadata fields, the check would find no serde_json dependency and
|
||||
pass vacuously without it.
|
||||
|
||||
### The re-poll a second boundary could skip — `scripts/pr-reviews-test.sh` (improvise-qnpi)
|
||||
|
||||
**What broke.** "an inline comment arriving mid-wait is seen" failed once in
|
||||
two identical runs of `checkers.sh` on a loaded machine. `pr-reviews --wait`
|
||||
computed `deadline = now + WAIT` and then tested `now < deadline` *before* its
|
||||
first re-poll. With `--wait 1`, a second boundary falling between those two
|
||||
reads of the clock made the loop run zero times: exit 1 with one poll, whatever
|
||||
the second poll would have shown.
|
||||
|
||||
**Reproduced under load.** A loop running that fixture with one busy loop per
|
||||
core failed once, as `exit 1, polls=1`, around run 52; the fixed script ran
|
||||
200 times under the same load with no failure. That is evidence of the
|
||||
mechanism, not proof: the window is a few milliseconds per run.
|
||||
|
||||
**The case that proves it.** "a second passing before the first re-poll does
|
||||
not skip it" puts a `date` on `PATH` that advances one second per read (a
|
||||
loaded machine, compressed) and a no-op `sleep`. Against the old loop it fails
|
||||
every time, exit 1 (22 passed, 1 failed). The fix checks the deadline *after*
|
||||
each poll, so any `--wait` above 0 polls at least once more. A larger timeout
|
||||
would not have helped: the case uses the shimmed clock, where only the order of
|
||||
check and poll matters.
|
||||
|
||||
### The harness lock that every build rewrote — `harness/build.sh` (improvise-mkbp)
|
||||
|
||||
**What broke.** `harness/opfs-contract/Cargo.lock` pinned `pest 2.9.1` while
|
||||
its path dependencies require `pest = "2.9.2"`, so the committed lock was
|
||||
invalid for its own manifest and every cargo invocation rewrote it.
|
||||
|
||||
**Why both halves.** The resolved lock is committed, and `build.sh` now starts
|
||||
with `cargo metadata --locked` against it. The lock alone would repeat the bug
|
||||
at the next workspace dependency bump; `--locked` alone fails today. The check
|
||||
is its own step because `wasm-pack build … -- --locked` does not work: with the
|
||||
stale lock restored, that version exited 0 and still rewrote the lock, because
|
||||
wasm-pack re-resolves before the flag reaches `cargo build`. The standalone
|
||||
check, against the same stale lock, exits 101 in seconds and leaves the file
|
||||
untouched.
|
||||
|
||||
**The assemble half.** `web/assemble.sh` starts with `rm -rf` of its output,
|
||||
which is what keeps the deployed tree free of stale files, so it is not taught
|
||||
to spare a harness directory. `opfs.spec.js` instead asserts `/opfs/index.html`
|
||||
is served, naming the fix. Injected by running `web/assemble.sh harness/serve`
|
||||
alone: 4 chromium failures, each carrying that message, where it used to fail
|
||||
with no hint of the cause.
|
||||
|
||||
### Polling past the second poll — `scripts/pr-reviews-test.sh` (improvise-7epn)
|
||||
|
||||
**What was unguarded.** Every multi-poll fixture resolved on poll 2, and the
|
||||
timeout case expects exit 1 whether the loop polls twice or twenty times. So
|
||||
replacing the post-poll deadline check with an unconditional `break` left the
|
||||
suite at 23 passed, 0 failed, and `--wait N` would have polled at most twice.
|
||||
|
||||
**Two cases, both on the one-tick-per-read clock.** "a review arriving on the
|
||||
third poll is seen" empties polls 1 and 2 and puts an inline comment at the head
|
||||
in `last`, with `--wait 10`, and asserts exit 0 *and* exactly 3 polls. "--wait
|
||||
keeps polling until its deadline" runs the empty fixture with `--wait 5` and
|
||||
asserts exit 1 after at least 5 polls. The second one is the timeout path. Its
|
||||
exit code alone cannot tell a loop that stopped early from one that reached the
|
||||
deadline, so it counts polls.
|
||||
|
||||
**Rival, injected and watched.** `break` in place of
|
||||
`[ "$(date +%s)" -lt "$deadline" ] || break`: 23 passed, 2 failed. The failures
|
||||
were exactly the two new cases, each "exit 1 after 2 polls". The first attempt
|
||||
to inject used `sed -i ''`, which the GNU sed on this PATH read as a filename.
|
||||
Nothing changed and the suite passed 25/25, which looks like a vacuous rival.
|
||||
The second attempt asserted the replacement landed before running.
|
||||
|
||||
**Still not covered.** Deleting the check outright makes the timeout case loop
|
||||
forever rather than fail. The suite has no wall-clock guard, so that fault shows
|
||||
as a hang, not a FAIL.
|
||||
|
||||
### A stale harness lock names its remedy — `harness/build.sh` (improvise-lnsh)
|
||||
|
||||
**What broke.** With a stale `harness/opfs-contract/Cargo.lock`, `build.sh`
|
||||
exited 101 and printed only cargo's text, whose `help:` line says to remove
|
||||
`--locked`. That is the one change that brings improvise-mkbp back. The real
|
||||
remedy was only in a source comment.
|
||||
|
||||
**Reproduced, then fixed, by hand.** Changing `pest`'s locked version from
|
||||
`2.9.2` to `2.9.1` in the lock reproduces the stale state. Before the fix:
|
||||
exit 101 and cargo's hint only. After: exit 1, and the script's own two lines
|
||||
follow cargo's, telling the reader not to remove `--locked` and naming
|
||||
`cargo update -w --manifest-path harness/opfs-contract/Cargo.toml`. The lock
|
||||
was untouched both times. Running that command on the stale lock restored it
|
||||
byte for byte (`cmp` against the committed file). With the committed lock,
|
||||
`build.sh` still runs to "harness/serve ready".
|
||||
|
||||
**Not automated.** There is no suite for `harness/build.sh`. A test would have
|
||||
to corrupt a tracked file and call cargo, which needs the crates.io index, from
|
||||
`checkers.sh`, whose environment is scrubbed. The by-hand run above is the
|
||||
evidence.
|
||||
|
||||
### The dead-ID audit compares positively — `scripts/bd-audit-ids-test.sh` (improvise-52eq)
|
||||
|
||||
**What broke.** The audit recipe in `AGENTS.md` ended
|
||||
`bd show $(cat ids) 2>&1 | grep -E '^(Issue … not found|…)' || echo "all resolve"`.
|
||||
It looked for the *absence* of a negative line. So a bd that was missing,
|
||||
failed, or never ran printed no such line, and the audit reported "all
|
||||
resolve". In this session one attempt at the audit printed "all resolve"
|
||||
because its output file did not exist.
|
||||
|
||||
**The fix.** `scripts/bd-audit-ids` keeps the file scan and the exclusions as
|
||||
they were. It then takes the known ids from `bd list --all --limit 0 --json`
|
||||
and `comm -23`s the cited set against them. It exits 3 if bd is missing, fails,
|
||||
prints no JSON list, or lists no issues. On the tree at `12c81dd` it reports
|
||||
460 cited ids, the same count the old pipeline produced there.
|
||||
|
||||
**The suite** runs a throwaway git repo and a fake bd that answers only `list`.
|
||||
It has eight cases: a pass, a dead id (exit 1, named), four shapes of a bd that
|
||||
does not answer (no output, `[]`, a failing exit, not on PATH), the real tree
|
||||
against a silent bd, and `--help`. The passing fixture also cites
|
||||
`improvise-core` and keeps a dead id in a git-ignored `harness/serve/` file.
|
||||
So the pass needs both the exclusions and git's file list.
|
||||
|
||||
**Rivals, each run:**
|
||||
- **The old recipe**, pasted in as the script's tail: 1 passed, 7 failed.
|
||||
Every "bd did not answer" case exited 0 with "all resolve", which is the
|
||||
bug. The dead-id case failed only because the fake bd refuses `show`. That is
|
||||
one more bd that did not answer, so it is not evidence the old recipe misses a
|
||||
dead id against a working bd, which it catches.
|
||||
- **The dead-id comparison disabled** (`: >` for `comm -23`): only "a dead id
|
||||
is exit 1, and named" failed, with exit 0.
|
||||
- **An empty exclusion list**: only the passing case failed, reporting
|
||||
`improvise-core` as dead.
|
||||
|
||||
After each rival the original was restored (`cmp` against a saved copy) and the
|
||||
suite re-run at 8 passed.
|
||||
|
||||
@@ -426,16 +426,69 @@ generator-driven proptests in `improvise-io`. What *those* cannot see is a
|
||||
checked-in document drifting out of the format, because they generate their
|
||||
inputs. This test is aimed at exactly that gap.
|
||||
|
||||
**It is deliberately a directory walk rather than a list of names**: a list is a
|
||||
**It walks the files the repository tracks, not a list of names**: a list is a
|
||||
second place to remember, and the file that gets forgotten is the one nobody
|
||||
added to it. The scope is the two directories `.gitignore` un-ignores —
|
||||
`examples/` and `web/`; everything else matching `*.improv` is somebody's
|
||||
working file.
|
||||
added to it. "Shipped" means "tracked", and `shipped_documents` reads it off
|
||||
the index: every `*.improv` and `*.improv.gz` path that `ls-files` lists. It does
|
||||
not include untracked files, so a working `.improv.gz` in someone's tree cannot
|
||||
fail their run. A document not yet added is walked once it is, which is the
|
||||
state CI tests.
|
||||
|
||||
**It matches on the full name, not on `Path::extension()`**: `.improv.gz` is the
|
||||
same document gzipped and `extension()` calls it `gz`, so matching on the
|
||||
extension alone would skip it and put back the very hole this test exists to
|
||||
close.
|
||||
**How the walk got here: two steps, each closing the previous one's holes.**
|
||||
|
||||
- **improvise-qlrh.** The test hard-coded `["examples", "web"]`. When
|
||||
improvise-plcx un-ignored `harness/fixtures/*.improv`, nothing added that
|
||||
directory. Its two fixtures were then loaded only by the Playwright job,
|
||||
where a missing seed degrades to an empty model and fails as a missing label,
|
||||
not a load error. The fix read `!<dir>/*.improv` lines from the root
|
||||
`.gitignore`, with a per-directory precondition. Red at `705c92d`:
|
||||
`harness/fixtures/tall.improv` replaced with `{"legacy": true}` passed the old
|
||||
test.
|
||||
- **improvise-c0aj.** That `.gitignore` reading was a parser for one shape of
|
||||
one file, so four other ways of shipping a document were missed silently:
|
||||
- a single-file un-ignore (`!web/demo.improv`);
|
||||
- a `.improv.gz` anywhere, since `*.improv` never ignores it and so no
|
||||
un-ignore line names it;
|
||||
- a `!` line in a nested `.gitignore`;
|
||||
- a force-added (`add -f`) document.
|
||||
|
||||
Each now has its own test, which builds the shape in a scratch repository
|
||||
and asserts the walk lists the document. **All four were red** against the
|
||||
`.gitignore` walk, each failing at its `walked` assertion with its setup
|
||||
commands succeeding. Walking the index closes all four by construction and
|
||||
deletes the parser, because git has already answered what the parser
|
||||
approximated.
|
||||
|
||||
**The one new failure mode, answered: no index.** `ls-files` needs a checkout.
|
||||
In CI (`actions/checkout`) and every worktree it has one. No nix build or
|
||||
packaging step runs this test; the flake is a dev shell only. Where there is no
|
||||
index, the walk must not answer "nothing", because an empty walk passes every
|
||||
per-document assertion vacuously. So it asserts `ls-files` succeeded, and
|
||||
`a_directory_that_is_not_a_checkout_fails_loudly` pins that. The whole-walk
|
||||
precondition "at least one document" stays as the second line of defence.
|
||||
|
||||
**Rivals, each run against the new walk:**
|
||||
|
||||
- **The `.improv.gz` pathspec removed.** Fails `a_gzipped_document_is_walked`,
|
||||
and nothing else.
|
||||
- **A failed `ls-files` answered with an empty list instead of a panic.** Fails
|
||||
`a_directory_that_is_not_a_checkout_fails_loudly`.
|
||||
- **`harness/fixtures/tall.improv` replaced with `{"legacy": true}`.** Fails
|
||||
`every_shipped_document_loads`, naming the file.
|
||||
|
||||
**It matches on the full name, not on `Path::extension()`.** `.improv.gz` is the
|
||||
same document gzipped, and `extension()` calls it `gz`. The pathspec lists both
|
||||
suffixes for the same reason.
|
||||
|
||||
### a_single_file_un_ignore_is_walked
|
||||
|
||||
improvise-c0aj. This test is the first of four shape tests. Its siblings are
|
||||
`a_gzipped_document_is_walked`,
|
||||
`a_document_un_ignored_by_a_nested_gitignore_is_walked` and
|
||||
`a_force_added_document_is_walked`. Each builds one way of shipping a document
|
||||
in a scratch repository and asserts the walk lists it. All four were red
|
||||
against the `.gitignore` walk. The account, including the rivals, is under
|
||||
`every_shipped_document_loads` above.
|
||||
|
||||
---
|
||||
|
||||
@@ -836,3 +889,60 @@ tests pass — including both of the ones *aimed* at the suppression — and onl
|
||||
this one fails. That is the shape design-principles §7 names: the assertion next
|
||||
door is satisfied by the wrong fix, and the discriminating input is the mode the
|
||||
change was not supposed to touch.
|
||||
|
||||
### the_help_footer_names_the_keys_the_keymap_binds
|
||||
|
||||
**What was wrong (improvise-17ak).** `src/ui/help.rs::render_footer`
|
||||
hand-wrote the popup's paging keys: `l/Tab: next`, `h/S-Tab: prev`,
|
||||
`q/Esc: close`. No check read them, since `every_hint_key_resolves_in_its_mode`
|
||||
cannot see a string literal in a widget (the blind spot improvise-rr8n and
|
||||
improvise-wk18 name). The literal was also already incomplete: the keymap binds
|
||||
`help-page-next` to `n` and `→` as well, and `?` closes help, and the footer
|
||||
advertised none of them.
|
||||
|
||||
**Derive rather than delete.** rr8n's rule is to keep whichever table is
|
||||
derived from the thing it describes, and the derived one here is the status
|
||||
row's `hint_line(Help)`. The popup's margins always leave that row uncovered,
|
||||
which argues for deleting the footer's keys. But `draw_status` prints
|
||||
`status_msg` *instead of* `hint_text()` whenever the message is non-empty, and
|
||||
nothing clears the message on entering Help. So after any message ("Saved to
|
||||
…", an error), a user who presses `?` would see no paging keys at all. The
|
||||
footer therefore now draws the page indicator followed by `hint_line(Help)`
|
||||
itself, truncated in display cells (gotcha 50).
|
||||
|
||||
**The test.** It renders the popup at 140×30, wide enough that the whole hint
|
||||
fits (the rr8n lesson: an 80-column render can hide the very thing asserted),
|
||||
on every page. It requires the footer row to contain `hint_line(&AppMode::Help)`
|
||||
exactly, and first asserts that hint is non-empty and pages.
|
||||
|
||||
**Red, and the rival that matters.**
|
||||
- At base it fails on page 0: `"page 1 of 5 l/Tab: next q/Esc: close"` is
|
||||
not `"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close"`.
|
||||
- A footer holding a literal copy of today's derived string passes today, so it
|
||||
was injected together with a rebinding (`n` removed from `help-page-next` in
|
||||
the protocol keymap). The test then fails: the copy still advertises `n`,
|
||||
which the keymap no longer binds. That rebinding case is exactly the one the
|
||||
ticket said the old footer would lie through.
|
||||
|
||||
### a_status_message_gives_way_to_the_hint_on_the_next_key
|
||||
|
||||
**What broke (improvise-cq3d).** Nothing in production ever cleared
|
||||
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
|
||||
first message of a session, the TUI never drew the key hint again, in any mode.
|
||||
|
||||
**The case, in three steps, each guarding one direction.**
|
||||
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
|
||||
the one the message was written in, the row must hold TileSelect's hint and
|
||||
not the stale message. This is the ticket's assertion.
|
||||
2. `Space` cycles an axis and writes a message: that message must be on the row
|
||||
after its own key. This guards against expiring too eagerly.
|
||||
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
|
||||
|
||||
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
|
||||
than `App::handle_key`, because the expiry is the front end's rule.
|
||||
|
||||
**Red, and the rival.**
|
||||
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
|
||||
at step 1: `" TILES Saved to x.improv …"`.
|
||||
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
|
||||
must say what it did", since every message is wiped before it can be drawn.
|
||||
|
||||
@@ -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,9 +1,8 @@
|
||||
use crate::model::Aggregation;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::effect::{self, Effect, ViewTarget};
|
||||
use crate::view::synthetic_record_info;
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::core::{AGGREGATE_REFUSAL, Cmd, CmdContext, INSIDE_A_DRILL_REFUSAL, NO_VALUE_REFUSAL};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -66,7 +65,9 @@ mod tests {
|
||||
("Type".into(), "Clothing".into()),
|
||||
("Month".into(), "Feb".into()),
|
||||
]);
|
||||
let cmd = PasteCell { key: key.clone() };
|
||||
let cmd = PasteCell {
|
||||
at: PasteAt::Spelled(key.clone()),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let mut app = crate::ui::app::App::new(two_cat_model(), None);
|
||||
@@ -88,7 +89,9 @@ mod tests {
|
||||
("Type".into(), "Food".into()),
|
||||
("Month".into(), "Jan".into()),
|
||||
]);
|
||||
let cmd = PasteCell { key };
|
||||
let cmd = PasteCell {
|
||||
at: PasteAt::Spelled(key),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
@@ -332,9 +335,9 @@ mod tests {
|
||||
app.cursor_mut().selected = (0, col_of(&app, "Revenue"));
|
||||
press(&mut app, 'p');
|
||||
let in_drill = app.view_state.status_msg.clone();
|
||||
assert_eq!(
|
||||
in_drill, PASTE_INSIDE_DRILL,
|
||||
"setup: the drilled paste declines"
|
||||
assert!(
|
||||
in_drill.ends_with(INSIDE_A_DRILL_REFUSAL),
|
||||
"setup: the drilled paste declines, got {in_drill:?}"
|
||||
);
|
||||
assert!(
|
||||
!advice.to_lowercase().contains("drill"),
|
||||
@@ -353,7 +356,10 @@ mod tests {
|
||||
ctx.yanked = &yanked;
|
||||
assert!(!ctx.none_cats().is_empty(), "setup: the view aggregates");
|
||||
let key = record("West", "Revenue").with("Product", "Gadget");
|
||||
let effects = PasteCell { key: key.clone() }.execute(&ctx);
|
||||
let effects = PasteCell {
|
||||
at: PasteAt::Spelled(key.clone()),
|
||||
}
|
||||
.execute(&ctx);
|
||||
let mut app = aggregated_app(crate::view::Axis::None);
|
||||
app.apply_effects(effects);
|
||||
assert_eq!(
|
||||
@@ -389,6 +395,163 @@ mod tests {
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
||||
}
|
||||
|
||||
/// improvise-q9eg: a record that lacks a later category is still a record —
|
||||
/// see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn p_onto_a_sparse_record_writes_it() {
|
||||
let mut app = records_app();
|
||||
app.model_state.workbook.add_category("Season").unwrap();
|
||||
app.rebuild_layout();
|
||||
assert!(
|
||||
app.layout.none_cats.iter().any(|c| c == "Season"),
|
||||
"setup: the new category is aggregated away, so every record lacks it"
|
||||
);
|
||||
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
|
||||
press(&mut app, 'y');
|
||||
press(&mut app, 'y');
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
|
||||
press(&mut app, 'p');
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&record("West", "Cost")),
|
||||
Some(&CellValue::Number(700.0)),
|
||||
"status: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-lx7w — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn x_in_records_mode_clears_the_real_record_under_the_cursor() {
|
||||
let mut app = records_app();
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
|
||||
press(&mut app, 'x');
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("West", "Cost")),
|
||||
None,
|
||||
"West/Cost"
|
||||
);
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("East", "Cost")),
|
||||
Some(&CellValue::Number(700.0)),
|
||||
"the neighbouring record is untouched"
|
||||
);
|
||||
let (text, back) = saved_and_reloaded(wb);
|
||||
assert!(!text.contains("West, _Measure=Cost"), "saved:\n{text}");
|
||||
assert_eq!(back.model.get_cell(&record("West", "Cost")), None);
|
||||
assert_eq!(synthetic_cells(&back), Vec::<CellKey>::new());
|
||||
}
|
||||
|
||||
/// improvise-lx7w: a coordinate column stores no value, so `x` declines there.
|
||||
#[test]
|
||||
fn x_on_a_records_coordinate_column_changes_nothing_and_says_so() {
|
||||
let mut app = records_app();
|
||||
let before = crate::persistence::format_md(&app.model_state.workbook);
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Region"));
|
||||
press(&mut app, 'x');
|
||||
assert_eq!(
|
||||
crate::persistence::format_md(&app.model_state.workbook),
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
app.view_state.status_msg.ends_with(NO_VALUE_REFUSAL),
|
||||
"declined out loud, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// Two regions by two raw measures and `Profit = Revenue - Cost`, the formula
|
||||
/// in the third column, so the cursor on it is off row 0 and column 0.
|
||||
fn formula_app() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.model.add_formula(
|
||||
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
||||
);
|
||||
wb.active_view_mut()
|
||||
.set_axis("Region", crate::view::Axis::Row);
|
||||
wb.active_view_mut()
|
||||
.set_axis("_Measure", crate::view::Axis::Column);
|
||||
for (region, measure, n) in [
|
||||
("East", "Revenue", 10.0),
|
||||
("East", "Cost", 4.0),
|
||||
("West", "Revenue", 9.0),
|
||||
("West", "Cost", 2.0),
|
||||
] {
|
||||
wb.model
|
||||
.set_cell(record(region, measure), CellValue::Number(n));
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.yanked = Some(CellValue::Number(99.0));
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-54g1 — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula() {
|
||||
let mut app = formula_app();
|
||||
app.cursor_mut().selected = (1, 2);
|
||||
assert_eq!(app.layout.col_label(2), "Profit", "setup: on the formula");
|
||||
press(&mut app, 'p');
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("_Measure=Profit"),
|
||||
"saved under the formula:\n{text}"
|
||||
);
|
||||
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
|
||||
assert!(
|
||||
app.view_state
|
||||
.status_msg
|
||||
.contains("Profit is computed by a formula"),
|
||||
"declined out loud, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
|
||||
// The control: one column left is a stored cell, and p writes it.
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'p');
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("West", "Cost")),
|
||||
Some(&CellValue::Number(99.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The second door onto a formula cell: Tab advances the editor onto it, where
|
||||
/// `i` would have declined. See context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn tab_advancing_onto_a_formula_cell_commits_nothing_under_it() {
|
||||
let mut app = formula_app();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'i');
|
||||
assert!(app.view_state.mode.is_editing(), "setup: editing West/Cost");
|
||||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.cursor().selected,
|
||||
(1, 2),
|
||||
"setup: Tab moved onto Profit"
|
||||
);
|
||||
for c in "7".chars() {
|
||||
press(&mut app, c);
|
||||
}
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("_Measure=Profit"),
|
||||
"saved under the formula:\n{text}"
|
||||
);
|
||||
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cell operations ──────────────────────────────────────────────────────────
|
||||
@@ -421,9 +584,7 @@ impl Cmd for YankCell {
|
||||
// A records cell is the layout's one resolution, as the browser yanks it (gotcha 1).
|
||||
let value = match synthetic_record_info(&self.key) {
|
||||
Some((row, col)) => ctx.layout.records_value(row, col),
|
||||
None => ctx
|
||||
.model
|
||||
.evaluate_aggregated(&self.key, Aggregation::of(ctx.none_cats())),
|
||||
None => ctx.layout.evaluate(ctx.model, &self.key),
|
||||
};
|
||||
vec![
|
||||
Box::new(effect::SetYanked(value)),
|
||||
@@ -432,20 +593,31 @@ impl Cmd for YankCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a paste lands: a key the cursor's door already resolved, or one a
|
||||
/// script spelled out, which only the key's own shape can vet (improvise-q9eg).
|
||||
#[derive(Debug)]
|
||||
pub enum PasteAt {
|
||||
Resolved(CellKey),
|
||||
Spelled(CellKey),
|
||||
}
|
||||
|
||||
/// Paste the yanked value into a cell.
|
||||
#[derive(Debug)]
|
||||
pub struct PasteCell {
|
||||
pub key: crate::model::cell::CellKey,
|
||||
pub at: PasteAt,
|
||||
}
|
||||
impl Cmd for PasteCell {
|
||||
fn name(&self) -> &'static str {
|
||||
"paste"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
match ctx.yanked.clone() {
|
||||
Some(value) => vec![paste_into(ctx, &self.key, value)],
|
||||
None => vec![],
|
||||
}
|
||||
let Some(value) = ctx.yanked.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
vec![match &self.at {
|
||||
PasteAt::Resolved(key) => Box::new(effect::SetCell(key.clone(), value)),
|
||||
PasteAt::Spelled(key) => paste_into(ctx, key, value),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +626,7 @@ impl Cmd for PasteCell {
|
||||
fn paste_into(ctx: &CmdContext, key: &CellKey, value: CellValue) -> Box<dyn Effect> {
|
||||
match synthetic_record_info(key) {
|
||||
Some((row, col)) => paste_into_record(ctx, row, col, value),
|
||||
None if aggregates(ctx, key) => effect::set_status(PASTE_ONTO_AGGREGATE),
|
||||
None if aggregates(ctx, key) => effect::refuse(AGGREGATE_REFUSAL),
|
||||
None => Box::new(effect::SetCell(key.clone(), value)),
|
||||
}
|
||||
}
|
||||
@@ -466,9 +638,9 @@ fn paste_into_record(
|
||||
value: CellValue,
|
||||
) -> Box<dyn Effect> {
|
||||
match (ctx.layout.record_cell_key(row, col), ctx.has_drill_state) {
|
||||
(Some(_), true) => effect::set_status(PASTE_INSIDE_DRILL),
|
||||
(Some(_), true) => effect::refuse(INSIDE_A_DRILL_REFUSAL),
|
||||
(Some(real), false) => Box::new(effect::SetCell(real, value)),
|
||||
(None, _) => effect::set_status(PASTE_ONTO_NO_VALUE),
|
||||
(None, _) => effect::refuse(NO_VALUE_REFUSAL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,10 +651,6 @@ fn aggregates(ctx: &CmdContext, key: &CellKey) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
const PASTE_ONTO_AGGREGATE: &str = "Cannot paste onto an aggregate — it combines several records, so there is no one cell to write";
|
||||
const PASTE_ONTO_NO_VALUE: &str = "Paste lands only on a measure column";
|
||||
const PASTE_INSIDE_DRILL: &str = "Paste is not staged inside a drill — edit the cell instead";
|
||||
|
||||
// ── View commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::formula::FormulaId;
|
||||
use crate::model::category::MEASURE_CATEGORY;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::protocol::FormulaSlot;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
use crate::view::{RecordCol, RecordRow};
|
||||
use crate::view::RecordCol;
|
||||
use improvise_protocol::{OpenedEditor, untouched};
|
||||
|
||||
use super::core::{Cmd, CmdContext, read_buffer};
|
||||
use super::grid::AddRecordRow;
|
||||
@@ -14,6 +17,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
fn commit(kind: CommitKind) -> CommitBuffer {
|
||||
@@ -180,6 +184,7 @@ mod tests {
|
||||
advance: super::AdvanceDir::Down,
|
||||
cursor: super::CursorState::from_ctx(&ctx),
|
||||
edit_mode: AppMode::records_editing(),
|
||||
opened: None,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -284,6 +289,7 @@ mod tests {
|
||||
advance: super::AdvanceDir::Down,
|
||||
cursor: super::CursorState::from_ctx(&ctx),
|
||||
edit_mode: AppMode::records_editing(),
|
||||
opened: None,
|
||||
}
|
||||
.execute(&ctx)
|
||||
};
|
||||
@@ -473,6 +479,357 @@ mod tests {
|
||||
"bulk entry lands at the start of the new row"
|
||||
);
|
||||
}
|
||||
|
||||
/// Values whose `Display` (four decimals) and value disagree, plus text.
|
||||
fn values_display_rounds() -> Vec<crate::model::cell::CellValue> {
|
||||
use crate::model::cell::CellValue::{Number, Text};
|
||||
vec![
|
||||
Number(1.23456789),
|
||||
Number(0.1 + 0.2),
|
||||
Number(98765.4321987),
|
||||
Number(123456789012345.67),
|
||||
Number(-0.00001234),
|
||||
Number(-0.0),
|
||||
Number(f64::NEG_INFINITY),
|
||||
Text("0.1 + 0.2".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Two regions, two measures, with `value` at (West, Revenue): never index 0.
|
||||
fn grid_holding(value: crate::model::cell::CellValue, records: bool) -> crate::ui::app::App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::App;
|
||||
use crate::view::Axis;
|
||||
|
||||
let mut wb = Workbook::new("Seed");
|
||||
wb.add_category("Region").unwrap();
|
||||
for region in ["North", "West"] {
|
||||
wb.add_item("Region", region).unwrap();
|
||||
}
|
||||
for measure in ["Cost", "Revenue"] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
}
|
||||
let key = |r: &str, m: &str| {
|
||||
CellKey::new(vec![
|
||||
("Region".into(), r.into()),
|
||||
("_Measure".into(), m.into()),
|
||||
])
|
||||
};
|
||||
wb.model
|
||||
.set_cell(key("North", "Cost"), CellValue::Number(1.0));
|
||||
wb.model
|
||||
.set_cell(key("North", "Revenue"), CellValue::Number(2.0));
|
||||
wb.model
|
||||
.set_cell(key("West", "Cost"), CellValue::Number(3.0));
|
||||
wb.model.set_cell(key("West", "Revenue"), value);
|
||||
{
|
||||
let v = wb.active_view_mut();
|
||||
match records {
|
||||
true => {
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
v.set_axis("Region", Axis::None);
|
||||
v.set_axis("_Measure", Axis::None);
|
||||
}
|
||||
false => {
|
||||
v.set_axis("Region", Axis::Row);
|
||||
v.set_axis("_Measure", Axis::Column);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
|
||||
app
|
||||
}
|
||||
|
||||
/// Open the editor on (West, Revenue) and commit it untouched; the stored value.
|
||||
fn no_op_commit(records: bool, value: crate::model::cell::CellValue) {
|
||||
use crate::model::cell::CellKey;
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let mut app = grid_holding(value.clone(), records);
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let at = match records {
|
||||
true => (1, 3),
|
||||
false => (1, 1),
|
||||
};
|
||||
app.cursor_mut().selected = at;
|
||||
if records {
|
||||
app.view_state.mode = AppMode::RecordsNormal;
|
||||
assert_eq!(
|
||||
app.layout.record_col(at.1),
|
||||
Some(&RecordCol::Measure("Revenue".into())),
|
||||
"the cursor is not on the Revenue column"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
Some(&value),
|
||||
"the fixture does not hold the value under test"
|
||||
);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
assert!(
|
||||
app.view_state.mode.is_editing(),
|
||||
"`i` did not open an editor"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("edit"),
|
||||
Some(&value.edit_text()),
|
||||
"the editor was not seeded from the stored value (improvise-umi4)"
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let stored = app.model_state.workbook.model.get_cell(&target).cloned();
|
||||
let same = match (&stored, &value) {
|
||||
(Some(CellValue::Number(a)), CellValue::Number(b)) => a.to_bits() == b.to_bits(),
|
||||
(stored, value) => stored.as_ref() == Some(value),
|
||||
};
|
||||
assert!(
|
||||
same,
|
||||
"{} mode: i then Enter on {value:?} stored {stored:?}",
|
||||
if records { "records" } else { "pivot" }
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_pivot_edit_stores_the_identical_value() {
|
||||
for value in values_display_rounds() {
|
||||
no_op_commit(false, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_records_edit_stores_the_identical_value() {
|
||||
for value in values_display_rounds() {
|
||||
no_op_commit(true, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored values whose edit text `typed()` reads back as something else.
|
||||
fn values_typed_misreads() -> Vec<crate::model::cell::CellValue> {
|
||||
use crate::model::cell::CellValue::{Error, Text};
|
||||
vec![
|
||||
Text("007".into()),
|
||||
Text("1e3".into()),
|
||||
Text("inf".into()),
|
||||
Text("2024".into()),
|
||||
Error("boom".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_pivot_edit_of_a_misread_value_writes_nothing() {
|
||||
for value in values_typed_misreads() {
|
||||
no_op_commit(false, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_records_edit_of_a_misread_value_writes_nothing() {
|
||||
for value in values_typed_misreads() {
|
||||
no_op_commit(true, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_edit_does_not_overwrite_a_write_made_while_it_was_open() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let mut app = grid_holding(CellValue::Text("007".into()), false);
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
target.clone(),
|
||||
CellValue::Number(5.0),
|
||||
))]);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
Some(&CellValue::Number(5.0)),
|
||||
"an editor nobody typed in overwrote the write that landed while it was open"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-2054) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_editor_typed_back_to_its_seed_still_writes() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let mut app = grid_holding(CellValue::Number(7.0), false);
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
target.clone(),
|
||||
CellValue::Number(5.0),
|
||||
))]);
|
||||
press(&mut app, KeyCode::Backspace);
|
||||
assert_eq!(
|
||||
app.view_state.opened_editor.as_ref().map(|o| o.touched),
|
||||
Some(true),
|
||||
"Backspace alone did not mark the editor touched"
|
||||
);
|
||||
press(&mut app, KeyCode::Char('7'));
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("edit").map(String::as_str),
|
||||
Some("7")
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
Some(&CellValue::Number(7.0)),
|
||||
"a value the user typed was dropped because it spelled the seed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Either user edit marks the open editor, mirroring the browser's
|
||||
/// `only_an_edit_of_the_editors_buffer_marks_it_touched`.
|
||||
#[test]
|
||||
fn appending_or_popping_marks_the_editor_touched() {
|
||||
use crate::model::cell::CellValue;
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
for key in [KeyCode::Char('x'), KeyCode::Backspace] {
|
||||
let mut app = grid_holding(CellValue::Number(7.0), false);
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
assert_eq!(
|
||||
app.view_state.opened_editor.as_ref().map(|o| o.touched),
|
||||
Some(false)
|
||||
);
|
||||
press(&mut app, key);
|
||||
assert_eq!(
|
||||
app.view_state.opened_editor.as_ref().map(|o| o.touched),
|
||||
Some(true),
|
||||
"{key:?} did not mark the editor touched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-wv3h) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_editor_opened_by_enter_mode_does_not_inherit_an_earlier_seed() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let empty = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Cost".into()),
|
||||
]);
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let mut app = grid_holding(CellValue::Number(2.0), false);
|
||||
app.model_state.workbook.model.clear_cell(&empty);
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
assert_eq!(
|
||||
app.view_state
|
||||
.opened_editor
|
||||
.as_ref()
|
||||
.map(|o| o.seed.as_str()),
|
||||
Some(""),
|
||||
"the first editor did not open on the empty cell"
|
||||
);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, KeyCode::Char(':'));
|
||||
for c in "enter-mode editing".chars() {
|
||||
press(&mut app, KeyCode::Char(c));
|
||||
}
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert!(
|
||||
app.view_state.mode.is_editing(),
|
||||
"`:enter-mode editing` did not open an editor"
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
None,
|
||||
"the empty commit was skipped against the earlier editor's seed"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-465t: the editor an advance reopens is a new open, so it starts
|
||||
/// untouched even when the commit before it was typed. See command.md.
|
||||
#[test]
|
||||
fn the_editor_an_advance_reopens_starts_untouched() {
|
||||
use improvise_protocol::key::KeyCode;
|
||||
let mut app = grid_holding(CellValue::Number(4.0), false);
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
press(&mut app, KeyCode::Char('9'));
|
||||
assert_eq!(
|
||||
app.view_state.opened_editor.as_ref().map(|o| o.touched),
|
||||
Some(true),
|
||||
"the first editor was never touched, so this proves nothing"
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(app.cursor().selected, (0, 1), "the advance did not run");
|
||||
assert_eq!(
|
||||
app.view_state.opened_editor,
|
||||
Some(OpenedEditor::on("2".into())),
|
||||
"the reopened editor inherited the previous editor's touched flag"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_commit_still_advances_and_reopens_the_editor() {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Cost".into()),
|
||||
]);
|
||||
let mut app = grid_holding(CellValue::Number(4.0), false);
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.set_cell(target.clone(), CellValue::Text("007".into()));
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
Some(&CellValue::Text("007".into())),
|
||||
"the untouched commit wrote"
|
||||
);
|
||||
assert_eq!(app.cursor().selected, (0, 1), "the advance did not run");
|
||||
assert!(
|
||||
app.view_state.mode.is_editing(),
|
||||
"the editor was not reopened"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view_state
|
||||
.opened_editor
|
||||
.as_ref()
|
||||
.map(|o| o.seed.as_str()),
|
||||
Some("2"),
|
||||
"the reopened editor did not capture the cell it landed on"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||
@@ -497,47 +854,8 @@ fn stage_drill_edit(record_idx: usize, col: RecordCol, value: &str) -> Box<dyn E
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename one coordinate of a records row. In wide format a row is a group of cells,
|
||||
/// so the rename moves **every** one of them (improvise-c5v, gotcha 1).
|
||||
fn rename_record_coordinate(
|
||||
model: &crate::model::Model,
|
||||
row: &RecordRow,
|
||||
category: &str,
|
||||
value: &str,
|
||||
effects: &mut Vec<Box<dyn Effect>>,
|
||||
) {
|
||||
if value.is_empty() {
|
||||
effects.push(effect::refuse(effect::RECORD_COORDS_CANNOT_BE_EMPTY));
|
||||
return;
|
||||
}
|
||||
effects.push(Box::new(effect::AddItem {
|
||||
category: category.to_string(),
|
||||
item: value.to_string(),
|
||||
}));
|
||||
|
||||
// Every cell in the row moves. A row seeded by `add-record-row` carries its
|
||||
// coordinates on a lone measure-less cell, which must move too.
|
||||
let cells: Vec<(CellKey, CellValue)> = if row.values.is_empty() {
|
||||
model
|
||||
.get_cell(&row.coords)
|
||||
.map(|v| vec![(row.coords.clone(), v.clone())])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
row.values
|
||||
.iter()
|
||||
.map(|(measure, v)| (row.cell_key(measure), v.clone()))
|
||||
.collect()
|
||||
};
|
||||
for (old_key, cell_value) in cells {
|
||||
effects.push(Box::new(effect::ClearCell(old_key.clone())));
|
||||
effects.push(Box::new(effect::SetCell(
|
||||
old_key.with(category, value),
|
||||
cell_value,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a records-mode edit directly to the model (no drill state).
|
||||
/// Apply a records-mode edit directly to the model (no drill state). A drill's staged
|
||||
/// edits go through the same `RecordRewrite`, all of a row's at once (gotcha 19).
|
||||
fn commit_plain_records_edit(
|
||||
ctx: &CmdContext,
|
||||
record_idx: usize,
|
||||
@@ -549,11 +867,8 @@ fn commit_plain_records_edit(
|
||||
return;
|
||||
};
|
||||
match col {
|
||||
RecordCol::Category(category) => {
|
||||
rename_record_coordinate(ctx.model, row, category, value, effects);
|
||||
}
|
||||
RecordCol::Measure(measure) => {
|
||||
commit_regular_cell_value(&row.cell_key(measure), value, effects);
|
||||
RecordCol::Category(_) | RecordCol::Measure(_) => {
|
||||
effects.extend(effect::RecordRewrite::of(ctx.model, row, [(col, value)]).effects());
|
||||
}
|
||||
RecordCol::NewMeasure => add_measure(value, effects),
|
||||
RecordCol::NewCategory => add_category(value, effects),
|
||||
@@ -645,6 +960,9 @@ pub struct CommitAndAdvance {
|
||||
pub advance: AdvanceDir,
|
||||
pub cursor: CursorState,
|
||||
pub edit_mode: AppMode,
|
||||
/// What the editor captured as it opened; `None` for a script line, which
|
||||
/// always writes (improvise-ixxx).
|
||||
pub opened: Option<OpenedEditor>,
|
||||
}
|
||||
impl Cmd for CommitAndAdvance {
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -658,14 +976,18 @@ impl Cmd for CommitAndAdvance {
|
||||
// Drop to normal **before** the commit, which can abort: tidy-up goes where
|
||||
// an abort cannot reach it (improvise-2zq, gotcha 21).
|
||||
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
|
||||
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
|
||||
// An untouched editor writes nothing; everything around the write still runs.
|
||||
let writes = !untouched(self.opened.as_ref(), &self.value);
|
||||
if writes {
|
||||
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
|
||||
}
|
||||
// The commit may reshape the grid and so reset the cursor. Restore it before
|
||||
// the advance, where an aborting advance cannot skip it (gotcha 21).
|
||||
effects.push(Box::new(effect::SetSelected(
|
||||
self.cursor.row,
|
||||
self.cursor.col,
|
||||
)));
|
||||
if inserts_a_column(ctx, &self.key, &self.value) {
|
||||
if writes && inserts_a_column(ctx, &self.key, &self.value) {
|
||||
// Staying put is what lands on the inserted column (improvise-2zq); the
|
||||
// restore above is what makes "staying put" something we do, not inherit.
|
||||
effects.push(Box::new(effect::RebuildLayout));
|
||||
@@ -722,6 +1044,12 @@ impl Cmd for CommitAndAdvance {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommitKind {
|
||||
Formula,
|
||||
/// Edit the formula the editor was opened on. Never parsed: `execute` reads
|
||||
/// it off the mode's slot, the capture made at open (improvise-km71).
|
||||
ReplaceFormula {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
},
|
||||
Category,
|
||||
/// Add an item to this category, resolved from [`AppMode::ItemAdd`] at
|
||||
/// construction so `execute` stays pure over the kind and its buffer.
|
||||
@@ -745,10 +1073,28 @@ pub fn parse_commit_kind(arg: &str, mode: &AppMode) -> Result<CommitKind, String
|
||||
}
|
||||
|
||||
impl CommitKind {
|
||||
/// A formula commit inside an editor opened on an existing formula edits
|
||||
/// that one. Read in `execute`, never at parse — `context/code/command.md`.
|
||||
fn in_mode(&self, mode: &AppMode) -> CommitKind {
|
||||
match (self, mode) {
|
||||
(
|
||||
Self::Formula,
|
||||
AppMode::FormulaEdit {
|
||||
slot: FormulaSlot::Existing { id, was },
|
||||
..
|
||||
},
|
||||
) => Self::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
},
|
||||
(kind, _) => kind.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The minibuffer this kind consumes.
|
||||
pub fn buffer(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Formula => "formula",
|
||||
Self::Formula | Self::ReplaceFormula { .. } => "formula",
|
||||
Self::Category => "category",
|
||||
Self::Item { .. } => "item",
|
||||
}
|
||||
@@ -758,7 +1104,7 @@ impl CommitKind {
|
||||
/// minibuffer.
|
||||
fn on_empty(&self) -> AppMode {
|
||||
match self {
|
||||
Self::Formula => AppMode::FormulaPanel,
|
||||
Self::Formula | Self::ReplaceFormula { .. } => AppMode::FormulaPanel,
|
||||
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
|
||||
}
|
||||
}
|
||||
@@ -767,7 +1113,7 @@ impl CommitKind {
|
||||
/// categories and items are entered in batches, formulas are not.
|
||||
fn on_commit(&self) -> Option<AppMode> {
|
||||
match self {
|
||||
Self::Formula => Some(AppMode::FormulaPanel),
|
||||
Self::Formula | Self::ReplaceFormula { .. } => Some(AppMode::FormulaPanel),
|
||||
Self::Category | Self::Item { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -775,6 +1121,7 @@ impl CommitKind {
|
||||
fn status(&self, name: &str) -> String {
|
||||
match self {
|
||||
Self::Formula => "Formula added".to_string(),
|
||||
Self::ReplaceFormula { .. } => "Formula updated".to_string(),
|
||||
Self::Category => format!("Added category \"{name}\""),
|
||||
Self::Item { .. } => format!("Added \"{name}\""),
|
||||
}
|
||||
@@ -788,6 +1135,11 @@ impl CommitKind {
|
||||
raw: name.to_string(),
|
||||
target_category: "_Measure".to_string(),
|
||||
}),
|
||||
Self::ReplaceFormula { id, was } => Box::new(effect::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
raw: name.to_string(),
|
||||
}),
|
||||
Self::Category => Box::new(effect::AddCategory(name.to_string())),
|
||||
Self::Item { category } => Box::new(effect::AddItem {
|
||||
category: category.clone(),
|
||||
@@ -808,18 +1160,19 @@ impl Cmd for CommitBuffer {
|
||||
"commit-buffer"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let raw = read_buffer(ctx, self.kind.buffer());
|
||||
let kind = self.kind.in_mode(ctx.mode);
|
||||
let raw = read_buffer(ctx, kind.buffer());
|
||||
let name = raw.trim();
|
||||
if name.is_empty() {
|
||||
return vec![effect::change_mode(self.kind.on_empty())];
|
||||
return vec![effect::change_mode(kind.on_empty())];
|
||||
}
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||
effect::set_status(self.kind.status(name)),
|
||||
effect::set_status(kind.status(name)),
|
||||
// The add goes last: it is the only party that knows whether it worked,
|
||||
// so the optimistic status must not clobber its error (improvise-6t3).
|
||||
self.kind.add_effect(name),
|
||||
kind.add_effect(name),
|
||||
];
|
||||
effects.extend(self.kind.on_commit().map(effect::change_mode));
|
||||
effects.extend(kind.on_commit().map(effect::change_mode));
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,29 @@ use std::fmt::Debug;
|
||||
|
||||
use crate::command::key::KeyCode;
|
||||
|
||||
use super::navigation::CursorState;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{Effect, Panel};
|
||||
use crate::view::{Axis, GridLayout, View};
|
||||
use crate::workbook::Workbook;
|
||||
use improvise_protocol::CellId;
|
||||
|
||||
/// What a command says when the cursor sits on a collapsed group's row (§14).
|
||||
pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first";
|
||||
const NO_CELL: &str = "no cell at cursor";
|
||||
pub const AGGREGATE_REFUSAL: &str =
|
||||
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
|
||||
pub const NO_VALUE_REFUSAL: &str = "Only a measure column holds a value";
|
||||
pub const INSIDE_A_DRILL_REFUSAL: &str =
|
||||
"A value is not staged inside a drill — edit the cell instead";
|
||||
|
||||
/// What a command says when the cell it would write is a formula's result.
|
||||
pub fn derived_refusal(target: &str) -> String {
|
||||
format!("{target} is computed by a formula — edit the formula, not the cell")
|
||||
}
|
||||
|
||||
/// Read-only context available to commands for decision-making: a `&Model`, the
|
||||
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
|
||||
@@ -37,6 +54,8 @@ pub struct CmdContext<'a> {
|
||||
pub tile_cat_idx: usize,
|
||||
/// Named text buffers
|
||||
pub buffers: &'a HashMap<String, String>,
|
||||
/// What the open cell editor captured as it opened (improvise-ixxx).
|
||||
pub opened_editor: Option<&'a improvise_protocol::OpenedEditor>,
|
||||
/// View navigation stacks (for drill back/forward)
|
||||
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
|
||||
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
|
||||
@@ -62,10 +81,75 @@ impl<'a> CmdContext<'a> {
|
||||
self.layout.is_records_mode()
|
||||
}
|
||||
|
||||
/// The cell under the cursor, for a command that only READS it. The writers'
|
||||
/// doors: `context/code/command.md`, "The cursor's cell has three doors".
|
||||
pub fn cell_key(&self) -> Option<CellKey> {
|
||||
self.layout.cell_key(self.selected.0, self.selected.1)
|
||||
}
|
||||
|
||||
/// The cursor's key for a command that resolves it itself (commit, drill),
|
||||
/// refusing a fold: its key names a group, which no record carries (§14).
|
||||
pub fn unfolded_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
||||
true => Err(FOLD_REFUSAL.to_string()),
|
||||
false => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's key for a commit, which resolves a records position itself
|
||||
/// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it.
|
||||
pub fn committable_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.cursor_cell_id() {
|
||||
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
|
||||
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
|
||||
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => {
|
||||
self.cell_key().ok_or_else(|| NO_CELL.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored cell a VALUE written at the cursor lands on, answered by the
|
||||
/// `CellId` the browser is given: `Record` is the one settable arm (ADR 002).
|
||||
pub fn value_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.cursor_cell_id() {
|
||||
Some(CellId::Record(_)) => self.stored_cell_key(),
|
||||
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
|
||||
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
|
||||
Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()),
|
||||
None => Err(match self.cell_key() {
|
||||
Some(_) => NO_VALUE_REFUSAL,
|
||||
None => NO_CELL,
|
||||
}
|
||||
.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cursor's cell is, asked exactly as the projection asks it.
|
||||
fn cursor_cell_id(&self) -> Option<CellId> {
|
||||
crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1)
|
||||
}
|
||||
|
||||
/// An aggregate's refusal, naming the fold where the aggregate is one (§14).
|
||||
fn aggregate_refusal(&self) -> String {
|
||||
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
||||
true => FOLD_REFUSAL,
|
||||
false => AGGREGATE_REFUSAL,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A `Record`'s real key: a records position resolves through its row, and a
|
||||
/// drill's records are staged, never written directly (gotcha 1, 19).
|
||||
fn stored_cell_key(&self) -> Result<CellKey, String> {
|
||||
let (row, col) = self.selected;
|
||||
let stored = match (self.is_records_mode(), self.has_drill_state) {
|
||||
(true, true) => return Err(INSIDE_A_DRILL_REFUSAL.to_string()),
|
||||
(true, false) => self.layout.record_cell_key(row, col),
|
||||
(false, _) => self.cell_key(),
|
||||
};
|
||||
stored.ok_or_else(|| NO_CELL.to_string())
|
||||
}
|
||||
|
||||
/// The formula that owns the cell under the cursor, if one does. In records
|
||||
/// mode the synthetic key names no measure, so the record's key is resolved.
|
||||
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
|
||||
@@ -139,13 +223,15 @@ pub trait Cmd: Debug + Send + Sync {
|
||||
/// Factory that constructs a Cmd from text arguments (headless/script).
|
||||
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
||||
|
||||
/// Fills the context-only fields of a command `parse` already built. It never
|
||||
/// sees the args, so it cannot discard them — `context/code/cli.md` §8.
|
||||
pub type ContextualizeFn<C> = fn(C, &CmdContext) -> C;
|
||||
|
||||
/// Builds a command from context, reached ONLY for args `parse` refused.
|
||||
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
|
||||
|
||||
/// A command whose one context-only field is the cursor it moves from. The
|
||||
/// registry fills it, and no registration can — `context/code/cli.md` §8.
|
||||
pub trait AtCursor: Cmd {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState;
|
||||
}
|
||||
|
||||
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
||||
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
||||
|
||||
@@ -186,25 +272,48 @@ impl CmdRegistry {
|
||||
name
|
||||
}
|
||||
|
||||
/// Register a command whose interactive arm is `parse` then `contextualize`, so
|
||||
/// args `parse` accepts are honoured whole — `context/code/cli.md` §8.
|
||||
/// Register a command whose interactive arm is `parse`, reaching `fallback`
|
||||
/// only where `parse` refused — `context/code/cli.md` §8.
|
||||
pub fn register<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
contextualize: ContextualizeFn<C>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |_, _| {});
|
||||
}
|
||||
|
||||
/// As [`register`](Self::register), then the interactive arm moves the
|
||||
/// command's cursor to the live one. The cursor is the only field it writes.
|
||||
pub fn register_at_cursor<C: AtCursor + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
|
||||
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn push<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
at_cursor: fn(&mut C, &CmdContext),
|
||||
) {
|
||||
self.entries.push(CmdEntry {
|
||||
name: prototype.name(),
|
||||
name,
|
||||
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
|
||||
interactive: Box::new(move |args, ctx| {
|
||||
let built = match (parse(args), fallback) {
|
||||
(Ok(cmd), _) => Ok(contextualize(cmd, ctx)),
|
||||
(Err(_), Some(fallback)) => fallback(args, ctx),
|
||||
(Err(err), None) => Err(err),
|
||||
let mut built = match (parse(args), fallback) {
|
||||
(Ok(cmd), _) => cmd,
|
||||
(Err(_), Some(fallback)) => fallback(args, ctx)?,
|
||||
(Err(err), None) => return Err(err),
|
||||
};
|
||||
built.map(|c| Box::new(c) as Box<dyn Cmd>)
|
||||
at_cursor(&mut built, ctx);
|
||||
Ok(Box::new(built) as Box<dyn Cmd>)
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! A command is a function of its context: two contexts built independently from
|
||||
//! equal data give equal effects — `context/code/command.md`, improvise-hzjo.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::grid::DrillIntoCell;
|
||||
use super::registry::tests::candidate_args;
|
||||
use crate::command::key::{KeyCode, KeyModifiers};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
|
||||
/// Commands whose effects depend on something outside the context, with why.
|
||||
const OUTSIDE_THE_CONTEXT: &[(&str, &str)] = &[];
|
||||
|
||||
const DOCUMENT: &str = include_str!("../../../../../web/demo.improv");
|
||||
|
||||
/// One `App` over the shipped demo. Every context map holds several entries that
|
||||
/// stay distinct under an edit like `pop()` — see the evidence.
|
||||
fn fixture() -> App {
|
||||
let workbook = crate::persistence::parse_md(DOCUMENT).expect("the demo parses");
|
||||
let mut app = App::new(workbook, None);
|
||||
let vs = &mut app.view_state;
|
||||
for i in 0..16 {
|
||||
vs.buffers
|
||||
.insert(format!("b{i}"), format!("value {i} of 16"));
|
||||
}
|
||||
for (name, text) in [
|
||||
("edit", "42"),
|
||||
("formula", "Margin = Profit / Revenue"),
|
||||
("category", "Region"),
|
||||
("command", "add-item Product Mocha"),
|
||||
("export", "/tmp/improvise-hzjo.csv"),
|
||||
] {
|
||||
vs.buffers.insert(name.into(), text.into());
|
||||
}
|
||||
for cat in ["Product", "Quarter", "_Measure", "_Index", "_Dim"] {
|
||||
vs.expanded_cats.insert(cat.into());
|
||||
}
|
||||
vs.yanked = Some(CellValue::Number(7.0));
|
||||
vs.formula_panel_open = true;
|
||||
vs.category_panel_open = true;
|
||||
vs.view_panel_open = true;
|
||||
vs.cat_panel_cursor = 2;
|
||||
vs.view_panel_cursor = 1;
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
app
|
||||
}
|
||||
|
||||
/// Every item as a one-coordinate drill: each spans the other categories whole,
|
||||
/// so the store has several records to order — see the evidence.
|
||||
fn one_coordinate_drills(model: &Model) -> Vec<Vec<String>> {
|
||||
let mut drills = Vec::new();
|
||||
for cat in model.regular_category_names() {
|
||||
for item in model.category(cat).expect("listed").ordered_item_names() {
|
||||
drills.push(vec![format!("{cat}/{item}")]);
|
||||
}
|
||||
}
|
||||
drills
|
||||
}
|
||||
|
||||
/// Records a compared drill froze, counted only if it narrowed the store and its
|
||||
/// ordered records are in `compared`, the text the law compared (improvise-btyw).
|
||||
fn records_narrowed(scratch: &mut App, effects: Vec<Box<dyn Effect>>, compared: &str) -> usize {
|
||||
let store = scratch
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.data
|
||||
.live_records()
|
||||
.count();
|
||||
scratch.apply_effects(effects);
|
||||
let frozen = scratch
|
||||
.drill_state
|
||||
.as_ref()
|
||||
.filter(|drill| compared.contains(&format!("{:?}", drill.records)))
|
||||
.map_or(0, |drill| drill.records.len());
|
||||
if frozen < store { frozen } else { 0 }
|
||||
}
|
||||
|
||||
/// The registry's candidates, plus names the demo really has: a drill or a fit
|
||||
/// over coordinates that match nothing never reaches the records it would order.
|
||||
fn args_the_document_names(model: &Model) -> Vec<Vec<String>> {
|
||||
let mut all = candidate_args();
|
||||
all.extend(one_coordinate_drills(model));
|
||||
all.extend(
|
||||
[
|
||||
vec!["Revenue"],
|
||||
vec!["Product/Latte", "Quarter/Q2"],
|
||||
vec!["Product/Latte", "Quarter/Q2", "_Measure/Cost"],
|
||||
vec![
|
||||
"editing",
|
||||
"5",
|
||||
"Product/Latte",
|
||||
"Quarter/Q2",
|
||||
"_Measure/Cost",
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.map(|v| v.into_iter().map(String::from).collect()),
|
||||
);
|
||||
all
|
||||
}
|
||||
|
||||
fn effects_of(name: &str, args: &[String], ctx: &CmdContext) -> Option<Vec<Box<dyn Effect>>> {
|
||||
let cmd = ctx.registry.interactive(name, args, ctx).ok()?;
|
||||
Some(cmd.execute(ctx))
|
||||
}
|
||||
|
||||
/// What running every registered command under `a` and under each of `others` found.
|
||||
#[derive(Default)]
|
||||
struct Comparison {
|
||||
diverged: BTreeSet<&'static str>,
|
||||
shown: Vec<String>,
|
||||
never_built: Vec<&'static str>,
|
||||
compared: usize,
|
||||
widest_drill: usize,
|
||||
}
|
||||
|
||||
fn compare(a: &CmdContext, others: &[CmdContext]) -> Comparison {
|
||||
let mut found = Comparison::default();
|
||||
let candidates = args_the_document_names(a.model);
|
||||
let drill = DrillIntoCell {
|
||||
key: CellKey::new(vec![]),
|
||||
};
|
||||
let mut scratch = fixture();
|
||||
for name in a.registry.names().collect::<Vec<_>>() {
|
||||
let before = found.compared;
|
||||
for args in &candidates {
|
||||
let Some(effects) = effects_of(name, args, a) else {
|
||||
continue;
|
||||
};
|
||||
let left = format!("{effects:?}");
|
||||
if name == drill.name() {
|
||||
found.widest_drill =
|
||||
found
|
||||
.widest_drill
|
||||
.max(records_narrowed(&mut scratch, effects, &left));
|
||||
}
|
||||
found.compared += 1;
|
||||
for b in others {
|
||||
let right = effects_of(name, args, b)
|
||||
.map(|e| format!("{e:?}"))
|
||||
.unwrap_or_default();
|
||||
if left != right {
|
||||
found.diverged.insert(name);
|
||||
found
|
||||
.shown
|
||||
.push(format!("{name} {args:?}:\n {left}\n {right}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.compared == before {
|
||||
found.never_built.push(name);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn every_command_gives_equal_effects_in_independently_built_equal_contexts() {
|
||||
let apps: Vec<App> = (0..4).map(|_| fixture()).collect();
|
||||
let contexts: Vec<CmdContext> = apps
|
||||
.iter()
|
||||
.map(|app| app.cmd_context(KeyCode::Null, KeyModifiers::NONE))
|
||||
.collect();
|
||||
let found = compare(&contexts[0], &contexts[1..]);
|
||||
assert!(
|
||||
found.widest_drill >= 4,
|
||||
"the widest narrowing drill compared froze {} records, so the store's order is nearly a coin toss",
|
||||
found.widest_drill
|
||||
);
|
||||
assert!(
|
||||
found.never_built.is_empty(),
|
||||
"no candidate args build these in the fixture, so the law never ran them: {:?}",
|
||||
found.never_built
|
||||
);
|
||||
let listed: BTreeSet<&str> = OUTSIDE_THE_CONTEXT.iter().map(|(n, _)| *n).collect();
|
||||
let unlisted: Vec<&str> = found
|
||||
.shown
|
||||
.iter()
|
||||
.filter(|s| !listed.iter().any(|n| s.starts_with(&format!("{n} "))))
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
assert!(
|
||||
unlisted.is_empty(),
|
||||
"effects depend on more than the context ({} pairs compared):\n{}",
|
||||
found.compared,
|
||||
unlisted.join("\n")
|
||||
);
|
||||
let stale: Vec<&&str> = listed
|
||||
.iter()
|
||||
.filter(|n| !found.diverged.contains(**n))
|
||||
.collect();
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"listed as depending on something outside the context, but did not: {stale:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iterates a context `HashMap` and emits in that order: the one nondeterminism
|
||||
/// this repo has shipped (gotcha 18). The law must see it; a reused context cannot.
|
||||
#[derive(Debug)]
|
||||
struct EmitsInHashOrder;
|
||||
impl Cmd for EmitsInHashOrder {
|
||||
fn name(&self) -> &'static str {
|
||||
"emits-in-hash-order"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
ctx.buffers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
Box::new(effect::SetBuffer {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
}) as Box<dyn Effect>
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn only_distinct_instances_can_see_a_command_that_follows_hash_order() {
|
||||
let (first, second) = (fixture(), fixture());
|
||||
let a = first.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
let b = second.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
let run = |ctx: &CmdContext| format!("{:?}", EmitsInHashOrder.execute(ctx));
|
||||
assert_eq!(
|
||||
run(&a),
|
||||
run(&a),
|
||||
"one reused context iterates its maps the same way twice"
|
||||
);
|
||||
assert_ne!(
|
||||
run(&a),
|
||||
run(&b),
|
||||
"two independently built contexts must iterate differently, or the law is vacuous"
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,96 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
fn press(app: &mut crate::ui::app::App, c: char) {
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// improvise-gw18 — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate() {
|
||||
let mut app = app_on_a_fold();
|
||||
let origin = app.view_state.active_view.clone();
|
||||
press(&mut app, '>');
|
||||
assert_eq!(app.view_state.active_view, origin, "no drill was opened");
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("ollapsed group"),
|
||||
"the refusal is out loud, got: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
press(&mut app, 'o');
|
||||
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
let data = text.split("## Data").nth(1).expect("a data section");
|
||||
assert!(
|
||||
!data.contains("Q2"),
|
||||
"a group coordinate was saved:\n{text}"
|
||||
);
|
||||
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
|
||||
}
|
||||
|
||||
/// The commit door onto a fold: Enter advances the editor onto it, and the next
|
||||
/// commit is what must refuse. See context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn enter_advancing_onto_a_fold_commits_nothing_there() {
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let mut app = app_on_a_fold();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'i');
|
||||
app.handle_key(enter).unwrap();
|
||||
assert_eq!(
|
||||
app.cursor().selected,
|
||||
(2, 1),
|
||||
"setup: Enter moved onto the fold"
|
||||
);
|
||||
press(&mut app, '7');
|
||||
app.handle_key(enter).unwrap();
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
let data = text.split("## Data").nth(1).expect("a data section");
|
||||
assert!(
|
||||
!data.contains("Q2"),
|
||||
"a group coordinate was saved:\n{text}"
|
||||
);
|
||||
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
|
||||
}
|
||||
|
||||
/// The control for the refusal above: the same key one row up drills.
|
||||
#[test]
|
||||
fn drilling_into_the_row_above_a_fold_still_opens_a_drill() {
|
||||
let mut app = app_on_a_fold();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
let origin = app.view_state.active_view.clone();
|
||||
press(&mut app, '>');
|
||||
assert_ne!(app.view_state.active_view, origin, "Feb × Cost drills");
|
||||
}
|
||||
|
||||
/// improvise-yk2s — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn hiding_a_folds_row_is_refused_and_persists_no_hidden_group() {
|
||||
let mut app = app_on_a_fold();
|
||||
press(&mut app, 'H');
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(!view.is_hidden("Month", "Q2"), "no ghost hidden entry");
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("ollapsed group"),
|
||||
"the refusal is out loud, got: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
let (text, _) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("hidden:"),
|
||||
"a hidden entry was saved:\n{text}"
|
||||
);
|
||||
|
||||
// The control: the row above is an item, and H hides it.
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'H');
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(view.is_hidden("Month", "Feb"), "H on Feb hides Feb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
||||
let m = two_cat_model();
|
||||
@@ -505,6 +595,10 @@ impl Cmd for HideSelectedRowItem {
|
||||
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
|
||||
return vec![];
|
||||
};
|
||||
// A fold's row names a group, not an item, so there is nothing to hide (§14).
|
||||
if ctx.layout.row_folds_a_group(ctx.selected.0) {
|
||||
return vec![effect::refuse(super::core::FOLD_REFUSAL)];
|
||||
}
|
||||
let sel_row = ctx.selected.0;
|
||||
let Some(items) = ctx
|
||||
.layout
|
||||
|
||||
@@ -13,6 +13,9 @@ pub mod text_buffer;
|
||||
pub mod tile;
|
||||
pub mod wizard;
|
||||
|
||||
#[cfg(test)]
|
||||
mod determinism;
|
||||
|
||||
// Re-export items used by external code
|
||||
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
||||
pub use registry::default_registry;
|
||||
@@ -71,14 +74,15 @@ pub(super) mod test_helpers {
|
||||
view_panel_cursor: 0,
|
||||
tile_cat_idx: 0,
|
||||
buffers: &EMPTY_BUFFERS,
|
||||
opened_editor: None,
|
||||
view_back_stack: &[],
|
||||
view_forward_stack: &[],
|
||||
has_drill_state: false,
|
||||
display_value: {
|
||||
let key = layout.cell_key(sr, sc);
|
||||
key.as_ref()
|
||||
.and_then(|k| workbook.model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.and_then(|k| workbook.model.get_cell(k))
|
||||
.map(crate::model::cell::CellValue::edit_text)
|
||||
.unwrap_or_default()
|
||||
},
|
||||
visible_rows: 20,
|
||||
@@ -117,6 +121,22 @@ pub(super) mod test_helpers {
|
||||
wb
|
||||
}
|
||||
|
||||
/// `two_cat_model` with one `_Measure` formula per target, in order.
|
||||
pub fn with_formulas(targets: &[&str]) -> Workbook {
|
||||
let mut wb = two_cat_model();
|
||||
for target in targets {
|
||||
wb.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: format!("{target} = 0"),
|
||||
target: target.to_string(),
|
||||
target_category: "_Measure".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
|
||||
format!("{:?}", effects)
|
||||
}
|
||||
@@ -124,4 +144,62 @@ pub(super) mod test_helpers {
|
||||
pub fn make_registry() -> CmdRegistry {
|
||||
default_registry()
|
||||
}
|
||||
|
||||
/// Two quarters of months on Row, two measures on Column, every cell funded,
|
||||
/// Q2 collapsed: rows `[Jan, Feb, Q2]`, and the cursor on the fold at `(2, 1)`.
|
||||
pub fn app_on_a_fold() -> crate::ui::app::App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(10.0 + i as f64));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = crate::ui::app::App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (2, 1);
|
||||
assert!(
|
||||
app.layout.folds_a_group(2, 1),
|
||||
"setup: the cursor is on the fold"
|
||||
);
|
||||
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is Q2's row");
|
||||
app
|
||||
}
|
||||
|
||||
/// Every stored coordinate naming no item of its category — what a write to a
|
||||
/// fold leaves behind (core.md §14).
|
||||
pub fn non_item_coordinates(wb: &Workbook) -> Vec<(String, String)> {
|
||||
let model = &wb.model;
|
||||
model
|
||||
.data
|
||||
.iter_cells()
|
||||
.flat_map(|(key, _)| key.0.clone())
|
||||
.filter(|(cat, item)| {
|
||||
!model
|
||||
.category(cat)
|
||||
.is_some_and(|c| c.items.contains_key(item.as_str()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document saved and read back: what reaches the file, not the store.
|
||||
pub fn saved_and_reloaded(wb: &Workbook) -> (String, Workbook) {
|
||||
let text = crate::persistence::format_md(wb);
|
||||
let back = crate::persistence::parse_md(&text).expect("the saved text reloads");
|
||||
(text, back)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ mod tests {
|
||||
edit_mode: AppMode::editing(),
|
||||
}
|
||||
.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
|
||||
}
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
edit_mode: AppMode::records_editing(),
|
||||
}
|
||||
.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2);
|
||||
assert_eq!(effects.len(), 3, "buffer, opened editor, mode");
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("RecordsEditing"),
|
||||
@@ -281,6 +281,10 @@ impl Cmd for EnterMode {
|
||||
if let Some(mb) = self.0.minibuffer() {
|
||||
effects.push(write_buffer(&mb.buffer_key, String::new()));
|
||||
}
|
||||
// An editor opened this way captured no seed, so no earlier one may stand.
|
||||
if self.0.is_editing() {
|
||||
effects.push(Box::new(effect::SetOpenedEditor(None)));
|
||||
}
|
||||
effects.push(effect::change_mode(self.0.clone()));
|
||||
effects
|
||||
}
|
||||
@@ -355,7 +359,7 @@ impl Cmd for EditOrDrill {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
// Neither edit nor drill: no record carries a group's name (core.md §14).
|
||||
if ctx.layout.folds_a_group(ctx.selected.0, ctx.selected.1) {
|
||||
return vec![effect::set_status(
|
||||
return vec![effect::refuse(
|
||||
"Collapsed group — press z to expand it, then edit an item",
|
||||
)];
|
||||
}
|
||||
@@ -380,9 +384,8 @@ impl Cmd for EditOrDrill {
|
||||
// The same question `cell_id` asks, off the same predicate. In records mode
|
||||
// the synthetic key names no measure, so the record's key is resolved.
|
||||
if let Some(formula) = ctx.owning_formula() {
|
||||
return vec![effect::refuse(format!(
|
||||
"{} is computed by a formula — edit the formula, not the cell",
|
||||
formula.target
|
||||
return vec![effect::refuse(super::core::derived_refusal(
|
||||
&formula.target,
|
||||
))];
|
||||
}
|
||||
vec![
|
||||
@@ -390,6 +393,9 @@ impl Cmd for EditOrDrill {
|
||||
name: "edit".to_string(),
|
||||
value: ctx.display_value.clone(),
|
||||
}),
|
||||
Box::new(effect::SetOpenedEditor(Some(
|
||||
improvise_protocol::OpenedEditor::on(ctx.display_value.clone()),
|
||||
))),
|
||||
effect::change_mode(self.edit_mode.clone()),
|
||||
]
|
||||
}
|
||||
@@ -442,8 +448,11 @@ impl Cmd for EnterTileSelect {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let count = ctx.model.category_names().len();
|
||||
if count > 0 {
|
||||
let tiles = crate::ui::project::tiles_of(ctx.model, ctx.view);
|
||||
vec![
|
||||
Box::new(effect::SetTileCatIdx(0)),
|
||||
Box::new(effect::SetTileCatIdx(improvise_protocol::opening_tile(
|
||||
&tiles,
|
||||
))),
|
||||
effect::change_mode(AppMode::TileSelect),
|
||||
]
|
||||
} else {
|
||||
|
||||
@@ -118,7 +118,7 @@ impl Cmd for Move {
|
||||
}
|
||||
}
|
||||
MoveKind::Page(dir) => {
|
||||
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir;
|
||||
let delta = improvise_protocol::resolve::page_rows(self.cursor.visible_rows) * dir;
|
||||
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
|
||||
(nr, self.cursor.col)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect, Panel, ViewTarget};
|
||||
use crate::view::Axis;
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::core::{Cmd, CmdContext, write_buffer};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -192,11 +192,7 @@ mod tests {
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
@@ -208,11 +204,7 @@ mod tests {
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = format!("{:?}", effects[0]);
|
||||
@@ -231,11 +223,7 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.category_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -256,11 +244,7 @@ mod tests {
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::FormulaPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -281,11 +265,7 @@ mod tests {
|
||||
ctx.category_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: true,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -305,11 +285,7 @@ mod tests {
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::ViewPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: false,
|
||||
view_open: true,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -329,11 +305,7 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
@@ -344,15 +316,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_panel_cursor_down_from_zero() {
|
||||
let m = two_cat_model();
|
||||
let m = with_formulas(&["A", "B"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: 1,
|
||||
current: 0,
|
||||
max: 5,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
@@ -363,17 +333,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn move_panel_cursor_moves_from_the_named_panels_own_cursor() {
|
||||
let m = with_formulas(&["A", "B", "C", "D", "E"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_cursor = 1;
|
||||
let args = ["formula".to_string(), "2".to_string()];
|
||||
let effects = reg
|
||||
.interactive("move-panel-cursor", &args, &ctx)
|
||||
.expect("move-panel-cursor resolves")
|
||||
.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("SetPanelCursor { panel: Formula, cursor: 3 }"),
|
||||
"must move from the formula cursor (1) by the parsed delta (2), got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_panel_cursor_clamps_at_zero() {
|
||||
let m = two_cat_model();
|
||||
let m = with_formulas(&["A", "B"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: -1,
|
||||
current: 0,
|
||||
max: 5,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
@@ -388,46 +376,55 @@ mod tests {
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: 1,
|
||||
current: 0,
|
||||
max: 0,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn delete_formula_at_cursor_with_formulas() {
|
||||
let mut m = two_cat_model();
|
||||
m.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: "Profit = Revenue - Cost".to_string(),
|
||||
target: "Profit".to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
for (target, raw) in [("Profit", "Profit = 1"), ("Tax", "Tax = 2")] {
|
||||
m.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: raw.to_string(),
|
||||
target: target.to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
}
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_cursor = 1;
|
||||
let effects = DeleteFormulaAtCursor.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("RemoveFormula { target: \"Profit\", target_category: \"Type\" }"),
|
||||
"must remove the formula AT THE CURSOR, got: {dbg}"
|
||||
dbg.contains("RemoveFormula { target: \"Tax\", target_category: \"Type\" }"),
|
||||
"must remove the formula AT THE CURSOR (Tax, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
!dbg.contains("\"Profit\""),
|
||||
"removed the first formula, not the one under the cursor: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn switch_view_at_cursor_with_valid_cursor() {
|
||||
let m = two_cat_model();
|
||||
let mut m = two_cat_model();
|
||||
m.create_view("Other");
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.view_panel_cursor = 1;
|
||||
let effects = SwitchViewAtCursor.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("SwitchView(\"Default\")"),
|
||||
"must switch to the view UNDER THE CURSOR, got: {dbg}"
|
||||
dbg.contains("SwitchView(\"Other\")"),
|
||||
"must switch to the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||
}
|
||||
@@ -504,6 +501,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor() {
|
||||
let mut m = two_cat_model();
|
||||
m.create_view("Other");
|
||||
m.create_view("Third");
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.view_panel_cursor = 1;
|
||||
let dbg = effects_debug(&DeleteViewAtCursor.execute(&ctx));
|
||||
assert!(
|
||||
dbg.contains("DeleteView(\"Other\")"),
|
||||
"must delete the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("SetPanelCursor { panel: View, cursor: 0 }"),
|
||||
"a delete off position 0 steps the cursor back, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_view_at_cursor_zero_does_not_adjust_cursor() {
|
||||
let m = two_cat_model();
|
||||
@@ -559,16 +577,15 @@ impl Cmd for TogglePanelAndFocus {
|
||||
#[derive(Debug)]
|
||||
pub struct TogglePanelVisibility {
|
||||
pub panel: Panel,
|
||||
pub currently_open: bool,
|
||||
}
|
||||
impl Cmd for TogglePanelVisibility {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-panel-visibility"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SetPanelOpen {
|
||||
panel: self.panel,
|
||||
open: !self.currently_open,
|
||||
open: !ctx.panel_open(self.panel),
|
||||
})]
|
||||
}
|
||||
}
|
||||
@@ -576,20 +593,16 @@ impl Cmd for TogglePanelVisibility {
|
||||
/// Tab through open panels Formula → Category → View, wrapping, starting from the
|
||||
/// focused one. One open panel self-transitions; none open is a no-op.
|
||||
#[derive(Debug)]
|
||||
pub struct CyclePanelFocus {
|
||||
pub formula_open: bool,
|
||||
pub category_open: bool,
|
||||
pub view_open: bool,
|
||||
}
|
||||
pub struct CyclePanelFocus;
|
||||
impl Cmd for CyclePanelFocus {
|
||||
fn name(&self) -> &'static str {
|
||||
"cycle-panel-focus"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let order = [
|
||||
(self.formula_open, AppMode::FormulaPanel),
|
||||
(self.category_open, AppMode::CategoryPanel),
|
||||
(self.view_open, AppMode::ViewPanel),
|
||||
(ctx.formula_panel_open, AppMode::FormulaPanel),
|
||||
(ctx.category_panel_open, AppMode::CategoryPanel),
|
||||
(ctx.view_panel_open, AppMode::ViewPanel),
|
||||
];
|
||||
// Index of the first candidate: the panel after the focused one,
|
||||
// or the first panel when invoked from a non-panel mode.
|
||||
@@ -614,16 +627,17 @@ impl Cmd for CyclePanelFocus {
|
||||
pub struct MovePanelCursor {
|
||||
pub panel: Panel,
|
||||
pub delta: i32,
|
||||
pub current: usize,
|
||||
pub max: usize,
|
||||
}
|
||||
impl Cmd for MovePanelCursor {
|
||||
fn name(&self) -> &'static str {
|
||||
"move-panel-cursor"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let cursor = self.current;
|
||||
let max = self.max;
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let (cursor, max) = match self.panel {
|
||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||
};
|
||||
if max == 0 {
|
||||
return vec![];
|
||||
}
|
||||
@@ -671,6 +685,25 @@ impl Cmd for DeleteFormulaAtCursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the formula editor on the formula under the cursor, seeded with its
|
||||
/// text and naming it from here on — the commit never re-reads the cursor.
|
||||
#[derive(Debug)]
|
||||
pub struct EditFormulaAtCursor;
|
||||
impl Cmd for EditFormulaAtCursor {
|
||||
fn name(&self) -> &'static str {
|
||||
"edit-formula-at-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
match ctx.model.formulas().get(ctx.formula_cursor) {
|
||||
Some(f) => vec![
|
||||
effect::change_mode(AppMode::formula_replace(f.id(), f.raw.clone())),
|
||||
write_buffer("formula", f.raw.clone()),
|
||||
],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category panel commands ─────────────────────────────────────────────────
|
||||
|
||||
/// Cycle the axis assignment of the category at the cursor.
|
||||
|
||||
@@ -22,24 +22,37 @@ use super::text_buffer::*;
|
||||
use super::tile::*;
|
||||
use super::wizard::*;
|
||||
|
||||
/// The cell under the cursor: what a coordinate command falls back to when given none.
|
||||
/// The cell under the cursor for `drill-into-cell`, which opens aggregates but
|
||||
/// not a fold. Which command takes which door: `context/code/command.md`.
|
||||
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.cell_key().clone().ok_or("no cell at cursor".into())
|
||||
ctx.unfolded_cell_key()
|
||||
}
|
||||
|
||||
/// A `Move` built from args takes the live cursor as its origin.
|
||||
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
|
||||
Move {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
..cmd
|
||||
/// The stored cell a value lands on, for `clear-cell` and `paste`.
|
||||
fn cursor_value_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.value_cell_key()
|
||||
}
|
||||
|
||||
/// [`cursor_key`] for the one command that only READS the cell: `yank`.
|
||||
fn cursor_key_to_read(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.cell_key().ok_or("no cell at cursor".into())
|
||||
}
|
||||
|
||||
impl AtCursor for Move {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
/// A commit built from args still advances from the live cursor.
|
||||
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
|
||||
CommitAndAdvance {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
..cmd
|
||||
impl AtCursor for EnterAdvance {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
impl AtCursor for CommitAndAdvance {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,17 +68,13 @@ fn commit_at_cursor(
|
||||
Ok(CommitAndAdvance {
|
||||
edit_mode: parse_mode_name(mode)?,
|
||||
value: read_buffer(ctx, "edit"),
|
||||
key: cursor_key(ctx)?,
|
||||
key: ctx.committable_cell_key()?,
|
||||
advance,
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
opened: ctx.opened_editor.cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// For a command with no context-only fields: `parse`'s result is used as built.
|
||||
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Build the default command registry with all commands.
|
||||
/// Registry names MUST match the `Cmd::name()` return value.
|
||||
pub fn default_registry() -> CmdRegistry {
|
||||
@@ -90,10 +99,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(ClearCellCommand {
|
||||
key: cursor_key(ctx)?,
|
||||
key: cursor_value_key(ctx)?,
|
||||
})
|
||||
}),
|
||||
);
|
||||
@@ -121,7 +129,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
|
||||
|
||||
// ── Navigation (unified Move) ──────────────────────────────────────
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Relative(0, 0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -137,13 +145,12 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "move-selection",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
// Jump-to-edge commands: first/last row/col
|
||||
macro_rules! reg_jump {
|
||||
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
|
||||
$r.register(
|
||||
$r.register_at_cursor(
|
||||
&Move {
|
||||
kind: if $to_end {
|
||||
MoveKind::ToEnd($is_row)
|
||||
@@ -164,7 +171,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: $name,
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
};
|
||||
@@ -173,7 +179,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
reg_jump!(r, true, true, "jump-last-row");
|
||||
reg_jump!(r, false, false, "jump-first-col");
|
||||
reg_jump!(r, false, true, "jump-last-col");
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Relative(0, 0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -188,10 +194,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "scroll-rows",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Page(0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -206,10 +211,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "page-scroll",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&EnterAdvance {
|
||||
cursor: CursorState::default(),
|
||||
},
|
||||
@@ -227,9 +231,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
},
|
||||
})
|
||||
},
|
||||
|_, ctx| EnterAdvance {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -246,29 +247,27 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(YankCell {
|
||||
key: cursor_key(ctx)?,
|
||||
key: cursor_key_to_read(ctx)?,
|
||||
})
|
||||
}),
|
||||
);
|
||||
r.register(
|
||||
&PasteCell {
|
||||
key: CellKey::new(vec![]),
|
||||
at: PasteAt::Spelled(CellKey::new(vec![])),
|
||||
},
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("paste requires at least one Cat/Item coordinate".into());
|
||||
}
|
||||
Ok(PasteCell {
|
||||
key: parse_cell_key_from_args(args),
|
||||
at: PasteAt::Spelled(parse_cell_key_from_args(args)),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(PasteCell {
|
||||
key: cursor_key(ctx)?,
|
||||
at: PasteAt::Resolved(cursor_value_key(ctx)?),
|
||||
})
|
||||
}),
|
||||
);
|
||||
@@ -318,7 +317,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(DrillIntoCell {
|
||||
key: cursor_key(ctx)?,
|
||||
@@ -356,81 +354,35 @@ pub fn default_registry() -> CmdRegistry {
|
||||
});
|
||||
Ok(TogglePanelAndFocus { panel, target })
|
||||
},
|
||||
as_parsed,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
&TogglePanelVisibility {
|
||||
panel: Panel::Formula,
|
||||
currently_open: false,
|
||||
},
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
Ok(TogglePanelVisibility {
|
||||
panel,
|
||||
currently_open: false,
|
||||
})
|
||||
},
|
||||
|cmd, ctx| TogglePanelVisibility {
|
||||
currently_open: ctx.panel_open(cmd.panel),
|
||||
..cmd
|
||||
},
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
&CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
},
|
||||
|_| {
|
||||
Ok(CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
})
|
||||
},
|
||||
|_, ctx| CyclePanelFocus {
|
||||
formula_open: ctx.formula_panel_open,
|
||||
category_open: ctx.category_panel_open,
|
||||
view_open: ctx.view_panel_open,
|
||||
Ok(TogglePanelVisibility { panel })
|
||||
},
|
||||
None,
|
||||
);
|
||||
r.register_nullary(|| Box::new(CyclePanelFocus));
|
||||
r.register(
|
||||
&MovePanelCursor {
|
||||
panel: Panel::Formula,
|
||||
delta: 0,
|
||||
current: 0,
|
||||
max: 0,
|
||||
},
|
||||
|args| {
|
||||
require_args(args, 2)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
Ok(MovePanelCursor {
|
||||
panel,
|
||||
delta,
|
||||
current: 0,
|
||||
max: 0,
|
||||
})
|
||||
},
|
||||
|cmd, ctx| {
|
||||
let (current, max) = match cmd.panel {
|
||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||
};
|
||||
MovePanelCursor {
|
||||
current,
|
||||
max,
|
||||
..cmd
|
||||
}
|
||||
Ok(MovePanelCursor { panel, delta })
|
||||
},
|
||||
None,
|
||||
);
|
||||
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
|
||||
r.register_nullary(|| Box::new(EditFormulaAtCursor));
|
||||
r.register_nullary(|| Box::new(AddRecordRow));
|
||||
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||
@@ -493,13 +445,14 @@ pub fn default_registry() -> CmdRegistry {
|
||||
|
||||
// ── Commit ───────────────────────────────────────────────────────────
|
||||
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&CommitAndAdvance {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
advance: AdvanceDir::Down,
|
||||
cursor: CursorState::default(),
|
||||
edit_mode: AppMode::editing(),
|
||||
opened: None,
|
||||
},
|
||||
|args| {
|
||||
if args.len() < 3 {
|
||||
@@ -512,21 +465,21 @@ pub fn default_registry() -> CmdRegistry {
|
||||
advance: AdvanceDir::Down,
|
||||
cursor: CursorState::default(),
|
||||
edit_mode,
|
||||
opened: None,
|
||||
})
|
||||
},
|
||||
commit_from_cursor,
|
||||
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&CommitAndAdvance {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
advance: AdvanceDir::Right,
|
||||
cursor: CursorState::default(),
|
||||
edit_mode: AppMode::editing(),
|
||||
opened: None,
|
||||
},
|
||||
|_| Err("commit-and-advance-right requires context".into()),
|
||||
commit_from_cursor,
|
||||
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
|
||||
);
|
||||
r.register(
|
||||
@@ -538,7 +491,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
|
||||
Ok(CommitBuffer { kind })
|
||||
},
|
||||
as_parsed,
|
||||
Some(|args, ctx| {
|
||||
require_args(args, 1)?;
|
||||
let kind = parse_commit_kind(&args[0], ctx.mode)?;
|
||||
@@ -562,13 +514,13 @@ pub fn default_registry() -> CmdRegistry {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
/// Argument vectors to try when constructing a command, most specific
|
||||
/// first. A command is exercised by the first one its parser accepts.
|
||||
fn candidate_args() -> Vec<Vec<String>> {
|
||||
pub(in crate::command::cmd) fn candidate_args() -> Vec<Vec<String>> {
|
||||
[
|
||||
vec![],
|
||||
vec!["1"],
|
||||
@@ -863,12 +815,16 @@ mod panel_open_tests {
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
for asked in Panel::ALL {
|
||||
let args = [asked.name().to_string()];
|
||||
let cmd = ctx
|
||||
let effects = ctx
|
||||
.registry
|
||||
.interactive("toggle-panel-visibility", &args, &ctx)
|
||||
.expect("toggle-panel-visibility resolves");
|
||||
let dbg = format!("{cmd:?}");
|
||||
let expected = format!("currently_open: {}", asked == opened);
|
||||
.expect("toggle-panel-visibility resolves")
|
||||
.execute(&ctx);
|
||||
let dbg = format!("{effects:?}");
|
||||
let expected = format!(
|
||||
"SetPanelOpen {{ panel: {asked:?}, open: {} }}",
|
||||
asked != opened
|
||||
);
|
||||
assert!(
|
||||
dbg.contains(&expected),
|
||||
"with only {opened:?} open, toggling {asked:?} read another \
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::model::Aggregation;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect, Panel};
|
||||
@@ -113,10 +112,7 @@ impl Cmd for SearchNavigate {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let s = match ctx
|
||||
.model
|
||||
.evaluate_aggregated(&key, Aggregation::of(ctx.none_cats()))
|
||||
{
|
||||
let s = match ctx.layout.evaluate(ctx.model, &key) {
|
||||
Some(CellValue::Number(n)) => format!("{n}"),
|
||||
Some(CellValue::Text(t)) => t,
|
||||
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||
|
||||
@@ -297,7 +297,7 @@ impl Cmd for AppendChar {
|
||||
if let KeyCode::Char(c) = ctx.key_code {
|
||||
let mut val = read_buffer(ctx, &self.buffer);
|
||||
val.push(c);
|
||||
vec![write_buffer(&self.buffer, val)]
|
||||
typed_into(ctx, &self.buffer, val)
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
@@ -316,10 +316,21 @@ impl Cmd for PopChar {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let mut val = read_buffer(ctx, &self.buffer);
|
||||
val.pop();
|
||||
vec![write_buffer(&self.buffer, val)]
|
||||
typed_into(ctx, &self.buffer, val)
|
||||
}
|
||||
}
|
||||
|
||||
/// A user's edit of buffer `name`: the write, then the open editor marked
|
||||
/// touched, so an edit back to the seed still commits (improvise-2054).
|
||||
fn typed_into(ctx: &CmdContext, name: &str, value: String) -> Vec<Box<dyn Effect>> {
|
||||
let mut effects = vec![write_buffer(name, value)];
|
||||
effects.extend(
|
||||
improvise_protocol::edited(ctx.opened_editor, name)
|
||||
.map(|opened| Box::new(effect::SetOpenedEditor(Some(opened))) as Box<dyn Effect>),
|
||||
);
|
||||
effects
|
||||
}
|
||||
|
||||
/// Handle backspace in command mode — pop char or return to Normal if empty.
|
||||
#[derive(Debug)]
|
||||
pub struct CommandModeBackspace;
|
||||
|
||||
@@ -59,6 +59,26 @@ mod tests {
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
|
||||
/// improvise-jgzw: `_Index`/`_Dim` stay tileable on purpose, and `_Measure` on
|
||||
/// a column must too — `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included() {
|
||||
let mut m = two_cat_model();
|
||||
m.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
let mut offered = Vec::new();
|
||||
for idx in 0..8 {
|
||||
ctx.tile_cat_idx = idx;
|
||||
let dbg = effects_debug(&TileAxisOp { axis: None }.execute(&ctx));
|
||||
if let Some(rest) = dbg.split("CycleAxis(Active, \"").nth(1) {
|
||||
offered.push(rest.split('"').next().unwrap_or_default().to_string());
|
||||
}
|
||||
}
|
||||
assert_eq!(offered, ["_Index", "_Dim", "_Measure", "Type", "Month"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_tile_cursor_right() {
|
||||
let m = two_cat_model();
|
||||
|
||||
@@ -477,6 +477,69 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// improvise-gw18: every Normal key, prefixes walked, pressed on a fold with a
|
||||
/// value yanked — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate() {
|
||||
use improvise_protocol::key::KeyEvent;
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let normal = ks
|
||||
.maps()
|
||||
.get(&ModeKey::Normal)
|
||||
.expect("a Normal keymap")
|
||||
.clone();
|
||||
let mods = [
|
||||
KeyModifiers::NONE,
|
||||
KeyModifiers::SHIFT,
|
||||
KeyModifiers::CONTROL,
|
||||
KeyModifiers::ALT,
|
||||
];
|
||||
let mut strokes: Vec<Vec<(KeyCode, KeyModifiers)>> = Vec::new();
|
||||
for key in probe_keys() {
|
||||
for m in mods {
|
||||
match normal.lookup(key, m) {
|
||||
Some(Binding::Prefix(sub)) => strokes.extend(
|
||||
probe_keys()
|
||||
.into_iter()
|
||||
.flat_map(|k2| mods.map(|m2| (k2, m2)))
|
||||
.filter(|&(k2, m2)| sub.lookup(k2, m2).is_some())
|
||||
.map(|second| vec![(key, m), second]),
|
||||
),
|
||||
Some(_) => strokes.push(vec![(key, m)]),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
strokes
|
||||
.iter()
|
||||
.any(|s| s == &[(KeyCode::Char('p'), KeyModifiers::NONE)]),
|
||||
"the sweep reaches paste"
|
||||
);
|
||||
// No key on a fold may touch the stored data or the hidden set at all.
|
||||
let touched = |app: &crate::ui::app::App| {
|
||||
let wb = &app.model_state.workbook;
|
||||
let text = crate::persistence::format_md(wb);
|
||||
let data = text.split("## Data").nth(1).unwrap_or_default().to_string();
|
||||
(data, wb.active_view().hidden_items.clone())
|
||||
};
|
||||
let before = touched(&test_helpers::app_on_a_fold());
|
||||
for stroke in &strokes {
|
||||
let mut app = test_helpers::app_on_a_fold();
|
||||
app.view_state.yanked = Some(crate::model::cell::CellValue::Number(5.0));
|
||||
for &(key, m) in stroke {
|
||||
let _ = app.handle_key(KeyEvent::new(key, m));
|
||||
}
|
||||
let stray = test_helpers::non_item_coordinates(&app.model_state.workbook);
|
||||
assert!(stray.is_empty(), "{stroke:?} on a fold stored {stray:?}");
|
||||
assert_eq!(
|
||||
touched(&app),
|
||||
before,
|
||||
"{stroke:?} on a fold changed the document"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A representative `AppMode` for each keymap, so a test can put a
|
||||
/// `CmdContext` in the mode whose bindings it is walking.
|
||||
fn app_mode_for(mode: &ModeKey) -> AppMode {
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::ui::viewport::{compute_col_widths, compute_row_header_width, compute_
|
||||
use crate::view::{GridLayout, View};
|
||||
use crate::workbook::Workbook;
|
||||
use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||
use improvise_protocol::RecordCell;
|
||||
use improvise_protocol::{CellId, RecordCell};
|
||||
|
||||
/// A cell's coordinate tuple with `_Measure` dropped: the unit of atomicity
|
||||
/// ADR 001 settles on, and not the cell — see `context/code/command.md`.
|
||||
@@ -386,7 +386,9 @@ impl App {
|
||||
let view = workbook.active_view();
|
||||
GridLayout::with_frozen_records(&workbook.model, view, None)
|
||||
};
|
||||
Self {
|
||||
// A document may open on a records grid, so the first mode is settled
|
||||
// by the rule every batch ends with (improvise-y5eo).
|
||||
let mut app = Self {
|
||||
model_state: ModelState {
|
||||
workbook,
|
||||
file_path,
|
||||
@@ -416,7 +418,9 @@ impl App {
|
||||
last_reframed_elsewhere: std::collections::BTreeSet::new(),
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
storage,
|
||||
}
|
||||
};
|
||||
app.settle_normal_mode();
|
||||
app
|
||||
}
|
||||
|
||||
/// This client's cursor in the active view. Absent means the origin, so
|
||||
@@ -444,6 +448,9 @@ impl App {
|
||||
pub fn install_workbook(&mut self, workbook: Workbook) {
|
||||
self.view_state.active_view = workbook.active_view.clone();
|
||||
self.view_state.cursors.clear();
|
||||
// The history names the outgoing document's views too (improvise-lrrx).
|
||||
self.view_state.view_back_stack.clear();
|
||||
self.view_state.view_forward_stack.clear();
|
||||
self.model_state.workbook = workbook;
|
||||
// Every client's mirror is scoped to the outgoing document too, and an
|
||||
// effect cannot reach the session list. The generation is how it hears.
|
||||
@@ -569,24 +576,79 @@ impl App {
|
||||
.map(|key| record_of(&key))
|
||||
}
|
||||
|
||||
/// Why a wire write to `cell` is refused: the host itself projects it as no
|
||||
/// `CellId::Record` (improvise-ciw8, ADR 002). `None` for a writable cell.
|
||||
pub fn unwritable(&self, cell: &RecordCell) -> Option<String> {
|
||||
let key = self.resolve_cell(cell)?;
|
||||
let model = &self.model_state.workbook.model;
|
||||
let derived = model.formula_for(&key).map(|f| {
|
||||
format!(
|
||||
"{} is computed by a formula — edit the formula, not the cell",
|
||||
f.target
|
||||
)
|
||||
});
|
||||
let refusal = self.group_refusal(&key).or(derived);
|
||||
refusal.or_else(|| self.aggregate_refusal(cell.record, &key))
|
||||
}
|
||||
|
||||
/// A coordinate naming a group rather than an item is no cell in ANY view, so
|
||||
/// this asks the document, never the layout (improvise-b114).
|
||||
fn group_refusal(&self, key: &CellKey) -> Option<String> {
|
||||
let model = &self.model_state.workbook.model;
|
||||
key.0.iter().find_map(|(cat, name)| {
|
||||
let category = model.category(cat)?;
|
||||
let group = !category.items.contains_key(name.as_str())
|
||||
&& !category.items_in_group(name).is_empty();
|
||||
group.then(|| format!("{name} is a group of {cat}, so there is no one cell to write"))
|
||||
})
|
||||
}
|
||||
|
||||
/// A record the store does not hold is known only by its layout position, and
|
||||
/// is a record only where the projection at ITS CELL says so (improvise-sjdb).
|
||||
fn aggregate_refusal(&self, hash: RecordHash, key: &CellKey) -> Option<String> {
|
||||
let stored = self.model_state.workbook.model.data.resolve_record(hash);
|
||||
let (row, col) = stored.map_or_else(|| self.cell_position(key), |_| None)?;
|
||||
let projected =
|
||||
crate::ui::project::cell_id(&self.model_state.workbook.model, &self.layout, row, col);
|
||||
matches!(projected, Some(CellId::Aggregate(_))).then(|| {
|
||||
"an aggregate combines several records, so there is no one cell to write".into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where in the current layout the cell `key` sits, if anywhere. By the whole
|
||||
/// key: a `RecordHash` drops `_Measure`, so a row's columns all share one.
|
||||
fn cell_position(&self, key: &CellKey) -> Option<(usize, usize)> {
|
||||
self.layout_cells()
|
||||
.find(|(_, _, at)| at == key)
|
||||
.map(|(row, col, _)| (row, col))
|
||||
}
|
||||
|
||||
/// Every cell key the current grid names. Records mode is excluded: its
|
||||
/// `cell_key` carries *positions*, the trap ADR 002 rejected `_Index` for.
|
||||
fn layout_cell_keys(&self) -> impl Iterator<Item = CellKey> + '_ {
|
||||
self.layout_cells().map(|(_, _, key)| key)
|
||||
}
|
||||
|
||||
/// [`Self::layout_cell_keys`] with each key's position.
|
||||
fn layout_cells(&self) -> impl Iterator<Item = (usize, usize, CellKey)> + '_ {
|
||||
let layout = &self.layout;
|
||||
let rows = match layout.is_records_mode() {
|
||||
true => 0,
|
||||
false => layout.row_count(),
|
||||
};
|
||||
(0..rows)
|
||||
.flat_map(move |r| (0..layout.col_count()).filter_map(move |c| layout.cell_key(r, c)))
|
||||
(0..rows).flat_map(move |r| {
|
||||
(0..layout.col_count()).filter_map(move |c| layout.cell_key(r, c).map(|k| (r, c, k)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Point **this client's** slice at `name`, if the document has such a view.
|
||||
/// The document's own default stays put (improvise-jytm, gotcha 29).
|
||||
pub fn switch_to_view(&mut self, name: &str) {
|
||||
if self.model_state.workbook.views.contains_key(name) {
|
||||
/// Point **this client's** slice at `name`, if the document has such a view,
|
||||
/// and say whether it did. The document's default stays put (gotcha 29).
|
||||
pub fn switch_to_view(&mut self, name: &str) -> bool {
|
||||
let accepted = self.model_state.workbook.views.contains_key(name);
|
||||
if accepted {
|
||||
self.view_state.active_view = name.to_string();
|
||||
}
|
||||
accepted
|
||||
}
|
||||
|
||||
/// What `name` names in this document, falling back to the default for a name
|
||||
@@ -761,14 +823,14 @@ impl App {
|
||||
match crate::view::synthetic_record_info(key) {
|
||||
Some((idx, col)) => self
|
||||
.staged_drill_edit(layout, idx, col)
|
||||
.or_else(|| layout.resolve_display(key))
|
||||
.or_else(|| layout.records_value(idx, col).map(|v| v.edit_text()))
|
||||
.unwrap_or_default(),
|
||||
None => self
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(key)
|
||||
.map(|v| v.to_string())
|
||||
.map(CellValue::edit_text)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -807,6 +869,7 @@ impl App {
|
||||
category_panel_open: self.view_state.category_panel_open,
|
||||
view_panel_open: self.view_state.view_panel_open,
|
||||
buffers: &self.view_state.buffers,
|
||||
opened_editor: self.view_state.opened_editor.as_ref(),
|
||||
formula_cursor: self.view_state.formula_cursor,
|
||||
cat_panel_cursor: self.view_state.cat_panel_cursor,
|
||||
view_panel_cursor: self.view_state.view_panel_cursor,
|
||||
@@ -2753,6 +2816,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-y5eo: a document that opens on a records grid opens the host in
|
||||
/// `RecordsNormal`. See `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_document_opening_on_a_records_view_opens_the_host_in_records_normal() {
|
||||
use crate::view::Axis;
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.active_view_mut().set_axis("_Index", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Dim", Axis::Column);
|
||||
let mut app = App::new(wb, None);
|
||||
assert!(app.layout.is_records_mode(), "setup: the grid is records");
|
||||
assert_eq!(app.view_state.mode, AppMode::RecordsNormal);
|
||||
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert!(
|
||||
app.view_state.mode.is_records(),
|
||||
"the host's first `i` left records mode: {:?}",
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
/// The settle's `RecordsNormal` arm, driven from the mode it names. The
|
||||
/// `:` crossing above enters the settle in `Normal` both ways — the evidence.
|
||||
#[test]
|
||||
@@ -2990,6 +3076,516 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-cz8d: a refusal need not abort, so a failed document swap must
|
||||
/// ask for the abort itself. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_failed_load_or_import_does_not_run_the_quit_after_it() {
|
||||
use improvise_protocol::keymap::Keymap;
|
||||
|
||||
// One case per `refuse_and_stop` site (improvise-514o).
|
||||
let cases: [(&str, &[&str], &str); 7] = [
|
||||
("load", &["absent.improv"], "Load error"),
|
||||
("import-json", &["absent.json"], "Cannot read 'absent.json'"),
|
||||
("import-json", &["absent.csv"], "Cannot read 'absent.csv'"),
|
||||
("import-json", &["bad.csv"], "CSV error"),
|
||||
("import-json", &["bad.json"], "JSON parse error"),
|
||||
(
|
||||
"import-json",
|
||||
&["nested.json", "M", "nope"],
|
||||
"No array at path",
|
||||
),
|
||||
("import-json", &["measures.json"], "Import error"),
|
||||
];
|
||||
for (command, args, arm) in cases {
|
||||
let path = args.join(" ");
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.storage = Box::new(crate::ui::host::import_fixture_storage());
|
||||
let key = KeyCode::Char('x');
|
||||
let mods = KeyModifiers::NONE;
|
||||
let mut km = Keymap::new();
|
||||
km.bind_seq(
|
||||
key,
|
||||
mods,
|
||||
vec![
|
||||
(command, args.iter().map(|a| a.to_string()).collect()),
|
||||
("force-quit", vec![]),
|
||||
],
|
||||
);
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(key, mods);
|
||||
app.keymap_set.dispatch_transient(&km, &ctx, key, mods)
|
||||
}
|
||||
.expect("both steps resolve in the registry");
|
||||
app.apply_effects(effects);
|
||||
|
||||
assert!(
|
||||
app.last_batch_refused() && app.view_state.status_msg.contains(arm),
|
||||
"setup: `{command} {path}` must fail in the {arm:?} arm, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
assert_ne!(
|
||||
app.view_state.mode,
|
||||
AppMode::Quit,
|
||||
"`{command} {path} . q` quit having loaded nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-v4hg: Enter on a formula that does not parse threw the typed text
|
||||
/// away. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text() {
|
||||
let type_and_commit = |text: &str| {
|
||||
let mut app = two_col_model();
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
let press = |app: &mut App, code| {
|
||||
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||
.unwrap()
|
||||
};
|
||||
press(&mut app, KeyCode::Char('a'));
|
||||
assert!(
|
||||
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
|
||||
"setup: `a` in the formula panel opens the editor"
|
||||
);
|
||||
for c in text.chars() {
|
||||
press(&mut app, KeyCode::Char(c));
|
||||
}
|
||||
press(&mut app, KeyCode::Enter);
|
||||
app
|
||||
};
|
||||
|
||||
// The control: a formula that parses is stored and the editor closes.
|
||||
let ok = type_and_commit("Total = 1");
|
||||
assert_eq!(ok.view_state.mode, AppMode::FormulaPanel);
|
||||
assert!(
|
||||
ok.model_state
|
||||
.workbook
|
||||
.model
|
||||
.formulas()
|
||||
.iter()
|
||||
.any(|f| f.target == "Total"),
|
||||
"the control formula must land"
|
||||
);
|
||||
|
||||
let bad = type_and_commit("Total = (");
|
||||
assert!(
|
||||
bad.last_batch_refused() && bad.view_state.status_msg.contains("Formula error"),
|
||||
"the parse error must be reported, got {:?}",
|
||||
bad.view_state.status_msg
|
||||
);
|
||||
assert!(
|
||||
matches!(bad.view_state.mode, AppMode::FormulaEdit { .. }),
|
||||
"a refused formula must leave the user in the editor, got {:?}",
|
||||
bad.view_state.mode
|
||||
);
|
||||
assert_eq!(
|
||||
bad.view_state.buffers.get("formula").map(String::as_str),
|
||||
Some("Total = ("),
|
||||
"and must keep what they typed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a `:` line the way `improvise cmd` does, one batch per segment; true
|
||||
/// when any segment refused, which is what its exit code reads.
|
||||
fn run_line(app: &mut App, line: &str) -> bool {
|
||||
let mut refused = false;
|
||||
for segment in crate::command::parse::segments(line) {
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
crate::command::parse::parse_segment_interactive(segment, &ctx)
|
||||
.unwrap_or_else(|e| panic!("`{segment}` must parse: {e}"))
|
||||
.map(|cmd| cmd.execute(&ctx))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
app.apply_effects(effects);
|
||||
refused |= app.last_batch_refused();
|
||||
}
|
||||
refused
|
||||
}
|
||||
|
||||
/// Commit `text` in a minibuffer mode with Enter; true when that batch refused.
|
||||
fn commit_in(app: &mut App, mode: AppMode, buffer: &str, text: &str) -> bool {
|
||||
app.view_state.mode = mode;
|
||||
app.view_state
|
||||
.buffers
|
||||
.insert(buffer.to_string(), text.to_string());
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
app.last_batch_refused()
|
||||
}
|
||||
|
||||
fn with_import_fixtures(mut app: App) -> App {
|
||||
app.storage = Box::new(crate::ui::host::import_fixture_storage());
|
||||
app
|
||||
}
|
||||
|
||||
/// Eleven regular categories: one short of the limit.
|
||||
fn one_category_short_of_the_limit() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
for n in 0..11 {
|
||||
wb.add_category(format!("C{n}")).unwrap();
|
||||
}
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
/// Stage one coordinate rename in a drill over the records fixture, then commit it.
|
||||
fn commit_drill_rename(app: &mut App, to: &str) -> bool {
|
||||
let records = app.layout.records.clone().expect("records mode");
|
||||
let mut pending_edits = HashMap::new();
|
||||
pending_edits.insert(
|
||||
(1, crate::view::RecordCol::Category("Region".into())),
|
||||
to.into(),
|
||||
);
|
||||
app.drill_state = Some(DrillState {
|
||||
records,
|
||||
pending_edits,
|
||||
});
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::ApplyAndClearDrill)]);
|
||||
app.last_batch_refused()
|
||||
}
|
||||
|
||||
/// Records mode with `Region` paged to `page`: `add-record-row` then names
|
||||
/// the row whose coordinates are exactly that page.
|
||||
fn records_paged_to(app: &mut App, page: &str) {
|
||||
app.model_state.workbook.add_item("Region", "West").unwrap();
|
||||
let view = app
|
||||
.view_mut_of(&ViewTarget::Active)
|
||||
.expect("the active view");
|
||||
view.set_axis("Region", crate::view::Axis::Page);
|
||||
view.set_page_selection("Region", page);
|
||||
app.rebuild_layout();
|
||||
}
|
||||
|
||||
/// A grid with a regular category on None, so every cell is an aggregate.
|
||||
fn aggregated_grid(with_items: bool) -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
for cat in ["Row", "Col", "Hidden"] {
|
||||
wb.add_category(cat).unwrap();
|
||||
if with_items {
|
||||
wb.add_item(cat, "A").unwrap();
|
||||
}
|
||||
}
|
||||
wb.active_view_mut()
|
||||
.set_axis("Hidden", crate::view::Axis::None);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// `Region` down, `_Measure` across: `Revenue` at column 0, then `Total`,
|
||||
/// which a formula owns, at column 1.
|
||||
fn a_formula_column() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut()
|
||||
.set_axis("_Measure", crate::view::Axis::Column);
|
||||
let mut app = App::new(wb, None);
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::AddFormula {
|
||||
raw: "Total = Revenue * 2".into(),
|
||||
target_category: "_Measure".into(),
|
||||
})]);
|
||||
app
|
||||
}
|
||||
|
||||
fn with_a_cell(mut app: App) -> App {
|
||||
app.model_state.workbook.model.set_cell(
|
||||
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
|
||||
CellValue::Number(42.0),
|
||||
);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// Months grouped into quarters with Q2 collapsed: row 2 is the fold.
|
||||
fn folded_quarter() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_category("Type").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
fn yanked(mut app: App) -> App {
|
||||
app.view_state.yanked = Some(CellValue::Number(5.5));
|
||||
app
|
||||
}
|
||||
|
||||
/// A drill frozen over the records the grid already shows.
|
||||
fn in_a_drill(mut app: App) -> App {
|
||||
let records = app.layout.records.clone().expect("records mode");
|
||||
app.drill_state = Some(DrillState {
|
||||
records,
|
||||
pending_edits: HashMap::new(),
|
||||
});
|
||||
app
|
||||
}
|
||||
|
||||
fn with_a_second_view(mut app: App) -> App {
|
||||
app.model_state.workbook.create_view("Second");
|
||||
app
|
||||
}
|
||||
|
||||
fn switch_to_second(app: &mut App) {
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView(
|
||||
"Second".into(),
|
||||
))]);
|
||||
}
|
||||
|
||||
/// improvise-0v8l: one case per refusal site no other test pinned, each with a
|
||||
/// succeeding twin. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn every_refusal_site_refuses_its_failure_and_only_its_failure() {
|
||||
type Setup = fn() -> App;
|
||||
type Drive = fn(&mut App) -> bool;
|
||||
let cases: Vec<(&str, Setup, Drive, &str, Setup, Drive)> = vec![
|
||||
(
|
||||
"add-category past the limit",
|
||||
one_category_short_of_the_limit,
|
||||
|app| run_line(app, "add-category A . add-category B"),
|
||||
"Maximum of 12 categories",
|
||||
one_category_short_of_the_limit,
|
||||
|app| run_line(app, "add-category A"),
|
||||
),
|
||||
(
|
||||
"a drill commit that empties a coordinate",
|
||||
records_app_for_drill,
|
||||
|app| commit_drill_rename(app, ""),
|
||||
crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY,
|
||||
records_app_for_drill,
|
||||
|app| commit_drill_rename(app, "West"),
|
||||
),
|
||||
(
|
||||
"import of a JSON that does not parse",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import bad.json"),
|
||||
"JSON parse error",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import good.json"),
|
||||
),
|
||||
(
|
||||
"import of an absent file",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import absent.json"),
|
||||
"Cannot read file",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import good.json"),
|
||||
),
|
||||
(
|
||||
"import-json of an absent CSV",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json absent.csv"),
|
||||
"Cannot read 'absent.csv'",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json good.json"),
|
||||
),
|
||||
(
|
||||
"import-json of a CSV that is not text",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json bad.csv"),
|
||||
"CSV error",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json good.json"),
|
||||
),
|
||||
(
|
||||
"import-json of JSON that does not parse",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json bad.json"),
|
||||
"JSON parse error",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json good.json"),
|
||||
),
|
||||
(
|
||||
"import-json at a path with no array",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json nested.json M nope"),
|
||||
"No array at path",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json good.json"),
|
||||
),
|
||||
(
|
||||
"import-json that builds no model",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json measures.json"),
|
||||
"Import error",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| run_line(app, "import-json good.json"),
|
||||
),
|
||||
(
|
||||
"a records coordinate edited to nothing",
|
||||
records_model_with_two_rows,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
commit_in(app, AppMode::records_editing(), "edit", "")
|
||||
},
|
||||
crate::ui::effect::RECORD_COORDS_CANNOT_BE_EMPTY,
|
||||
records_model_with_two_rows,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
commit_in(app, AppMode::records_editing(), "edit", "West")
|
||||
},
|
||||
),
|
||||
(
|
||||
"an export with no path",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| commit_in(app, AppMode::export_prompt(), "export", ""),
|
||||
"not exported",
|
||||
|| with_import_fixtures(two_col_model()),
|
||||
|app| commit_in(app, AppMode::export_prompt(), "export", "out.csv"),
|
||||
),
|
||||
(
|
||||
"view-forward with nowhere to go",
|
||||
|| with_a_second_view(two_col_model()),
|
||||
|app| run_line(app, "view-forward"),
|
||||
"No forward view",
|
||||
|| with_a_second_view(two_col_model()),
|
||||
|app| {
|
||||
switch_to_second(app);
|
||||
run_line(app, "view-back");
|
||||
run_line(app, "view-forward")
|
||||
},
|
||||
),
|
||||
(
|
||||
"view-back with nowhere to go",
|
||||
|| with_a_second_view(two_col_model()),
|
||||
|app| run_line(app, "view-back"),
|
||||
"No previous view",
|
||||
|| with_a_second_view(two_col_model()),
|
||||
|app| {
|
||||
switch_to_second(app);
|
||||
run_line(app, "view-back")
|
||||
},
|
||||
),
|
||||
(
|
||||
"add-record-row outside records mode",
|
||||
two_col_model,
|
||||
|app| run_line(app, "add-record-row"),
|
||||
"only works in records mode",
|
||||
records_model_with_two_rows,
|
||||
|app| run_line(app, "add-record-row"),
|
||||
),
|
||||
(
|
||||
"add-record-row onto a row that is already there",
|
||||
records_model_with_two_rows,
|
||||
|app| {
|
||||
records_paged_to(app, "South");
|
||||
run_line(app, "add-record-row")
|
||||
},
|
||||
"already here",
|
||||
records_model_with_two_rows,
|
||||
|app| {
|
||||
records_paged_to(app, "West");
|
||||
run_line(app, "add-record-row")
|
||||
},
|
||||
),
|
||||
(
|
||||
"edit-or-drill on an aggregate grid with no cell",
|
||||
|| aggregated_grid(false),
|
||||
|app| run_line(app, "edit-or-drill editing"),
|
||||
"No cell at cursor",
|
||||
|| aggregated_grid(true),
|
||||
|app| run_line(app, "edit-or-drill editing"),
|
||||
),
|
||||
(
|
||||
"edit-or-drill on a cell a formula owns",
|
||||
a_formula_column,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (0, 1);
|
||||
run_line(app, "edit-or-drill editing")
|
||||
},
|
||||
"computed by a formula",
|
||||
a_formula_column,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
run_line(app, "edit-or-drill editing")
|
||||
},
|
||||
),
|
||||
(
|
||||
"a search with no matches",
|
||||
|| with_a_cell(two_col_model()),
|
||||
|app| {
|
||||
app.view_state.search_query = "zzz".into();
|
||||
run_line(app, "search-navigate forward")
|
||||
},
|
||||
"No matches",
|
||||
|| with_a_cell(two_col_model()),
|
||||
|app| {
|
||||
app.view_state.search_query = "42".into();
|
||||
run_line(app, "search-navigate forward")
|
||||
},
|
||||
),
|
||||
(
|
||||
"a : line that does not parse",
|
||||
two_col_model,
|
||||
|app| commit_in(app, AppMode::command_mode(), "command", "no-such-command"),
|
||||
":no-such-command",
|
||||
two_col_model,
|
||||
|app| commit_in(app, AppMode::command_mode(), "command", "add-category Z"),
|
||||
),
|
||||
// improvise-i3o5: these four wrote their decline with `set_status`.
|
||||
(
|
||||
"edit-or-drill on a collapsed group's row",
|
||||
folded_quarter,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (2, 0);
|
||||
run_line(app, "edit-or-drill editing")
|
||||
},
|
||||
"Collapsed group",
|
||||
folded_quarter,
|
||||
|app| {
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
run_line(app, "edit-or-drill editing")
|
||||
},
|
||||
),
|
||||
(
|
||||
"a spelled paste onto an aggregate",
|
||||
|| yanked(aggregated_grid(true)),
|
||||
|app| run_line(app, "paste Row/A Col/A"),
|
||||
crate::command::cmd::core::AGGREGATE_REFUSAL,
|
||||
|| yanked(aggregated_grid(true)),
|
||||
|app| run_line(app, "paste Row/A Col/A Hidden/A"),
|
||||
),
|
||||
(
|
||||
"a spelled paste onto a records coordinate column",
|
||||
|| yanked(records_model_with_two_rows()),
|
||||
|app| run_line(app, "paste _Index/1 _Dim/0"),
|
||||
crate::command::cmd::core::NO_VALUE_REFUSAL,
|
||||
|| yanked(records_model_with_two_rows()),
|
||||
|app| run_line(app, "paste _Index/1 _Dim/2"),
|
||||
),
|
||||
(
|
||||
"a spelled paste inside a drill",
|
||||
|| yanked(in_a_drill(records_model_with_two_rows())),
|
||||
|app| run_line(app, "paste _Index/1 _Dim/2"),
|
||||
crate::command::cmd::core::INSIDE_A_DRILL_REFUSAL,
|
||||
|| yanked(records_model_with_two_rows()),
|
||||
|app| run_line(app, "paste _Index/1 _Dim/2"),
|
||||
),
|
||||
];
|
||||
for (what, fail_setup, fails, says, ok_setup, succeeds) in cases {
|
||||
let mut app = fail_setup();
|
||||
let refused = fails(&mut app);
|
||||
assert!(
|
||||
refused && app.view_state.status_msg.contains(says),
|
||||
"{what}: expected a refusal saying {says:?}, got refused={refused} {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
let mut app = ok_setup();
|
||||
assert!(
|
||||
!succeeds(&mut app),
|
||||
"{what}: its succeeding twin was refused: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A prefix is consumed whether or not the next key matches, so a stray key
|
||||
/// after `g` does nothing rather than acting as a fresh Normal binding.
|
||||
#[test]
|
||||
@@ -4281,3 +4877,261 @@ mod basis_tiers {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing a formula from the panel (improvise-km71), driven through
|
||||
/// `handle_key` at a NONZERO cursor over three formulas.
|
||||
#[cfg(test)]
|
||||
mod formula_edit_tests {
|
||||
use super::*;
|
||||
use crate::formula::parse_formula;
|
||||
|
||||
const PROFIT: &str = "Profit = Revenue - Cost";
|
||||
const TAX: &str = "Tax = Revenue * 0.1";
|
||||
const MARGIN: &str = "Margin = Profit / Revenue";
|
||||
|
||||
fn app_with_three_formulas() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for raw in [PROFIT, TAX, MARGIN] {
|
||||
wb.model
|
||||
.add_formula(parse_formula(raw, "_Measure").unwrap());
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, code: KeyCode) {
|
||||
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Clear the minibuffer by keys and type `text`, as a user would.
|
||||
fn retype(app: &mut App, text: &str) {
|
||||
let len = app.view_state.buffers.get("formula").map_or(0, String::len);
|
||||
for _ in 0..len {
|
||||
press(app, KeyCode::Backspace);
|
||||
}
|
||||
for c in text.chars() {
|
||||
press(app, KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
fn raws(app: &App) -> Vec<String> {
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.formulas()
|
||||
.iter()
|
||||
.map(|f| f.raw.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn buffer(app: &App) -> Option<&str> {
|
||||
app.view_state.buffers.get("formula").map(String::as_str)
|
||||
}
|
||||
|
||||
fn open_editor(app: &mut App) {
|
||||
press(app, KeyCode::Enter);
|
||||
assert!(
|
||||
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
|
||||
"Enter on a formula must open the editor, got {:?}",
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
fn in_editor(app: &App) -> bool {
|
||||
matches!(app.view_state.mode, AppMode::FormulaEdit { .. })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_opens_the_editor_seeded_with_the_formula_under_the_cursor() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
assert_eq!(buffer(&app), Some(TAX), "cursor 1 is Tax, not the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_opens_the_same_editor() {
|
||||
let mut app = app_with_three_formulas();
|
||||
press(&mut app, KeyCode::Char('e'));
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some(TAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_an_edit_replaces_the_formula_in_place() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.2", MARGIN]);
|
||||
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
|
||||
}
|
||||
|
||||
/// The rival "add instead of replace" passes the test above, because
|
||||
/// `add_formula` replaces by target. A rename is where it cannot hide.
|
||||
#[test]
|
||||
fn changing_the_target_renames_the_formula_in_its_own_place() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Levy = Revenue * 0.1");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Levy = Revenue * 0.1", MARGIN]);
|
||||
let measures = app.model_state.workbook.model.measure_item_names();
|
||||
assert!(
|
||||
!measures.contains(&"Tax".to_string()),
|
||||
"Tax was a formula target only, so it goes: {measures:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_says_which_formulas_still_name_the_old_target() {
|
||||
let mut app = app_with_three_formulas();
|
||||
app.view_state.formula_cursor = 0;
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Gross = Revenue - Cost");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app)[0], "Gross = Revenue - Cost");
|
||||
let status = &app.view_state.status_msg;
|
||||
assert!(
|
||||
status.contains("Profit") && status.contains("Margin"),
|
||||
"Margin still refers to Profit, and the user must be told: {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_onto_another_formulas_target_refuses_and_keeps_both() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Margin = Revenue * 0.1");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert!(app.view_state.status_msg.contains("Margin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parse_error_keeps_the_editor_the_text_and_the_formula() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = = ");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Tax = = "));
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("Formula error"),
|
||||
"{:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// The editor names its formula when it OPENS. Deleting an earlier one
|
||||
/// moves the edited formula to index 0 and puts `Margin` under cursor 1.
|
||||
#[test]
|
||||
fn an_edit_lands_on_its_formula_after_an_earlier_one_is_deleted_meanwhile() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let model = &mut app.model_state.workbook.model;
|
||||
model.remove_formula("Profit", "_Measure");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec!["Tax = Revenue * 0.2", MARGIN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_whose_formula_was_deleted_meanwhile_refuses_out_loud() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let model = &mut app.model_state.workbook.model;
|
||||
model.remove_formula("Tax", "_Measure");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, MARGIN], "nothing re-added");
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Tax = Revenue * 0.2"));
|
||||
assert!(app.last_batch_refused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let theirs = parse_formula("Tax = Revenue * 0.3", "_Measure").unwrap();
|
||||
app.model_state.workbook.model.add_formula(theirs);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.3", MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert!(app.last_batch_refused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_leaves_the_formula_untouched() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = 0");
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
|
||||
}
|
||||
|
||||
/// `n` still adds: the replace slot must not leak into a fresh editor.
|
||||
#[test]
|
||||
fn a_new_formula_after_an_edit_is_added_not_replaced() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
press(&mut app, KeyCode::Char('n'));
|
||||
assert_eq!(buffer(&app), Some(""), "a new formula starts empty");
|
||||
retype(&mut app, "Levy = Revenue * 0.3");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![PROFIT, TAX, MARGIN, "Levy = Revenue * 0.3"]
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-kb1d: a Region-targeted formula at index 1. Renaming it would
|
||||
/// strand `Total` as an empty item, so the edit refuses and stays open.
|
||||
#[test]
|
||||
fn renaming_a_formula_outside_measure_refuses_and_keeps_the_editor() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
for (raw, cat) in [
|
||||
(PROFIT, "_Measure"),
|
||||
("Total = East + West", "Region"),
|
||||
(MARGIN, "_Measure"),
|
||||
] {
|
||||
wb.model.add_formula(parse_formula(raw, cat).unwrap());
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let regions = |app: &App| {
|
||||
let region = app.model_state.workbook.model.category("Region").unwrap();
|
||||
region
|
||||
.ordered_item_names()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = regions(&app);
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Sum = East + West");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Total = East + West", MARGIN]);
|
||||
assert_eq!(regions(&app), before, "no ghost item, nothing appended");
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Sum = East + West"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -162,15 +162,33 @@ impl Host {
|
||||
let undo = self.app.snapshot();
|
||||
let mirrors = self.sessions.clone();
|
||||
let mut out = Vec::new();
|
||||
let mut asker_reset = false;
|
||||
for cmd in batch {
|
||||
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
|
||||
return self.undo_frame(id, undo, mirrors, reason);
|
||||
}
|
||||
// Only a mutation runs `apply_effects`, and only `apply_effects`
|
||||
// resets the flag — so after anything else it is a STALE answer.
|
||||
// Only a mutation runs `apply_effects`, which resets both flags — so
|
||||
// after anything else they are STALE. A refusal need not abort (cz8d).
|
||||
let mutation = matches!(cmd.command(), Command::Model { .. });
|
||||
out.extend(self.command(id, cmd));
|
||||
if mutation && self.app.last_batch_aborted() {
|
||||
let command = cmd.command();
|
||||
let mut answered = self.command(id, cmd);
|
||||
// The asker folded this placement BEFORE hearing its reset, so it is owed
|
||||
// again after it (improvise-wdmp, `context/code/command.md`).
|
||||
if asker_reset && places_cursor(command) {
|
||||
answered.insert(
|
||||
0,
|
||||
Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::Commands(vec![command.clone()]),
|
||||
},
|
||||
);
|
||||
}
|
||||
asker_reset |= answered
|
||||
.iter()
|
||||
.any(|a| a.to == id && is_cursor_reset(&a.msg));
|
||||
out.extend(answered);
|
||||
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
|
||||
if mutation && failed {
|
||||
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
|
||||
}
|
||||
}
|
||||
@@ -265,6 +283,40 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that writes this client's cursor, and so survives a reset only if
|
||||
/// it is folded after it.
|
||||
fn places_cursor(cmd: &Command) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
Command::SetSelected { .. } | Command::SetRowOffset(_) | Command::SetColOffset(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cursor_reset(msg: &ServerFrame) -> bool {
|
||||
matches!(msg, ServerFrame::Commands(cmds) if cmds.contains(&Command::ResetCursor))
|
||||
}
|
||||
|
||||
/// One file per way `ImportJsonHeadless` can fail, plus one that imports; the
|
||||
/// `absent.*` paths are left unwritten. Shared by the frame and key-path tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn import_fixture_storage() -> crate::storage::MemoryFs {
|
||||
let storage = crate::storage::MemoryFs::new();
|
||||
let files: [(&str, &[u8]); 5] = [
|
||||
// Every field numeric, so no category is accepted and the build fails.
|
||||
("measures.json", br#"[{"x": 1}, {"x": 2}]"#),
|
||||
("bad.json", b"{not json"),
|
||||
("nested.json", br#"{"data": [{"Region": "North"}]}"#),
|
||||
// A record that is not UTF-8 is a CSV read error, not a warning.
|
||||
("bad.csv", b"Region,Revenue\n\xff\xfe,1\n"),
|
||||
("good.json", br#"[{"Region": "North", "Revenue": 1}]"#),
|
||||
];
|
||||
for (name, bytes) in files {
|
||||
crate::storage::Storage::write(&storage, std::path::Path::new(name), bytes)
|
||||
.expect("fixture writes");
|
||||
}
|
||||
storage
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -1336,6 +1388,312 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn month_cell(month: &str, measure: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("Month".to_string(), month.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Months in two quarters on Row, Revenue/Cost and `Profit = Revenue - Cost` on
|
||||
/// Column, every month funded, Q2 collapsed: rows `[Jan, Feb, Q2]`.
|
||||
fn host_on_a_fold() -> Host {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
let value = CellValue::Number(10.0);
|
||||
wb.model.set_cell(month_cell(month, measure), value);
|
||||
}
|
||||
}
|
||||
let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure");
|
||||
wb.model.add_formula(profit.unwrap());
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert!(
|
||||
app.layout.folds_a_group(2, 1),
|
||||
"setup: row 2 is the Q2 fold"
|
||||
);
|
||||
Host::new(app)
|
||||
}
|
||||
|
||||
/// One wire write, as a hand-rolled client composes it: a surrogate built from
|
||||
/// a key, and a basis claiming whatever the host says is current.
|
||||
fn wire_write(host: &mut Host, key: &CellKey, clear: bool) -> Vec<Addressed> {
|
||||
use improvise_protocol::ModelCommand;
|
||||
let cell = RecordCell {
|
||||
record: RecordHash::of(key),
|
||||
measure: key.get("_Measure").map(String::from),
|
||||
};
|
||||
let cmd = match clear {
|
||||
true => ModelCommand::ClearCell(cell),
|
||||
false => ModelCommand::SetCell {
|
||||
cell,
|
||||
value: CellValue::Number(7.0),
|
||||
},
|
||||
};
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let frame = crate::ui::reduce::caught_up(host.app(), cmd);
|
||||
host.handle(asker, ClientFrame::Commands(vec![frame]))
|
||||
}
|
||||
|
||||
fn refused(answer: &[Addressed]) -> bool {
|
||||
answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::MutationRefused)))
|
||||
}
|
||||
|
||||
fn document_text(host: &Host) -> String {
|
||||
crate::persistence::format_md(&host.app().model_state.workbook)
|
||||
}
|
||||
|
||||
/// improvise-ciw8 — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_collapsed_fold_is_rejected_and_stores_nothing() {
|
||||
for clear in [false, true] {
|
||||
let mut host = host_on_a_fold();
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &month_cell("Q2", "Cost"), clear);
|
||||
assert!(refused(&answer), "clear={clear}: not refused: {answer:?}");
|
||||
assert_eq!(
|
||||
document_text(&host),
|
||||
before,
|
||||
"clear={clear}: the fold was written"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-ciw8: a formula's result is `CellId::Derived`, no write's target.
|
||||
#[test]
|
||||
fn a_wire_write_under_a_formula_is_rejected_and_stores_nothing() {
|
||||
let mut host = host_on_a_fold();
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &month_cell("Feb", "Profit"), false);
|
||||
assert!(refused(&answer), "not refused: {answer:?}");
|
||||
assert_eq!(
|
||||
document_text(&host),
|
||||
before,
|
||||
"a value was stored under the formula"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-ciw8: a key the None axis aggregates over is `CellId::Aggregate`.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_none_axis_aggregate_is_rejected_and_stores_nothing() {
|
||||
let mut host = host();
|
||||
host.app
|
||||
.model_state
|
||||
.workbook
|
||||
.add_category("Product")
|
||||
.unwrap();
|
||||
host.app
|
||||
.model_state
|
||||
.workbook
|
||||
.add_item("Product", "Widget")
|
||||
.unwrap();
|
||||
let view = host.app.model_state.workbook.active_view_mut();
|
||||
view.set_axis("Product", Axis::None);
|
||||
host.app.rebuild_layout();
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &cell("South"), false);
|
||||
assert!(refused(&answer), "not refused: {answer:?}");
|
||||
assert_eq!(document_text(&host), before, "the partial key was written");
|
||||
}
|
||||
|
||||
/// improvise-ciw8's second question: a write naming a record that never existed
|
||||
/// is refused by the frame, not dropped — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_record_that_never_existed_is_rejected() {
|
||||
use improvise_protocol::ModelCommand;
|
||||
let mut host = host_on_a_fold();
|
||||
let before = document_text(&host);
|
||||
// Claimed by a caught-up basis: structural staleness, answered up front.
|
||||
let answer = wire_write(&mut host, &month_cell("Nowhere", "Cost"), false);
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
|
||||
"claimed: {answer:?}"
|
||||
);
|
||||
// Unclaimed: the create exemption covers only LATER commands in a frame,
|
||||
// so a lone write is answered up front too.
|
||||
let key = month_cell("Nowhere", "Cost");
|
||||
let cmd = ModelCommand::SetCell {
|
||||
cell: RecordCell {
|
||||
record: RecordHash::of(&key),
|
||||
measure: Some("Cost".into()),
|
||||
},
|
||||
value: CellValue::Number(7.0),
|
||||
};
|
||||
let basis = improvise_protocol::Basis {
|
||||
document_generation: host.app().document_generation(),
|
||||
layout_version: host.app().layout_version(),
|
||||
records: vec![],
|
||||
};
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let frame = ClientFrame::Commands(vec![Command::Model { basis, cmd }]);
|
||||
let answer = host.handle(asker, frame);
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))),
|
||||
"unclaimed: {answer:?}"
|
||||
);
|
||||
assert_eq!(document_text(&host), before);
|
||||
}
|
||||
|
||||
/// The control for the three above: the stored cell beside the fold is written.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_stored_cell_beside_a_fold_is_accepted() {
|
||||
let mut host = host_on_a_fold();
|
||||
let answer = wire_write(&mut host, &month_cell("Feb", "Cost"), false);
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Accepted)),
|
||||
"{answer:?}"
|
||||
);
|
||||
let model = &host.app().model_state.workbook.model;
|
||||
let value = model.get_cell(&month_cell("Feb", "Cost"));
|
||||
assert_eq!(value, Some(&CellValue::Number(7.0)));
|
||||
}
|
||||
|
||||
/// improvise-sjdb: an empty record is judged at its own cell, not at the first
|
||||
/// column carrying it — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn an_empty_cell_beside_a_collapsed_measure_group_is_writable() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.add_item("Month", month).unwrap();
|
||||
}
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Units").unwrap();
|
||||
let jan = month_cell("Jan", "Revenue");
|
||||
wb.model.set_cell(jan, CellValue::Number(10.0));
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("_Measure", "Money");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let target = month_cell("Feb", "Units");
|
||||
let units = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.cell_key(1, c).as_ref() == Some(&target))
|
||||
.expect("setup: Feb x Units is drawn");
|
||||
let fold = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.folds_a_group(1, c))
|
||||
.expect("setup: a folded measure column");
|
||||
assert!(fold < units, "setup: the fold comes first on Feb's row");
|
||||
let id =
|
||||
crate::ui::project::cell_id(&app.model_state.workbook.model, &app.layout, 1, units);
|
||||
let projected = matches!(id, Some(improvise_protocol::CellId::Record(_)));
|
||||
assert!(projected, "setup: Feb x Units projects as {id:?}");
|
||||
let mut host = Host::new(app);
|
||||
let answer = wire_write(&mut host, &target, false);
|
||||
let accepted = answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Accepted));
|
||||
assert!(accepted, "a projected Record was refused: {answer:?}");
|
||||
let model = &host.app().model_state.workbook.model;
|
||||
assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0)));
|
||||
}
|
||||
|
||||
/// improvise-b114: a coordinate naming a group is no item, in any view — see
|
||||
/// context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_naming_a_measure_group_is_rejected_in_any_view() {
|
||||
for collapsed in [true, false] {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.add_item("Month", month).unwrap();
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
|
||||
let key = month_cell(month, measure);
|
||||
wb.model.set_cell(key, CellValue::Number(10.0));
|
||||
}
|
||||
}
|
||||
wb.add_item("_Measure", "Units").unwrap();
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
if collapsed {
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("_Measure", "Money");
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let mut host = Host::new(app);
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &month_cell("Feb", "Money"), false);
|
||||
assert!(refused(&answer), "collapsed={collapsed}: {answer:?}");
|
||||
let after = document_text(&host);
|
||||
assert_eq!(after, before, "collapsed={collapsed}: a group was stored");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-xur5 (the reviewer's `probe_fold_hidden_measure`): a fold's record
|
||||
/// with a measure the host draws nowhere — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected() {
|
||||
let hidden = month_cell("Q2", "Cost");
|
||||
let bogus = month_cell("Q2", "Bogus");
|
||||
let bare = CellKey::new(vec![("Month".to_string(), "Q2".to_string())]);
|
||||
for key in [hidden, bogus, bare] {
|
||||
let mut host = host_on_a_fold();
|
||||
let view = host.app.model_state.workbook.active_view_mut();
|
||||
view.hide_item("_Measure", "Cost");
|
||||
host.app.rebuild_layout();
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &key, false);
|
||||
assert!(refused(&answer), "{key:?} accepted: {answer:?}");
|
||||
assert_eq!(document_text(&host), before, "{key:?} stored a group");
|
||||
}
|
||||
}
|
||||
|
||||
/// A STORED record is writable whatever the host's view does to it — see
|
||||
/// context/test-evidence/command.md (improvise-sjdb).
|
||||
#[test]
|
||||
fn a_stored_record_the_hosts_view_aggregates_is_writable() {
|
||||
let mut wb = workbook();
|
||||
wb.model.set_cell(cell("South"), CellValue::Number(1.0));
|
||||
wb.add_category("Product").unwrap();
|
||||
wb.add_item("Product", "Widget").unwrap();
|
||||
wb.active_view_mut().set_axis("Product", Axis::None);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let south = (0..app.layout.row_count())
|
||||
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
|
||||
.find(|&(r, c)| app.layout.cell_key(r, c).as_ref() == Some(&cell("South")))
|
||||
.expect("setup: South's cell is drawn");
|
||||
let id = crate::ui::project::cell_id(
|
||||
&app.model_state.workbook.model,
|
||||
&app.layout,
|
||||
south.0,
|
||||
south.1,
|
||||
);
|
||||
let aggregate = matches!(id, Some(improvise_protocol::CellId::Aggregate(_)));
|
||||
assert!(aggregate, "setup: the host's view aggregates South: {id:?}");
|
||||
let mut host = Host::new(app);
|
||||
let answer = wire_write(&mut host, &cell("South"), false);
|
||||
let accepted = answer
|
||||
.iter()
|
||||
.any(|a| matches!(a.msg, ServerFrame::Accepted));
|
||||
assert!(accepted, "a stored record was refused: {answer:?}");
|
||||
let model = &host.app().model_state.workbook.model;
|
||||
assert_eq!(
|
||||
model.get_cell(&cell("South")),
|
||||
Some(&CellValue::Number(7.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Clause 4 must not fire on a frame that merely names a record it is
|
||||
/// creating. See `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
@@ -1430,6 +1788,71 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-wdmp: the asker's own frame placed its cursor after the reframe,
|
||||
/// and the relayed reset undid it. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed() {
|
||||
use improvise_protocol::{AppMode, ClientState};
|
||||
|
||||
// With a placement after the reframe, and without one: the asker ends where
|
||||
// the TUI would, and its client agrees with its mirror either way.
|
||||
for (placed, expected) in [(true, (1, 0)), (false, (0, 0))] {
|
||||
let mut host = host();
|
||||
let (asker, snapshot) = host.connect(10, 10);
|
||||
let (bystander, _) = host.connect(10, 10);
|
||||
let mut client = ClientState::default();
|
||||
match snapshot.msg {
|
||||
ServerFrame::Snapshot(s) => client.bootstrap(*s),
|
||||
other => panic!("setup: connect answers a Snapshot, got {other:?}"),
|
||||
}
|
||||
let send = |host: &mut Host, client: &mut ClientState, frame: Vec<Command>| {
|
||||
client.fold_outgoing(&frame);
|
||||
let answer = host.handle(asker, ClientFrame::Commands(frame));
|
||||
for msg in to(&answer, asker) {
|
||||
match msg {
|
||||
ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)),
|
||||
ServerFrame::Accepted => client.accepted(),
|
||||
other => panic!("the frame must be accepted: {other:?}"),
|
||||
}
|
||||
}
|
||||
answer
|
||||
};
|
||||
// Off the origin first, so a reset that never arrived is visible.
|
||||
send(
|
||||
&mut host,
|
||||
&mut client,
|
||||
vec![Command::SetSelected { row: 1, col: 0 }],
|
||||
);
|
||||
|
||||
// The `+cat` commit's shape: tidy-up, the schema change, then (maybe)
|
||||
// the cursor put back where the user was (resolve.rs::create_column).
|
||||
let add = ModelCommand::AddCategory("Product".into());
|
||||
let mut frame = vec![
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
crate::ui::reduce::caught_up(host.app(), add),
|
||||
];
|
||||
if placed {
|
||||
frame.push(Command::SetSelected { row: 1, col: 0 });
|
||||
}
|
||||
let answer = send(&mut host, &mut client, frame);
|
||||
assert!(
|
||||
to(&answer, bystander).contains(&ServerFrame::Commands(vec![Command::ResetCursor])),
|
||||
"setup: the frame must really reframe, or nothing here is at stake"
|
||||
);
|
||||
|
||||
let mirror = host.sessions.get(asker).unwrap().view().cursor();
|
||||
assert_eq!(
|
||||
mirror.selected, expected,
|
||||
"setup, placed={placed}: the mirror"
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.cursor(),
|
||||
mirror,
|
||||
"placed={placed}: the asker's client and its mirror disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A refused batch is answered to the **asker and nobody else** (ADR 001):
|
||||
/// nothing happened, so there is nothing to tell the others.
|
||||
#[test]
|
||||
@@ -1541,10 +1964,12 @@ mod tests {
|
||||
.any(|f| matches!(f.msg, ServerFrame::Snapshot(_))),
|
||||
"a failed load cost every client its cursors: {frames:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app().view_state.status_msg.starts_with("Load error"),
|
||||
"the fixture did not actually fail the load: {:?}",
|
||||
host.app().view_state.status_msg
|
||||
// Since improvise-bok4 a failed load is REFUSED, so the refusal is the
|
||||
// witness that it failed; the host's status line is rolled back with it.
|
||||
assert_eq!(
|
||||
to(&frames, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"the fixture did not actually fail the load"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1778,4 +2203,271 @@ mod tests {
|
||||
let json = serde_json::to_string(&inbound).unwrap();
|
||||
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
|
||||
}
|
||||
|
||||
/// Two model commands, each caught up against the document as the client
|
||||
/// saw it, so no basis tier can refuse the frame first (improvise-bok4).
|
||||
fn two_command_frame(host: &Host, first: ModelCommand, then: ModelCommand) -> ClientFrame {
|
||||
ClientFrame::Commands(vec![
|
||||
crate::ui::reduce::caught_up(host.app(), first),
|
||||
crate::ui::reduce::caught_up(host.app(), then),
|
||||
])
|
||||
}
|
||||
|
||||
/// improvise-bok4, the view-addressed case: the second command's view is
|
||||
/// gone. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_frame_writing_a_view_its_own_first_command_deleted_is_refused() {
|
||||
let mut host = host();
|
||||
host.app_mut().model_state.workbook.create_view("Scratch");
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::DeleteView("Scratch".into()),
|
||||
ModelCommand::SetNumberFormat {
|
||||
view: "Scratch".into(),
|
||||
format: ",.2f".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"half the frame did nothing and was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.views
|
||||
.contains_key("Scratch"),
|
||||
"the refused frame's deletion must be undone"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-bok4, the category-addressed case: it narrated its failure into
|
||||
/// the HOST's status line and told the client nothing.
|
||||
#[test]
|
||||
fn a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::RemoveCategory("Region".into()),
|
||||
ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "East".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"half the frame did nothing and was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.category("Region")
|
||||
.is_some(),
|
||||
"the refused frame's removal must be undone"
|
||||
);
|
||||
}
|
||||
|
||||
/// The one-command case of the same class: deleting the last view was
|
||||
/// swallowed with `let _`, so the frame was accepted having done nothing.
|
||||
#[test]
|
||||
fn deleting_the_documents_last_view_is_refused_rather_than_accepted() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let only = host.app().view_name().to_string();
|
||||
|
||||
let answer = host.model(asker, ModelCommand::DeleteView(only.clone()));
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"a deletion the workbook refused was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(host.app().model_state.workbook.views.contains_key(&only));
|
||||
}
|
||||
|
||||
/// The control: two commands that both land are accepted, so the refusals
|
||||
/// above are about a failure and not about the frame's shape.
|
||||
#[test]
|
||||
fn a_frame_whose_commands_all_land_is_accepted() {
|
||||
let mut host = host();
|
||||
host.app_mut().model_state.workbook.create_view("Scratch");
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::SetNumberFormat {
|
||||
view: "Scratch".into(),
|
||||
format: ",.2f".into(),
|
||||
},
|
||||
ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "East".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert!(
|
||||
to(&answer, asker).contains(&ServerFrame::Accepted),
|
||||
"a frame with nothing to refuse was refused: {answer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-40j4: the formula was stored for a category the frame's own
|
||||
/// first command removed, and it survived save and reload.
|
||||
#[test]
|
||||
fn a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::RemoveCategory("Region".into()),
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = 1".into(),
|
||||
target_category: "Region".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"a formula for a vanished category was accepted: {answer:?}"
|
||||
);
|
||||
let model = &host.app().model_state.workbook.model;
|
||||
assert!(
|
||||
model.category("Region").is_some(),
|
||||
"the removal must be undone"
|
||||
);
|
||||
assert!(
|
||||
model.formulas().iter().all(|f| f.target != "Total"),
|
||||
"and the formula must not be stored"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host whose storage holds one file per way `ImportJsonHeadless` can
|
||||
/// fail, plus nothing at the two paths that must fail to read.
|
||||
fn host_with_import_fixtures() -> Host {
|
||||
let storage = super::import_fixture_storage();
|
||||
// A second view, so deleting an absent one is not refused as "the last".
|
||||
let mut wb = workbook();
|
||||
wb.create_view("Scratch");
|
||||
let mut app = App::new(wb, None);
|
||||
app.storage = Box::new(storage);
|
||||
app.rebuild_layout();
|
||||
Host::new(app)
|
||||
}
|
||||
|
||||
fn import(path: &str, array_path: Option<&str>) -> ModelCommand {
|
||||
ModelCommand::ImportJsonHeadless {
|
||||
path: PathBuf::from(path),
|
||||
model_name: None,
|
||||
array_path: array_path.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-9fpl: one case per refusal #181 converted that no other test
|
||||
/// witnesses. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn every_failing_model_command_refuses_its_whole_frame() {
|
||||
let cases: Vec<(&str, ModelCommand)> = vec![
|
||||
(
|
||||
"AddItemInGroup into an absent category",
|
||||
ModelCommand::AddItemInGroup {
|
||||
category: "Ghost".into(),
|
||||
item: "East".into(),
|
||||
group: "Coastal".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"AddFormula that does not parse",
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = (".into(),
|
||||
target_category: "_Measure".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of an absent JSON",
|
||||
import("absent.json", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of an absent CSV",
|
||||
import("absent.csv", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of a CSV that is not text",
|
||||
import("bad.csv", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of JSON that does not parse",
|
||||
import("bad.json", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless at a path with no array",
|
||||
import("nested.json", Some("nope")),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless that builds no model",
|
||||
import("measures.json", None),
|
||||
),
|
||||
// improvise-40j4: `delete_view` answers Ok for a name it lacks.
|
||||
(
|
||||
"DeleteView of a view the document lacks",
|
||||
ModelCommand::DeleteView("Ghost".into()),
|
||||
),
|
||||
(
|
||||
"AddFormula into a category the document lacks",
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = 1".into(),
|
||||
target_category: "Ghost".into(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (what, failing) in cases {
|
||||
let mut host = host_with_import_fixtures();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let landed = ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "Landed".into(),
|
||||
};
|
||||
|
||||
let answer = host.handle(asker, two_command_frame(&host, landed, failing));
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"{what}: the frame was not refused: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.category("Region")
|
||||
.is_some_and(|c| c.ordered_item_names().contains(&"Landed")),
|
||||
"{what}: the half of the frame that landed was not undone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ use improvise_io::import::analyzer::{DateComponent, FieldKind};
|
||||
use improvise_io::import::wizard::{ImportWizard, WizardStep};
|
||||
use improvise_protocol::{
|
||||
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
|
||||
CellId, Command, CreateKind, DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache,
|
||||
GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache, TileEntry,
|
||||
ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
|
||||
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
WizardFieldKind, WizardProposal, WizardStepKind, basis, reduce_view,
|
||||
};
|
||||
|
||||
@@ -222,7 +222,6 @@ fn axis_frame(
|
||||
pending.clear();
|
||||
}
|
||||
pending.push(FrameEntry::Group(FrameGroup {
|
||||
cat: cat_name.clone(),
|
||||
name: group_name.clone(),
|
||||
collapsed: view.is_group_collapsed(cat_name, group_name),
|
||||
}));
|
||||
@@ -277,11 +276,11 @@ fn cell_display(
|
||||
// with `parse::<f64>()`, which called a coordinate `2024` a number.
|
||||
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
|
||||
return CellDisplay {
|
||||
edit_text: Some(text.clone()),
|
||||
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
|
||||
text,
|
||||
numeric: matches!(value, Some(CellValue::Number(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
id: cell_id(app, layout, row, col),
|
||||
id: cell_id(&app.model_state.workbook.model, layout, row, col),
|
||||
value: value.clone(),
|
||||
};
|
||||
}
|
||||
@@ -290,7 +289,7 @@ fn cell_display(
|
||||
text: plan.at(key.as_ref()).value(value.as_ref()),
|
||||
numeric: matches!(value, Some(CellValue::Number(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
id: cell_id(app, layout, row, col),
|
||||
id: cell_id(&app.model_state.workbook.model, layout, row, col),
|
||||
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
|
||||
// beats a client un-formatting `text` (gotcha 47).
|
||||
edit_text: key.map(|key| app.display_value_in(layout, &key)),
|
||||
@@ -300,8 +299,12 @@ fn cell_display(
|
||||
|
||||
/// What the cell at `(row, col)` *is*, as the surrogate the client echoes back. The
|
||||
/// fork is `EditOrDrill`'s, asked the same way — see `context/code/command.md`.
|
||||
fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<CellId> {
|
||||
let model = &app.model_state.workbook.model;
|
||||
pub(crate) fn cell_id(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
row: usize,
|
||||
col: usize,
|
||||
) -> Option<CellId> {
|
||||
if layout.is_records_mode() {
|
||||
// A measure can be BOTH an explicit `_Measure` item and a formula target,
|
||||
// so the document is asked about the record's real key (improvise-ir7l).
|
||||
@@ -324,7 +327,7 @@ fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<Cel
|
||||
RecordCol::NewCategory => return Some(CellId::Create(CreateKind::Category)),
|
||||
// Renaming a coordinate moves every measure cell in the row, which is a
|
||||
// different intent from `SetCell` and still the host's (gotcha 1).
|
||||
RecordCol::Category(_) => return None,
|
||||
RecordCol::Category(_) => return Some(CellId::Coordinate),
|
||||
};
|
||||
return Some(CellId::Record(RecordCell {
|
||||
record: RecordHash::of(record),
|
||||
@@ -372,14 +375,15 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The raw text of every formula, which is all the formula panel draws.
|
||||
pub fn formula_texts(app: &App) -> Vec<String> {
|
||||
/// Every formula's text, which the panel draws, and its identity, which an
|
||||
/// edit names it by (improvise-km71).
|
||||
pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.formulas()
|
||||
.iter()
|
||||
.map(|f| f.raw.clone())
|
||||
.map(FormulaEntry::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -404,12 +408,17 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
|
||||
/// The tiles the tile bar offers — the same set the TUI's tile bar draws,
|
||||
/// including the virtual categories, minus the ones a drill has fixed.
|
||||
pub fn tile_entries(app: &App, view: &View) -> Vec<TileEntry> {
|
||||
let workbook = &app.model_state.workbook;
|
||||
view.tileable_cats(&workbook.model.category_names())
|
||||
tiles_of(&app.model_state.workbook.model, view)
|
||||
}
|
||||
|
||||
/// The same list from a `Model` alone, for a command that has no `App`.
|
||||
pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
|
||||
view.tileable_cats(&model.category_names())
|
||||
.into_iter()
|
||||
.map(|cat| TileEntry {
|
||||
category: cat.to_string(),
|
||||
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
|
||||
regular: model.category(cat).is_some_and(|c| c.kind.is_regular()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -431,10 +440,10 @@ pub fn category_tree_in(app: &App, view: &View) -> CategoryTreeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula panel's cache: the raw text of every formula.
|
||||
/// The formula panel's cache: every formula's text and identity.
|
||||
pub fn formulas_of(app: &App) -> FormulaCache {
|
||||
FormulaCache {
|
||||
formulas: formula_texts(app),
|
||||
formulas: formula_entries(app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +502,10 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis {
|
||||
.flatten()
|
||||
.filter_map(|cell| match cell.id.as_ref()? {
|
||||
CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))),
|
||||
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
|
||||
CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::Create(_)
|
||||
| CellId::Coordinate => None,
|
||||
})
|
||||
.collect();
|
||||
basis::published(app.document_generation(), app.layout_version(), records)
|
||||
@@ -1117,12 +1129,11 @@ fn view_names(app: &App) -> std::collections::BTreeSet<String> {
|
||||
/// The view slice a newly-connected client starts from: empty, but pointed at the
|
||||
/// document's active view, because that is what keys its cursor map (gotcha 29).
|
||||
fn fresh_view(app: &App) -> ViewState {
|
||||
let mut view = ViewState::default();
|
||||
reduce_view(
|
||||
&mut view,
|
||||
&Command::SwitchView(app.model_state.workbook.active_view.clone()),
|
||||
);
|
||||
view
|
||||
// Pointed, not switched: a fold would record a visit to `Default` (improvise-lxg0).
|
||||
ViewState {
|
||||
active_view: app.model_state.workbook.active_view.clone(),
|
||||
..ViewState::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1283,6 +1294,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `.improv` carries `nan` as a value (improvise-55v2); the NaN sits at
|
||||
/// (1, 1), off the origin. Evidence file.
|
||||
fn app_with_a_nan_cell() -> App {
|
||||
let wb = crate::persistence::parse_md(
|
||||
"v2025-04-09\n# NaN\nInitial View: Default\n\n\
|
||||
## View: Default\nRegion: row\n_Measure: column\n_Index: none\n_Dim: none\n\n\
|
||||
## Category: _Measure\n- Revenue, Cost\n\n\
|
||||
## Category: Region\n- North, South\n\n\
|
||||
## Data\n\
|
||||
Region=North, _Measure=Revenue = 1\n\
|
||||
Region=North, _Measure=Cost = 2\n\
|
||||
Region=South, _Measure=Revenue = 3\n\
|
||||
Region=South, _Measure=Cost = nan\n",
|
||||
)
|
||||
.expect("a document holding nan parses");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// A NaN cell compares unequal to itself under `CellValue`'s `PartialEq`,
|
||||
/// so the grid never quiesced (improvise-4w09). Evidence file.
|
||||
#[test]
|
||||
fn a_grid_holding_nan_quiesces() {
|
||||
let app = app_with_a_nan_cell();
|
||||
let grid = grid_of(
|
||||
&app,
|
||||
Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: 10,
|
||||
cols: 10,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
matches!(grid.cell(1, 1).and_then(|c| c.value.clone()),
|
||||
Some(CellValue::Number(n)) if n.is_nan()),
|
||||
"precondition: the NaN must be projected, or nothing below is tested"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
grid_updates(&grid, &grid),
|
||||
vec![],
|
||||
"a grid diffed against itself owes the client nothing"
|
||||
);
|
||||
assert!(
|
||||
GridCache::folded(&grid.seed()) == grid,
|
||||
"the seed must compact to the cache it came from (gotcha 32)"
|
||||
);
|
||||
|
||||
let mut sessions = Sessions::default();
|
||||
sessions.connect(&app, 10, 10);
|
||||
sessions.project_all(&app, Dirty::EVERYTHING);
|
||||
let again = sessions.project_all(&app, Dirty::EVERYTHING);
|
||||
let grid_again: Vec<_> = again
|
||||
.iter()
|
||||
.flat_map(|(_, projections)| projections)
|
||||
.filter(|p| matches!(p, Projection::Grid(_)))
|
||||
.collect();
|
||||
assert!(
|
||||
grid_again.is_empty(),
|
||||
"an unchanged grid must not be re-sent: {grid_again:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Region on Row with `East` in a group that comes **last**, so the header is
|
||||
/// the final entry and nothing follows it: `axis_frame`'s trailing rule.
|
||||
fn app_with_a_trailing_group(collapsed: bool) -> App {
|
||||
@@ -1502,6 +1578,82 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// A document with the views `names`, opening on `active`.
|
||||
fn opening_on(names: &[&str], active: &str) -> Workbook {
|
||||
let mut wb = Workbook::new("Fresh");
|
||||
for name in names.iter().filter(|n| **n != "Default") {
|
||||
wb.create_view(name.to_string());
|
||||
}
|
||||
wb.active_view = active.to_string();
|
||||
if !names.contains(&"Default") {
|
||||
wb.delete_view("Default")
|
||||
.expect("setup: drop the Default view");
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
/// improvise-lxg0: a new session has visited nothing, and `<` must not
|
||||
/// take it anywhere. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_fresh_session_has_no_history_to_step_back_through() {
|
||||
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
|
||||
let app = App::new(opening_on(names, "Second"), None);
|
||||
let mut sessions = Sessions::default();
|
||||
let (_, snapshot) = sessions.connect(&app, 10, 10);
|
||||
assert_eq!(snapshot.view.active_view, "Second", "setup: {names:?}");
|
||||
assert_eq!(snapshot.view.view_back_stack, vec![], "{names:?}");
|
||||
|
||||
let mut client = snapshot.view.clone();
|
||||
reduce_view(&mut client, &Command::ViewBack);
|
||||
assert_eq!(
|
||||
client.active_view, "Second",
|
||||
"`<` moved a fresh {names:?} client"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-v8ab: a fresh client starts from the host's own startup slice.
|
||||
/// See `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_fresh_sessions_slice_is_the_hosts_startup_slice() {
|
||||
for (names, records) in [
|
||||
(&["Default", "Second"][..], false),
|
||||
(&["Main", "Second"][..], false),
|
||||
(&["Main", "Second"][..], true),
|
||||
] {
|
||||
let mut wb = opening_on(names, "Second");
|
||||
if records {
|
||||
// `Second` opens on a records grid (improvise-y5eo).
|
||||
wb.active_view_mut().set_axis("_Index", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Dim", Axis::Column);
|
||||
}
|
||||
let app = App::new(wb, None);
|
||||
assert_eq!(app.view_state.active_view, "Second", "setup: {names:?}");
|
||||
assert_eq!(app.layout.is_records_mode(), records, "setup: {names:?}");
|
||||
let (_, snapshot) = Sessions::default().connect(&app, 10, 10);
|
||||
assert_eq!(
|
||||
snapshot.view, app.view_state,
|
||||
"{names:?}, records {records}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-lxg0: a document swap reseeds through the same door.
|
||||
#[test]
|
||||
fn a_rebased_session_has_no_history_to_step_back_through() {
|
||||
for names in [&["Default", "Second"][..], &["Main", "Second"][..]] {
|
||||
let mut app = App::new(opening_on(&["Default", "Other"], "Default"), None);
|
||||
let mut sessions = Sessions::default();
|
||||
sessions.connect(&app, 10, 10);
|
||||
app.install_workbook(opening_on(names, "Second"));
|
||||
let snapshots = sessions.rebase(&app);
|
||||
assert_eq!(snapshots.len(), 1, "setup: one session rebased");
|
||||
let view = &snapshots[0].1.view;
|
||||
assert_eq!(view.active_view, "Second", "setup: {names:?}");
|
||||
assert_eq!(view.view_back_stack, vec![], "{names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-el9h: the mirror must settle against the grid it ends up
|
||||
/// holding. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
@@ -1675,8 +1827,13 @@ mod tests {
|
||||
client.fold(&projections);
|
||||
}
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
vec!["Total = Revenue".to_string()],
|
||||
client
|
||||
.formulas
|
||||
.formulas
|
||||
.iter()
|
||||
.map(|f| f.raw.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Total = Revenue"],
|
||||
"the client must be holding the formula before it can lose it"
|
||||
);
|
||||
|
||||
@@ -1690,7 +1847,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
Vec::<String>::new(),
|
||||
Vec::<FormulaEntry>::new(),
|
||||
"the client is still showing a formula the document no longer has"
|
||||
);
|
||||
}
|
||||
@@ -1715,8 +1872,13 @@ mod tests {
|
||||
let mut client = ClientCaches::default();
|
||||
client.fold(&snapshot.seed);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
vec!["Total = Revenue".to_string()],
|
||||
client
|
||||
.formulas
|
||||
.formulas
|
||||
.iter()
|
||||
.map(|f| f.raw.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Total = Revenue"],
|
||||
"the client must be holding the formula before it can lose it"
|
||||
);
|
||||
|
||||
@@ -1739,7 +1901,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
Vec::<String>::new(),
|
||||
Vec::<FormulaEntry>::new(),
|
||||
"the client is still showing a formula the document no longer has"
|
||||
);
|
||||
}
|
||||
@@ -2612,7 +2774,7 @@ mod tests {
|
||||
}
|
||||
for name in ["Default", "Second"] {
|
||||
if name != "Default" {
|
||||
wb.create_view(name);
|
||||
wb.create_view(name.to_string());
|
||||
}
|
||||
let v = &mut wb.views[name];
|
||||
v.set_axis("Region", Axis::Row);
|
||||
@@ -2716,7 +2878,7 @@ mod tests {
|
||||
);
|
||||
for name in ["Default", "Second", "Third"] {
|
||||
if name != "Default" {
|
||||
wb.create_view(name);
|
||||
wb.create_view(name.to_string());
|
||||
}
|
||||
let v = &mut wb.views[name];
|
||||
v.set_axis("Region", Axis::Row);
|
||||
@@ -4788,20 +4950,6 @@ mod cell_identity {
|
||||
);
|
||||
}
|
||||
|
||||
/// A coordinate column is not a cell to write into (gotcha 1), so it is projected
|
||||
/// with no id and a client cannot compose a write against it.
|
||||
#[test]
|
||||
fn a_coordinate_column_is_projected_with_no_identity() {
|
||||
let app = records_app();
|
||||
let region = column_named(&app, "Region");
|
||||
assert_eq!(
|
||||
grid_of(&app, WHOLE)
|
||||
.cell(0, region)
|
||||
.and_then(|c| c.id.clone()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A document where `Profit` is BOTH an explicit `_Measure` item and a
|
||||
/// formula target — which the `+` column lets a user build.
|
||||
fn shadowed_measure_records_app() -> App {
|
||||
@@ -5073,10 +5221,9 @@ mod cell_identity {
|
||||
let ids: Vec<Option<CellId>> = (0..app.layout.col_count())
|
||||
.map(|c| grid.cell(0, c).and_then(|cell| cell.id.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids.iter().filter(|id| id.is_none()).count(),
|
||||
1,
|
||||
"only the one Region coordinate column may be nameless: {ids:?}"
|
||||
assert!(
|
||||
ids.iter().all(Option::is_some),
|
||||
"no records column is nameless since improvise-exdg: {ids:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
ids.iter()
|
||||
@@ -5097,6 +5244,46 @@ mod cell_identity {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-exdg: a coordinate cell is drawn and holds text, so it is not
|
||||
/// "no cell". `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_coordinate_cell_is_named_rather_than_left_as_no_cell() {
|
||||
let app = records_app();
|
||||
let grid = grid_of(&app, WHOLE);
|
||||
let region = column_named(&app, "Region");
|
||||
let north = grid.cell(1, region).expect("the second row's coordinate");
|
||||
assert_eq!(north.text, "North", "setup: a visible coordinate cell");
|
||||
assert_eq!(
|
||||
north.id,
|
||||
Some(CellId::Coordinate),
|
||||
"a drawn coordinate is named"
|
||||
);
|
||||
// Each cell's kind against its column's, not merely "some kind" (improvise-w1bu).
|
||||
let misnamed: Vec<(usize, usize, Option<CellId>)> = (0..app.layout.row_count())
|
||||
.flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c)))
|
||||
.map(|(r, c)| (r, c, grid.cell(r, c).and_then(|cell| cell.id.clone())))
|
||||
.filter(|(_, c, id)| !names_its_column(app.layout.record_col(*c), id.as_ref()))
|
||||
.collect();
|
||||
assert!(
|
||||
misnamed.is_empty(),
|
||||
"a records cell of the wrong kind: {misnamed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `id` is the kind `col` projects in a document with **no formula**,
|
||||
/// so `Derived` is never right here (improvise-hoxy).
|
||||
fn names_its_column(col: Option<&RecordCol>, id: Option<&CellId>) -> bool {
|
||||
match (col, id) {
|
||||
(Some(RecordCol::Category(_)), Some(CellId::Coordinate)) => true,
|
||||
(Some(RecordCol::Measure(m)), Some(CellId::Record(cell))) => {
|
||||
cell.measure.as_deref() == Some(m.as_str())
|
||||
}
|
||||
(Some(RecordCol::NewMeasure), Some(CellId::Create(CreateKind::Measure))) => true,
|
||||
(Some(RecordCol::NewCategory), Some(CellId::Create(CreateKind::Category))) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version counter is keyed by the surrogate the client holds, so one
|
||||
/// identity answers ADR 002's question and ADR 001's.
|
||||
#[test]
|
||||
@@ -5122,6 +5309,66 @@ mod cell_identity {
|
||||
"another record's basis was invalidated for nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-eccl/f2oo at the projection boundary: the grid a client is sent and
|
||||
/// the CSV an export writes agree on a fold row — see the evidence file.
|
||||
#[test]
|
||||
fn the_grid_and_a_csv_export_agree_on_a_fold_row() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
for month in ["Jan", "Feb", "Mar"] {
|
||||
wb.add_item_in_group("Month", month, "Q1").unwrap();
|
||||
}
|
||||
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)]
|
||||
{
|
||||
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(n));
|
||||
}
|
||||
}
|
||||
wb.model.add_formula(
|
||||
crate::formula::parse_formula("Margin = (Revenue - Cost) / Revenue", "_Measure")
|
||||
.unwrap(),
|
||||
);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().hide_item("Month", "Mar");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
let app = App::new(wb, None);
|
||||
|
||||
let grid = grid_of(&app, WHOLE);
|
||||
let q1 = grid.row_frame.leaf_labels().iter().position(|l| l == "Q1");
|
||||
let q1 = q1.expect("a Q1 fold row");
|
||||
let drawn: Vec<String> = (0..grid.col_frame.leaf_labels().len())
|
||||
.map(|c| grid.cell(q1, c).map(|d| d.text.clone()).unwrap_or_default())
|
||||
.collect();
|
||||
|
||||
let storage = crate::storage::MemoryFs::new();
|
||||
let path = std::path::Path::new("out.csv");
|
||||
crate::persistence::export_csv_to(&storage, &app.model_state.workbook, "Default", path)
|
||||
.expect("the export writes");
|
||||
let bytes = crate::storage::Storage::read(&storage, path).unwrap();
|
||||
let csv = String::from_utf8(bytes).unwrap();
|
||||
let exported: Vec<String> = csv
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Q1,"))
|
||||
.expect("a Q1 line")
|
||||
.split(',')
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
drawn,
|
||||
["40.00", "8.00", "0.80"],
|
||||
"March is hidden, so not folded"
|
||||
);
|
||||
assert_eq!(exported, drawn, "the export and the grid disagree:\n{csv}");
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR 001's basis check, both arms per tier (improvise-xwi6/fkfg). The fixtures
|
||||
|
||||
@@ -135,18 +135,20 @@ pub fn apply_command(app: &mut App, cmd: Checked<'_>) -> Dirty {
|
||||
/// was derived from (improvise-kmgs). Exhaustive on purpose.
|
||||
pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
||||
let one: Box<dyn Effect> = match cmd {
|
||||
// A cell the host projects as no `Record` is refused, so the frame is
|
||||
// Rejected rather than written (improvise-ciw8, ADR 002).
|
||||
ModelCommand::SetCell { cell, value } => {
|
||||
let Some(key) = app.resolve_cell(cell) else {
|
||||
return vec![];
|
||||
};
|
||||
Box::new(effect::SetCell(key, value.clone()))
|
||||
}
|
||||
ModelCommand::ClearCell(cell) => {
|
||||
let Some(key) = app.resolve_cell(cell) else {
|
||||
return vec![];
|
||||
};
|
||||
Box::new(effect::ClearCell(key))
|
||||
match (app.unwritable(cell), app.resolve_cell(cell)) {
|
||||
(Some(why), _) => effect::refuse(why),
|
||||
(None, None) => return vec![],
|
||||
(None, Some(key)) => Box::new(effect::SetCell(key, value.clone())),
|
||||
}
|
||||
}
|
||||
ModelCommand::ClearCell(cell) => match (app.unwritable(cell), app.resolve_cell(cell)) {
|
||||
(Some(why), _) => effect::refuse(why),
|
||||
(None, None) => return vec![],
|
||||
(None, Some(key)) => Box::new(effect::ClearCell(key)),
|
||||
},
|
||||
ModelCommand::AddItem { category, item } => Box::new(effect::AddItem {
|
||||
category: category.clone(),
|
||||
item: item.clone(),
|
||||
@@ -178,6 +180,11 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
||||
target: target.clone(),
|
||||
target_category: target_category.clone(),
|
||||
}),
|
||||
ModelCommand::ReplaceFormula { id, was, raw } => Box::new(effect::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
raw: raw.clone(),
|
||||
}),
|
||||
ModelCommand::CleanEmptyRecords => Box::new(effect::CleanEmptyRecords),
|
||||
ModelCommand::SortData => Box::new(effect::SortData),
|
||||
|
||||
@@ -685,6 +692,13 @@ pub(crate) mod tests {
|
||||
target: "Profit".into(),
|
||||
target_category: "_Measure".into(),
|
||||
},
|
||||
ModelCommand::ReplaceFormula {
|
||||
id: crate::formula::parse_formula("Profit = Revenue", "_Measure")
|
||||
.unwrap()
|
||||
.id(),
|
||||
was: "Profit = Revenue".into(),
|
||||
raw: "Profit = Revenue - Cost".into(),
|
||||
},
|
||||
ModelCommand::CleanEmptyRecords,
|
||||
ModelCommand::SortData,
|
||||
ModelCommand::AddCategory("Product".into()),
|
||||
@@ -749,6 +763,7 @@ pub(crate) mod tests {
|
||||
ModelCommand::RemoveItem { .. } => "RemoveItem",
|
||||
ModelCommand::AddFormula { .. } => "AddFormula",
|
||||
ModelCommand::RemoveFormula { .. } => "RemoveFormula",
|
||||
ModelCommand::ReplaceFormula { .. } => "ReplaceFormula",
|
||||
ModelCommand::CleanEmptyRecords => "CleanEmptyRecords",
|
||||
ModelCommand::SortData => "SortData",
|
||||
ModelCommand::AddCategory(_) => "AddCategory",
|
||||
@@ -916,15 +931,47 @@ pub(crate) mod tests {
|
||||
/// to the same mutation lands, `Dirty` included.
|
||||
#[test]
|
||||
fn a_model_command_lands_where_its_keypress_lands() {
|
||||
/// Two categories on different axes: a one-category fixture cannot tell
|
||||
/// a transpose from a cycle (`doctrine(check_the_checker)`).
|
||||
/// Two categories on two axes, plus cells and 2+-entry view maps: why each
|
||||
/// is needed is in `context/test-evidence/command.md` (improvise-djcn).
|
||||
fn pivoted() -> App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut app = app();
|
||||
app.model_state.workbook.add_category("Month").unwrap();
|
||||
app.model_state.workbook.add_item("Month", "Jan").unwrap();
|
||||
let view = app.model_state.workbook.active_view_mut();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
let items = [
|
||||
("Region", ["South", "East", "West"]),
|
||||
("Month", ["Jan", "Feb", "Mar"]),
|
||||
("Channel", ["Web", "Store", "Phone"]),
|
||||
("Year", ["2024", "2025", "2026"]),
|
||||
];
|
||||
for (category, names) in items {
|
||||
wb.add_category(category).ok();
|
||||
names.iter().for_each(|n| drop(wb.add_item(category, *n)));
|
||||
}
|
||||
for (n, (region, month)) in [("North", "Jan"), ("South", "Feb"), ("East", "Jan")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let key = CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("Month".into(), month.into()),
|
||||
("Channel".into(), "Web".into()),
|
||||
("Year".into(), "2024".into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0));
|
||||
}
|
||||
let view = wb.active_view_mut();
|
||||
view.set_axis("Region", Axis::Row);
|
||||
view.set_axis("Month", Axis::Column);
|
||||
view.set_axis("Channel", Axis::Page);
|
||||
view.set_axis("Year", Axis::Page);
|
||||
view.set_page_selection("Channel", "Web");
|
||||
view.set_page_selection("Year", "2024");
|
||||
view.hide_item("Region", "West");
|
||||
view.hide_item("Region", "South");
|
||||
view.hide_item("Month", "Mar");
|
||||
view.toggle_group_collapse("Region", "Coastal");
|
||||
view.toggle_group_collapse("Region", "Inland");
|
||||
view.toggle_group_collapse("Month", "Q1");
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use unicode_width::UnicodeWidthStr;
|
||||
use improvise_protocol::header_level_widths;
|
||||
|
||||
use crate::format::{FormatPlan, NumberFormat};
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::{Aggregation, Model};
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
@@ -110,9 +110,7 @@ fn cell_value(
|
||||
true => (None, layout.records_value(row, col)),
|
||||
false => {
|
||||
let key = layout.cell_key(row, col);
|
||||
let value = key
|
||||
.as_ref()
|
||||
.and_then(|k| model.evaluate_aggregated(k, Aggregation::of(&layout.none_cats)));
|
||||
let value = key.as_ref().and_then(|k| layout.evaluate(model, k));
|
||||
(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
[dependencies]
|
||||
improvise-formula = { path = "../improvise-formula" }
|
||||
anyhow = "1"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
indexmap = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
# SipHash-2-4 under a constant key: the record surrogate must not move
|
||||
# between processes, which rules out anything built on `RandomState`
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type CategoryId = usize;
|
||||
pub type ItemId = usize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
@@ -27,7 +26,7 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Group {
|
||||
pub name: String,
|
||||
/// Parent group name for nested hierarchies
|
||||
@@ -50,7 +49,7 @@ impl Group {
|
||||
|
||||
/// Regular categories store their items explicitly; virtual ones are
|
||||
/// synthesized at query time by the layout layer. context/code/core.md §9.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum CategoryKind {
|
||||
#[default]
|
||||
Regular,
|
||||
@@ -125,7 +124,7 @@ impl CategoryKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Category {
|
||||
pub id: CategoryId,
|
||||
pub name: String,
|
||||
@@ -136,7 +135,6 @@ pub struct Category {
|
||||
/// Next item id counter
|
||||
next_item_id: ItemId,
|
||||
/// Whether this is a regular or virtual category
|
||||
#[serde(default)]
|
||||
pub kind: CategoryKind,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::symbol::{Symbol, SymbolTable};
|
||||
|
||||
/// A cell key: (category, item) pairs, always sorted by category name. The
|
||||
/// constructors are the only way to build one — context/code/core.md §1.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct CellKey(pub Vec<(String, String)>);
|
||||
|
||||
impl CellKey {
|
||||
@@ -134,6 +134,17 @@ impl CellValue {
|
||||
}
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
/// The text an editor opens on, which commits back to this value unchanged.
|
||||
/// `Display` rounds for the screen; this must not (`context/code/core.md` §17).
|
||||
pub fn edit_text(&self) -> String {
|
||||
match self {
|
||||
CellValue::Number(n) => nonfinite_token(*n).map_or_else(|| n.to_string(), String::from),
|
||||
CellValue::Text(_) | CellValue::Error(_) => self.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -183,9 +194,7 @@ impl RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized as a list of (key, value) pairs, so `CellKey` need not
|
||||
/// serialize as a string for JSON object keys.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DataStore {
|
||||
/// Primary storage: interned keys, insertion-ordered so records mode can
|
||||
/// show rows in entry order. That order is correctness — core.md §8.
|
||||
@@ -199,26 +208,34 @@ pub struct DataStore {
|
||||
records: HashMap<RecordHash, RecordEntry>,
|
||||
}
|
||||
|
||||
impl Serialize for DataStore {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeSeq;
|
||||
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||
for (k, v) in &self.cells {
|
||||
let cell_key = self.to_cell_key(k);
|
||||
seq.serialize_element(&(cell_key, v))?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DataStore {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
||||
let mut store = DataStore::default();
|
||||
for (key, value) in pairs {
|
||||
store.set(key, value);
|
||||
}
|
||||
Ok(store)
|
||||
/// Resolved through the symbol table and sorted wherever the storage is a
|
||||
/// `HashMap`, so two equal stores print alike (improvise-djcn).
|
||||
impl std::fmt::Debug for DataStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let DataStore {
|
||||
cells,
|
||||
symbols,
|
||||
index,
|
||||
records,
|
||||
} = self;
|
||||
let cells: Vec<_> = cells
|
||||
.iter()
|
||||
.map(|(key, value)| (self.to_cell_key(key), value))
|
||||
.collect();
|
||||
let index: std::collections::BTreeMap<_, std::collections::BTreeSet<_>> = index
|
||||
.iter()
|
||||
.map(|((cat, item), keys)| {
|
||||
let pair = (symbols.resolve(*cat), symbols.resolve(*item));
|
||||
(pair, keys.iter().map(|k| self.to_cell_key(k).0).collect())
|
||||
})
|
||||
.collect();
|
||||
let records: std::collections::BTreeMap<_, _> = records.iter().collect();
|
||||
f.debug_struct("DataStore")
|
||||
.field("cells", &cells)
|
||||
.field("symbols", symbols)
|
||||
.field("index", &index)
|
||||
.field("records", &records)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,15 +1187,4 @@ mod record_index {
|
||||
assert_eq!(store.resolve_record(hash), Some(record));
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived state must come back *with* the data, rebuilt through `set`,
|
||||
/// rather than be written alongside it.
|
||||
#[test]
|
||||
fn a_deserialized_store_can_resolve_its_records() {
|
||||
let json = serde_json::to_string(&store()).expect("serializes");
|
||||
let back: DataStore = serde_json::from_str(&json).expect("deserializes");
|
||||
let east = key(&[("Product", "Shirts"), ("Region", "East")]);
|
||||
assert_eq!(back.resolve_record(RecordHash::of(&east)), Some(&east));
|
||||
assert_eq!(back.live_records().count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,22 @@ use std::collections::HashMap;
|
||||
pub struct Symbol(u64);
|
||||
|
||||
/// Bidirectional string ↔ Symbol mapping.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SymbolTable {
|
||||
to_id: HashMap<String, Symbol>,
|
||||
to_str: Vec<String>,
|
||||
}
|
||||
|
||||
/// The interned strings, sorted: which `Symbol` each got is interning order,
|
||||
/// not content, so two equal stores print alike (improvise-djcn).
|
||||
impl std::fmt::Debug for SymbolTable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let SymbolTable { to_id, to_str: _ } = self;
|
||||
let interned: std::collections::BTreeSet<&String> = to_id.keys().collect();
|
||||
f.debug_set().entries(interned).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolTable {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -2,11 +2,10 @@ use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use indexmap::{IndexMap, IndexSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
use crate::formula::{AggFunc, Expr, Filter, Formula};
|
||||
use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId};
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
|
||||
@@ -51,17 +50,50 @@ fn member_records(model: &Model, partial: &CellKey) -> Vec<CellKey> {
|
||||
seen.into_iter().collect()
|
||||
}
|
||||
|
||||
/// A formula-level aggregate over the data matching `context` minus the
|
||||
/// target. Deliberately *not* [`apply_agg`]'s emptiness rule — §2.
|
||||
/// A formula-level aggregate over the data matching each of `contexts` minus the
|
||||
/// target, pooled. Deliberately *not* [`apply_agg`]'s emptiness rule — §2.
|
||||
fn eval_agg(
|
||||
model: &Model,
|
||||
context: &CellKey,
|
||||
contexts: &[CellKey],
|
||||
target_category: &str,
|
||||
func: &AggFunc,
|
||||
inner: &Expr,
|
||||
agg_filter: Option<&Filter>,
|
||||
eval_at: &dyn Fn(&Expr, &CellKey) -> Result<f64, String>,
|
||||
) -> Result<f64, String> {
|
||||
// A filter overrides its coordinate, so members it rewrites to one item name
|
||||
// one record set: pool each distinct context once (core.md §14).
|
||||
let distinct: IndexSet<CellKey> = contexts
|
||||
.iter()
|
||||
.map(|c| match agg_filter {
|
||||
Some(f) => c.clone().with(&f.category, &f.item),
|
||||
None => c.clone(),
|
||||
})
|
||||
.collect();
|
||||
let values = distinct
|
||||
.iter()
|
||||
.map(|c| agg_values(model, c, target_category, inner, agg_filter, eval_at))
|
||||
.collect::<Result<Vec<_>, String>>()?
|
||||
.concat();
|
||||
match func {
|
||||
AggFunc::Sum => Ok(values.iter().sum()),
|
||||
AggFunc::Count => Ok(values.len() as f64),
|
||||
AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
|
||||
apply_agg(&values, func).ok_or_else(|| "empty".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values an aggregate folds at one context: a stored ref's cells, or the
|
||||
/// expression asked of each member record — §13.
|
||||
fn agg_values(
|
||||
model: &Model,
|
||||
context: &CellKey,
|
||||
target_category: &str,
|
||||
inner: &Expr,
|
||||
agg_filter: Option<&Filter>,
|
||||
eval_at: &dyn Fn(&Expr, &CellKey) -> Result<f64, String>,
|
||||
) -> Result<Vec<f64>, String> {
|
||||
let mut partial = context.without(target_category);
|
||||
// The fast path is the fold specialised to a ref that names a STORED
|
||||
// cell; where it does not, the premise fails — §13, s08t, s2w3.
|
||||
@@ -83,15 +115,15 @@ fn eval_agg(
|
||||
if let Some(f) = agg_filter {
|
||||
partial = partial.with(&f.category, &f.item);
|
||||
}
|
||||
let values: Vec<f64> = match select_raw {
|
||||
match select_raw {
|
||||
// A bare ref asks for the values that exist; anything else asks the
|
||||
// expression of each member record and folds the answers — §13.
|
||||
true => model
|
||||
true => Ok(model
|
||||
.data
|
||||
.matching_values(&partial.0)
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_f64())
|
||||
.collect(),
|
||||
.collect()),
|
||||
false => member_records(model, &partial)
|
||||
.into_iter()
|
||||
.map(|record| {
|
||||
@@ -101,28 +133,48 @@ fn eval_agg(
|
||||
.fold(partial.clone(), |k, (cat, item)| k.with(cat, item));
|
||||
eval_at(inner, &at)
|
||||
})
|
||||
.collect::<Result<Vec<f64>, String>>()?,
|
||||
};
|
||||
|
||||
match func {
|
||||
AggFunc::Sum => Ok(values.iter().sum()),
|
||||
AggFunc::Count => Ok(values.len() as f64),
|
||||
AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
|
||||
apply_agg(&values, func).ok_or_else(|| "empty".to_string())
|
||||
}
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// How many formulas a fold has evaluated: what improvise-o1y0's regression counts.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FOLD_FORMULA_EVALUATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// One fold evaluation's memory: each formula key is evaluated once, and a key met
|
||||
/// while still in progress (`None`) is a cycle (improvise-o1y0).
|
||||
type FoldMemo = std::cell::RefCell<HashMap<CellKey, Option<Option<CellValue>>>>;
|
||||
|
||||
/// Which members of a collapsed group its fold covers. The model knows no
|
||||
/// view, so the grid passes the members it draws (core.md §14).
|
||||
pub type Covers<'a> = &'a dyn Fn(&str, &str) -> bool;
|
||||
|
||||
/// Where a formula's references are read: at one record, or across the members
|
||||
/// a collapsed group's fold covers, as a None axis reads them (core.md §14).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Reach<'a> {
|
||||
Record,
|
||||
Fold {
|
||||
agg: Aggregation,
|
||||
covers: Covers<'a>,
|
||||
memo: &'a FoldMemo,
|
||||
},
|
||||
}
|
||||
|
||||
/// The **record-level** evaluator: no cache, because a member key is not a stem
|
||||
/// the cache holds. Errors propagate immediately — §3, §13.
|
||||
/// the cache holds. Errors propagate immediately — §3, §13. `reach`: §14.
|
||||
fn eval_expr(
|
||||
expr: &Expr,
|
||||
context: &CellKey,
|
||||
model: &Model,
|
||||
target_category: &str,
|
||||
depth: u8,
|
||||
reach: Reach,
|
||||
) -> Result<f64, String> {
|
||||
use crate::formula::BinOp;
|
||||
let eval = |e: &Expr| eval_expr(e, context, model, target_category, depth, reach);
|
||||
match expr {
|
||||
Expr::Number(n) => Ok(*n),
|
||||
Expr::Ref(name) => {
|
||||
@@ -130,15 +182,21 @@ fn eval_expr(
|
||||
.find_item_category(name)
|
||||
.ok_or_else(|| format!("ref:{name}"))?;
|
||||
let new_key = context.clone().with(cat, name);
|
||||
match model.evaluate_depth(&new_key, depth) {
|
||||
let value = match reach {
|
||||
Reach::Record => model.evaluate_depth(&new_key, depth),
|
||||
Reach::Fold { agg, covers, memo } => {
|
||||
model.evaluate_fold(&new_key, agg, covers, memo)
|
||||
}
|
||||
};
|
||||
match value {
|
||||
Some(CellValue::Number(n)) => Ok(n),
|
||||
Some(CellValue::Error(e)) => Err(e),
|
||||
_ => Err(format!("ref:{name}")),
|
||||
}
|
||||
}
|
||||
Expr::BinOp(op, l, r) => {
|
||||
let lv = eval_expr(l, context, model, target_category, depth)?;
|
||||
let rv = eval_expr(r, context, model, target_category, depth)?;
|
||||
let lv = eval(l)?;
|
||||
let rv = eval(r)?;
|
||||
match op {
|
||||
BinOp::Add => Ok(lv + rv),
|
||||
BinOp::Sub => Ok(lv - rv),
|
||||
@@ -153,22 +211,26 @@ fn eval_expr(
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?),
|
||||
Expr::UnaryMinus(e) => Ok(-eval(e)?),
|
||||
// A fold's aggregate pools every member record it covers, never the
|
||||
// members' own aggregates (§14).
|
||||
Expr::Agg(func, inner, agg_filter) => eval_agg(
|
||||
model,
|
||||
context,
|
||||
&model.contexts_of(context, reach),
|
||||
target_category,
|
||||
func,
|
||||
inner.as_ref(),
|
||||
agg_filter.as_ref(),
|
||||
&|e, at| eval_expr(e, at, model, target_category, depth),
|
||||
&|e, at| eval_expr(e, at, model, target_category, depth, Reach::Record),
|
||||
),
|
||||
Expr::Is(filter) => Ok(eval_is(context, filter)),
|
||||
Expr::Is(filter) => Ok(eval_is(
|
||||
model.read_coordinate(context, &filter.category, reach),
|
||||
filter,
|
||||
)),
|
||||
Expr::If(cond, then, else_) => {
|
||||
let cv = eval_bool(cond, context, model, target_category, depth)?;
|
||||
match cv {
|
||||
true => eval_expr(then, context, model, target_category, depth),
|
||||
false => eval_expr(else_, context, model, target_category, depth),
|
||||
match eval_bool(cond, context, model, target_category, depth, reach)? {
|
||||
true => eval(then),
|
||||
false => eval(else_),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,12 +244,13 @@ fn eval_bool(
|
||||
model: &Model,
|
||||
target_category: &str,
|
||||
depth: u8,
|
||||
reach: Reach,
|
||||
) -> Result<bool, String> {
|
||||
use crate::formula::BinOp;
|
||||
match expr {
|
||||
Expr::BinOp(op, l, r) => {
|
||||
let lv = eval_expr(l, context, model, target_category, depth)?;
|
||||
let rv = eval_expr(r, context, model, target_category, depth)?;
|
||||
let lv = eval_expr(l, context, model, target_category, depth, reach)?;
|
||||
let rv = eval_expr(r, context, model, target_category, depth, reach)?;
|
||||
match op {
|
||||
BinOp::Eq => Ok((lv - rv).abs() < FLOAT_EQ_EPSILON),
|
||||
BinOp::Ne => Ok((lv - rv).abs() >= FLOAT_EQ_EPSILON),
|
||||
@@ -211,10 +274,8 @@ fn eval_bool(
|
||||
|
||||
/// `Cat IS Item` as a number. A cell with no `Cat` coordinate is **not** that
|
||||
/// item, matching `Formula::filter`'s own `unwrap_or(false)` — §13.
|
||||
fn eval_is(context: &CellKey, filter: &Filter) -> f64 {
|
||||
context
|
||||
.get(&filter.category)
|
||||
.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
|
||||
fn eval_is(coordinate: Option<&str>, filter: &Filter) -> f64 {
|
||||
coordinate.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
|
||||
}
|
||||
|
||||
/// Whether a grid hides dimensions a cell key does not name. The emptiness of
|
||||
@@ -238,7 +299,7 @@ impl Aggregation {
|
||||
|
||||
/// Pure-data document model: categories, cells, formulas. It knows nothing
|
||||
/// about views — see [`crate::workbook::Workbook`] and core.md §10.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone)]
|
||||
pub struct Model {
|
||||
pub name: String,
|
||||
pub categories: IndexMap<String, Category>,
|
||||
@@ -247,13 +308,40 @@ pub struct Model {
|
||||
next_category_id: CategoryId,
|
||||
/// Per-measure aggregation, used when collapsing `Axis::None`
|
||||
/// categories. Defaults to `Sum`.
|
||||
#[serde(default)]
|
||||
pub measure_agg: HashMap<String, AggFunc>,
|
||||
/// Cached formula evaluation results. Recomputed by `recompute_formulas`.
|
||||
#[serde(skip)]
|
||||
formula_cache: HashMap<CellKey, CellValue>,
|
||||
}
|
||||
|
||||
/// Every field, with the two `HashMap`s in key order: a recompute refills the
|
||||
/// cache in hash order, which is not a change (improvise-pc3b, gotcha 18).
|
||||
impl std::fmt::Debug for Model {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Destructured, so a new field is a compile error here (improvise-djcn).
|
||||
let Model {
|
||||
name,
|
||||
categories,
|
||||
data,
|
||||
formulas,
|
||||
next_category_id,
|
||||
measure_agg,
|
||||
formula_cache,
|
||||
} = self;
|
||||
let aggs: std::collections::BTreeMap<_, _> = measure_agg.iter().collect();
|
||||
let mut cache: Vec<_> = formula_cache.iter().collect();
|
||||
cache.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||
f.debug_struct("Model")
|
||||
.field("name", name)
|
||||
.field("categories", categories)
|
||||
.field("data", data)
|
||||
.field("formulas", formulas)
|
||||
.field("next_category_id", next_category_id)
|
||||
.field("measure_agg", &aggs)
|
||||
.field("formula_cache", &cache)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
@@ -417,6 +505,38 @@ impl Model {
|
||||
&self.formulas
|
||||
}
|
||||
|
||||
pub fn formula(&self, id: &FormulaId) -> Option<&Formula> {
|
||||
self.formulas.iter().find(|f| f.id() == *id)
|
||||
}
|
||||
|
||||
/// Put `formula` where `id` is, keeping its position; refuses rather than
|
||||
/// let a new target displace another formula — `context/code/core.md` §18.
|
||||
pub fn replace_formula(&mut self, id: &FormulaId, formula: Formula) -> Result<()> {
|
||||
let new_id = formula.id();
|
||||
let renames = new_id != *id;
|
||||
// A regular category's target is a real item; renaming it is sboz's (kb1d).
|
||||
if renames && !CategoryKind::of_name(&formula.target_category).is_measure() {
|
||||
return Err(anyhow!(
|
||||
"Renaming a formula outside _Measure is not supported yet; delete and re-add it"
|
||||
));
|
||||
}
|
||||
if renames && self.formula(&new_id).is_some() {
|
||||
return Err(anyhow!("A formula for {} already exists", formula.target));
|
||||
}
|
||||
let pos = self
|
||||
.formulas
|
||||
.iter()
|
||||
.position(|f| f.id() == *id)
|
||||
.ok_or_else(|| anyhow!("That formula no longer exists"))?;
|
||||
if !CategoryKind::of_name(&formula.target_category).is_measure()
|
||||
&& let Some(cat) = self.categories.get_mut(&formula.target_category)
|
||||
{
|
||||
cat.add_item(&formula.target);
|
||||
}
|
||||
self.formulas[pos] = formula;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The formula that owns `key`, if one does. **The one home** of "is this
|
||||
/// cell derived" — every other answer to it is a second derivation.
|
||||
pub fn formula_for(&self, key: &CellKey) -> Option<&Formula> {
|
||||
@@ -523,33 +643,88 @@ impl Model {
|
||||
}
|
||||
|
||||
pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
||||
let members = self.group_expansion(key);
|
||||
if !members.is_empty() {
|
||||
return self.evaluate_group(key, &members, agg);
|
||||
self.evaluate_covering(key, agg, &|_, _| true)
|
||||
}
|
||||
|
||||
/// [`Self::evaluate_aggregated`], with a collapsed group's fold covering only
|
||||
/// the members `covers` admits — the ones a grid draws (core.md §14).
|
||||
pub fn evaluate_covering(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
) -> Option<CellValue> {
|
||||
self.evaluate_fold(key, agg, covers, &FoldMemo::default())
|
||||
}
|
||||
|
||||
/// A key naming a group folds; any other key reads as before. A formula is
|
||||
/// evaluated OVER the fold, never folded itself — why: core.md §14.
|
||||
fn evaluate_fold(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
agg: Aggregation,
|
||||
covers: Covers,
|
||||
memo: &FoldMemo,
|
||||
) -> Option<CellValue> {
|
||||
let members = self.group_expansion(key, covers);
|
||||
match (members.is_empty(), self.formula_for(key)) {
|
||||
(true, _) => self.evaluate_unfolded(key, agg),
|
||||
(false, Some(formula)) => {
|
||||
self.fold_formula_once(formula, key, &members, (agg, covers, memo))
|
||||
}
|
||||
(false, None) => self.fold_stored(key, &members, agg),
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula at a group key, evaluated at most once per fold evaluation; a key
|
||||
/// already in progress is a cycle, so `circular` still comes out (improvise-o1y0).
|
||||
fn fold_formula_once(
|
||||
&self,
|
||||
formula: &Formula,
|
||||
key: &CellKey,
|
||||
members: &[CellKey],
|
||||
(agg, covers, memo): (Aggregation, Covers, &FoldMemo),
|
||||
) -> Option<CellValue> {
|
||||
let seen = memo.borrow().get(key).cloned();
|
||||
match seen {
|
||||
Some(Some(value)) => value,
|
||||
Some(None) => Some(CellValue::Error("circular".into())),
|
||||
None => {
|
||||
memo.borrow_mut().insert(key.clone(), None);
|
||||
#[cfg(test)]
|
||||
FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1));
|
||||
let reach = Reach::Fold { agg, covers, memo };
|
||||
let value = self.eval_formula_folded(formula, key, members, agg, reach);
|
||||
memo.borrow_mut().insert(key.clone(), Some(value.clone()));
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that names no group: exact, the formula cache, or raw aggregation.
|
||||
fn evaluate_unfolded(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
||||
if agg == Aggregation::Exact {
|
||||
return self.evaluate(key);
|
||||
}
|
||||
|
||||
// Check the formula cache first.
|
||||
if let Some(cached) = self.formula_cache.get(key) {
|
||||
return Some(cached.clone());
|
||||
}
|
||||
|
||||
// Aggregate raw data across all None-axis categories.
|
||||
aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key))
|
||||
}
|
||||
|
||||
/// One key per member where `key` names a collapsed GROUP rather than an item —
|
||||
/// the cross product over every such coordinate. Empty when none does (§14).
|
||||
fn group_expansion(&self, key: &CellKey) -> Vec<CellKey> {
|
||||
/// One key per covered member where `key` names a collapsed GROUP rather than
|
||||
/// an item — the cross product over every such coordinate. Empty when none does.
|
||||
fn group_expansion(&self, key: &CellKey, covers: Covers) -> Vec<CellKey> {
|
||||
let mut expanded = vec![key.clone()];
|
||||
for (cat_name, name) in &key.0 {
|
||||
let members = self
|
||||
let members: Vec<&str> = self
|
||||
.category(cat_name)
|
||||
.filter(|cat| !cat.items.contains_key(name.as_str()))
|
||||
.map(|cat| cat.items_in_group(name))
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|m| covers(cat_name, m))
|
||||
.collect();
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -564,29 +739,85 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's cell: stored values fold ONCE over every record the group covers,
|
||||
/// a derived one folds its members' results — why the two differ: core.md §14.
|
||||
fn evaluate_group(
|
||||
/// A coordinate as a formula reads it. At a fold a group's name is no item, so
|
||||
/// the folded category reads as absent, as on `Axis::None` (improvise-lnkb).
|
||||
fn read_coordinate<'k>(
|
||||
&self,
|
||||
context: &'k CellKey,
|
||||
cat: &str,
|
||||
reach: Reach,
|
||||
) -> Option<&'k str> {
|
||||
let value = context.get(cat)?;
|
||||
match reach {
|
||||
Reach::Fold { .. } if self.names_a_group(cat, value) => None,
|
||||
Reach::Fold { .. } | Reach::Record => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is a group of `cat` rather than one of its items.
|
||||
fn names_a_group(&self, cat: &str, name: &str) -> bool {
|
||||
self.category(cat)
|
||||
.is_some_and(|c| !c.items.contains_key(name) && !c.items_in_group(name).is_empty())
|
||||
}
|
||||
|
||||
/// The contexts an aggregate inside a formula pools over at `context`.
|
||||
fn contexts_of(&self, context: &CellKey, reach: Reach) -> Vec<CellKey> {
|
||||
let members = match reach {
|
||||
Reach::Record => Vec::new(),
|
||||
Reach::Fold { covers, .. } => self.group_expansion(context, covers),
|
||||
};
|
||||
match members.is_empty() {
|
||||
true => vec![context.clone()],
|
||||
false => members,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's stored values fold ONCE over every record the group covers.
|
||||
fn fold_stored(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
members: &[CellKey],
|
||||
agg: Aggregation,
|
||||
) -> Option<CellValue> {
|
||||
let values: Vec<CellValue> = match self.formula_for(key) {
|
||||
Some(_) => members
|
||||
.iter()
|
||||
.filter_map(|k| self.evaluate_aggregated(k, agg))
|
||||
.collect(),
|
||||
None => members
|
||||
.iter()
|
||||
.flat_map(|k| match agg {
|
||||
Aggregation::Exact => self.data.get(k).into_iter().collect(),
|
||||
Aggregation::OverHidden => self.data.matching_values(&k.0),
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
};
|
||||
aggregate_cell_values(&values.iter().collect::<Vec<_>>(), self.agg_for(key))
|
||||
let values: Vec<&CellValue> = members
|
||||
.iter()
|
||||
.flat_map(|k| match agg {
|
||||
Aggregation::Exact => self.data.get(k).into_iter().collect(),
|
||||
Aggregation::OverHidden => self.data.matching_values(&k.0),
|
||||
})
|
||||
.collect();
|
||||
aggregate_cell_values(&values, self.agg_for(key))
|
||||
}
|
||||
|
||||
/// A formula at a group key, its refs read through the fold — the value the
|
||||
/// same formula has with the group's category on `Axis::None` (§14).
|
||||
fn eval_formula_folded(
|
||||
&self,
|
||||
formula: &Formula,
|
||||
key: &CellKey,
|
||||
members: &[CellKey],
|
||||
agg: Aggregation,
|
||||
reach: Reach,
|
||||
) -> Option<CellValue> {
|
||||
let applies = formula
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_none_or(|f| self.read_coordinate(key, &f.category, reach) == Some(f.item.as_str()));
|
||||
if !applies {
|
||||
return self.fold_stored(key, members, agg);
|
||||
}
|
||||
let depth = Self::MAX_EVAL_DEPTH;
|
||||
match eval_expr(
|
||||
&formula.expr,
|
||||
key,
|
||||
self,
|
||||
&formula.target_category,
|
||||
depth,
|
||||
reach,
|
||||
) {
|
||||
Ok(n) => Some(CellValue::Number(n)),
|
||||
Err(e) => Some(CellValue::Error(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell.
|
||||
@@ -744,16 +975,19 @@ impl Model {
|
||||
)?),
|
||||
Expr::Agg(func, inner, agg_filter) => eval_agg(
|
||||
model,
|
||||
context,
|
||||
std::slice::from_ref(context),
|
||||
target_category,
|
||||
func,
|
||||
inner.as_ref(),
|
||||
agg_filter.as_ref(),
|
||||
// The fold evaluates at a MEMBER key, which is not a stem the
|
||||
// cache holds — so the record-level evaluator, in both paths.
|
||||
&|e, at| eval_expr(e, at, model, target_category, Model::MAX_EVAL_DEPTH),
|
||||
&|e, at| {
|
||||
let depth = Model::MAX_EVAL_DEPTH;
|
||||
eval_expr(e, at, model, target_category, depth, Reach::Record)
|
||||
},
|
||||
),
|
||||
Expr::Is(filter) => Ok(eval_is(context, filter)),
|
||||
Expr::Is(filter) => Ok(eval_is(context.get(&filter.category), filter)),
|
||||
Expr::If(cond, then, else_) => {
|
||||
let cv = eval_bool_cached(cond, context, model, target_category, none_cats)?;
|
||||
if cv {
|
||||
@@ -841,6 +1075,7 @@ impl Model {
|
||||
self,
|
||||
&formula.target_category,
|
||||
depth,
|
||||
Reach::Record,
|
||||
) {
|
||||
Ok(n) => Some(CellValue::Number(n)),
|
||||
Err(e) => Some(CellValue::Error(e)),
|
||||
@@ -891,7 +1126,17 @@ mod agg_helper_tests {
|
||||
let empty = Expr::Ref("Revenue".to_string());
|
||||
let ctx = CellKey::new(vec![]);
|
||||
let never = |_: &Expr, _: &CellKey| unreachable!("a bare Ref never folds per record");
|
||||
let run = |func| eval_agg(&model, &ctx, "_Measure", &func, &empty, None, &never);
|
||||
let run = |func| {
|
||||
eval_agg(
|
||||
&model,
|
||||
std::slice::from_ref(&ctx),
|
||||
"_Measure",
|
||||
&func,
|
||||
&empty,
|
||||
None,
|
||||
&never,
|
||||
)
|
||||
};
|
||||
|
||||
assert_eq!(run(AggFunc::Sum), Ok(0.0));
|
||||
assert_eq!(run(AggFunc::Count), Ok(0.0));
|
||||
@@ -1493,6 +1738,78 @@ mod model_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-o1y0: a fold evaluates each formula it reaches once — see
|
||||
/// context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_fold_evaluates_each_formula_it_reaches_once() {
|
||||
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
|
||||
m.add_formula(crate::formula::parse_formula("F0 = Revenue", "_Measure").unwrap());
|
||||
for i in 1..=8 {
|
||||
let text = format!("F{i} = F{p} + F{p}", p = i - 1);
|
||||
m.add_formula(crate::formula::parse_formula(&text, "_Measure").unwrap());
|
||||
}
|
||||
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "F8")]);
|
||||
super::FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
|
||||
let value = m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true);
|
||||
let evaluations = super::FOLD_FORMULA_EVALUATIONS.with(|n| n.get());
|
||||
assert_eq!(
|
||||
value,
|
||||
Some(CellValue::Number(7.0 * 256.0)),
|
||||
"the chain's value"
|
||||
);
|
||||
assert_eq!(evaluations, 9, "F0..F8 once each, not 2^9 - 1");
|
||||
}
|
||||
|
||||
/// improvise-o1y0's other half: a key met while still in progress is a cycle.
|
||||
#[test]
|
||||
fn a_cycle_at_a_fold_is_still_circular() {
|
||||
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
|
||||
m.add_formula(crate::formula::parse_formula("P = Q + 1", "_Measure").unwrap());
|
||||
m.add_formula(crate::formula::parse_formula("Q = P + 1", "_Measure").unwrap());
|
||||
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "P")]);
|
||||
assert_eq!(
|
||||
m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true),
|
||||
Some(CellValue::Error("circular".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-d7aj: the memo is per KEY, not per formula — one formula met at two
|
||||
/// fold keys in one evaluation. Evidence: context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn one_formula_at_two_fold_keys_is_evaluated_at_each() {
|
||||
let mut m = quarter_model(&[
|
||||
("Jan", "A", 3.0),
|
||||
("Feb", "A", 4.0),
|
||||
("Jan", "B", 100.0),
|
||||
("Feb", "B", 200.0),
|
||||
]);
|
||||
for text in ["G = Revenue * 2", "F = IF(Revenue > 50, G, G + B)"] {
|
||||
m.add_formula(crate::formula::parse_formula(text, "_Measure").unwrap());
|
||||
}
|
||||
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "F")]);
|
||||
assert_eq!(
|
||||
m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true),
|
||||
Some(CellValue::Number(614.0)),
|
||||
"G at A (14) plus F at B, which is G at B (600)"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-d7aj: a formula reached twice by two paths is finished, not in
|
||||
/// progress, the second time. Evidence: context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_diamond_at_a_fold_is_not_circular() {
|
||||
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
|
||||
for text in ["A1 = B2 + C3", "B2 = D * 2", "C3 = D * 3", "D = Revenue"] {
|
||||
m.add_formula(crate::formula::parse_formula(text, "_Measure").unwrap());
|
||||
}
|
||||
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "A1")]);
|
||||
super::FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0));
|
||||
let value = m.evaluate_covering(&key, Aggregation::Exact, &|_, _| true);
|
||||
let evaluations = super::FOLD_FORMULA_EVALUATIONS.with(|n| n.get());
|
||||
assert_eq!(value, Some(CellValue::Number(35.0)), "14 + 21 over D = 7");
|
||||
assert_eq!(evaluations, 4, "A1, B2, C3 and D once each");
|
||||
}
|
||||
|
||||
/// Evidence: context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_groups_stored_values_fold_once_not_per_member() {
|
||||
@@ -1861,6 +2178,88 @@ mod formula_tests {
|
||||
assert_eq!(m.evaluate(&k), None);
|
||||
}
|
||||
|
||||
fn three_formulas() -> Model {
|
||||
let mut m = revenue_cost_model();
|
||||
for raw in [
|
||||
"Profit = Revenue - Cost",
|
||||
"Tax = Revenue * 0.1",
|
||||
"Margin = Profit / Revenue",
|
||||
] {
|
||||
m.add_formula(parse_formula(raw, "_Measure").unwrap());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn targets(m: &Model) -> Vec<&str> {
|
||||
m.formulas.iter().map(|f| f.target.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_renames_in_place_and_evaluates_under_the_new_name() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
m.replace_formula(
|
||||
&id,
|
||||
parse_formula("Levy = Revenue * 0.5", "_Measure").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(targets(&m), vec!["Profit", "Levy", "Margin"]);
|
||||
let levy = coord(&[("_Measure", "Levy"), ("Region", "East")]);
|
||||
assert_eq!(m.evaluate(&levy).and_then(|v| v.as_f64()), Some(500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_refuses_to_displace_another_formula() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
let onto_margin = parse_formula("Margin = Revenue", "_Measure").unwrap();
|
||||
assert!(m.replace_formula(&id, onto_margin).is_err());
|
||||
assert_eq!(targets(&m), vec!["Profit", "Tax", "Margin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_refuses_an_id_that_names_nothing() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
m.remove_formula("Tax", "_Measure");
|
||||
let tax = parse_formula("Tax = Revenue", "_Measure").unwrap();
|
||||
assert!(m.replace_formula(&id, tax).is_err());
|
||||
assert_eq!(targets(&m), vec!["Profit", "Margin"]);
|
||||
}
|
||||
|
||||
/// improvise-kb1d: a Region-targeted formula at index 1. A rename would
|
||||
/// leave `Total` as an empty item and append `Sum`, so it is refused.
|
||||
#[test]
|
||||
fn replace_formula_refuses_to_rename_a_target_outside_measure() {
|
||||
let mut m = three_formulas();
|
||||
m.formulas
|
||||
.insert(1, parse_formula("Total = East + West", "Region").unwrap());
|
||||
let shape = |m: &Model| {
|
||||
let region = m.category("Region").unwrap().ordered_item_names();
|
||||
let owned: Vec<String> = region.iter().map(|s| s.to_string()).collect();
|
||||
(
|
||||
targets(m).iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
owned,
|
||||
)
|
||||
};
|
||||
let before = shape(&m);
|
||||
let id = m.formulas[1].id();
|
||||
let sum = parse_formula("Sum = East + West", "Region").unwrap();
|
||||
assert!(m.replace_formula(&id, sum).is_err());
|
||||
assert_eq!(shape(&m), before, "nothing moved, nothing added");
|
||||
let edit = parse_formula("Total = East - West", "Region").unwrap();
|
||||
m.replace_formula(&id, edit).unwrap();
|
||||
assert_eq!(targets(&m), vec!["Profit", "Total", "Tax", "Margin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_mentions_what_its_expression_or_its_where_names() {
|
||||
let margin = parse_formula("Margin = Profit / Revenue", "_Measure").unwrap();
|
||||
assert!(margin.mentions("Profit") && !margin.mentions("Tax"));
|
||||
let scoped = parse_formula("East = SUM(Revenue) WHERE Region = \"East\"", "_Measure");
|
||||
assert!(scoped.unwrap().mentions("Region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_aggregation_across_region() {
|
||||
let mut m = revenue_cost_model();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
|
||||
@@ -282,60 +283,53 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
|
||||
.nth(data_idx)
|
||||
}
|
||||
|
||||
/// The run a header opened has ended. A header still pending saw either no
|
||||
/// item at all — it is collapsed, and survives — or only rejected ones.
|
||||
fn close_pending_run(
|
||||
kept: &mut Vec<AxisEntry>,
|
||||
pending: Option<AxisEntry>,
|
||||
had_item: bool,
|
||||
) -> bool {
|
||||
pending
|
||||
.filter(|_| !had_item)
|
||||
.map(|h| kept.push(h))
|
||||
.is_some()
|
||||
/// A header ends the pending run of its OWN category and every run nested inside
|
||||
/// it; an outer category's header is still waiting for an item (improvise-b5zh).
|
||||
fn close_runs_of(pending: &mut Vec<AxisEntry>, header: &AxisEntry) {
|
||||
let cat_of = |e: &AxisEntry| match e {
|
||||
AxisEntry::GroupHeader { cat_name, .. } => Some(cat_name.clone()),
|
||||
AxisEntry::GroupEnd | AxisEntry::DataItem { .. } => None,
|
||||
};
|
||||
if let Some(at) = pending.iter().position(|h| cat_of(h) == cat_of(header)) {
|
||||
pending.truncate(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the data items `keep` rejects, keeping a header only if an item under
|
||||
/// it survives or the group is collapsed. A predicate — why: core.md §6.
|
||||
/// it survives. A predicate — why: core.md §6.
|
||||
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
|
||||
let mut kept: Vec<AxisEntry> = Vec::new();
|
||||
let mut pending_header: Option<AxisEntry> = None;
|
||||
let mut run_had_item = false;
|
||||
// Headers no kept item has reached yet, outermost first.
|
||||
let mut pending: Vec<AxisEntry> = Vec::new();
|
||||
let mut run_open = false;
|
||||
let mut data_idx = 0usize;
|
||||
for entry in items.drain(..) {
|
||||
match &entry {
|
||||
// A header ends the previous run: another group's header is where
|
||||
// `expand_category` stops emitting a `GroupEnd` (improvise-gd83).
|
||||
// Another group's header is where `expand_category` stops emitting a
|
||||
// `GroupEnd` (improvise-gd83), so it closes that group's run.
|
||||
AxisEntry::GroupHeader { .. } => {
|
||||
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
|
||||
pending_header = Some(entry);
|
||||
run_had_item = false;
|
||||
close_runs_of(&mut pending, &entry);
|
||||
pending.push(entry);
|
||||
}
|
||||
// Kept whenever a run is open in `kept`, even where the header it
|
||||
// closed was pruned: an earlier one is then re-opened (u6gc).
|
||||
AxisEntry::GroupEnd => {
|
||||
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
|
||||
run_had_item = false;
|
||||
pending.pop();
|
||||
if run_open {
|
||||
kept.push(entry);
|
||||
run_open = false;
|
||||
}
|
||||
}
|
||||
AxisEntry::DataItem { .. } => {
|
||||
run_had_item = true;
|
||||
if keep(data_idx) {
|
||||
if let Some(h) = pending_header.take() {
|
||||
kept.push(h);
|
||||
run_open = true;
|
||||
}
|
||||
run_open |= !pending.is_empty();
|
||||
kept.append(&mut pending);
|
||||
kept.push(entry);
|
||||
}
|
||||
data_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
close_pending_run(&mut kept, pending_header.take(), run_had_item);
|
||||
*items = kept;
|
||||
}
|
||||
|
||||
@@ -384,6 +378,8 @@ pub struct GridLayout {
|
||||
/// In records mode: what each column holds, parallel to `col_items`.
|
||||
/// Empty for normal pivot views.
|
||||
record_cols: Vec<RecordCol>,
|
||||
/// The view's hidden items: a fold covers only the members this grid draws.
|
||||
hidden: HashMap<String, HashSet<String>>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
@@ -467,6 +463,7 @@ impl GridLayout {
|
||||
none_cats,
|
||||
records: None,
|
||||
record_cols: Vec::new(),
|
||||
hidden: view.hidden_items.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,6 +497,7 @@ impl GridLayout {
|
||||
none_cats,
|
||||
records: Some(Rc::new(records)),
|
||||
record_cols,
|
||||
hidden: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +616,7 @@ impl GridLayout {
|
||||
for (ci, cell) in row.iter_mut().enumerate() {
|
||||
*cell = self
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats)))
|
||||
.and_then(|k| self.evaluate(model, &k))
|
||||
.is_some();
|
||||
}
|
||||
}
|
||||
@@ -656,11 +654,11 @@ impl GridLayout {
|
||||
axis_label(&self.col_items, col)
|
||||
}
|
||||
|
||||
/// Display string for a synthetic records-mode key; `None` for a pivot
|
||||
/// key.
|
||||
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
|
||||
let (row, col) = synthetic_record_info(key)?;
|
||||
self.records_display(row, col)
|
||||
/// The value of a pivot key as THIS grid reads it: aggregated over its None
|
||||
/// axis, and a fold over the members it draws (core.md §14). One evaluation.
|
||||
pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option<CellValue> {
|
||||
let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item));
|
||||
model.evaluate_covering(key, Aggregation::of(&self.none_cats), &drawn)
|
||||
}
|
||||
|
||||
/// Display text for (row, col) in either mode.
|
||||
@@ -676,8 +674,7 @@ impl GridLayout {
|
||||
} else {
|
||||
self.cell_key(row, col)
|
||||
.and_then(|key| {
|
||||
let value =
|
||||
model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))?;
|
||||
let value = self.evaluate(model, &key)?;
|
||||
Some(plan.at(Some(&key)).value(Some(&value)))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -750,7 +747,12 @@ impl GridLayout {
|
||||
/// Whether the cell at `(row, col)` folds a collapsed group: an aggregate
|
||||
/// with no record under it. The one home of that answer (§14).
|
||||
pub fn folds_a_group(&self, row: usize, col: usize) -> bool {
|
||||
folds(&self.row_items, row) || folds(&self.col_items, col)
|
||||
self.row_folds_a_group(row) || folds(&self.col_items, col)
|
||||
}
|
||||
|
||||
/// The row half of [`Self::folds_a_group`], for a command about the row alone.
|
||||
pub fn row_folds_a_group(&self, row: usize) -> bool {
|
||||
folds(&self.row_items, row)
|
||||
}
|
||||
|
||||
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
|
||||
@@ -1045,7 +1047,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_resolve_display_returns_values() {
|
||||
fn records_mode_records_display_returns_values() {
|
||||
let mut wb = records_workbook();
|
||||
let v = wb.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
@@ -1057,13 +1059,11 @@ mod tests {
|
||||
|
||||
// A measure column resolves to that measure's value
|
||||
let value_col = cols.iter().position(|c| c == "Revenue").unwrap();
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
assert_eq!(layout.resolve_display(&key).as_deref(), Some("100"));
|
||||
assert_eq!(layout.records_display(0, value_col).as_deref(), Some("100"));
|
||||
|
||||
// Category column resolves to the coordinate value
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
let display = layout.resolve_display(&key).unwrap();
|
||||
let display = layout.records_display(0, region_col).unwrap();
|
||||
assert!(
|
||||
!display.is_empty(),
|
||||
"Region column should resolve to a value"
|
||||
@@ -1706,6 +1706,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-b5zh: an OUTER header is followed by an inner one, not by an item
|
||||
/// — see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn an_outer_group_whose_rows_all_prune_loses_its_header() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Region", "North", "Upper").unwrap();
|
||||
wb.add_item_in_group("Region", "South", "Lower").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "South".into()),
|
||||
("Month".into(), month.into()),
|
||||
("Type".into(), "Food".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Type", Axis::Column);
|
||||
wb.active_view_mut().prune_empty = true;
|
||||
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
|
||||
assert_eq!(layout.row_count(), 2, "only South's rows are funded");
|
||||
assert!(
|
||||
has_header(&layout.row_items, "Lower"),
|
||||
"South's group stays"
|
||||
);
|
||||
assert!(
|
||||
!has_header(&layout.row_items, "Upper"),
|
||||
"every North row pruned, so Upper has nothing under it: {:?}",
|
||||
layout.row_items
|
||||
);
|
||||
}
|
||||
|
||||
/// The commonest case and the one the other three cannot see: SOME of a
|
||||
/// group's items prune. context/test-evidence/core.md.
|
||||
#[test]
|
||||
@@ -1750,4 +1790,241 @@ mod tests {
|
||||
"Sep is in no group — Q2's GroupEnd closed Q1's re-opened run"
|
||||
);
|
||||
}
|
||||
|
||||
/// Q2 = {Apr}, Q1 = {Jan, Feb}, so the Q1 fold is row 1. Jan and Feb carry
|
||||
/// ratios 0.5 and 0.9 whose sum, mean and pooled ratio all differ; Apr is empty.
|
||||
fn margin_book(formulas: &[&str]) -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
|
||||
for (measure, n) in [("Revenue", revenue), ("Cost", cost)] {
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]),
|
||||
CellValue::Number(n),
|
||||
);
|
||||
}
|
||||
}
|
||||
for f in formulas {
|
||||
wb.model
|
||||
.add_formula(crate::formula::parse_formula(f, "_Measure").unwrap());
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb
|
||||
}
|
||||
|
||||
/// `measure` at the row labelled `row_label`, as the grid reads it.
|
||||
fn grid_value(wb: &mut Workbook, row_label: &str, measure: &str) -> Option<f64> {
|
||||
wb.model.recompute_formulas(&wb.active_view().none_cats());
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let row = (0..layout.row_count()).find(|&r| layout.row_label(r) == row_label)?;
|
||||
let col = (0..layout.col_count()).find(|&c| layout.col_label(c) == measure)?;
|
||||
layout
|
||||
.evaluate(&wb.model, &layout.cell_key(row, col)?)
|
||||
.and_then(|v| v.as_f64())
|
||||
}
|
||||
|
||||
/// improvise-eccl — see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() {
|
||||
for formulas in [
|
||||
&["Margin = (Revenue - Cost) / Revenue"][..],
|
||||
&["Profit = Revenue - Cost", "Margin = Profit / Revenue"][..],
|
||||
] {
|
||||
let mut folded = margin_book(formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
|
||||
|
||||
let mut none = margin_book(formulas);
|
||||
none.active_view_mut().set_axis("Month", Axis::None);
|
||||
let over_none = grid_value(&mut none, "", "Margin").expect("a None-axis cell");
|
||||
|
||||
assert!((fold - 0.8).abs() < 1e-9, "{formulas:?}: fold {fold}");
|
||||
assert!(
|
||||
(fold - over_none).abs() < 1e-9,
|
||||
"{formulas:?}: {fold} vs {over_none}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-lnkb: the folded category reads as absent, as on `Axis::None` — see
|
||||
/// context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_fold_answers_is_and_where_on_its_category_as_the_none_axis_does() {
|
||||
let formulas = [
|
||||
"Flag = Month IS \"Q1\"",
|
||||
"Marked = Revenue * 2 WHERE Month = \"Q1\"",
|
||||
];
|
||||
for measure in ["Flag", "Marked"] {
|
||||
let mut folded = margin_book(&formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
let fold = grid_value(&mut folded, "Q1", measure);
|
||||
let mut none = margin_book(&formulas);
|
||||
none.active_view_mut().set_axis("Month", Axis::None);
|
||||
let over_none = grid_value(&mut none, "", measure);
|
||||
assert_eq!(
|
||||
fold, over_none,
|
||||
"{measure}: the fold and the None axis differ"
|
||||
);
|
||||
}
|
||||
let mut folded = margin_book(&formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
assert_eq!(
|
||||
grid_value(&mut folded, "Q1", "Flag"),
|
||||
Some(0.0),
|
||||
"false, not absent"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-eccl's guard on the other side: an aggregate INSIDE the formula
|
||||
/// still pools every record the fold covers.
|
||||
#[test]
|
||||
fn a_folds_aggregate_formula_pools_its_members_records() {
|
||||
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0));
|
||||
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
|
||||
}
|
||||
|
||||
/// An inline `WHERE` on the folded category makes every member context the
|
||||
/// same record set — see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn a_folds_aggregate_filtered_on_the_folded_category_counts_each_record_once() {
|
||||
let formula = ["Jans = SUM(Revenue WHERE Month = \"Jan\")"];
|
||||
let mut folded = margin_book(&formula);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
let fold = grid_value(&mut folded, "Q1", "Jans");
|
||||
|
||||
let mut none = margin_book(&formula);
|
||||
none.active_view_mut().set_axis("Month", Axis::None);
|
||||
assert_eq!(fold, Some(10.0), "Jan's revenue, once");
|
||||
assert_eq!(
|
||||
fold,
|
||||
grid_value(&mut none, "", "Jans"),
|
||||
"the None-axis answer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every row's value in column 0, summed: the totals strip's arithmetic.
|
||||
fn column_total(wb: &Workbook) -> f64 {
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
(0..layout.row_count())
|
||||
.filter_map(|r| layout.evaluate(&wb.model, &layout.cell_key(r, 0)?))
|
||||
.filter_map(|v| v.as_f64())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// improvise-f2oo — see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for (month, quarter, n) in [("Jan", "Q1", 3.0), ("Feb", "Q1", 4.0), ("Apr", "Q2", 100.0)] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("Type".into(), "Food".into()),
|
||||
]),
|
||||
CellValue::Number(n),
|
||||
);
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Type", Axis::Column);
|
||||
wb.active_view_mut().hide_item("Month", "Feb");
|
||||
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn");
|
||||
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
assert_eq!(column_total(&wb), 103.0, "z moved the total");
|
||||
assert_eq!(
|
||||
grid_value(&mut wb, "Q1", "Food"),
|
||||
Some(3.0),
|
||||
"Feb is hidden"
|
||||
);
|
||||
|
||||
// Every member hidden: no fold is drawn, and the total still agrees.
|
||||
wb.active_view_mut().hide_item("Month", "Jan");
|
||||
assert_eq!(grid_value(&mut wb, "Q1", "Food"), None, "no Q1 row");
|
||||
assert_eq!(column_total(&wb), 100.0);
|
||||
}
|
||||
|
||||
/// improvise-t0ma: the steps themselves, not their translation into effects —
|
||||
/// see context/test-evidence/core.md.
|
||||
#[test]
|
||||
fn records_steps_are_the_spine_then_every_non_index_dim_category_on_none() {
|
||||
use super::{ViewStep, records_spine, records_steps};
|
||||
let mut m = crate::model::Model::new("T");
|
||||
m.add_category("Region").unwrap();
|
||||
m.add_category("Product").unwrap();
|
||||
let none = |c: &str| ViewStep::Axis {
|
||||
category: c.to_string(),
|
||||
axis: Axis::None,
|
||||
};
|
||||
let mut expected = records_spine();
|
||||
expected.extend(
|
||||
m.categories
|
||||
.keys()
|
||||
.filter(|c| !matches!(c.as_str(), "_Index" | "_Dim"))
|
||||
.map(|c| none(c)),
|
||||
);
|
||||
assert!(
|
||||
expected.contains(&none("_Measure")) && expected.contains(&none("Region")),
|
||||
"setup: the model carries _Measure and a regular category"
|
||||
);
|
||||
assert_eq!(records_steps(&m), expected);
|
||||
}
|
||||
|
||||
/// improvise-t0ma: a drill FIXES its coordinates on Filter and frees only the
|
||||
/// unfixed, non-virtual aggregated categories.
|
||||
#[test]
|
||||
fn drill_steps_filter_each_coordinate_and_free_the_rest() {
|
||||
use super::{ViewStep, drill_steps, records_spine};
|
||||
let key = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("Month".into(), "Feb".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let none_cats: Vec<String> = ["Product", "Region", "_Index", "Customer"]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
let fixed = |c: &str, i: &str| {
|
||||
[
|
||||
ViewStep::Axis {
|
||||
category: c.into(),
|
||||
axis: Axis::Filter,
|
||||
},
|
||||
ViewStep::PageSelection {
|
||||
category: c.into(),
|
||||
item: i.into(),
|
||||
},
|
||||
]
|
||||
};
|
||||
let mut expected = records_spine();
|
||||
expected.extend(fixed("Month", "Feb"));
|
||||
expected.extend(fixed("Region", "North"));
|
||||
expected.extend(fixed("_Measure", "Revenue"));
|
||||
expected.extend(["Product", "Customer"].map(|c| ViewStep::Axis {
|
||||
category: c.into(),
|
||||
axis: Axis::None,
|
||||
}));
|
||||
assert_eq!(drill_steps(&key, &none_cats), expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::axis::Axis;
|
||||
use crate::model::category::CategoryKind;
|
||||
|
||||
fn default_prune() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Scratch views the app rebuilds on demand rather than persisting, because
|
||||
/// they put the never-persisted `_Index`/`_Dim` on axes (improvise-60z).
|
||||
pub const VIRTUAL_VIEW_NAMES: [&str; 2] = ["_Records", "_Drill"];
|
||||
@@ -32,7 +27,7 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator<Item = &'a str>) -> Stri
|
||||
.expect("a name is free below usize::MAX, there being fewer views")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
/// Axis assignment for each category
|
||||
@@ -46,10 +41,41 @@ pub struct View {
|
||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||
pub number_format: String,
|
||||
/// When true, empty rows/columns are pruned from the display.
|
||||
#[serde(default = "default_prune")]
|
||||
pub prune_empty: bool,
|
||||
}
|
||||
|
||||
/// Sorted wherever the storage is a `HashMap`, so two equal views print alike;
|
||||
/// destructured, so a new field is a compile error here (improvise-djcn).
|
||||
impl std::fmt::Debug for View {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let View {
|
||||
name,
|
||||
category_axes,
|
||||
page_selections,
|
||||
hidden_items,
|
||||
collapsed_groups,
|
||||
number_format,
|
||||
prune_empty,
|
||||
} = self;
|
||||
let sorted = |m: &HashMap<String, HashSet<String>>| -> BTreeMap<String, BTreeSet<String>> {
|
||||
m.iter()
|
||||
.map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
|
||||
.collect()
|
||||
};
|
||||
let pages: BTreeMap<_, _> = page_selections.iter().collect();
|
||||
f.debug_struct("View")
|
||||
.field("name", name)
|
||||
.field("category_axes", category_axes)
|
||||
.field("page_selections", &pages)
|
||||
.field("hidden_items", &sorted(hidden_items))
|
||||
.field("collapsed_groups", &sorted(collapsed_groups))
|
||||
.field("number_format", number_format)
|
||||
.field("prune_empty", prune_empty)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// True for the on-demand scratch views, rebuilt rather than persisted.
|
||||
pub fn is_virtual(&self) -> bool {
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::category::{CategoryId, CategoryKind, ItemId};
|
||||
use crate::view::{Axis, DEFAULT_VIEW, View};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workbook {
|
||||
pub model: Model,
|
||||
pub views: IndexMap<String, View>,
|
||||
|
||||
@@ -10,6 +10,7 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
anyhow = "1"
|
||||
pest = "2.9.2"
|
||||
pest_derive = "2.9.2"
|
||||
# For FormulaId alone, which crosses the wire (improvise-km71); no AST derive.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AggFunc {
|
||||
Sum,
|
||||
Avg,
|
||||
@@ -11,7 +9,7 @@ pub enum AggFunc {
|
||||
|
||||
/// Arithmetic and comparison operators. An enum rather than a `String`, so an
|
||||
/// invalid operator is caught at parse time — design-principles §2.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinOp {
|
||||
Add,
|
||||
Sub,
|
||||
@@ -26,13 +24,13 @@ pub enum BinOp {
|
||||
Ge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Filter {
|
||||
pub category: String,
|
||||
pub item: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Expr {
|
||||
Number(f64),
|
||||
Ref(String),
|
||||
@@ -45,7 +43,7 @@ pub enum Expr {
|
||||
Is(Filter),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Formula {
|
||||
/// The raw formula text, e.g. "Profit = Revenue - Cost"
|
||||
pub raw: String,
|
||||
@@ -59,7 +57,6 @@ pub struct Formula {
|
||||
pub filter: Option<Filter>,
|
||||
/// How this formula's target is drawn, overriding the view's `format:` for
|
||||
/// that one measure. `None` means the view answers — `context/code/core.md`.
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
@@ -87,4 +84,47 @@ impl Formula {
|
||||
self.format = format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this formula's expression or its `WHERE` names `name`.
|
||||
pub fn mentions(&self, name: &str) -> bool {
|
||||
self.expr.mentions(name) || self.filter.as_ref().is_some_and(|f| f.names(name))
|
||||
}
|
||||
|
||||
/// Which formula this is. The one constructor of a [`FormulaId`].
|
||||
pub fn id(&self) -> FormulaId {
|
||||
FormulaId {
|
||||
target: self.target.clone(),
|
||||
target_category: self.target_category.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which formula an edit means, opaque so its content can change in one place:
|
||||
/// today a target and its category — `context/code/core.md` §18.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FormulaId {
|
||||
target: String,
|
||||
target_category: String,
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// Whether this expression names `name` anywhere, as a reference or as
|
||||
/// either half of a coordinate filter.
|
||||
pub fn mentions(&self, name: &str) -> bool {
|
||||
match self {
|
||||
Expr::Number(_) => false,
|
||||
Expr::Ref(r) => r == name,
|
||||
Expr::BinOp(_, l, r) => l.mentions(name) || r.mentions(name),
|
||||
Expr::UnaryMinus(e) => e.mentions(name),
|
||||
Expr::Agg(_, e, f) => e.mentions(name) || f.as_ref().is_some_and(|f| f.names(name)),
|
||||
Expr::If(c, t, e) => c.mentions(name) || t.mentions(name) || e.mentions(name),
|
||||
Expr::Is(f) => f.names(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn names(&self, name: &str) -> bool {
|
||||
self.category == name || self.item == name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ pub mod ast;
|
||||
pub mod parser;
|
||||
pub mod quote;
|
||||
|
||||
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
||||
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula, FormulaId};
|
||||
pub use parser::parse_formula;
|
||||
pub use quote::{is_bare_name, quote_name};
|
||||
|
||||
@@ -13,11 +13,13 @@ anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
csv = "1"
|
||||
flate2 = "1"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
indexmap = "2"
|
||||
pest = "2.9.2"
|
||||
pest_derive = "2.9.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Parses JSON a user's numbers cross, so it must read back the f64 written: per crate,
|
||||
# because each build stands alone (improvise-6yvg, context/code/protocol.md).
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
|
||||
# The browser backend. Only the wasm build pulls these in; the native
|
||||
# binary never sees them (improvise-i34).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `.improv` document lifecycle — save, load, gzip, legacy JSON — over a
|
||||
//! `.improv` document lifecycle — save, load, gzip — over a
|
||||
//! [`Storage`] backend. The text format is in `format`, `parse` and `export`.
|
||||
|
||||
mod export;
|
||||
@@ -96,11 +96,16 @@ pub fn load_from(storage: &dyn Storage, path: &Path) -> Result<Workbook> {
|
||||
};
|
||||
let text =
|
||||
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?;
|
||||
// `.improv` text is the only native format; a JSON workbook is named, not
|
||||
// handed to the grammar to fail on its first byte (improvise-tt22).
|
||||
if text.trim_start().starts_with('{') {
|
||||
serde_json::from_str(&text).context("Failed to deserialize workbook")
|
||||
} else {
|
||||
parse_md(&text)
|
||||
anyhow::bail!(
|
||||
"{} is a legacy JSON workbook, which is no longer supported: \
|
||||
only .improv text can be opened",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
parse_md(&text)
|
||||
}
|
||||
|
||||
pub fn autosave_path(path: &Path) -> std::path::PathBuf {
|
||||
@@ -194,15 +199,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-tt22: `.improv` text is the only native format, so a legacy
|
||||
/// JSON workbook is refused by name. Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn load_dispatcher_detects_legacy_json_by_brace() {
|
||||
// The load() function routes to JSON deserializer when text starts with '{'
|
||||
let m = two_cat_model();
|
||||
let json = serde_json::to_string_pretty(&m).unwrap();
|
||||
assert!(json.trim_start().starts_with('{'), "sanity check");
|
||||
// Deserialise via the JSON path
|
||||
let m2: Workbook = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(m2.model.name, "Budget");
|
||||
fn a_legacy_json_workbook_is_refused_by_name() {
|
||||
use crate::storage::{MemoryFs, Storage};
|
||||
// The shape the removed loader read: `Workbook`'s serde form.
|
||||
let legacy = r#"
|
||||
{"model": {"name": "Budget", "categories": {}, "data": [], "formulas": []},
|
||||
"views": {}, "active_view": "Default"}"#;
|
||||
for name in ["legacy.improv", "legacy.improv.gz"] {
|
||||
let fs = MemoryFs::new();
|
||||
let path = std::path::Path::new(name);
|
||||
let bytes = if name.ends_with(".gz") {
|
||||
use std::io::Write;
|
||||
let mut enc =
|
||||
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
enc.write_all(legacy.as_bytes()).unwrap();
|
||||
enc.finish().unwrap()
|
||||
} else {
|
||||
legacy.as_bytes().to_vec()
|
||||
};
|
||||
fs.write(path, &bytes).unwrap();
|
||||
let err = format!("{:#}", super::load_from(&fs, path).unwrap_err());
|
||||
assert!(
|
||||
err.contains("legacy JSON") && err.contains("no longer supported"),
|
||||
"{name}: expected the format named and refused, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── save/load roundtrip via file ────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,23 @@ impl BasisCache {
|
||||
}
|
||||
}
|
||||
|
||||
impl BasisCache {
|
||||
/// A basis for what was read EARLIER, such as when an editor opened: the
|
||||
/// document and its records then, the layout now (improvise-0j0f, improvise-vndi).
|
||||
pub fn claim_as_read(
|
||||
&self,
|
||||
document_generation: u64,
|
||||
read: impl IntoIterator<Item = (RecordHash, u64)>,
|
||||
) -> Option<Basis> {
|
||||
let current = self.current.as_ref()?;
|
||||
Some(Basis {
|
||||
document_generation,
|
||||
layout_version: current.layout_version,
|
||||
records: read.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetCache for BasisCache {
|
||||
type Update = Basis;
|
||||
|
||||
|
||||
@@ -172,7 +172,8 @@ impl ClientState {
|
||||
.map(|mb| mb.buffer_key.clone())
|
||||
.filter(|key| !self.view.buffers.contains_key(key));
|
||||
if let (Some(key), Some(seed)) = (absent, crate::resolve::edit_seed(self)) {
|
||||
self.view.buffers.insert(key, seed);
|
||||
self.view.buffers.insert(key, seed.clone());
|
||||
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +281,7 @@ mod tests {
|
||||
])));
|
||||
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
regular: true,
|
||||
axis: Axis::Row,
|
||||
}])));
|
||||
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn advertised(mode: &AppMode) -> Vec<(&'static str, Option<Vec<String
|
||||
],
|
||||
AppMode::FormulaPanel => vec![
|
||||
("enter-mode", arg("formula-edit"), "new"),
|
||||
("edit-formula-at-cursor", None, "edit"),
|
||||
("delete-formula-at-cursor", None, "delete"),
|
||||
("move-panel-cursor", None, "nav"),
|
||||
("enter-mode", arg("normal"), "back"),
|
||||
@@ -304,14 +305,11 @@ pub enum HelpLine {
|
||||
Heading(&'static str),
|
||||
Text(&'static str),
|
||||
Accent(&'static str),
|
||||
/// A keys-and-description row whose keys came off the keymap, so it
|
||||
/// cannot go stale against the bindings it documents.
|
||||
Keys {
|
||||
keys: Vec<String>,
|
||||
description: &'static str,
|
||||
},
|
||||
/// A row naming something the keymap does not own: an ex-command, or a
|
||||
/// prefix sequence, which is two addresses and not one binding.
|
||||
/// A keys-and-description row whose keys came off the keymap. A [`KeyRow`]
|
||||
/// has no public constructor, so no builder can type one (improvise-v29m).
|
||||
Keys(KeyRow),
|
||||
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
|
||||
/// colon is what stops a key being typed here (improvise-wk18).
|
||||
Literal {
|
||||
keys: &'static str,
|
||||
description: &'static str,
|
||||
@@ -326,38 +324,127 @@ pub struct HelpPage {
|
||||
pub lines: Vec<HelpLine>,
|
||||
}
|
||||
|
||||
/// A keys row, looked up in `mode`. An unbound command yields no keys and the
|
||||
/// row is dropped, so the page never advertises a key that does not exist.
|
||||
fn keys(
|
||||
mode: &ModeKey,
|
||||
command: &str,
|
||||
args: Option<&[&str]>,
|
||||
description: &'static str,
|
||||
) -> Option<HelpLine> {
|
||||
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||
let found = keys_for(mode, command, args.as_deref());
|
||||
match found.is_empty() {
|
||||
true => None,
|
||||
false => Some(HelpLine::Keys {
|
||||
keys: found.iter().map(|(c, m)| key_label(c, *m)).collect(),
|
||||
description,
|
||||
}),
|
||||
pub use key_row::KeyRow;
|
||||
use key_row::keys;
|
||||
|
||||
/// The one door to a keys row: its fields are private to this module, and its
|
||||
/// only constructor looks the keys up.
|
||||
mod key_row {
|
||||
use super::{HelpLine, ModeKey, key_label, keys_for, prefixed_labels};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KeyRow {
|
||||
keys: Vec<String>,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl KeyRow {
|
||||
pub fn keys(&self) -> &[String] {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
self.description
|
||||
}
|
||||
|
||||
/// An unbound command yields no keys and no row, so the page never
|
||||
/// advertises a key that does not exist.
|
||||
fn looked_up(
|
||||
mode: &ModeKey,
|
||||
command: &str,
|
||||
args: Option<&[String]>,
|
||||
description: &'static str,
|
||||
) -> Option<Self> {
|
||||
let mut keys: Vec<String> = keys_for(mode, command, args)
|
||||
.iter()
|
||||
.map(|(c, m)| key_label(c, *m))
|
||||
.collect();
|
||||
keys.extend(prefixed_labels(mode, command, args));
|
||||
(!keys.is_empty()).then_some(Self { keys, description })
|
||||
}
|
||||
}
|
||||
|
||||
/// A keys row, looked up in `mode`: the one caller of [`KeyRow::looked_up`],
|
||||
/// and the lookup `pages` hands every builder (improvise-ssoe).
|
||||
pub(super) fn keys(
|
||||
mode: &ModeKey,
|
||||
command: &'static str,
|
||||
args: Option<&[&str]>,
|
||||
description: &'static str,
|
||||
) -> Option<HelpLine> {
|
||||
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||
KeyRow::looked_up(mode, command, args.as_deref(), description).map(HelpLine::Keys)
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(keys: &'static str, description: &'static str) -> HelpLine {
|
||||
HelpLine::Literal {
|
||||
keys,
|
||||
description,
|
||||
dim: false,
|
||||
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
|
||||
fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Vec<String> {
|
||||
let mut labels: Vec<String> = match Keymaps::shared().get(mode) {
|
||||
Some(keymap) => keymap.prefixed_keys_running(command, args),
|
||||
None => Vec::new(),
|
||||
}
|
||||
.iter()
|
||||
.map(|[(c1, m1), (c2, m2)]| format!("{}{}", key_label(c1, *m1), key_label(c2, *m2)))
|
||||
.collect();
|
||||
labels.sort();
|
||||
labels.dedup();
|
||||
labels
|
||||
}
|
||||
|
||||
fn dim(keys: &'static str, description: &'static str) -> HelpLine {
|
||||
HelpLine::Literal {
|
||||
keys,
|
||||
description,
|
||||
dim: true,
|
||||
/// How a page asks for a keys row. `pages` answers with [`keys`]; a test
|
||||
/// answers by recording the question, so a guard reads commands, not text.
|
||||
type KeysRow<'a> =
|
||||
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
|
||||
|
||||
/// An ex-command row. The colon is the macro's, and every word after the
|
||||
/// command's name must be a placeholder, checked when the page compiles.
|
||||
macro_rules! ex {
|
||||
($text:literal, $description:expr $(,)?) => {{
|
||||
const _: () = assert!(ex_usage_is_placeholders($text));
|
||||
HelpLine::Literal {
|
||||
keys: concat!(":", $text),
|
||||
description: $description,
|
||||
dim: false,
|
||||
}
|
||||
}};
|
||||
(dim $text:literal, $description:expr $(,)?) => {{
|
||||
const _: () = assert!(ex_usage_is_placeholders($text));
|
||||
HelpLine::Literal {
|
||||
keys: concat!(" :", $text),
|
||||
description: $description,
|
||||
dim: true,
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// After the name, every word opens with `<`, `[` or `"`, or is `...`: so an
|
||||
/// ex row cannot also advertise a key ("q or press Q"). Quotes span spaces.
|
||||
const fn ex_usage_is_placeholders(text: &str) -> bool {
|
||||
let b = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() && b[i] != b' ' {
|
||||
i += 1;
|
||||
}
|
||||
loop {
|
||||
while i < b.len() && b[i] == b' ' {
|
||||
i += 1;
|
||||
}
|
||||
if i == b.len() {
|
||||
return true;
|
||||
}
|
||||
let start = i;
|
||||
let mut quoted = false;
|
||||
while i < b.len() && (quoted || b[i] != b' ') {
|
||||
if b[i] == b'"' {
|
||||
quoted = !quoted;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let ellipsis =
|
||||
i - start == 3 && b[start] == b'.' && b[start + 1] == b'.' && b[start + 2] == b'.';
|
||||
if !(ellipsis || b[start] == b'<' || b[start] == b'[' || b[start] == b'"') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,30 +454,48 @@ pub fn pages() -> Vec<HelpPage> {
|
||||
let pages = vec![
|
||||
HelpPage {
|
||||
title: "Welcome",
|
||||
lines: welcome(),
|
||||
lines: welcome(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Navigation",
|
||||
lines: navigation(),
|
||||
lines: navigation(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Editing",
|
||||
lines: editing(),
|
||||
lines: editing(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Panels & Views",
|
||||
lines: panels(),
|
||||
lines: panels(&keys),
|
||||
},
|
||||
HelpPage {
|
||||
title: "Commands",
|
||||
lines: commands(),
|
||||
lines: commands(&keys),
|
||||
},
|
||||
];
|
||||
debug_assert_eq!(pages.len(), HELP_PAGE_COUNT);
|
||||
pages
|
||||
}
|
||||
|
||||
fn welcome() -> Vec<HelpLine> {
|
||||
/// Every keys row the pages ask for, as `(mode, command, args, description)`
|
||||
/// — the same builders, so a guard over it cannot drift from what is drawn.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn help_bindings() -> Vec<(ModeKey, &'static str, Option<Vec<String>>, &'static str)> {
|
||||
let asked = std::cell::RefCell::new(Vec::new());
|
||||
let record: KeysRow = &|mode, command, args, description| {
|
||||
let args = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
|
||||
asked
|
||||
.borrow_mut()
|
||||
.push((mode.clone(), command, args, description));
|
||||
None
|
||||
};
|
||||
for build in [welcome, navigation, editing, panels, commands] {
|
||||
build(record);
|
||||
}
|
||||
asked.into_inner()
|
||||
}
|
||||
|
||||
fn welcome(_: KeysRow) -> Vec<HelpLine> {
|
||||
vec![
|
||||
HelpLine::Blank,
|
||||
HelpLine::Banner(" improvise"),
|
||||
@@ -427,12 +532,10 @@ fn welcome() -> Vec<HelpLine> {
|
||||
),
|
||||
HelpLine::Text(" Tile The row/column/page assignment of a category."),
|
||||
HelpLine::Text(" Formula A computed item: derives its value from other items."),
|
||||
HelpLine::Blank,
|
||||
dim("Tip: press Tab or l/n to go to the next page.", ""),
|
||||
]
|
||||
}
|
||||
|
||||
fn navigation() -> Vec<HelpLine> {
|
||||
fn navigation(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -445,7 +548,7 @@ fn navigation() -> Vec<HelpLine> {
|
||||
None,
|
||||
"Move down, wrapping to the next column",
|
||||
),
|
||||
Some(literal("gg", "Jump to first row")),
|
||||
keys(n, "jump-first-row", None, "Jump to first row"),
|
||||
keys(n, "jump-last-row", None, "Jump to last row"),
|
||||
keys(n, "jump-first-col", None, "Jump to first column"),
|
||||
keys(n, "jump-last-col", None, "Jump to last column"),
|
||||
@@ -512,7 +615,7 @@ fn navigation() -> Vec<HelpLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn editing() -> Vec<HelpLine> {
|
||||
fn editing(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
let e = &ModeKey::Editing;
|
||||
[
|
||||
@@ -555,7 +658,7 @@ fn editing() -> Vec<HelpLine> {
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Copy and paste")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal("yy", "Yank (copy) the current cell value")),
|
||||
keys(n, "yank", None, "Yank (copy) the current cell value"),
|
||||
keys(
|
||||
n,
|
||||
"paste",
|
||||
@@ -593,7 +696,7 @@ fn editing() -> Vec<HelpLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn panels() -> Vec<HelpLine> {
|
||||
fn panels(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -618,6 +721,12 @@ fn panels() -> Vec<HelpLine> {
|
||||
Some(&["formula-edit"]),
|
||||
"New formula",
|
||||
),
|
||||
keys(
|
||||
&ModeKey::FormulaPanel,
|
||||
"edit-formula-at-cursor",
|
||||
None,
|
||||
"Edit selected formula",
|
||||
),
|
||||
keys(
|
||||
&ModeKey::FormulaPanel,
|
||||
"delete-formula-at-cursor",
|
||||
@@ -741,14 +850,14 @@ fn panels() -> Vec<HelpLine> {
|
||||
"Toggle collapse of nearest group above cursor",
|
||||
),
|
||||
keys(n, "hide-selected-row-item", None, "Hide current row item"),
|
||||
Some(dim(" :show-item <cat> <item>", "Restore a hidden item")),
|
||||
Some(ex!(dim "show-item <cat> <item>", "Restore a hidden item")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn commands() -> Vec<HelpLine> {
|
||||
fn commands(keys: KeysRow) -> Vec<HelpLine> {
|
||||
let n = &ModeKey::Normal;
|
||||
[
|
||||
Some(HelpLine::Blank),
|
||||
@@ -763,55 +872,40 @@ fn commands() -> Vec<HelpLine> {
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("File operations")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(
|
||||
":w [path]",
|
||||
"Save (path optional after first save)",
|
||||
)),
|
||||
Some(literal(":wq", "Save and quit")),
|
||||
Some(literal(":q", "Quit (warns if unsaved changes)")),
|
||||
Some(literal(":q!", "Force quit without saving")),
|
||||
Some(literal("ZZ", "Save and quit (same as :wq)")),
|
||||
Some(ex!("w [path]", "Save (path optional after first save)",)),
|
||||
Some(ex!("wq", "Save and quit")),
|
||||
Some(ex!("q", "Quit (warns if unsaved changes)")),
|
||||
Some(ex!("q!", "Force quit without saving")),
|
||||
keys(n, "wq", None, "Save and quit (same as :wq)"),
|
||||
keys(n, "save", None, "Save (same as :w)"),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Import and export")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":import <path>", "Open the JSON/CSV import wizard")),
|
||||
Some(literal(
|
||||
":export [path.csv]",
|
||||
"Export the active view to CSV",
|
||||
)),
|
||||
Some(ex!("import <path>", "Open the JSON/CSV import wizard")),
|
||||
Some(ex!("export [path.csv]", "Export the active view to CSV",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Model building")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":add-cat <name>", "Add a new category")),
|
||||
Some(literal(
|
||||
":add-item <cat> <item>",
|
||||
"Add one item to a category",
|
||||
)),
|
||||
Some(literal(
|
||||
":add-items <cat> a b c ...",
|
||||
Some(ex!("add-cat <name>", "Add a new category")),
|
||||
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
|
||||
Some(ex!(
|
||||
"add-items <cat> <item> ...",
|
||||
"Add multiple items at once",
|
||||
)),
|
||||
Some(literal(
|
||||
":formula <cat> \"<Name> = <expr>\"",
|
||||
"Add a formula",
|
||||
)),
|
||||
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Views")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(":add-view [name]", "Create a new view")),
|
||||
Some(ex!("add-view [name]", "Create a new view")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Display")),
|
||||
Some(HelpLine::Blank),
|
||||
Some(literal(
|
||||
":set-format <fmt>",
|
||||
"Set number format (e.g. ',.2')",
|
||||
)),
|
||||
Some(ex!("set-format <fmt>", "Set number format (e.g. ',.2')",)),
|
||||
Some(HelpLine::Blank),
|
||||
Some(HelpLine::Heading("Other keys")),
|
||||
Some(HelpLine::Blank),
|
||||
keys(n, "enter-mode", Some(&["help"]), "Open this help screen"),
|
||||
Some(literal(":help", "Open this help screen")),
|
||||
Some(ex!("help", "Open this help screen")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -884,8 +978,12 @@ mod tests {
|
||||
for page in &pages {
|
||||
assert!(!page.title.is_empty());
|
||||
for line in &page.lines {
|
||||
if let HelpLine::Keys { keys, description } = line {
|
||||
assert!(!keys.is_empty(), "{description} resolved to no keys");
|
||||
if let HelpLine::Keys(row) = line {
|
||||
assert!(
|
||||
!row.keys().is_empty(),
|
||||
"{} resolved to no keys",
|
||||
row.description()
|
||||
);
|
||||
rows += 1;
|
||||
}
|
||||
}
|
||||
@@ -896,6 +994,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-wk18: a key the pages name is looked up, never typed. The only
|
||||
/// hand-written row left is an ex-command. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn no_help_row_spells_a_key_by_hand() {
|
||||
let spelled: Vec<&str> = pages()
|
||||
.iter()
|
||||
.flat_map(|page| page.lines.iter())
|
||||
.filter_map(|line| match line {
|
||||
HelpLine::Literal { keys, .. } => Some(*keys),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|keys| !keys.trim_start().starts_with(':'))
|
||||
.collect();
|
||||
assert!(
|
||||
spelled.is_empty(),
|
||||
"these rows name keys the keymap never saw: {spelled:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-v29m: `ex!` refuses a usage with a bare word in it at compile
|
||||
/// time; this pins the rule it asks. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn an_ex_row_can_name_placeholders_and_nothing_else() {
|
||||
for usage in [
|
||||
"w [path]",
|
||||
"q!",
|
||||
"formula <cat> \"<Name> = <expr>\"",
|
||||
"add-items <cat> <item> ...",
|
||||
] {
|
||||
assert!(ex_usage_is_placeholders(usage), "{usage} is refused");
|
||||
}
|
||||
for usage in ["q or press Q", "add-items <cat> a b c", "help ?"] {
|
||||
assert!(!ex_usage_is_placeholders(usage), "{usage} is accepted");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-ssoe: a keys row drawn but never asked for through the builders'
|
||||
/// parameter is invisible to every guard. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn every_keys_row_drawn_is_one_the_builders_asked_for() {
|
||||
let drawn: Vec<HelpLine> = pages()
|
||||
.into_iter()
|
||||
.flat_map(|page| page.lines)
|
||||
.filter(|line| matches!(line, HelpLine::Keys(_)))
|
||||
.collect();
|
||||
// Content, in order, not a count: a bypass that also drops a real row
|
||||
// keeps the count and changes this (improvise-aszt).
|
||||
let asked: Vec<HelpLine> = help_bindings()
|
||||
.iter()
|
||||
.filter_map(|(mode, command, args, description)| {
|
||||
let args: Option<Vec<&str>> = args
|
||||
.as_ref()
|
||||
.map(|a| a.iter().map(String::as_str).collect());
|
||||
keys(mode, command, args.as_deref(), description)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(drawn, asked);
|
||||
}
|
||||
|
||||
/// A row whose lookup finds no key is dropped silently, so a broken lookup
|
||||
/// looks like a shorter page. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn every_keys_row_the_pages_ask_for_finds_its_keys() {
|
||||
let asked = help_bindings();
|
||||
assert!(asked.len() > 20, "only {} rows asked", asked.len());
|
||||
let dropped: Vec<String> = asked
|
||||
.iter()
|
||||
.filter(|(mode, command, args, description)| {
|
||||
let args: Option<Vec<&str>> = args
|
||||
.as_ref()
|
||||
.map(|a| a.iter().map(String::as_str).collect());
|
||||
keys(mode, command, args.as_deref(), description).is_none()
|
||||
})
|
||||
.map(|(mode, command, _, _)| format!("{mode:?} {command}"))
|
||||
.collect();
|
||||
assert!(dropped.is_empty(), "rows with no key: {dropped:?}");
|
||||
}
|
||||
|
||||
/// `compact`'s three shapes, including the one no shipped mode reaches:
|
||||
/// keys that are all chords leave nothing to draw.
|
||||
#[test]
|
||||
@@ -966,7 +1142,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.flat_map(|page| page.lines)
|
||||
.filter_map(|line| match line {
|
||||
HelpLine::Keys { keys, description } => Some((keys, description)),
|
||||
HelpLine::Keys(row) => Some((row.keys().to_vec(), row.description())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@@ -998,16 +1174,26 @@ mod tests {
|
||||
/// See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn only_the_edit_key_is_captioned_as_editing_the_cell() {
|
||||
let edit = labels_of("edit-or-drill", Some(&["editing".to_string()]));
|
||||
let claims: Vec<_> = rows()
|
||||
let normal: Vec<_> = help_bindings()
|
||||
.into_iter()
|
||||
.filter(|(_, d)| d.contains("Edit current cell"))
|
||||
.filter(|(mode, ..)| *mode == ModeKey::Normal)
|
||||
.collect();
|
||||
assert!(!claims.is_empty(), "the check needs a row to check");
|
||||
for (keys, description) in claims {
|
||||
// By COMMAND, not phrase (improvise-jhdf): any wording of "edit" on a
|
||||
// Normal row that is not edit-or-drill's is the lie s8h7 removed.
|
||||
let liars: Vec<_> = normal
|
||||
.iter()
|
||||
.filter(|(_, command, _, d)| {
|
||||
d.to_lowercase().contains("edit") && *command != "edit-or-drill"
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
liars.is_empty(),
|
||||
"captioned as editing but do not: {liars:?}"
|
||||
);
|
||||
for command in ["edit-or-drill", "enter-advance"] {
|
||||
assert!(
|
||||
keys.iter().all(|k| edit.contains(k)),
|
||||
"{keys:?} is captioned {description:?} but only {edit:?} edit"
|
||||
normal.iter().any(|(_, c, ..)| *c == command),
|
||||
"{command} has no Normal help row, so the check above cannot see it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1024,8 +1210,8 @@ mod tests {
|
||||
println!("\n=== {} ===", page.title);
|
||||
for line in &page.lines {
|
||||
match line {
|
||||
HelpLine::Keys { keys, description } => {
|
||||
println!(" {:<28}{description}", keys.join(" "))
|
||||
HelpLine::Keys(row) => {
|
||||
println!(" {:<28}{}", row.keys().join(" "), row.description())
|
||||
}
|
||||
HelpLine::Literal {
|
||||
keys, description, ..
|
||||
|
||||
@@ -235,6 +235,32 @@ impl Keymap {
|
||||
keys
|
||||
}
|
||||
|
||||
/// Every two-key sequence here or in a parent that runs `command`: a
|
||||
/// prefix key, then a key of the sub-keymap it opens (flat, gotcha 13).
|
||||
pub fn prefixed_keys_running(
|
||||
&self,
|
||||
command: &str,
|
||||
args: Option<&[String]>,
|
||||
) -> Vec<[(KeyCode, KeyModifiers); 2]> {
|
||||
let mut keys: Vec<[(KeyCode, KeyModifiers); 2]> = self
|
||||
.bindings
|
||||
.iter()
|
||||
.filter_map(|(pattern, binding)| match (pattern, binding) {
|
||||
(KeyPattern::Key(code, mods), Binding::Prefix(sub)) => Some((*code, *mods, sub)),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|(code, mods, sub)| {
|
||||
sub.keys_running(command, args)
|
||||
.into_iter()
|
||||
.map(move |second| [(code, mods), second])
|
||||
})
|
||||
.collect();
|
||||
if let Some(parent) = &self.parent {
|
||||
keys.extend(parent.prefixed_keys_running(command, args));
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Look up the binding for a key in this keymap's own bindings.
|
||||
fn lookup_local(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
|
||||
self.bindings
|
||||
@@ -740,6 +766,8 @@ impl Keymaps {
|
||||
"enter-mode",
|
||||
vec!["formula-edit".into()],
|
||||
)
|
||||
.bind(KeyCode::Enter, "edit-formula-at-cursor")
|
||||
.bind(KeyCode::Char('e'), "edit-formula-at-cursor")
|
||||
.delete("delete-formula-at-cursor")
|
||||
.finish(),
|
||||
),
|
||||
|
||||
@@ -30,13 +30,14 @@ pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
|
||||
pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view};
|
||||
pub use render::{
|
||||
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
|
||||
DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate,
|
||||
MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry,
|
||||
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
|
||||
WizardProposal, WizardStepKind, header_level_widths,
|
||||
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
|
||||
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, opening_tile,
|
||||
};
|
||||
pub use resolve::{
|
||||
Click, KeyOutcome, Request, click, read_buffer, resolve, typed, typed_cell_command,
|
||||
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
|
||||
typed_cell_command, untouched,
|
||||
};
|
||||
pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary};
|
||||
// `improvise-core`'s, because the index that resolves one lives in
|
||||
@@ -48,6 +49,9 @@ pub use improvise_core::view::layout::PageAxis;
|
||||
// without depending on `improvise-core` directly.
|
||||
pub use improvise_core::model::cell::CellValue;
|
||||
|
||||
// On the wire in `ModelCommand::ReplaceFormula` and in `AppMode::FormulaEdit`.
|
||||
pub use improvise_core::formula::FormulaId;
|
||||
|
||||
/// Number of pages in the Help screen. Lives here with the `help_page` state
|
||||
/// it bounds, so help-page effects stay terminal-independent.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
@@ -139,6 +143,7 @@ pub enum AppMode {
|
||||
},
|
||||
FormulaEdit {
|
||||
minibuf: MinibufferConfig,
|
||||
slot: FormulaSlot,
|
||||
},
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
@@ -176,6 +181,18 @@ pub enum AppMode {
|
||||
},
|
||||
}
|
||||
|
||||
/// What a formula editor commits to, captured when it OPENS and never
|
||||
/// recomputed at commit — `context/code/protocol.md` §1 (improvise-km71).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FormulaSlot {
|
||||
New,
|
||||
/// Replace the formula `id` names, provided it still reads `was`.
|
||||
Existing {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode an
|
||||
/// end holds must answer for the grid it holds — `context/code/protocol.md`.
|
||||
pub fn settled_mode(mode: &AppMode, records: bool) -> AppMode {
|
||||
@@ -333,12 +350,22 @@ impl AppMode {
|
||||
}
|
||||
|
||||
pub fn formula_edit() -> Self {
|
||||
Self::formula_editor("formula: ", FormulaSlot::New)
|
||||
}
|
||||
|
||||
/// The editor opened on an existing formula, which it names from here on.
|
||||
pub fn formula_replace(id: FormulaId, was: String) -> Self {
|
||||
Self::formula_editor("edit formula: ", FormulaSlot::Existing { id, was })
|
||||
}
|
||||
|
||||
fn formula_editor(prompt: &str, slot: FormulaSlot) -> Self {
|
||||
Self::FormulaEdit {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "formula".into(),
|
||||
prompt: "formula: ".into(),
|
||||
prompt: prompt.into(),
|
||||
color: MinibufColor::Cyan,
|
||||
},
|
||||
slot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +460,8 @@ pub struct ViewState {
|
||||
/// The prefix key currently armed (g→gg, y→yy, Z→ZZ). The key that opened
|
||||
/// it, not the sub-keymap it names — gotcha 13, improvise-jc8.
|
||||
pub transient_prefix: Option<(KeyCode, KeyModifiers)>,
|
||||
/// What the open cell editor captured when it opened (improvise-ixxx).
|
||||
pub opened_editor: Option<OpenedEditor>,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
@@ -458,6 +487,7 @@ impl Default for ViewState {
|
||||
expanded_cats: std::collections::HashSet::new(),
|
||||
buffers: HashMap::new(),
|
||||
transient_prefix: None,
|
||||
opened_editor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ use improvise_core::view::Axis;
|
||||
|
||||
use crate::basis::Basis;
|
||||
use crate::render::{Projection, RecordCell};
|
||||
use crate::{AppMode, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
||||
use crate::resolve::OpenedEditor;
|
||||
use crate::{AppMode, FormulaId, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
||||
use improvise_core::model::record::RecordHash;
|
||||
|
||||
/// One message on the wire, in either direction.
|
||||
@@ -39,6 +40,9 @@ pub enum Command {
|
||||
value: String,
|
||||
},
|
||||
SetTileCatIdx(usize),
|
||||
/// What a cell editor captured as it opened, or `None` for one opened
|
||||
/// with nothing to compare against (improvise-ixxx).
|
||||
SetOpenedEditor(Option<OpenedEditor>),
|
||||
SetPanelOpen {
|
||||
panel: Panel,
|
||||
open: bool,
|
||||
@@ -75,7 +79,7 @@ pub enum Command {
|
||||
Project(Projection),
|
||||
}
|
||||
|
||||
/// A mutation of the shared document: 24 variants, derived not invented (§2). A
|
||||
/// A mutation of the shared document: 25 variants, derived not invented (§2). A
|
||||
/// view-configuration variant **names its view** (improvise-lltt).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ModelCommand {
|
||||
@@ -107,6 +111,13 @@ pub enum ModelCommand {
|
||||
target: String,
|
||||
target_category: String,
|
||||
},
|
||||
/// Edit the formula `id` names in place, if it still reads `was` — an edit
|
||||
/// of that formula, not a remove-then-add (improvise-km71).
|
||||
ReplaceFormula {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
raw: String,
|
||||
},
|
||||
CleanEmptyRecords,
|
||||
SortData,
|
||||
|
||||
@@ -176,6 +187,7 @@ pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
||||
| ModelCommand::RemoveItem { .. }
|
||||
| ModelCommand::AddFormula { .. }
|
||||
| ModelCommand::RemoveFormula { .. }
|
||||
| ModelCommand::ReplaceFormula { .. }
|
||||
| ModelCommand::CleanEmptyRecords
|
||||
| ModelCommand::SortData
|
||||
| ModelCommand::AddCategory(_)
|
||||
@@ -268,6 +280,7 @@ pub fn reduce_view(view: &mut ViewState, cmd: &Command) {
|
||||
view.buffers.insert(name.clone(), value.clone());
|
||||
}
|
||||
Command::SetTileCatIdx(i) => view.tile_cat_idx = *i,
|
||||
Command::SetOpenedEditor(opened) => view.opened_editor = opened.clone(),
|
||||
Command::SetPanelOpen { panel, open } => *view.panel_open_mut(*panel) = *open,
|
||||
Command::SetPanelCursor { panel, cursor } => *view.panel_cursor_mut(*panel) = *cursor,
|
||||
Command::ToggleCatExpand(cat) => {
|
||||
@@ -499,6 +512,7 @@ mod tests {
|
||||
}])),
|
||||
Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
regular: true,
|
||||
axis: Axis::Page,
|
||||
}])),
|
||||
// One populated value per payload family: `RecordCell`, `Axis`,
|
||||
@@ -587,6 +601,9 @@ mod tests {
|
||||
fn surrogate(_: &RecordHash) -> usize {
|
||||
1
|
||||
}
|
||||
fn formula(_: &FormulaId) -> usize {
|
||||
1
|
||||
}
|
||||
fn record(RecordCell { record, measure }: &RecordCell) -> usize {
|
||||
surrogate(record) + optional_name(measure)
|
||||
}
|
||||
@@ -621,6 +638,7 @@ mod tests {
|
||||
target,
|
||||
target_category,
|
||||
} => name(target) + name(target_category),
|
||||
ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw),
|
||||
ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0,
|
||||
ModelCommand::AddCategory(category)
|
||||
| ModelCommand::RemoveCategory(category)
|
||||
|
||||
@@ -9,7 +9,8 @@ use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||
use improvise_core::view::Axis;
|
||||
use improvise_core::view::layout::PageAxis;
|
||||
|
||||
use crate::ViewState;
|
||||
use crate::{FormulaId, ViewState};
|
||||
use improvise_core::formula::Formula;
|
||||
|
||||
/// A widget's local copy of what it draws. **Total and deterministic**: an
|
||||
/// update naming something absent is dropped, never a panic.
|
||||
@@ -55,6 +56,9 @@ pub enum CellId {
|
||||
/// A records-mode creating column: typing a **name** into it adds that
|
||||
/// measure or category and writes no cell at all (gotcha 1, improvise-poia).
|
||||
Create(CreateKind),
|
||||
/// A records-mode coordinate. Renaming it moves every measure cell in the
|
||||
/// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg).
|
||||
Coordinate,
|
||||
}
|
||||
|
||||
/// What a creating column creates. The name typed into it is the whole
|
||||
@@ -67,7 +71,7 @@ pub enum CreateKind {
|
||||
|
||||
/// One cell as the client draws it. Drawn from `text`, never from `value` — the
|
||||
/// formatting has already happened, and re-deriving it is how ends drift.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CellDisplay {
|
||||
pub text: String,
|
||||
/// Right-align numerics, left-align text — decided by the server, which
|
||||
@@ -82,7 +86,7 @@ pub struct CellDisplay {
|
||||
/// **typed** it, not as it is drawn. The difference corrupts data (§3).
|
||||
pub edit_text: Option<String>,
|
||||
/// The value the host evaluated here, unrounded and untyped-by-text: what
|
||||
/// `yank` copies. Neither `text` nor `edit_text` can be re-parsed into it (§3).
|
||||
/// `yank` copies. `text` cannot be re-parsed into it, nor can text like `007` (§3).
|
||||
pub value: Option<CellValue>,
|
||||
}
|
||||
|
||||
@@ -127,6 +131,43 @@ impl CellDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal when the client could not tell them apart, so a NaN equals itself and
|
||||
/// the diff quiesces (improvise-4w09) — `context/code/protocol.md` §3.
|
||||
impl PartialEq for CellDisplay {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
let CellDisplay {
|
||||
text,
|
||||
numeric,
|
||||
error,
|
||||
id,
|
||||
edit_text,
|
||||
value,
|
||||
} = self;
|
||||
*text == other.text
|
||||
&& *numeric == other.numeric
|
||||
&& *error == other.error
|
||||
&& *id == other.id
|
||||
&& *edit_text == other.edit_text
|
||||
&& match (value, &other.value) {
|
||||
(Some(a), Some(b)) => same_on_the_wire(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for CellDisplay {}
|
||||
|
||||
/// Every NaN is one token on the wire; any other number is its bits, so `0.0`
|
||||
/// and `-0.0` differ. An equivalence relation, which `f64`'s `==` is not.
|
||||
fn same_on_the_wire(a: &CellValue, b: &CellValue) -> bool {
|
||||
match (a, b) {
|
||||
(CellValue::Number(a), CellValue::Number(b)) => {
|
||||
a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())
|
||||
}
|
||||
(a, b) => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a title bar draws: facts about the **document**, shared by every
|
||||
/// client. Why a cache and not a `ViewState` field: `context/code/protocol.md` §3.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -157,9 +198,6 @@ pub const MAX_ROW_HEADER_W: u16 = 24;
|
||||
/// because it is a fact about the *view*, which only the server holds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FrameGroup {
|
||||
/// The category the group belongs to, echoed back in `ToggleGroup` so a
|
||||
/// client never works out which category a label came from.
|
||||
pub cat: String,
|
||||
pub name: String,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
@@ -422,7 +460,7 @@ fn shows(items: &[&Vec<String>], idx: usize, first_rendered: usize, level: usize
|
||||
|
||||
/// The grid widget's copy of the visible cells (improvise-n10), indexed by
|
||||
/// position within the **viewport** and never by `CellKey`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GridCache {
|
||||
/// The row axis's frame: levels, label tuples and groups. **Not** a list
|
||||
/// of strings — see [`AxisFrame`].
|
||||
@@ -569,7 +607,7 @@ enum LabelAxis {
|
||||
|
||||
/// What can change about the grid: cell values, the frames that name them,
|
||||
/// the widths, the totals, the page bar and the extent.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GridUpdate {
|
||||
/// Display values for cells, by viewport position.
|
||||
Cells(Vec<((usize, usize), CellDisplay)>),
|
||||
@@ -691,17 +729,42 @@ impl WidgetCache for CategoryTreeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula panel's copy of the formula list (improvise-pca). One
|
||||
/// mutation: add and delete are the only effects, and both send whole.
|
||||
/// The formula panel's copy of the formula list (improvise-pca). Every
|
||||
/// formula effect sends it whole.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormulaCache {
|
||||
pub formulas: Vec<String>,
|
||||
pub formulas: Vec<FormulaEntry>,
|
||||
}
|
||||
|
||||
/// One formula as a client holds it: its text, and the identity an edit names
|
||||
/// it by, which the text alone cannot give a client (improvise-km71).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormulaEntry {
|
||||
pub id: FormulaId,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl From<&Formula> for FormulaEntry {
|
||||
fn from(f: &Formula) -> Self {
|
||||
Self {
|
||||
id: f.id(),
|
||||
raw: f.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `_Measure` formula's entry, identified exactly as the host identifies it.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl From<&str> for FormulaEntry {
|
||||
fn from(raw: &str) -> Self {
|
||||
Self::from(&improvise_core::formula::parse_formula(raw, "_Measure").expect(raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetCache for FormulaCache {
|
||||
type Update = Vec<String>;
|
||||
type Update = Vec<FormulaEntry>;
|
||||
|
||||
fn apply(&mut self, update: &Vec<String>) {
|
||||
fn apply(&mut self, update: &Vec<FormulaEntry>) {
|
||||
self.formulas = update.clone();
|
||||
}
|
||||
}
|
||||
@@ -730,11 +793,19 @@ impl WidgetCache for ViewListCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// One tile in the tile bar.
|
||||
/// One tile in the tile bar. `regular` is the host's `CategoryKind` answer: a
|
||||
/// client cannot tell `_Index` from a user category without sniffing names.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TileEntry {
|
||||
pub category: String,
|
||||
pub axis: Axis,
|
||||
pub regular: bool,
|
||||
}
|
||||
|
||||
/// Where tile select opens: the first regular category, so `T Enter` cannot
|
||||
/// put `_Index` alone on Row (improvise-oplp). Virtuals stay reachable.
|
||||
pub fn opening_tile(tiles: &[TileEntry]) -> usize {
|
||||
tiles.iter().position(|tile| tile.regular).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The tile bar's tileable categories and axes (improvise-pca). Its overlap
|
||||
@@ -837,8 +908,8 @@ pub struct WizardCache {
|
||||
pub enum Projection {
|
||||
Grid(GridUpdate),
|
||||
CategoryTree(CategoryTreeUpdate),
|
||||
/// The raw text of every formula.
|
||||
Formulas(Vec<String>),
|
||||
/// Every formula: its text and its identity.
|
||||
Formulas(Vec<FormulaEntry>),
|
||||
ViewList(Vec<ViewSummary>),
|
||||
TileBar(Vec<TileEntry>),
|
||||
/// Name, dirty marker and where the host keeps it (improvise-z754).
|
||||
@@ -860,6 +931,45 @@ pub struct Snapshot {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valued(value: CellValue) -> CellDisplay {
|
||||
CellDisplay {
|
||||
value: Some(value),
|
||||
..CellDisplay::number("x")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cells are equal when the wire cannot tell them apart: every NaN is one
|
||||
/// token, and `-0.0` is not `0.0`. Evidence file.
|
||||
#[test]
|
||||
fn a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart() {
|
||||
let host_nan = f64::from_bits(f64::NAN.to_bits() | (1 << 63));
|
||||
assert_ne!(
|
||||
host_nan.to_bits(),
|
||||
f64::NAN.to_bits(),
|
||||
"precondition: two NaNs whose bits differ"
|
||||
);
|
||||
assert_eq!(
|
||||
valued(CellValue::Number(host_nan)),
|
||||
valued(CellValue::Number(f64::NAN)),
|
||||
"a NaN the host computed and the NaN the client parsed are one value"
|
||||
);
|
||||
assert_ne!(
|
||||
valued(CellValue::Number(0.0)),
|
||||
valued(CellValue::Number(-0.0)),
|
||||
"the wire spells them differently, so a change between them is sent"
|
||||
);
|
||||
assert_ne!(
|
||||
valued(CellValue::Number(7.0)),
|
||||
valued(CellValue::Text("7".into())),
|
||||
"a number and number-like text are different values"
|
||||
);
|
||||
assert_ne!(
|
||||
valued(CellValue::Number(1.0)),
|
||||
CellDisplay::number("x"),
|
||||
"a value and no value differ"
|
||||
);
|
||||
}
|
||||
|
||||
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
|
||||
CategoryRow {
|
||||
name: name.into(),
|
||||
@@ -1061,7 +1171,6 @@ mod tests {
|
||||
FrameEntry::Item(vec!["A".into(), "x".into()]),
|
||||
FrameEntry::Item(vec!["A".into(), "y".into()]),
|
||||
FrameEntry::Group(FrameGroup {
|
||||
cat: "Type".into(),
|
||||
name: "Late".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
@@ -1074,7 +1183,6 @@ mod tests {
|
||||
/// ungrouped between them — improvise-h0iv's reachable shape.
|
||||
fn repeated_group_frame() -> AxisFrame {
|
||||
let q1 = || FrameGroup {
|
||||
cat: "Month".into(),
|
||||
name: "Q1".into(),
|
||||
collapsed: false,
|
||||
};
|
||||
@@ -1100,7 +1208,6 @@ mod tests {
|
||||
cats: vec!["Month".into()],
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
cat: "Month".into(),
|
||||
name: "Q1".into(),
|
||||
collapsed: false,
|
||||
}),
|
||||
@@ -1138,7 +1245,6 @@ mod tests {
|
||||
cats: vec!["Month".into()],
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
cat: "Month".into(),
|
||||
name: "Q1".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
@@ -1190,7 +1296,7 @@ mod tests {
|
||||
/// The pair `FrameGroup` carries cannot separate them — h0iv's own proposed
|
||||
/// fix. See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them() {
|
||||
fn the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them() {
|
||||
let frame = repeated_group_frame();
|
||||
let headers: Vec<&FrameGroup> = frame
|
||||
.entries
|
||||
@@ -1201,10 +1307,9 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
(&headers[0].cat, &headers[0].name),
|
||||
(&headers[1].cat, &headers[1].name),
|
||||
"both headers name the same category AND the same group, so the \
|
||||
ticket's (cat, name) comparison would still merge them"
|
||||
headers[0], headers[1],
|
||||
"both headers are the same group in the same state, so no \
|
||||
comparison of their fields can split them"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1268,7 +1373,7 @@ mod tests {
|
||||
assert_eq!(frame.item(2), Some(&vec!["B".to_string(), "z".to_string()]));
|
||||
assert!(frame.group_of(0).is_none(), "[A,x] is in no group");
|
||||
let group = frame.group_of(2).expect("[B,z] sits under Late");
|
||||
assert_eq!((group.cat.as_str(), group.name.as_str()), ("Type", "Late"));
|
||||
assert_eq!(group.name, "Late");
|
||||
assert!(group.collapsed, "the collapse state travels with the group");
|
||||
}
|
||||
|
||||
@@ -1411,10 +1516,10 @@ mod tests {
|
||||
#[test]
|
||||
fn the_list_caches_hold_the_last_update() {
|
||||
let formulas = FormulaCache::folded(&[
|
||||
vec!["Profit = Revenue - Cost".to_string()],
|
||||
vec!["Profit = Revenue - Cost".into()],
|
||||
vec![
|
||||
"Profit = Revenue - Cost".to_string(),
|
||||
"Tax = Revenue * 0.1".to_string(),
|
||||
"Profit = Revenue - Cost".into(),
|
||||
"Tax = Revenue * 0.1".into(),
|
||||
],
|
||||
]);
|
||||
assert_eq!(formulas.formulas.len(), 2);
|
||||
@@ -1428,6 +1533,7 @@ mod tests {
|
||||
let tiles = TileBarCache::folded(&[vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
regular: true,
|
||||
}]]);
|
||||
assert_eq!(tiles.tiles[0].axis, Axis::Page);
|
||||
}
|
||||
@@ -1771,7 +1877,7 @@ mod tests {
|
||||
|
||||
assert_eq!(booted, (grid, formulas));
|
||||
assert_eq!(booted.0.cell(0, 0).unwrap().text, "5");
|
||||
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1"]);
|
||||
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1".into()]);
|
||||
}
|
||||
|
||||
/// Two subscribers, each folding only what is addressed to it — the
|
||||
@@ -1825,6 +1931,7 @@ mod tests {
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
regular: true,
|
||||
}]),
|
||||
];
|
||||
for projection in &projections {
|
||||
@@ -1861,6 +1968,7 @@ mod tests {
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
regular: true,
|
||||
}]),
|
||||
],
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,28 +11,28 @@ pub fn production_code(roots: &[PathBuf]) -> Vec<(PathBuf, String)> {
|
||||
for root in roots {
|
||||
collect_rust(root, &mut files);
|
||||
}
|
||||
let code: Vec<(PathBuf, String)> = files
|
||||
let read: Vec<(PathBuf, Source)> = files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let text = std::fs::read_to_string(&file).expect("a listed source reads");
|
||||
let code = code_only(&text);
|
||||
(file, code)
|
||||
let source = Source::of(&text, &file);
|
||||
(file, source)
|
||||
})
|
||||
.collect();
|
||||
let test_modules: BTreeSet<PathBuf> = code
|
||||
let test_modules: BTreeSet<PathBuf> = read
|
||||
.iter()
|
||||
.flat_map(|(file, code)| test_module_paths(file, code))
|
||||
.flat_map(|(file, source)| test_module_paths(file, source))
|
||||
.collect();
|
||||
code.into_iter()
|
||||
read.into_iter()
|
||||
.filter(|(file, _)| !test_modules.iter().any(|m| is_module_file(m, file)))
|
||||
.map(|(file, code)| (file, strip_test_items(&code)))
|
||||
.map(|(file, source)| (file, source.production()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One file's text as [`production_code`] cleans it. Which *files* are test
|
||||
/// modules needs their declaring file too, so only `production_code` knows.
|
||||
pub fn production_text(text: &str) -> String {
|
||||
strip_test_items(&code_only(text))
|
||||
Source::of(text, Path::new("<text>")).production()
|
||||
}
|
||||
|
||||
fn collect_rust(dir: &Path, into: &mut Vec<PathBuf>) {
|
||||
@@ -55,83 +55,319 @@ fn is_module_file(m: &Path, file: &Path) -> bool {
|
||||
file == m.with_extension("rs") || file.starts_with(m)
|
||||
}
|
||||
|
||||
/// One file, read once. Its `cfg` predicates are read off the TEXT, whose
|
||||
/// `feature` values are literals; every bracket off the code (improvise-59q8).
|
||||
struct Source {
|
||||
code: Vec<char>,
|
||||
/// Test-only items, as `(attribute start, item start, item end)`.
|
||||
items: Vec<(usize, usize, usize)>,
|
||||
/// A leading `#![cfg(test)]`: the whole file is test code.
|
||||
whole_file: bool,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn of(text: &str, file: &Path) -> Self {
|
||||
let code: Vec<char> = code_only(text).chars().collect();
|
||||
let text: Vec<char> = text.chars().collect();
|
||||
let (items, whole_file) = test_only_items(&text, &code, file);
|
||||
Self {
|
||||
code,
|
||||
items,
|
||||
whole_file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The code with every test-only item removed.
|
||||
fn production(&self) -> String {
|
||||
if self.whole_file {
|
||||
return String::new();
|
||||
}
|
||||
let mut kept = String::with_capacity(self.code.len());
|
||||
let mut at = 0;
|
||||
for &(start, _, end) in &self.items {
|
||||
kept.extend(&self.code[at..start]);
|
||||
at = end;
|
||||
}
|
||||
kept.extend(&self.code[at..]);
|
||||
kept
|
||||
}
|
||||
|
||||
fn item_code(&self, start: usize, end: usize) -> String {
|
||||
self.code[start..end].iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The out-of-line modules `file` declares under a test-only `cfg`, as paths
|
||||
/// without extension (`#[cfg(test)] mod x;` is `<dir>/x`).
|
||||
fn test_module_paths(file: &Path, code: &str) -> Vec<PathBuf> {
|
||||
fn test_module_paths(file: &Path, source: &Source) -> Vec<PathBuf> {
|
||||
let dir = match file.file_stem().and_then(|s| s.to_str()) {
|
||||
Some("lib" | "main" | "mod") => file.parent().map(Path::to_path_buf),
|
||||
Some(stem) => file.parent().map(|p| p.join(stem)),
|
||||
None => None,
|
||||
}
|
||||
.expect("a source file has a directory and a stem");
|
||||
let lines: Vec<&str> = code.lines().map(str::trim).collect();
|
||||
let mut found = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !is_test_cfg(line) {
|
||||
continue;
|
||||
}
|
||||
let item = lines[i + 1..]
|
||||
.iter()
|
||||
.find(|l| !l.starts_with("#[") && !l.is_empty());
|
||||
if let Some(name) = item.and_then(|l| out_of_line_mod(l)) {
|
||||
found.push(dir.join(name));
|
||||
}
|
||||
}
|
||||
found
|
||||
source
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(_, start, end)| out_of_line_mod(source.item_code(start, end).trim()))
|
||||
.map(|name| dir.join(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `mod x;`, `pub mod x;`, `pub(crate) mod x;` → `x`.
|
||||
fn out_of_line_mod(line: &str) -> Option<&str> {
|
||||
let rest = line.split_once("mod ")?.1.strip_suffix(';')?;
|
||||
let prefix = line.split_once("mod ")?.0.trim();
|
||||
(prefix.is_empty() || prefix.starts_with("pub")).then_some(rest.trim())
|
||||
fn out_of_line_mod(item: &str) -> Option<String> {
|
||||
let (prefix, rest) = item.split_once("mod ")?;
|
||||
let name = rest.strip_suffix(';')?.trim();
|
||||
let prefix = prefix.trim();
|
||||
(prefix.is_empty() || prefix.starts_with("pub")).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// A `cfg` attribute that compiles its item only under `test`. `cfg(not(test))`
|
||||
/// is production, and `"test-support"` is a literal, already blanked.
|
||||
fn is_test_cfg(line: &str) -> bool {
|
||||
let line = line.trim();
|
||||
line.starts_with("#[cfg(")
|
||||
&& !line.contains("not(")
|
||||
&& line
|
||||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.any(|word| word == "test")
|
||||
/// Every test-only item in a file, bounded by balanced brackets on the code
|
||||
/// rather than by indentation. An item it cannot bound is a panic, never EOF.
|
||||
fn test_only_items(
|
||||
text: &[char],
|
||||
code: &[char],
|
||||
file: &Path,
|
||||
) -> (Vec<(usize, usize, usize)>, bool) {
|
||||
let mut items = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < code.len() {
|
||||
let Some((inner, end)) = attribute_at(code, k) else {
|
||||
k += 1;
|
||||
continue;
|
||||
};
|
||||
let predicate: String = text[k..end].iter().collect();
|
||||
if !is_test_only(&predicate) {
|
||||
k = end;
|
||||
continue;
|
||||
}
|
||||
if inner {
|
||||
// An inner attribute heads the file or it heads an inline module;
|
||||
// only the first is a shape this scan can honour.
|
||||
let leading = code[..k].iter().all(|c| c.is_whitespace());
|
||||
assert!(
|
||||
leading,
|
||||
"{}: an inner test-only cfg could not be bounded",
|
||||
file.display()
|
||||
);
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
let start = past_attributes(code, end);
|
||||
let item_end = item_end(code, start).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{}: a test-only item at char {k} could not be bounded",
|
||||
file.display()
|
||||
)
|
||||
});
|
||||
items.push((k, start, item_end));
|
||||
k = item_end;
|
||||
}
|
||||
(items, false)
|
||||
}
|
||||
|
||||
/// Drop every test-only item. rustfmt puts an item's closing brace at the
|
||||
/// attribute's own indentation, which is what makes this a line scan (§2).
|
||||
fn strip_test_items(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut kept = String::new();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
match is_test_cfg(line) {
|
||||
false => {
|
||||
kept.push_str(line);
|
||||
kept.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
true => {
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
let close = format!("{}}}", " ".repeat(indent));
|
||||
i += 1;
|
||||
while i < lines.len() && lines[i].trim_start().starts_with("#[") {
|
||||
i += 1;
|
||||
}
|
||||
match lines.get(i).is_some_and(|l| l.trim_end().ends_with(';')) {
|
||||
true => i += 1,
|
||||
false => {
|
||||
while i < lines.len() && lines[i].trim_end() != close {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
/// An attribute starting at `k`: whether it is inner (`#!`) and where it ends.
|
||||
fn attribute_at(code: &[char], k: usize) -> Option<(bool, usize)> {
|
||||
if code[k] != '#' || (k > 0 && (code[k - 1].is_alphanumeric() || code[k - 1] == '_')) {
|
||||
return None;
|
||||
}
|
||||
let inner = code.get(k + 1) == Some(&'!');
|
||||
let open = k + 1 + usize::from(inner);
|
||||
(code.get(open) == Some(&'[')).then(|| matching(code, open).map(|close| (inner, close + 1)))?
|
||||
}
|
||||
|
||||
/// The index of the bracket closing the one at `open`, counting all three kinds.
|
||||
fn matching(code: &[char], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (k, &c) in code.iter().enumerate().skip(open) {
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(k);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
kept
|
||||
None
|
||||
}
|
||||
|
||||
/// Skip whitespace and any further outer attributes on the same item.
|
||||
fn past_attributes(code: &[char], mut k: usize) -> usize {
|
||||
loop {
|
||||
while code.get(k).is_some_and(|c| c.is_whitespace()) {
|
||||
k += 1;
|
||||
}
|
||||
let next = (k < code.len()).then(|| attribute_at(code, k)).flatten();
|
||||
match next {
|
||||
Some((false, end)) => k = end,
|
||||
_ => return k,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the item starting at `start` ends: a `;` at depth zero, or the `}`
|
||||
/// closing its body. `None` at the end of the file, which is not an end.
|
||||
fn item_end(code: &[char], start: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
let mut k = start;
|
||||
while k < code.len() {
|
||||
match code[k] {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
';' if depth == 0 => return Some(k + 1),
|
||||
// The enclosing block closed first: the item was its last expression.
|
||||
')' | ']' | '}' if depth == 0 => return Some(k),
|
||||
')' | ']' => depth -= 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 && !continues(code, k + 1) {
|
||||
return Some(k + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the item goes on after a `}` at depth zero: `S { .. };`, a method
|
||||
/// call on a block, or an `if` with an `else`.
|
||||
fn continues(code: &[char], after: usize) -> bool {
|
||||
let rest: String = code[after..]
|
||||
.iter()
|
||||
.skip_while(|c| c.is_whitespace())
|
||||
.take(5)
|
||||
.collect();
|
||||
rest.starts_with([';', '.', '?']) || rest.starts_with("else")
|
||||
}
|
||||
|
||||
/// A `cfg` attribute whose predicate no shipped build satisfies. Evaluated,
|
||||
/// not substring-matched (improvise-59q8): `context/code/protocol.md` §2.
|
||||
fn is_test_only(attribute: &str) -> bool {
|
||||
let tokens = cfg_tokens(attribute);
|
||||
match tokens.as_slice() {
|
||||
[Token::Word(cfg), Token::Open, rest @ ..] if cfg == "cfg" => {
|
||||
let mut at = 0;
|
||||
predicate(rest, &mut at) == Truth::No
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `cfg` predicate is in a build without `test`. `Unknown` for what a
|
||||
/// source scan cannot know, such as a target or an ordinary feature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Truth {
|
||||
Yes,
|
||||
No,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Token {
|
||||
Word(String),
|
||||
Str(String),
|
||||
Open,
|
||||
Close,
|
||||
Comma,
|
||||
Equals,
|
||||
}
|
||||
|
||||
/// The tokens of an attribute's text, from its first word: `#`, `!` and the
|
||||
/// brackets around it are dropped.
|
||||
fn cfg_tokens(attribute: &str) -> Vec<Token> {
|
||||
let inner = attribute
|
||||
.trim_start_matches('#')
|
||||
.trim_start_matches('!')
|
||||
.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end()
|
||||
.trim_end_matches(']');
|
||||
let chars: Vec<char> = inner.chars().collect();
|
||||
let mut tokens = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < chars.len() {
|
||||
let c = chars[k];
|
||||
match c {
|
||||
'(' => tokens.push(Token::Open),
|
||||
')' => tokens.push(Token::Close),
|
||||
',' => tokens.push(Token::Comma),
|
||||
'=' => tokens.push(Token::Equals),
|
||||
'"' => {
|
||||
let end = quoted_end(&chars, k + 1, '"');
|
||||
tokens.push(Token::Str(chars[k + 1..end - 1].iter().collect()));
|
||||
k = end;
|
||||
continue;
|
||||
}
|
||||
c if c.is_alphanumeric() || c == '_' => {
|
||||
let word: String = chars[k..]
|
||||
.iter()
|
||||
.take_while(|c| c.is_alphanumeric() || **c == '_')
|
||||
.collect();
|
||||
k += word.chars().count();
|
||||
tokens.push(Token::Word(word));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// One predicate, advancing `at` past it. `test` is false; so is the
|
||||
/// `test-support` feature, which this repo never ships (improvise-26dm).
|
||||
fn predicate(tokens: &[Token], at: &mut usize) -> Truth {
|
||||
let Some(Token::Word(word)) = tokens.get(*at) else {
|
||||
return Truth::Unknown;
|
||||
};
|
||||
*at += 1;
|
||||
match (word.as_str(), tokens.get(*at)) {
|
||||
(op @ ("all" | "any" | "not"), Some(Token::Open)) => {
|
||||
*at += 1;
|
||||
let mut args = Vec::new();
|
||||
while !matches!(tokens.get(*at), Some(Token::Close) | None) {
|
||||
args.push(predicate(tokens, at));
|
||||
if tokens.get(*at) == Some(&Token::Comma) {
|
||||
*at += 1;
|
||||
}
|
||||
}
|
||||
*at += 1;
|
||||
combine(op, &args)
|
||||
}
|
||||
(key, Some(Token::Equals)) => {
|
||||
*at += 1;
|
||||
let value = match tokens.get(*at) {
|
||||
Some(Token::Str(v)) => v.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
*at += 1;
|
||||
match (key, value.as_str()) {
|
||||
("feature", "test-support") => Truth::No,
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
("test", _) => Truth::No,
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleene's three-valued `all`, `any` and `not`.
|
||||
fn combine(op: &str, args: &[Truth]) -> Truth {
|
||||
match op {
|
||||
"all" if args.contains(&Truth::No) => Truth::No,
|
||||
"all" if args.iter().all(|a| *a == Truth::Yes) => Truth::Yes,
|
||||
"any" if args.contains(&Truth::Yes) => Truth::Yes,
|
||||
"any" if args.iter().all(|a| *a == Truth::No) => Truth::No,
|
||||
"not" => match args.first() {
|
||||
Some(Truth::Yes) => Truth::No,
|
||||
Some(Truth::No) => Truth::Yes,
|
||||
_ => Truth::Unknown,
|
||||
},
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with comments, string, byte-string, raw-string and char literals
|
||||
@@ -166,8 +402,8 @@ fn literal_or_comment_end(c: &[char], i: usize) -> Option<usize> {
|
||||
(Some('/'), Some('/')) => Some(line_end(c, i)),
|
||||
(Some('/'), Some('*')) => Some(block_end(c, i)),
|
||||
(Some('"'), _) => Some(quoted_end(c, i + 1, '"')),
|
||||
(Some('b'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
|
||||
(Some('r' | 'b'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
|
||||
(Some('b' | 'c'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
|
||||
(Some('r' | 'b' | 'c'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
|
||||
(Some('\''), _) => char_end(c, i),
|
||||
_ => None,
|
||||
}
|
||||
@@ -211,9 +447,10 @@ fn quoted_end(c: &[char], mut k: usize, quote: char) -> usize {
|
||||
c.len()
|
||||
}
|
||||
|
||||
/// `r"…"`, `r#"…"#`, `br#"…"#`. `None` when the `r` is only an identifier.
|
||||
/// `r"…"`, `r#"…"#`, `br#"…"#`, `cr#"…"#`. `None` when the `r` is only an
|
||||
/// identifier.
|
||||
fn raw_end(c: &[char], i: usize) -> Option<usize> {
|
||||
let mut k = i + usize::from(c[i] == 'b');
|
||||
let mut k = i + usize::from(matches!(c[i], 'b' | 'c'));
|
||||
if c.get(k) != Some(&'r') {
|
||||
return None;
|
||||
}
|
||||
@@ -283,21 +520,20 @@ mod tests {
|
||||
"}\n",
|
||||
"#[cfg(test)]\n",
|
||||
"mod out_of_line;\n",
|
||||
"#[cfg(any(test, feature = \"x\"))]\n",
|
||||
"#[cfg(any(test, feature = \"test-support\"))]\n",
|
||||
"pub mod gated;\n",
|
||||
"#[cfg(not(test))]\n",
|
||||
"fn production_only() {}\n",
|
||||
"fn also_kept() {}\n",
|
||||
);
|
||||
let code = code_only(text);
|
||||
let stripped = strip_test_items(&code);
|
||||
let stripped = production_text(text);
|
||||
for kept in ["fn kept", "fn production_only", "fn also_kept"] {
|
||||
assert!(stripped.contains(kept), "{kept} was dropped: {stripped}");
|
||||
}
|
||||
assert!(!stripped.contains("gone_inline"), "{stripped}");
|
||||
let lib = Path::new("/c/src/lib.rs");
|
||||
assert_eq!(
|
||||
test_module_paths(lib, &code),
|
||||
test_module_paths(lib, &Source::of(text, lib)),
|
||||
vec![
|
||||
PathBuf::from("/c/src/out_of_line"),
|
||||
PathBuf::from("/c/src/gated")
|
||||
@@ -305,7 +541,7 @@ mod tests {
|
||||
);
|
||||
let nested = Path::new("/c/src/ui/reduce.rs");
|
||||
assert_eq!(
|
||||
test_module_paths(nested, &code)[0],
|
||||
test_module_paths(nested, &Source::of(text, nested))[0],
|
||||
PathBuf::from("/c/src/ui/reduce/out_of_line")
|
||||
);
|
||||
assert!(is_module_file(
|
||||
@@ -322,6 +558,112 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// improvise-59q8, the shapes that HID production code: a test-only item
|
||||
/// whose end the line scan could not find. Evidence: test-evidence/protocol.md.
|
||||
#[test]
|
||||
fn a_test_only_item_hides_nothing_after_it() {
|
||||
let cases = [
|
||||
(
|
||||
"a multi-line item ending in `];`",
|
||||
concat!(
|
||||
"impl X {\n",
|
||||
" #[cfg(test)]\n",
|
||||
" const NAMES: &[&str] = &[\n",
|
||||
" \"a\",\n",
|
||||
" ];\n",
|
||||
" fn prod() { let c = Command::Hidden1(1); }\n",
|
||||
"}\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"a multi-line statement ending in `);`",
|
||||
concat!(
|
||||
"fn f() {\n",
|
||||
" #[cfg(test)]\n",
|
||||
" let x = foo(\n",
|
||||
" 1,\n",
|
||||
" );\n",
|
||||
" let c = Command::Hidden1(1);\n",
|
||||
"}\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"a one-line `#[cfg(test)] use`",
|
||||
concat!(
|
||||
"#[cfg(test)] use foo::bar;\n",
|
||||
"fn prod() { Command::Hidden1(1) }\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let kept = production_text(text);
|
||||
for name in ["Hidden1", "Visible"] {
|
||||
assert!(kept.contains(name), "{what}: {name} was hidden: {kept:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-59q8, the shapes that COUNTED test code as production.
|
||||
#[test]
|
||||
fn a_test_only_cfg_is_recognised_whatever_its_spelling() {
|
||||
let cases = [
|
||||
(
|
||||
"`all(test, not(..))`",
|
||||
"#[cfg(all(test, not(target_arch = \"wasm32\")))]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
(
|
||||
"an inner `#![cfg(test)]` heading the file",
|
||||
"#![cfg(test)]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
(
|
||||
"an attribute split across lines",
|
||||
"#[cfg(\n test\n)]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let kept = production_text(text);
|
||||
assert!(
|
||||
!kept.contains("TestOnly"),
|
||||
"{what} was read as production: {kept:?}"
|
||||
);
|
||||
}
|
||||
// The controls: a predicate a shipped build can satisfy is production.
|
||||
for text in [
|
||||
"#[cfg(not(test))]\nfn p() { Command::Shipped(1) }\n",
|
||||
"#[cfg(any(test, feature = \"serde\"))]\nfn p() { Command::Shipped(1) }\n",
|
||||
"#[cfg(target_arch = \"wasm32\")]\nfn p() { Command::Shipped(1) }\n",
|
||||
] {
|
||||
assert!(
|
||||
production_text(text).contains("Shipped"),
|
||||
"{text:?} was dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-59q8's minor case: a C string literal leaked its contents.
|
||||
#[test]
|
||||
fn a_c_string_literal_is_blanked() {
|
||||
// The raw one quotes inside itself, so lexing it as ordinary strings
|
||||
// leaves the text BETWEEN the inner quotes in the open.
|
||||
let kept = code_only(
|
||||
"let a = c\"Command::InC(1)\"; let b = cr#\"x\" Command::InRawC(2) \"y\"#;\n",
|
||||
);
|
||||
for gone in ["InC", "InRawC"] {
|
||||
assert!(!kept.contains(gone), "{gone} survived: {kept}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A test-only item the scan cannot bound is REFUSED, never read to the
|
||||
/// end of the file, which is what silently hid every shape above.
|
||||
#[test]
|
||||
#[should_panic(expected = "could not be bounded")]
|
||||
fn an_unbounded_test_only_item_is_refused_not_swallowed() {
|
||||
let _ = production_text("#[cfg(test)]\nfn broken() {\n Command::X(1)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_literals_are_blanked_and_code_is_kept() {
|
||||
let text = concat!(
|
||||
|
||||
@@ -12,7 +12,9 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
improvise-protocol = { path = "../improvise-protocol" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Parses frames, so it must read back the f64 that was written: per crate, because
|
||||
# the wasm bundle builds alone (improvise-6oy4, context/code/protocol.md).
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
# Deliberately NOT a dependency: `improvise-command`. A keymap binding names
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod socket;
|
||||
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
|
||||
pub use keys::BrowserKey;
|
||||
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
|
||||
pub use socket::EditTarget;
|
||||
|
||||
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -98,16 +99,41 @@ impl Client {
|
||||
self.model_json().map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The frame for "the user typed `text` into the cell at this viewport
|
||||
/// position", or `None` — which is a race, not a normal path.
|
||||
/// Which stored cell an editor opened at this viewport position edits, as
|
||||
/// an opaque string the page holds until commit, or `None` (improvise-2wu3).
|
||||
#[wasm_bindgen]
|
||||
pub fn typed_cell(
|
||||
&mut self,
|
||||
row: usize,
|
||||
col: usize,
|
||||
text: &str,
|
||||
) -> Result<Option<String>, JsError> {
|
||||
self.typed_cell_frame(row, col, text)
|
||||
pub fn edit_target(&self, row: usize, col: usize) -> Result<Option<String>, JsError> {
|
||||
self.edit_target_at(row, col)
|
||||
.map(|target| serde_json::to_string(&target))
|
||||
.transpose()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// What the editor opened on `target` shows: the seed the target captured,
|
||||
/// never the page's paint, which can be stale (improvise-ugj4).
|
||||
#[wasm_bindgen]
|
||||
pub fn edit_seed(&self, target: &str) -> Result<String, JsError> {
|
||||
let target: EditTarget =
|
||||
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(target.opened.seed)
|
||||
}
|
||||
|
||||
/// `target` after the user's first edit of its `<input>`, still opaque to
|
||||
/// the page (improvise-jibp).
|
||||
#[wasm_bindgen]
|
||||
pub fn edited_target(&self, target: &str) -> Result<String, JsError> {
|
||||
let target: EditTarget =
|
||||
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&target.edited()).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The frame for "the user typed `text` into the cell `target` names". By
|
||||
/// identity, never position: rows renumber while an editor is open.
|
||||
#[wasm_bindgen]
|
||||
pub fn typed_cell(&mut self, target: &str, text: &str) -> Result<Option<String>, JsError> {
|
||||
let target: EditTarget =
|
||||
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
self.typed_target_frame(target, text)
|
||||
.map(|f| frame_json(&f))
|
||||
.transpose()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
@@ -126,8 +152,8 @@ impl Client {
|
||||
self.clicked(Click::ViewRow(index))
|
||||
}
|
||||
|
||||
/// The user clicked row `index` of the formula panel. Navigation only —
|
||||
/// that panel binds no `Enter` (improvise-iioi).
|
||||
/// The user clicked row `index` of the formula panel: focus it, move there,
|
||||
/// then open the editor on that formula, as `Enter` does (improvise-km71).
|
||||
#[wasm_bindgen]
|
||||
pub fn click_formula(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
||||
self.clicked(Click::FormulaRow(index))
|
||||
@@ -172,7 +198,10 @@ impl Client {
|
||||
pub fn record_cell_at(&self, row: usize, col: usize) -> Option<RecordCell> {
|
||||
match self.state.grid.cell(row, col)?.id.clone()? {
|
||||
CellId::Record(cell) => Some(cell),
|
||||
CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None,
|
||||
CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::Create(_)
|
||||
| CellId::Coordinate => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +285,62 @@ mod tests {
|
||||
assert_eq!(client.typed_cell_frame(0, 0, "42"), None);
|
||||
}
|
||||
|
||||
/// BUG (improvise-ugj4, improvise-jibp) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn the_page_opens_its_editor_on_what_the_target_captured() {
|
||||
let id = improvise_protocol::RecordCell {
|
||||
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
|
||||
measure: Some("Revenue".into()),
|
||||
};
|
||||
let at = |text: &str, stored: &str| {
|
||||
let mut cell = improvise_protocol::CellDisplay::number(text);
|
||||
cell.edit_text = Some(stored.into());
|
||||
cell.id = Some(CellId::Record(id.clone()));
|
||||
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)]))
|
||||
};
|
||||
let mut client = init();
|
||||
client.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![
|
||||
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
||||
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
||||
at("1,200.50", "1200.5"),
|
||||
Projection::Basis(improvise_protocol::basis::published(
|
||||
0,
|
||||
0,
|
||||
[(id.record, 0)].into_iter().collect(),
|
||||
)),
|
||||
],
|
||||
});
|
||||
let first = client.edit_target(0, 0).unwrap().expect("a record cell");
|
||||
assert_eq!(
|
||||
client.edit_seed(&first).unwrap(),
|
||||
"1200.5",
|
||||
"not the drawn text"
|
||||
);
|
||||
client.fold_frame(ServerFrame::Commands(vec![Command::Project(at(
|
||||
"777", "777",
|
||||
))]));
|
||||
let later = client.edit_target(0, 0).unwrap().expect("a record cell");
|
||||
assert_eq!(
|
||||
client.edit_seed(&later).unwrap(),
|
||||
"777",
|
||||
"not the cache at open"
|
||||
);
|
||||
assert_eq!(
|
||||
client.edit_seed(&first).unwrap(),
|
||||
"1200.5",
|
||||
"a target is one capture"
|
||||
);
|
||||
|
||||
assert!(client.typed_cell(&later, "777").unwrap().is_none());
|
||||
let edited = client.edited_target(&later).unwrap();
|
||||
assert!(
|
||||
client.typed_cell(&edited, "777").unwrap().is_some(),
|
||||
"an edited editor typed back to its seed composed nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The end-to-end shape a JS harness drives: bootstrap, key, message.
|
||||
#[test]
|
||||
fn a_client_can_be_driven_entirely_through_json() {
|
||||
|
||||
@@ -133,9 +133,6 @@ pub struct RenderCell {
|
||||
/// May this client compose a write for it? `CellId::Record` alone.
|
||||
pub editable: bool,
|
||||
pub selected: bool,
|
||||
/// What an editor should start with: the stored value, not the formatted
|
||||
/// one. Seeding from `text` turns `1200` into the string `"1,200"`.
|
||||
pub edit_text: String,
|
||||
}
|
||||
|
||||
impl RenderCell {
|
||||
@@ -144,9 +141,17 @@ impl RenderCell {
|
||||
text: cell.text.clone(),
|
||||
numeric: cell.numeric,
|
||||
error: cell.error,
|
||||
editable: matches!(cell.id, Some(CellId::Record(_))),
|
||||
editable: match cell.id {
|
||||
Some(CellId::Record(_)) => true,
|
||||
Some(
|
||||
CellId::Aggregate(_)
|
||||
| CellId::Derived { .. }
|
||||
| CellId::Create(_)
|
||||
| CellId::Coordinate,
|
||||
)
|
||||
| None => false,
|
||||
},
|
||||
selected,
|
||||
edit_text: cell.edit_text.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,8 +373,8 @@ fn formula_rows(state: &ClientState) -> Option<Vec<RenderFormulaRow>> {
|
||||
.formulas
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| RenderFormulaRow {
|
||||
text: text.clone(),
|
||||
.map(|(i, entry)| RenderFormulaRow {
|
||||
text: entry.raw.clone(),
|
||||
selected: i == state.view.formula_cursor,
|
||||
})
|
||||
.collect()
|
||||
@@ -418,9 +423,9 @@ impl RenderHelpRow {
|
||||
L::Heading(t) => prose("heading", t),
|
||||
L::Text(t) => prose("text", t),
|
||||
L::Accent(t) => prose("accent", t),
|
||||
L::Keys { keys, description } => Self {
|
||||
keys: keys.join(" "),
|
||||
description: description.to_string(),
|
||||
L::Keys(row) => Self {
|
||||
keys: row.keys().join(" "),
|
||||
description: row.description().to_string(),
|
||||
kind: "keys",
|
||||
},
|
||||
L::Literal {
|
||||
@@ -563,9 +568,8 @@ mod tests {
|
||||
|
||||
/// A 2x3 grid whose cells cover the four id shapes plus a padded slot, so
|
||||
/// one fixture answers every editability question.
|
||||
fn group(cat: &str, name: &str, collapsed: bool) -> FrameEntry {
|
||||
fn group(name: &str, collapsed: bool) -> FrameEntry {
|
||||
FrameEntry::Group(improvise_protocol::FrameGroup {
|
||||
cat: cat.into(),
|
||||
name: name.into(),
|
||||
collapsed,
|
||||
})
|
||||
@@ -583,11 +587,11 @@ mod tests {
|
||||
first: 0,
|
||||
cats: vec!["Month".into()],
|
||||
entries: vec![
|
||||
group("Month", "Q1", false),
|
||||
group("Q1", false),
|
||||
item("Jan"),
|
||||
group("Fiscal", "Q1", true),
|
||||
group("Q1", true),
|
||||
item("Feb"),
|
||||
group("Month", "Q2", false),
|
||||
group("Q2", false),
|
||||
item("Apr"),
|
||||
],
|
||||
};
|
||||
@@ -628,6 +632,10 @@ mod tests {
|
||||
}),
|
||||
),
|
||||
((1, 0), CellDisplay::text("pending")),
|
||||
(
|
||||
(1, 2),
|
||||
CellDisplay::text("North").with_id(CellId::Coordinate),
|
||||
),
|
||||
])),
|
||||
],
|
||||
});
|
||||
@@ -659,6 +667,10 @@ mod tests {
|
||||
!model.rows[1].cells()[1].editable,
|
||||
"and neither is a slot fit_to_labels padded"
|
||||
);
|
||||
assert!(
|
||||
!model.rows[1].cells()[2].editable,
|
||||
"renaming a coordinate moves the whole row, which is not a cell write"
|
||||
);
|
||||
}
|
||||
|
||||
/// The numeric/error flags are the server's answers, echoed — so the test
|
||||
@@ -786,10 +798,12 @@ mod tests {
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
regular: true,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Product".into(),
|
||||
axis: Axis::None,
|
||||
regular: true,
|
||||
},
|
||||
])));
|
||||
client
|
||||
@@ -1008,10 +1022,6 @@ mod tests {
|
||||
"a row says which of the two it is; the page does not guess"
|
||||
);
|
||||
assert_eq!(value["rows"][0]["labels"][0], "North");
|
||||
assert_eq!(
|
||||
value["rows"][0]["cells"][0]["edit_text"], "1200",
|
||||
"an editor seeds from the stored value, never from the drawn one"
|
||||
);
|
||||
}
|
||||
|
||||
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
|
||||
@@ -1113,10 +1123,12 @@ mod tests {
|
||||
TileEntry {
|
||||
category: "Quarter".into(),
|
||||
axis: Axis::Page,
|
||||
regular: true,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Column,
|
||||
regular: true,
|
||||
},
|
||||
])));
|
||||
let tiles = client.model().tiles;
|
||||
|
||||
@@ -2,7 +2,38 @@
|
||||
//! compositions a page makes. See `context/code/clients.md`.
|
||||
|
||||
use crate::Client;
|
||||
use improvise_protocol::{ClientFrame, Command, Rejection, ServerFrame, typed_cell_command};
|
||||
use improvise_protocol::{
|
||||
ClientFrame, Command, OpenedEditor, RecordCell, Rejection, ServerFrame, typed_cell_command,
|
||||
untouched,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Everything an editor captures when it opens, held opaquely by the page.
|
||||
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EditTarget {
|
||||
/// The document the editor opened on; a swap under it refuses the commit (improvise-vndi).
|
||||
pub document_generation: u64,
|
||||
pub cell: RecordCell,
|
||||
/// The record's write counter as this client had it at open.
|
||||
pub version: u64,
|
||||
/// The text the editor opened on, so an untouched commit writes nothing (improvise-ixxx).
|
||||
pub opened: OpenedEditor,
|
||||
}
|
||||
|
||||
impl EditTarget {
|
||||
/// The same target after the user's first edit: a commit of its seed is then
|
||||
/// a write (improvise-jibp). The page's `<input>` is not a buffer `resolve` sees.
|
||||
pub fn edited(self) -> Self {
|
||||
Self {
|
||||
opened: OpenedEditor {
|
||||
touched: true,
|
||||
..self.opened
|
||||
},
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a refused batch says to the person who composed it. Worded here rather
|
||||
/// than on `Rejection`, and why the wording matters: `context/code/clients.md`.
|
||||
@@ -80,11 +111,39 @@ impl Client {
|
||||
/// Compose the write a user made by typing `text` into a viewport position.
|
||||
/// The four `None` shapes, and why nothing folds here: clients.md.
|
||||
pub fn typed_cell_frame(&mut self, row: usize, col: usize, text: &str) -> Option<ClientFrame> {
|
||||
let target = self.edit_target_at(row, col)?;
|
||||
self.typed_target_frame(target, text)
|
||||
}
|
||||
|
||||
/// What an editor opened at this position edits: the record, and the
|
||||
/// version of it the user is looking at. `None` before any basis arrives.
|
||||
pub fn edit_target_at(&self, row: usize, col: usize) -> Option<EditTarget> {
|
||||
let cell = self.record_cell_at(row, col)?;
|
||||
let basis = self.state().basis.claim([cell.record])?;
|
||||
let basis = self.state().basis.current()?;
|
||||
let version = basis.record(cell.record)?;
|
||||
let document_generation = basis.document_generation;
|
||||
let seed = self.state().grid.cell(row, col)?.edit_text.clone();
|
||||
Some(EditTarget {
|
||||
document_generation,
|
||||
cell,
|
||||
version,
|
||||
opened: OpenedEditor::on(seed.unwrap_or_default()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The write an editor opened earlier commits: to the record it opened on,
|
||||
/// claimed at the version it read then (improvise-2wu3, improvise-0j0f).
|
||||
pub fn typed_target_frame(&mut self, target: EditTarget, text: &str) -> Option<ClientFrame> {
|
||||
if untouched(Some(&target.opened), text) {
|
||||
return None;
|
||||
}
|
||||
let basis = self.state().basis.claim_as_read(
|
||||
target.document_generation,
|
||||
[(target.cell.record, target.version)],
|
||||
)?;
|
||||
let cmds = vec![Command::Model {
|
||||
basis,
|
||||
cmd: typed_cell_command(&improvise_protocol::CellId::Record(cell), text)?,
|
||||
cmd: typed_cell_command(&improvise_protocol::CellId::Record(target.cell), text)?,
|
||||
}];
|
||||
// Folds nothing — a `Command::Model` is not the view slice's business —
|
||||
// but it **registers**, or the host's answers address the wrong frame.
|
||||
@@ -243,6 +302,86 @@ mod tests {
|
||||
client
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_mouse_commit_of_the_untouched_seed_composes_nothing() {
|
||||
for seed in ["007", "ERR:boom", "1.23456789"] {
|
||||
let mut cell = CellDisplay::text(format!("drawn {seed}"));
|
||||
cell.edit_text = Some(seed.into());
|
||||
let mut client = client_holding(cell);
|
||||
let target = client.edit_target_at(0, 0).expect("a record cell");
|
||||
assert_eq!(target.opened, OpenedEditor::on(seed.into()), "{seed}");
|
||||
assert_eq!(client.typed_target_frame(target, seed), None, "{seed}");
|
||||
}
|
||||
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
for text in ["12000", "1200 "] {
|
||||
let target = client.edit_target_at(0, 0).expect("a record cell");
|
||||
assert!(
|
||||
client.typed_target_frame(target, text).is_some(),
|
||||
"{text:?} differs from the seed 1200, whitespace included, so it is a write"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-jibp) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_mouse_target_marked_edited_writes_its_own_seed() {
|
||||
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
let target = client.edit_target_at(0, 0).expect("a record cell");
|
||||
assert_eq!(
|
||||
client.typed_target_frame(target.clone(), "1200"),
|
||||
None,
|
||||
"control: an untouched editor committing its seed writes nothing"
|
||||
);
|
||||
let edited = target.edited();
|
||||
assert_eq!(edited.opened.seed, "1200", "marking must not move the seed");
|
||||
assert!(
|
||||
client.typed_target_frame(edited, "1200").is_some(),
|
||||
"an edited editor typed back to its seed was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_mouse_commit_is_judged_against_the_seed_it_opened_with() {
|
||||
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
let target = client.edit_target_at(0, 0).expect("a record cell");
|
||||
let mut moved = CellDisplay::number("5");
|
||||
moved.id = Some(CellId::Record(record("Revenue")));
|
||||
client.fold_frame(ServerFrame::Commands(vec![Command::Project(
|
||||
Projection::Grid(GridUpdate::Cells(vec![((0, 0), moved)])),
|
||||
)]));
|
||||
assert_eq!(
|
||||
client
|
||||
.state()
|
||||
.grid
|
||||
.cell(0, 0)
|
||||
.and_then(|c| c.edit_text.clone()),
|
||||
Some("5".into()),
|
||||
"the other client's write did not reach this cache, so this proves nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
client.typed_target_frame(target, "1200"),
|
||||
None,
|
||||
"an untouched editor wrote its stale seed over the other client's value"
|
||||
);
|
||||
}
|
||||
|
||||
fn client_holding(mut cell: CellDisplay) -> Client {
|
||||
cell.id = Some(CellId::Record(record("Revenue")));
|
||||
let mut client = init();
|
||||
client.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![
|
||||
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
||||
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
||||
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)])),
|
||||
Projection::Basis(published_basis()),
|
||||
],
|
||||
});
|
||||
client
|
||||
}
|
||||
|
||||
/// The basis a server would publish for the one record this fixture's
|
||||
/// grid names.
|
||||
fn published_basis() -> improvise_protocol::Basis {
|
||||
@@ -300,7 +439,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(client_showing(None).typed_cell_frame(0, 0, "42"), None);
|
||||
assert_eq!(
|
||||
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42"),
|
||||
client_showing(Some(CellId::Record(record("Revenue")))).typed_cell_frame(9, 9, "42",),
|
||||
None,
|
||||
"and neither is a position outside the grid"
|
||||
);
|
||||
|
||||
@@ -135,8 +135,12 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
|
||||
this fixture has no group headers, so the two row coordinates \
|
||||
coincide here — see a_grouped_row_axis_does_not_shift_the_write"
|
||||
);
|
||||
let target = client
|
||||
.edit_target(at.data_row, at.col)
|
||||
.expect("serialization must not fail")
|
||||
.expect("an editable cell has an edit target");
|
||||
let frame = client
|
||||
.typed_cell(at.data_row, at.col, "7500")
|
||||
.typed_cell(&target, "7500")
|
||||
.expect("serialization must not fail")
|
||||
.expect("an editable cell must compose a write");
|
||||
let frames = send(&mut host, id, &frame);
|
||||
@@ -218,8 +222,12 @@ fn a_grouped_row_axis_does_not_shift_the_write() {
|
||||
coordinates coincide and nothing below is being tested"
|
||||
);
|
||||
|
||||
let target = client
|
||||
.edit_target(at.data_row, at.col)
|
||||
.expect("serialization must not fail")
|
||||
.expect("an editable cell has an edit target");
|
||||
let frame = client
|
||||
.typed_cell(at.data_row, at.col, "4200")
|
||||
.typed_cell(&target, "4200")
|
||||
.expect("serialization must not fail")
|
||||
.expect("an editable cell must compose a write");
|
||||
send(&mut host, id, &frame);
|
||||
@@ -287,9 +295,9 @@ fn an_aggregated_cell_reaches_the_page_as_not_editable() {
|
||||
"every cell here aggregates over Product, so none may be written"
|
||||
);
|
||||
assert_eq!(
|
||||
client.typed_cell(0, 0, "42").unwrap(),
|
||||
client.edit_target(0, 0).unwrap(),
|
||||
None,
|
||||
"and typing into one composes nothing, because the id is of the wrong kind"
|
||||
"and there is no cell to type into, because the id is of the wrong kind"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -634,3 +642,344 @@ fn a_refused_commit_leaves_the_client_where_the_host_says_it_is() {
|
||||
"the rollback restores the typing rather than costing it (improvise-3al1)"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-2wu3: the page freezes the screen while an editor is open, but the
|
||||
/// fold goes on. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{Command, ModelCommand};
|
||||
|
||||
let mut host = host();
|
||||
let mut editor = Client::new();
|
||||
let (a, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut editor, &[snapshot]);
|
||||
let (b, _) = host.connect(10, 10);
|
||||
|
||||
// A opens an editor on the North/Revenue cell it is looking at.
|
||||
let clicked = editor
|
||||
.record_cell_at(0, 0)
|
||||
.expect("North/Revenue is stored");
|
||||
let target = editor
|
||||
.edit_target(0, 0)
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell has an edit target");
|
||||
|
||||
// B hides North; A folds the result without repainting, as the page does.
|
||||
let view = host.app().view_name().to_string();
|
||||
let hide = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::HideItem {
|
||||
view,
|
||||
category: "Region".into(),
|
||||
item: "North".into(),
|
||||
},
|
||||
);
|
||||
let relayed = host.handle(b, ClientFrame::command(hide));
|
||||
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
|
||||
deliver(&mut editor, &for_a);
|
||||
assert_ne!(
|
||||
editor.record_cell_at(0, 0),
|
||||
Some(clicked.clone()),
|
||||
"the precondition: the rows really renumbered under the editor"
|
||||
);
|
||||
|
||||
let json = editor
|
||||
.typed_cell(&target, "42")
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell composes a write");
|
||||
let ClientFrame::Commands(cmds) = serde_json::from_str(&json).expect("a client frame") else {
|
||||
panic!("a write is a Commands frame");
|
||||
};
|
||||
let written = match cmds.as_slice() {
|
||||
[
|
||||
Command::Model {
|
||||
cmd: ModelCommand::SetCell { cell, .. },
|
||||
..
|
||||
},
|
||||
] => cell.clone(),
|
||||
other => panic!("expected one SetCell, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
written, clicked,
|
||||
"the write landed on a row the user never looked at"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-0j0f: the editor's identity was captured at open but its basis at
|
||||
/// commit, so another client's write was lost. Evidence: test-evidence/clients.md.
|
||||
#[test]
|
||||
fn an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
||||
|
||||
let north = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let mut host = host();
|
||||
let mut editor = Client::new();
|
||||
let (a, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut editor, &[snapshot]);
|
||||
let (b, _) = host.connect(10, 10);
|
||||
|
||||
// A opens an editor on North/Revenue, which shows 1200.
|
||||
let clicked = editor
|
||||
.record_cell_at(0, 0)
|
||||
.expect("North/Revenue is stored");
|
||||
let target = editor
|
||||
.edit_target(0, 0)
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell has an edit target");
|
||||
|
||||
// B writes 999 there; A folds the fresh value and basis, and does not repaint.
|
||||
let write = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::SetCell {
|
||||
cell: clicked,
|
||||
value: CellValue::Number(999.0),
|
||||
},
|
||||
);
|
||||
let relayed = host.handle(b, ClientFrame::command(write));
|
||||
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
|
||||
deliver(&mut editor, &for_a);
|
||||
|
||||
let commit = editor
|
||||
.typed_cell(&target, "42")
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell composes a write");
|
||||
let answer = send(&mut host, a, &commit);
|
||||
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
|
||||
"the edit was composed against 1200 and must be refused: {answer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.model.get_cell(&north),
|
||||
Some(&CellValue::Number(999.0)),
|
||||
"B's write was silently overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-4img: B CLEARS the cell while A's editor is open. Claimed at
|
||||
/// commit, A's write re-created it. Evidence: test-evidence/clients.md.
|
||||
#[test]
|
||||
fn an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
||||
|
||||
let north = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let mut host = host();
|
||||
let mut editor = Client::new();
|
||||
let (a, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut editor, &[snapshot]);
|
||||
let (b, _) = host.connect(10, 10);
|
||||
|
||||
let clicked = editor
|
||||
.record_cell_at(0, 0)
|
||||
.expect("North/Revenue is stored");
|
||||
let target = editor
|
||||
.edit_target(0, 0)
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell has an edit target");
|
||||
|
||||
let clear = caught_up(host.app(), ModelCommand::ClearCell(clicked));
|
||||
let relayed = host.handle(b, ClientFrame::command(clear));
|
||||
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
|
||||
deliver(&mut editor, &for_a);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.model.get_cell(&north),
|
||||
None,
|
||||
"the precondition: B really cleared the cell"
|
||||
);
|
||||
|
||||
let commit = editor
|
||||
.typed_cell(&target, "42")
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell composes a write");
|
||||
let answer = send(&mut host, a, &commit);
|
||||
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordMoved(_)))),
|
||||
"the edit was composed against 1200 and must be refused: {answer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.model.get_cell(&north),
|
||||
None,
|
||||
"A's commit re-created a cell B had cleared"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-vndi: an editor opened before another client's `LoadModel` must not
|
||||
/// write into the loaded document. Evidence: test-evidence/clients.md.
|
||||
#[test]
|
||||
fn an_edit_opened_before_a_document_swap_is_refused_in_the_new_document() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
||||
|
||||
let north = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
// The incoming document keeps every name, view included (gotcha 39's
|
||||
// fixture warning), and differs only in the one value the edit targets.
|
||||
let path = std::path::PathBuf::from("other.improv");
|
||||
let mut host = host();
|
||||
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
|
||||
let mut other = host.app().model_state.workbook.clone();
|
||||
other
|
||||
.model
|
||||
.set_cell(north.clone(), CellValue::Number(5555.0));
|
||||
improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap();
|
||||
|
||||
let mut editor = Client::new();
|
||||
let (a, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut editor, &[snapshot]);
|
||||
let (b, _) = host.connect(10, 10);
|
||||
|
||||
// A opens an editor on North/Revenue, which shows 1200.
|
||||
let target = editor
|
||||
.edit_target(0, 0)
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell has an edit target");
|
||||
|
||||
// B loads the other document; A folds its rebase Snapshot, editor still open.
|
||||
let generation = host.app().document_generation();
|
||||
let load = caught_up(host.app(), ModelCommand::LoadModel(path));
|
||||
let relayed = host.handle(b, ClientFrame::command(load));
|
||||
assert_eq!(
|
||||
host.app().document_generation(),
|
||||
generation + 1,
|
||||
"setup: the load must really replace the document"
|
||||
);
|
||||
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
|
||||
deliver(&mut editor, &for_a);
|
||||
|
||||
let commit = editor
|
||||
.typed_cell(&target, "42")
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell composes a write");
|
||||
let answer = send(&mut host, a, &commit);
|
||||
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::DocumentReplaced))),
|
||||
"the edit was composed against the outgoing document: {answer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.model.get_cell(&north),
|
||||
Some(&CellValue::Number(5555.0)),
|
||||
"the loaded document's value was overwritten by an edit its user never saw"
|
||||
);
|
||||
}
|
||||
|
||||
// ── numbers across the serialized wire (improvise-6oy4) ─────────────────
|
||||
|
||||
/// A value serde_json's default f64 parser reads back one ULP low. Evidence file.
|
||||
const LOSSY: f64 = 0.248_157_142_857_142_85;
|
||||
|
||||
fn south(measure: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "South".into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
])
|
||||
}
|
||||
|
||||
/// `host()` with the lossy value in South/Revenue: row 1, off the cursor's origin.
|
||||
fn lossy_host() -> Host {
|
||||
let mut host = host();
|
||||
host.app_mut()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.set_cell(south("Revenue"), CellValue::Number(LOSSY));
|
||||
host.app_mut().rebuild_layout();
|
||||
host
|
||||
}
|
||||
|
||||
fn bits(value: Option<&CellValue>) -> Option<u64> {
|
||||
match value {
|
||||
Some(CellValue::Number(n)) => Some(n.to_bits()),
|
||||
other => panic!("expected a number, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Both hops of `yy`/`p` in a browser are serialized: the value down in the
|
||||
/// projection, the paste back up in a `SetCell`. Evidence file.
|
||||
#[test]
|
||||
fn a_yanked_number_crosses_the_serialized_wire_exactly() {
|
||||
let mut host = lossy_host();
|
||||
let mut client = Client::new();
|
||||
// `set_viewport` records the window; without it the cursor keys do nothing.
|
||||
client.set_viewport(10, 10);
|
||||
let (id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
for key in ["j", "y", "y"] {
|
||||
press(&mut host, id, &mut client, key);
|
||||
}
|
||||
assert_eq!(
|
||||
client.state().view.cursor().selected,
|
||||
(1, 0),
|
||||
"the source must be South/Revenue, off index 0"
|
||||
);
|
||||
assert_eq!(
|
||||
bits(client.state().view.yanked.as_ref()),
|
||||
Some(LOSSY.to_bits()),
|
||||
"host to client: the projected value must arrive bit for bit"
|
||||
);
|
||||
|
||||
for key in ["l", "p"] {
|
||||
press(&mut host, id, &mut client, key);
|
||||
}
|
||||
assert_eq!(
|
||||
bits(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&south("Cost"))
|
||||
),
|
||||
Some(LOSSY.to_bits()),
|
||||
"client to host: the pasted value must land bit for bit"
|
||||
);
|
||||
}
|
||||
|
||||
/// A typed number is parsed exactly in the client and re-parsed from JSON by
|
||||
/// the host. Evidence file.
|
||||
#[test]
|
||||
fn a_typed_number_crosses_the_serialized_wire_exactly() {
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
let (id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
let target = client
|
||||
.edit_target(1, 1)
|
||||
.expect("serialization must not fail")
|
||||
.expect("South/Cost is a stored cell");
|
||||
let frame = client
|
||||
.typed_cell(&target, "0.24815714285714285")
|
||||
.expect("serialization must not fail")
|
||||
.expect("South/Cost is a stored cell");
|
||||
send(&mut host, id, &frame);
|
||||
assert_eq!(
|
||||
bits(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&south("Cost"))
|
||||
),
|
||||
Some(LOSSY.to_bits()),
|
||||
"the host must store the number the user typed, not its neighbour"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,6 +266,70 @@ fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Twenty rows by three columns, the host's window eight rows by three: not
|
||||
/// square, so a rule keyed off the wrong axis lands somewhere else.
|
||||
fn paging_host() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
for i in 0..20 {
|
||||
wb.add_item("Region", format!("R{i:02}")).unwrap();
|
||||
}
|
||||
for m in ["A", "B", "C"] {
|
||||
wb.add_item("_Measure", m).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 8 + 8;
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
/// Page and half-page scrolls land in the same place on both ends, and in the
|
||||
/// place the TUI's rule names. Why the fixture and the pinned rows: the evidence file.
|
||||
#[test]
|
||||
fn a_locally_resolved_page_scroll_lands_where_the_host_would_put_it() {
|
||||
let window = (8, 3);
|
||||
let mut app = paging_host();
|
||||
let mut client = client_of(&app, window);
|
||||
let key = |code, mods| KeyEvent::new(code, mods);
|
||||
let none = KeyModifiers::NONE;
|
||||
let ctrl = KeyModifiers::CONTROL;
|
||||
let presses = [
|
||||
(key(KeyCode::Char('j'), none), 1),
|
||||
(key(KeyCode::Char('j'), none), 2),
|
||||
(key(KeyCode::PageDown, none), 8),
|
||||
(key(KeyCode::PageDown, none), 14),
|
||||
(key(KeyCode::PageUp, none), 8),
|
||||
(key(KeyCode::Char('d'), ctrl), 13),
|
||||
(key(KeyCode::PageDown, none), 19),
|
||||
(key(KeyCode::Char('u'), ctrl), 14),
|
||||
(key(KeyCode::PageUp, none), 8),
|
||||
(key(KeyCode::PageUp, none), 2),
|
||||
];
|
||||
for (press, (event, want_row)) in presses.into_iter().enumerate() {
|
||||
match resolve(&client, event) {
|
||||
KeyOutcome::Local(cmds) => {
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
}
|
||||
other => panic!("press {press}: the client must answer {event:?}, got {other:?}"),
|
||||
}
|
||||
reduce_key(&mut app, event);
|
||||
assert_eq!(
|
||||
client.view.cursor(),
|
||||
app.view_state.cursor(),
|
||||
"press {press} ({event:?}): client and host disagree"
|
||||
);
|
||||
assert_eq!(
|
||||
client.view.cursor().selected,
|
||||
(want_row, 0),
|
||||
"press {press} ({event:?}): not the row the TUI's page rule names"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for `G`, the key `GridExtent` exists for: it is *defined* as "the
|
||||
/// last row", so a window-only client would have nothing to jump to.
|
||||
#[test]
|
||||
@@ -790,6 +854,53 @@ fn a_locally_resolved_tile_axis_change_agrees_with_the_host() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `T` then `Enter` from Normal: the tile cursor must open on a regular
|
||||
/// category, not `_Index` (improvise-oplp). Why these rivals: the evidence file.
|
||||
#[test]
|
||||
fn tile_select_opens_on_a_regular_category_on_both_ends() {
|
||||
let mut app = furnished_host();
|
||||
let mut twin = twin_of(&app);
|
||||
let mut client = furnished_client(&app, (2, 1));
|
||||
let axis_of = |app: &App, cat: &str| app.view().axis_of(cat);
|
||||
assert_eq!(
|
||||
axis_of(&app, "_Index"),
|
||||
Axis::None,
|
||||
"the fixture's _Index is off-grid"
|
||||
);
|
||||
assert_eq!(axis_of(&app, "Region"), Axis::Row);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "T");
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
|
||||
assert_eq!(
|
||||
axis_of(&app, "_Index"),
|
||||
Axis::None,
|
||||
"T Enter moved _Index; the tile cursor opened on a virtual category"
|
||||
);
|
||||
assert_eq!(
|
||||
axis_of(&app, "Region"),
|
||||
Axis::Column,
|
||||
"the first regular category is the one T Enter should cycle"
|
||||
);
|
||||
|
||||
// The virtuals stay tileable, and reachable from where the cursor opens
|
||||
// (#192): the browser's only way into records mode.
|
||||
let names: Vec<&str> = client
|
||||
.tiles
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| t.category.as_str())
|
||||
.collect();
|
||||
let opened = client.view.tile_cat_idx;
|
||||
for virtual_cat in ["_Index", "_Dim"] {
|
||||
let at = names.iter().position(|n| *n == virtual_cat);
|
||||
assert!(
|
||||
at.is_some_and(|i| i < opened),
|
||||
"{virtual_cat} must stay a tile left of {opened}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The category panel's four bindings, each of which reads the flattened tree
|
||||
/// both ends share (improvise-edp): Space, `a`, Enter and `d`.
|
||||
#[test]
|
||||
@@ -1830,3 +1941,422 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
|
||||
"client and host yanked different values over an aggregate"
|
||||
);
|
||||
}
|
||||
|
||||
// The cursor's cell as a write target (improvise-lx7w, improvise-54g1, improvise-gw18).
|
||||
|
||||
/// improvise-lx7w: `x` on a records measure clears the real record on both ends.
|
||||
/// Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn x_in_records_mode_clears_the_record_the_browser_clears() {
|
||||
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
||||
let two_records = || {
|
||||
let mut app = furnished_host();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
// Two measures, so clearing one leaves South's row where the cursor is.
|
||||
for (measure, n) in [("Revenue", 900.0), ("Cost", 40.0)] {
|
||||
let key = north_revenue()
|
||||
.with("Region", "South")
|
||||
.with("_Measure", measure);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(n));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
reduce_key(&mut app, r);
|
||||
app
|
||||
};
|
||||
let (mut app, mut twin) = (two_records(), two_records());
|
||||
let revenue = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.col_label(c) == "Revenue")
|
||||
.expect("a Revenue column");
|
||||
assert_eq!(
|
||||
app.layout.record_row(1).unwrap().coords.get("Region"),
|
||||
Some("South"),
|
||||
"setup: row 1 is South"
|
||||
);
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (1, revenue);
|
||||
}
|
||||
let mut client = records_client(&app);
|
||||
client.view.cursor_mut().selected = (1, revenue);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "x");
|
||||
let model = &app.model_state.workbook.model;
|
||||
assert_eq!(
|
||||
model.get_cell(&north_revenue().with("Region", "South")),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
model.get_cell(&north_revenue()).is_some(),
|
||||
"North is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-q9eg: `yy` then `p` onto a record that lacks a later category,
|
||||
/// on both ends. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn p_onto_a_sparse_record_writes_what_the_browser_writes() {
|
||||
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
||||
let sparse = || {
|
||||
let mut app = furnished_host();
|
||||
let south = north_revenue().with("Region", "South");
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
|
||||
app.rebuild_layout();
|
||||
reduce_key(&mut app, r);
|
||||
// As `+cat` does: every existing record now lacks `Season`.
|
||||
app.model_state.workbook.add_category("Season").unwrap();
|
||||
app.rebuild_layout();
|
||||
app
|
||||
};
|
||||
let (mut app, mut twin) = (sparse(), sparse());
|
||||
assert!(
|
||||
app.layout.none_cats.iter().any(|c| c == "Season"),
|
||||
"setup: the new category is aggregated away"
|
||||
);
|
||||
let revenue = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.col_label(c) == "Revenue")
|
||||
.expect("a Revenue column");
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (0, revenue);
|
||||
}
|
||||
let mut client = records_client(&app);
|
||||
client.view.cursor_mut().selected = (0, revenue);
|
||||
both_ends(&mut app, &mut twin, &mut client, "y");
|
||||
both_ends(&mut app, &mut twin, &mut client, "y");
|
||||
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (1, revenue);
|
||||
}
|
||||
client.view.cursor_mut().selected = (1, revenue);
|
||||
both_ends(&mut app, &mut twin, &mut client, "p");
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&north_revenue().with("Region", "South")),
|
||||
Some(&improvise_protocol::CellValue::Number(1200.0)),
|
||||
"the paste must land, or both ends agreeing is both declining"
|
||||
);
|
||||
}
|
||||
|
||||
/// `key` at `at` on a host and on a client seeded from it: neither writes.
|
||||
fn declines_on_both_ends(mut app: App, at: (usize, usize), key: char) {
|
||||
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
|
||||
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
||||
app.cursor_mut().selected = at;
|
||||
let client = client_ready_to_edit(&app, (6, 6));
|
||||
assert!(
|
||||
!composes_a_write(&client, event),
|
||||
"the browser composed a write"
|
||||
);
|
||||
let before = document_text(&app);
|
||||
reduce_key(&mut app, event);
|
||||
assert_eq!(
|
||||
document_text(&app),
|
||||
before,
|
||||
"the TUI wrote where the browser declines"
|
||||
);
|
||||
assert!(
|
||||
!app.view_state.status_msg.is_empty(),
|
||||
"the TUI declined silently"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-54g1: `p` on a formula-owned cell. The control is the stored cell
|
||||
/// beside it. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn p_onto_a_formula_owned_cell_declines_on_both_ends() {
|
||||
let app = yank_host();
|
||||
assert_eq!(
|
||||
app.layout.col_label(3),
|
||||
"Profit",
|
||||
"setup: column 3 is the formula"
|
||||
);
|
||||
declines_on_both_ends(app, (1, 3), 'p');
|
||||
|
||||
let mut app = yank_host();
|
||||
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
||||
app.cursor_mut().selected = (1, 2);
|
||||
let client = client_ready_to_edit(&app, (6, 6));
|
||||
let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
|
||||
assert!(
|
||||
composes_a_write(&client, p),
|
||||
"the control: Cost is settable"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin.
|
||||
#[test]
|
||||
fn a_write_onto_a_fold_declines_on_both_ends() {
|
||||
let fold_host = || {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Month").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for month in ["Jan", "Feb", "Apr", "May"] {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
let key = improvise_command::model::cell::CellKey::new(vec![
|
||||
("Month".to_string(), month.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
]);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(3.0));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
|
||||
app
|
||||
};
|
||||
declines_on_both_ends(fold_host(), (2, 1), 'p');
|
||||
declines_on_both_ends(fold_host(), (2, 1), 'x');
|
||||
}
|
||||
|
||||
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
|
||||
/// so "the cell under the cursor" is not "the first cell".
|
||||
fn precise_host(value: improvise_protocol::CellValue) -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
for (region, measure, v) in [
|
||||
("North", "Cost", improvise_protocol::CellValue::Number(1.0)),
|
||||
(
|
||||
"North",
|
||||
"Revenue",
|
||||
improvise_protocol::CellValue::Number(2.0),
|
||||
),
|
||||
("South", "Cost", improvise_protocol::CellValue::Number(3.0)),
|
||||
("South", "Revenue", value),
|
||||
] {
|
||||
wb.model.set_cell(region_measure(region, measure), v);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 2 + 8;
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
app
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_untouched_browser_edit_stores_the_identical_value() {
|
||||
use improvise_protocol::CellValue::{Number, Text};
|
||||
for value in [
|
||||
Number(1.23456789),
|
||||
Number(0.1 + 0.2),
|
||||
Number(98765.4321987),
|
||||
Number(123456789012345.67),
|
||||
Number(-0.00001234),
|
||||
Number(-0.0),
|
||||
Number(f64::NEG_INFINITY),
|
||||
Text("0.1 + 0.2".into()),
|
||||
] {
|
||||
untouched_browser_edit(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() {
|
||||
use improvise_protocol::CellValue::{Error, Text};
|
||||
for value in [
|
||||
Text("007".into()),
|
||||
Text("1e3".into()),
|
||||
Text("inf".into()),
|
||||
Error("boom".into()),
|
||||
] {
|
||||
untouched_browser_edit(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver
|
||||
/// and a real host: the seed is the stored value's, and the cell is unchanged.
|
||||
fn untouched_browser_edit(value: improvise_protocol::CellValue) {
|
||||
use improvise_protocol::CellValue::Number;
|
||||
let mut host = precise_host(value.clone());
|
||||
let mut client = client_ready_to_edit(&host, (2, 2));
|
||||
for key in ["i", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{key}: the client must answer this key locally");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut host, &cmds);
|
||||
if key == "i" {
|
||||
assert_eq!(
|
||||
improvise_protocol::read_buffer(&client.view, "edit"),
|
||||
value.edit_text(),
|
||||
"the editor was not seeded from the stored value (improvise-umi4)"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!client.view.mode.is_editing(),
|
||||
"Enter did not commit, so this proves nothing"
|
||||
);
|
||||
let stored = host
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("South", "Revenue"))
|
||||
.cloned();
|
||||
let same = match (&stored, &value) {
|
||||
(Some(Number(a)), Number(b)) => a.to_bits() == b.to_bits(),
|
||||
(stored, value) => stored.as_ref() == Some(value),
|
||||
};
|
||||
assert!(same, "i then Enter on {value:?} stored {stored:?}");
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn every_projected_edit_text_commits_back_to_its_value() {
|
||||
use improvise_protocol::CellValue::Number;
|
||||
let value = Number(1.23456789);
|
||||
for records in [false, true] {
|
||||
let mut app = precise_host(value.clone());
|
||||
if records {
|
||||
reduce_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
|
||||
let grid = grid_of(
|
||||
&app,
|
||||
Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: 8,
|
||||
cols: 8,
|
||||
},
|
||||
);
|
||||
let cells: Vec<_> = grid
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|cell| matches!(cell.id, Some(improvise_protocol::CellId::Record(_))))
|
||||
.collect();
|
||||
assert!(
|
||||
cells.iter().any(|cell| cell.value.as_ref() == Some(&value)),
|
||||
"records={records}: the precise value was never projected, so this proves nothing"
|
||||
);
|
||||
for cell in cells {
|
||||
let text = cell.edit_text.clone().unwrap_or_default();
|
||||
assert_eq!(
|
||||
improvise_protocol::typed(&text),
|
||||
cell.value,
|
||||
"records={records}: edit text {text:?} does not commit back to {:?}",
|
||||
cell.value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FORMULAS: [&str; 3] = [
|
||||
"Profit = Revenue * 2",
|
||||
"Tax = Revenue * 0.1",
|
||||
"Margin = Profit / Revenue",
|
||||
];
|
||||
|
||||
fn add_formulas(app: &mut App) {
|
||||
for raw in FORMULAS {
|
||||
let f = improvise_command::formula::parse_formula(raw, "_Measure").unwrap();
|
||||
app.model_state.workbook.model.add_formula(f);
|
||||
}
|
||||
app.rebuild_layout();
|
||||
}
|
||||
|
||||
fn raws(app: &App) -> Vec<String> {
|
||||
let formulas = app.model_state.workbook.model.formulas();
|
||||
formulas.iter().map(|f| f.raw.clone()).collect()
|
||||
}
|
||||
|
||||
/// The formula panel's edit (improvise-km71), opened at a NONZERO cursor over
|
||||
/// three formulas, typed into and committed, with both ends agreeing throughout.
|
||||
#[test]
|
||||
fn editing_a_formula_from_the_panel_agrees_with_the_host() {
|
||||
let mut app = furnished_host();
|
||||
add_formulas(&mut app);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let mut twin = twin_of(&app);
|
||||
add_formulas(&mut twin);
|
||||
let mut client = furnished_client(&app, (2, 1));
|
||||
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
assert_eq!(
|
||||
client.view.buffers.get("formula"),
|
||||
app.view_state.buffers.get("formula"),
|
||||
"the two ends seeded the editor differently"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("formula").map(String::as_str),
|
||||
Some(FORMULAS[1]),
|
||||
"Enter must seed the formula under cursor 1"
|
||||
);
|
||||
for key in ["Backspace", "2", "Enter"] {
|
||||
both_ends(&mut app, &mut twin, &mut client, key);
|
||||
}
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![FORMULAS[0], "Tax = Revenue * 0.2", FORMULAS[2]],
|
||||
"the commit must replace the formula it opened on, in place"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rename through the browser: the one input that separates "replace" from
|
||||
/// "add", since `add_formula` upserts by target. The status diverges on purpose.
|
||||
#[test]
|
||||
fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() {
|
||||
let mut app = furnished_host();
|
||||
add_formulas(&mut app);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let mut twin = twin_of(&app);
|
||||
add_formulas(&mut twin);
|
||||
let mut client = furnished_client(&app, (2, 1));
|
||||
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
||||
view.buffers
|
||||
.insert("formula".into(), "Levy = Revenue * 0.1".into());
|
||||
}
|
||||
|
||||
let event = BrowserKey::new("Enter").to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("Enter in the formula editor resolves here");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut twin, &cmds);
|
||||
reduce_key(&mut app, event);
|
||||
|
||||
assert_eq!(document_text(&twin), document_text(&app));
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![FORMULAS[0], "Levy = Revenue * 0.1", FORMULAS[2]]
|
||||
);
|
||||
assert_eq!(client.view.mode, app.view_state.mode);
|
||||
assert_eq!(client.view.status_msg, "Formula updated");
|
||||
assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy");
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ improvise-protocol = { path = "../improvise-protocol" }
|
||||
improvise-command = { path = "../improvise-command" }
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Parses frames, so it must read back the f64 that was written: per crate, because
|
||||
# the wasm bundle builds alone (improvise-6oy4, context/code/protocol.md).
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
|
||||
# The browser half. Only the wasm build pulls these in, so `cargo test` on the
|
||||
# host builds and drives `worker.rs`/`vfs.rs` with no JS anywhere near them —
|
||||
|
||||
@@ -17,7 +17,9 @@ improvise-io = { path = "../improvise-io" }
|
||||
improvise-protocol = { path = "../improvise-protocol" }
|
||||
improvise-command = { path = "../improvise-command" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Parses frames, so it must read back the f64 that was written (improvise-6oy4,
|
||||
# context/code/protocol.md).
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
anyhow = "1"
|
||||
clap = { version = "4.6.7", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["rt", "macros", "net", "sync"] }
|
||||
|
||||
Generated
+16
@@ -45,6 +45,21 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1789760033,
|
||||
"narHash": "sha256-jtT4yxZpR8seYnlCMMWSSPlFN92zO6ICuZ8pJrmi86k=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "73b980519cefc727a5f6cc8e5c0947a2f9be6edd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
@@ -341,6 +356,7 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"beads": "beads",
|
||||
"crane": "crane",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
};
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
crate2nix.url = "github:nix-community/crate2nix";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
@@ -18,6 +19,7 @@
|
||||
rust-overlay,
|
||||
flake-utils,
|
||||
crate2nix,
|
||||
crane,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {
|
||||
@@ -36,12 +38,76 @@
|
||||
cargoNix = import ./Cargo.nix {
|
||||
inherit nixpkgs pkgs;
|
||||
};
|
||||
# wasm-pack downloads wasm-bindgen and binaryen from GitHub releases
|
||||
# unless they are on PATH, and its downloader ignores SSL_CERT_FILE, so
|
||||
# behind the runners' TLS-auditing proxy those downloads fail (binaryen
|
||||
# with no fallback). On PATH, it downloads nothing. wasm-pack only takes
|
||||
# a PATH wasm-bindgen whose version equals Cargo.lock's `wasm-bindgen`,
|
||||
# so this pin moves with the lock; CI checks that it does.
|
||||
wasmBindgenCli = pkgs.buildWasmBindgenCli rec {
|
||||
src = pkgs.fetchCrate {
|
||||
pname = "wasm-bindgen-cli";
|
||||
version = "0.2.128";
|
||||
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
|
||||
};
|
||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
inherit (src) pname version;
|
||||
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
|
||||
};
|
||||
};
|
||||
|
||||
# Everything CI runs, and nothing else: `devShells.ci` is exactly this,
|
||||
# and the default shell is this plus editor and release tooling, so a
|
||||
# local `nix develop` runs the toolchain the checks run.
|
||||
ciInputs = [
|
||||
rustToolchain
|
||||
pkgs.pkg-config
|
||||
|
||||
# `scripts/kb` and the CI knowledge-base check both run trealla. It
|
||||
# was reached through `nix shell nixpkgs#trealla` per invocation,
|
||||
# which cost more than the work: the tool's first real use took past
|
||||
# two minutes for a write that takes half a second on PATH.
|
||||
pkgs.trealla
|
||||
|
||||
# `web/build.sh` — and so the Pages job's local dry run — needs this.
|
||||
# The `wasm` CI job only runs `cargo check`, which does not, so the
|
||||
# shell was missing the one tool that turns a passing check into a
|
||||
# loadable page (improvise-d31). Must match WASM_PACK_VERSION in
|
||||
# scripts/web-toolchain-versions.env; CI checks that it does.
|
||||
pkgs.wasm-pack
|
||||
wasmBindgenCli
|
||||
pkgs.binaryen
|
||||
|
||||
# NODE_VERSION's major, same check. The js and browser jobs ran 22
|
||||
# while `build` ran the pinned 24; one shell means one node.
|
||||
pkgs.nodejs_24
|
||||
pkgs.python3
|
||||
pkgs.jq
|
||||
];
|
||||
|
||||
# Playwright's downloaded browsers are generic-Linux builds that cannot
|
||||
# start on NixOS, so nixpkgs' build is used. harness/package.json pins
|
||||
# @playwright/test to this playwright-driver's version: they must match.
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain (_: rustToolchain);
|
||||
ci = import ./nix/ci.nix {
|
||||
inherit pkgs craneLib wasmBindgenCli;
|
||||
src = self;
|
||||
# What the site's cache-bust token names; CI checks out a clean tree.
|
||||
rev = self.rev or "dirty";
|
||||
};
|
||||
|
||||
playwrightEnv = {
|
||||
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true";
|
||||
RUST_BACKTRACE = "1";
|
||||
};
|
||||
in {
|
||||
inherit cargoNix;
|
||||
devShells.default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
rustToolchain
|
||||
pkgs.pkg-config
|
||||
devShells.ci = pkgs.mkShell (playwrightEnv // {nativeBuildInputs = ciInputs;});
|
||||
devShells.default = pkgs.mkShell (playwrightEnv // {
|
||||
nativeBuildInputs = ciInputs ++ [
|
||||
pkgs.rust-analyzer
|
||||
crate2nix.packages.${system}.default
|
||||
pkgs.cargo-expand
|
||||
@@ -50,18 +116,6 @@
|
||||
pkgs.universal-ctags
|
||||
beads.packages.${system}.default
|
||||
|
||||
# `scripts/kb` and the CI knowledge-base check both run trealla. It
|
||||
# was reached through `nix shell nixpkgs#trealla` per invocation,
|
||||
# which cost more than the work: the tool's first real use took past
|
||||
# two minutes for a write that takes half a second on PATH.
|
||||
pkgs.trealla
|
||||
|
||||
# `web/build.sh` — and so the Pages job's local dry run — needs this.
|
||||
# The `wasm` CI job only runs `cargo check`, which does not, so the
|
||||
# shell was missing the one tool that turns a passing check into a
|
||||
# loadable page (improvise-d31).
|
||||
pkgs.wasm-pack
|
||||
|
||||
# Demo recording and release tooling
|
||||
pkgs.asciinema
|
||||
pkgs.vhs
|
||||
@@ -69,12 +123,21 @@
|
||||
# nixpkgs cargo-dist installs as "dist"; alias so `cargo dist` works
|
||||
(pkgs.writeShellScriptBin "cargo-dist" ''exec ${pkgs.cargo-dist}/bin/dist "$@"'')
|
||||
];
|
||||
RUST_BACKTRACE = "1";
|
||||
};
|
||||
});
|
||||
|
||||
packages = {
|
||||
improvise = cargoNix.workspaceMembers.improvise.build;
|
||||
default = self.packages.${system}.improvise;
|
||||
};
|
||||
packages =
|
||||
ci.packages
|
||||
// {
|
||||
improvise = cargoNix.workspaceMembers.improvise.build;
|
||||
default = self.packages.${system}.improvise;
|
||||
};
|
||||
|
||||
# `nix flake check` builds every CI build: crane's checks plus the
|
||||
# crate2nix package and the bundles.
|
||||
checks =
|
||||
ci.checks
|
||||
// {
|
||||
inherit (self.packages.${system}) improvise web;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,10 +9,25 @@
|
||||
# half is the *deployed* arrangement rather than `web/` itself, because a
|
||||
# harness that runs against a different tree from the one that ships is
|
||||
# testing something nobody deploys (improvise-vvl3).
|
||||
#
|
||||
# Re-run THIS script, never `web/assemble.sh harness/serve` alone: assembling
|
||||
# starts with `rm -rf` of its output, which deletes opfs/ and the fixtures
|
||||
# (improvise-mkbp). opfs.spec.js says so if it finds opfs/ missing.
|
||||
set -eu
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
serve="$root/harness/serve"
|
||||
|
||||
# A stale opfs-contract/Cargo.lock fails HERE rather than being rewritten in
|
||||
# every agent's tree (improvise-mkbp). wasm-pack cannot enforce this: it
|
||||
# re-resolves the lock itself before any `-- --locked` reaches cargo build.
|
||||
# Fix a failure with `cargo update -w` in harness/opfs-contract, and commit it.
|
||||
cargo metadata --locked --format-version 1 \
|
||||
--manifest-path "$root/harness/opfs-contract/Cargo.toml" >/dev/null || {
|
||||
echo "harness/build.sh: harness/opfs-contract/Cargo.lock is stale. Do not remove --locked." >&2
|
||||
echo " Run: cargo update -w --manifest-path harness/opfs-contract/Cargo.toml, and commit the lock." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$root/web/build.sh" >/dev/null
|
||||
"$root/web/assemble.sh" "$serve" harness >/dev/null
|
||||
|
||||
|
||||
Generated
+8
-10
@@ -228,8 +228,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -308,9 +306,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
|
||||
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -318,9 +316,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
|
||||
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -328,9 +326,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
|
||||
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -341,9 +339,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
|
||||
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
|
||||
Generated
+15
-33
@@ -6,70 +6,52 @@
|
||||
"": {
|
||||
"name": "improvise-harness",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1"
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.1"
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1"
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ import { test, expect } from "@playwright/test";
|
||||
// the same with the handshake window).
|
||||
import { NOTICE_KEY } from "../../web/shell.js";
|
||||
|
||||
/** Every string the page posts to its worker, kept on `window.__posted`. */
|
||||
function recordPosts() {
|
||||
window.__posted = [];
|
||||
const post = Worker.prototype.postMessage;
|
||||
Worker.prototype.postMessage = function (message, ...rest) {
|
||||
window.__posted.push(String(message));
|
||||
return post.call(this, message, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
const setCells = (page) =>
|
||||
page.evaluate(() => window.__posted.filter((m) => m.includes("SetCell")).length);
|
||||
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -126,8 +139,9 @@ test.describe("every field the render model projects reaches the screen", () =>
|
||||
const rowHeaders = page.locator("#grid tbody th");
|
||||
await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
|
||||
|
||||
// Tile select opens on the first regular category, which in the demo is
|
||||
// Product (improvise-oplp), so no arrow key is needed to reach it.
|
||||
await page.keyboard.press("Shift+T");
|
||||
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
|
||||
await expect(product).toHaveClass(/selected/);
|
||||
await page.keyboard.press("c");
|
||||
|
||||
@@ -250,15 +264,16 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
await expect(page.locator("#views li.active")).toContainText("Season");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Season");
|
||||
|
||||
// A formula row: navigation only, because FormulaPanel binds no Enter and
|
||||
// a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md.
|
||||
// `#grid input` toHaveCount(0) belongs here and is deliberately absent:
|
||||
// it cannot fail, measured by injection (improvise-qf57, clients.md).
|
||||
// A formula row: FormulaPanel's Enter edits the formula (improvise-km71)
|
||||
// and a click mirrors Enter, so the click opens the editor on that row.
|
||||
// `#grid input` toHaveCount(0) is deliberately absent (improvise-qf57).
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#formulas li").first()).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const formula = await page.locator("#formulas li").first().innerText();
|
||||
await page.locator("#formulas li").first().click();
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULA");
|
||||
await expect(page.locator("#minibuffer")).toContainText(formula.trim());
|
||||
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -287,6 +302,109 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// improvise-ixxx. The editor hands wasm the seed it captured at open, and
|
||||
// only wasm decides an untouched commit writes nothing — JS must pass it.
|
||||
test("an untouched mouse editor sends no write, and a touched one does", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
window.__posted = [];
|
||||
const post = Worker.prototype.postMessage;
|
||||
Worker.prototype.postMessage = function (message, ...rest) {
|
||||
window.__posted.push(String(message));
|
||||
return post.call(this, message, ...rest);
|
||||
};
|
||||
});
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
const writes = () =>
|
||||
page.evaluate(() => window.__posted.filter((m) => m.includes("SetCell")).length);
|
||||
|
||||
await cells.nth(4).click();
|
||||
await expect(cells.nth(4).locator("input")).toHaveCount(1);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#grid input")).toHaveCount(0);
|
||||
await page.waitForTimeout(300);
|
||||
expect(await writes()).toBe(0);
|
||||
|
||||
// The control: without it a page that never sends anything passes above.
|
||||
await cells.nth(4).click();
|
||||
await expect(cells.nth(4).locator("input")).toHaveCount(1);
|
||||
await page.keyboard.type("1");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(writes).toBe(1);
|
||||
});
|
||||
|
||||
// improvise-jibp. The page reports its first edit, so retyping the seed over
|
||||
// the selection is a write, not an untouched editor.
|
||||
test("a mouse editor typed back to its own seed still writes", async ({ page }) => {
|
||||
await page.addInitScript(recordPosts);
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
await cells.nth(4).click();
|
||||
const input = cells.nth(4).locator("input");
|
||||
await expect(input).toHaveCount(1);
|
||||
const seed = await input.inputValue();
|
||||
expect(seed, "the fixture cell has no seed to retype").not.toBe("");
|
||||
await page.keyboard.type(seed);
|
||||
await expect(input).toHaveValue(seed);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => setCells(page)).toBe(1);
|
||||
});
|
||||
|
||||
// improvise-ugj4. A frame that lands while an editor is open changes the
|
||||
// cache but not the paint; the next editor must open on what it captured.
|
||||
test("an editor opened after an unpainted write shows and commits what it captured", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(recordPosts);
|
||||
await page.addInitScript(() => {
|
||||
window.__frames = [];
|
||||
const add = Worker.prototype.addEventListener;
|
||||
Worker.prototype.addEventListener = function (type, listener, ...rest) {
|
||||
if (type !== "message") return add.call(this, type, listener, ...rest);
|
||||
window.__deliver = (data) => listener.call(this, new MessageEvent("message", { data }));
|
||||
const recorded = (event) => {
|
||||
if (typeof event.data === "string") window.__frames.push(event.data);
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return add.call(this, type, recorded, ...rest);
|
||||
};
|
||||
});
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
const target = cells.nth(4);
|
||||
const where = await target.evaluate((td) => [Number(td.dataset.row), Number(td.dataset.col)]);
|
||||
|
||||
await cells.nth(0).click();
|
||||
await expect(cells.nth(0).locator("input")).toHaveCount(1);
|
||||
// Another client's write to cell 4, delivered while cell 0's editor is open.
|
||||
await page.evaluate(([row, col]) => {
|
||||
let latest = null;
|
||||
const walk = (node) => {
|
||||
if (Array.isArray(node)) node.forEach(walk);
|
||||
else if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "Cells" && Array.isArray(value)) {
|
||||
for (const [[r, c], cell] of value) if (r === row && c === col) latest = cell;
|
||||
} else walk(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__frames.forEach((f) => walk(JSON.parse(f)));
|
||||
const moved = { ...latest, text: "777", edit_text: "777", value: { Number: 777 } };
|
||||
window.__deliver(JSON.stringify({ Commands: [{ Project: { Grid: { Cells: [[[row, col], moved]] } } }] }));
|
||||
}, where);
|
||||
|
||||
await target.click();
|
||||
const input = target.locator("input");
|
||||
await expect(input).toHaveCount(1);
|
||||
await expect(input, "the editor opened on the stale paint").toHaveValue("777");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(300);
|
||||
expect(await setCells(page), "an untouched editor wrote the stale text").toBe(0);
|
||||
});
|
||||
|
||||
// improvise-b8ot moved these five surfaces onto one handler per stable root.
|
||||
// The three tests below are the properties a naive delegation loses, each of
|
||||
// which is silent: the page still paints, and only the pointer stops working.
|
||||
|
||||
@@ -132,4 +132,30 @@ test.describe("the help overlay draws the shared content", () => {
|
||||
await expect(rows_.last().locator("td.selected")).toHaveCount(1);
|
||||
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// improvise-1c5m. Below the width at which the app bar wraps, `#help` covers
|
||||
// `#hint`, and Welcome itself says nothing about paging. The contract: the
|
||||
// mode's own hint is UNCOVERED somewhere while help is open.
|
||||
test("a narrow window still shows how to page through help", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 600, height: 800 });
|
||||
await openHelp(page);
|
||||
|
||||
// The string is the page's own `#hint`, i.e. `hint_line(Help)`; this spec
|
||||
// spells no keys. Asserting it names paging keeps an empty hint out.
|
||||
const hint = (await page.locator("#hint").textContent()).trim();
|
||||
expect(hint).toContain("next-page");
|
||||
|
||||
// An element showing exactly that string, topmost at its own centre: the
|
||||
// overlay covering `#hint` is precisely what `elementFromPoint` sees.
|
||||
const uncovered = await page.evaluate((text) =>
|
||||
[...document.querySelectorAll("body *")].some((node) => {
|
||||
if (node.children.length > 0 || node.textContent.trim() !== text) return false;
|
||||
const r = node.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return false;
|
||||
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return top !== null && (top === node || node.contains(top));
|
||||
}),
|
||||
hint);
|
||||
expect(uncovered, "the paging hint is under the help overlay").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,11 @@ const EXPECTED = [
|
||||
|
||||
test.describe("OpfsFs in a real browser", () => {
|
||||
test.beforeEach(async ({ page, browserName }, testInfo) => {
|
||||
await page.goto("/opfs/index.html");
|
||||
const served = await page.goto("/opfs/index.html");
|
||||
expect(
|
||||
served?.ok(),
|
||||
"harness/serve/opfs is missing: re-run harness/build.sh, not web/assemble.sh alone",
|
||||
).toBe(true);
|
||||
|
||||
// The skip is conditional on the PROBE, not on the browser name, so it
|
||||
// cannot quietly swallow a real regression. See clients.md.
|
||||
|
||||
@@ -187,6 +187,21 @@ test.describe("the standalone deployment", () => {
|
||||
await expect(page.locator("#grid td.editable").first()).toBeVisible();
|
||||
});
|
||||
|
||||
// improvise-i0v9: "in-page worker · <doc>" was written at 13ms, before the
|
||||
// worker had answered anything, and read as success over a blank page.
|
||||
test("the worker is not called up until its first frame has arrived", async ({ page }) => {
|
||||
const doc = `first-frame-${Date.now()}.improv`;
|
||||
await page.addInitScript(logStatus);
|
||||
await page.goto(`/app/index.html?doc=${doc}`);
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(status(page)).toHaveText(`in-page worker · ${doc}`);
|
||||
const log = await page.evaluate(() => window.__statusLog);
|
||||
expect(log, `#status said: ${json(log)}`).toEqual([
|
||||
`starting the worker · ${doc}…`,
|
||||
`in-page worker · ${doc}`,
|
||||
]);
|
||||
});
|
||||
|
||||
// improvise-9cma: a `?doc=` this backend cannot hold used to reach the
|
||||
// generic watchdog 8s later. The REASON now crosses the port (improvise-1wft).
|
||||
test("an invalid ?doc= is named on screen, and not blamed on the browser", async ({
|
||||
@@ -235,6 +250,7 @@ test.describe("the standalone deployment", () => {
|
||||
|
||||
const second = await context.newPage();
|
||||
await second.clock.install();
|
||||
await second.addInitScript(logStatus);
|
||||
await second.goto(`/app/index.html?doc=${doc}`);
|
||||
|
||||
// It PAINTS. That is the whole point: a locked document is read-only, not
|
||||
@@ -242,6 +258,15 @@ test.describe("the standalone deployment", () => {
|
||||
await expect(second.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(second.locator("#status")).toContainText("read-only");
|
||||
await expect(second.locator("#status")).toContainText("another tab");
|
||||
// The page's clock has not been walked, so a reason that arrived AFTER the
|
||||
// watchdog would have been logged behind "did not answer". Nor was success
|
||||
// ever claimed: the Snapshot that follows the notice must not overwrite it.
|
||||
const early = await second.evaluate(() => window.__statusLog);
|
||||
expect(early.at(-1), `#status said: ${json(early)}`).toContain("read-only");
|
||||
expect(
|
||||
early.filter((t) => t.includes("did not answer") || t === `in-page worker · ${doc}`),
|
||||
`#status said: ${json(early)}`,
|
||||
).toEqual([]);
|
||||
|
||||
// And the reason STANDS: it is not replaced 8s later by a watchdog
|
||||
// blaming the browser for something the code knew precisely.
|
||||
@@ -252,6 +277,10 @@ test.describe("the standalone deployment", () => {
|
||||
await second.clock.runFor(HANDSHAKE_TIMEOUT_MS + 2_000);
|
||||
await expect(second.locator("#status")).toContainText("read-only");
|
||||
await expect(second.locator("#status")).not.toContainText("did not answer");
|
||||
const late = await second.evaluate(() => window.__statusLog);
|
||||
expect(late.some((t) => t.includes("did not answer")), `#status said: ${json(late)}`).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await second.close();
|
||||
await first.close();
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Everything CI *builds*, as Nix derivations (crane). The script-only checks
|
||||
# (node tests, kb, the checkers, the comment and CI-graph gates, fmt) stay dev
|
||||
# shell steps in ci.yml: they compile nothing, and their `#!/usr/bin/env bash`
|
||||
# shebangs -- which scripts/checkers.sh itself enforces -- cannot run in the
|
||||
# build sandbox, which has no /usr/bin/env.
|
||||
#
|
||||
# Why Nix: the self-hosted runners substitute from titan's harmonia first, and
|
||||
# titan builds gh/main's outputs hourly. Dependencies are separate derivations
|
||||
# (buildDepsOnly, keyed on the Cargo.lock), so a PR that leaves the lock alone
|
||||
# rebuilds only the workspace.
|
||||
{
|
||||
pkgs,
|
||||
craneLib,
|
||||
wasmBindgenCli,
|
||||
src,
|
||||
rev,
|
||||
}: let
|
||||
inherit (pkgs) lib;
|
||||
|
||||
# Two lockfiles: harness/opfs-contract is its own workspace root.
|
||||
cargoVendorDir = craneLib.vendorMultipleCargoDeps {
|
||||
cargoLockList = [../Cargo.lock ../harness/opfs-contract/Cargo.lock];
|
||||
};
|
||||
|
||||
common = {
|
||||
inherit src cargoVendorDir;
|
||||
pname = "improvise";
|
||||
version = "0";
|
||||
strictDeps = true;
|
||||
# The `test` job ran the dev/test profiles, not crane's default release:
|
||||
# debug assertions and overflow checks are part of what it tested.
|
||||
CARGO_PROFILE = "";
|
||||
};
|
||||
|
||||
wasmTarget = "wasm32-unknown-unknown";
|
||||
|
||||
# The crates in the browser bundle, checked for wasm32 one at a time: a
|
||||
# single `cargo check -p a -p b` unifies features across them and can hide
|
||||
# a crate that only builds with a sibling's features (improvise-ywd).
|
||||
wasmCrates = [
|
||||
"improvise-core"
|
||||
"improvise-formula"
|
||||
"improvise-protocol"
|
||||
"improvise-io"
|
||||
"improvise-command"
|
||||
"improvise-wasm-client"
|
||||
"improvise-worker-server"
|
||||
"improvise-gp"
|
||||
];
|
||||
pFlags = crates: lib.concatMapStringsSep " " (c: "-p ${c}") crates;
|
||||
|
||||
nativeDeps = craneLib.buildDepsOnly (common
|
||||
// {
|
||||
pname = "improvise-deps";
|
||||
cargoExtraArgs = "--locked --workspace";
|
||||
});
|
||||
|
||||
wasmCheckDeps = craneLib.buildDepsOnly (common
|
||||
// {
|
||||
pname = "improvise-wasm-check-deps";
|
||||
cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags wasmCrates}";
|
||||
cargoCheckExtraArgs = "";
|
||||
doCheck = false;
|
||||
});
|
||||
|
||||
# What wasm-pack's `cargo build --release --target wasm32` needs.
|
||||
bundleDeps = craneLib.buildDepsOnly (common
|
||||
// {
|
||||
pname = "improvise-bundle-deps";
|
||||
CARGO_PROFILE = "release";
|
||||
cargoExtraArgs = "--locked --target ${wasmTarget} ${pFlags ["improvise-wasm-client" "improvise-worker-server"]}";
|
||||
cargoCheckCommand = "true";
|
||||
doCheck = false;
|
||||
});
|
||||
|
||||
# A derivation that runs cargo commands and only has to succeed.
|
||||
cargoCheck = name: args:
|
||||
craneLib.mkCargoDerivation (common
|
||||
// {
|
||||
pname = "improvise-${name}";
|
||||
doInstallCargoArtifacts = false;
|
||||
installPhaseCommand = "mkdir -p $out";
|
||||
}
|
||||
// args);
|
||||
in rec {
|
||||
checks = {
|
||||
clippy = craneLib.cargoClippy (common
|
||||
// {
|
||||
pname = "improvise-clippy";
|
||||
cargoArtifacts = nativeDeps;
|
||||
cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings";
|
||||
});
|
||||
|
||||
# The evidence gate runs `cargo test --workspace -- --list`, so it runs
|
||||
# here, where the tests are already compiled, not as a separate step.
|
||||
test = craneLib.cargoTest (common
|
||||
// {
|
||||
pname = "improvise-test";
|
||||
cargoArtifacts = nativeDeps;
|
||||
cargoTestExtraArgs = "--workspace";
|
||||
nativeBuildInputs = [pkgs.python3 pkgs.git];
|
||||
# tests/shipped_documents_load.rs walks the git index (`git ls-files`)
|
||||
# and makes scratch repos. The flake source is exactly the tracked
|
||||
# files, so force-adding all of it (but crane's target/) rebuilds the
|
||||
# checkout's index, ignored-but-tracked documents included.
|
||||
preBuild = ''
|
||||
export HOME=$TMPDIR
|
||||
export GIT_AUTHOR_NAME=nix GIT_AUTHOR_EMAIL=nix@localhost
|
||||
export GIT_COMMITTER_NAME=nix GIT_COMMITTER_EMAIL=nix@localhost
|
||||
git init -q .
|
||||
git add --all --force -- . ':!target'
|
||||
'';
|
||||
postBuild = ''
|
||||
python3 scripts/check-comment-runs.py --check-evidence
|
||||
'';
|
||||
});
|
||||
|
||||
wasm-check = cargoCheck "wasm-check" {
|
||||
cargoArtifacts = wasmCheckDeps;
|
||||
buildPhaseCargoCommand = lib.concatMapStringsSep "\n" (c: ''
|
||||
echo "== ${c}"
|
||||
cargo check --locked -p ${c} --target ${wasmTarget}
|
||||
'')
|
||||
wasmCrates;
|
||||
};
|
||||
|
||||
# harness/opfs-contract is its own workspace, so the root clippy never
|
||||
# reaches it; `-p` keeps cargo fmt to this one crate (see ci.yml).
|
||||
harness-lint = cargoCheck "harness-lint" {
|
||||
cargoArtifacts = null;
|
||||
buildPhaseCargoCommand = ''
|
||||
cd harness/opfs-contract
|
||||
cargo fmt -p opfs-contract -- --check
|
||||
cargo clippy --locked --target ${wasmTarget} --all-targets -- -D warnings
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
packages = {
|
||||
# The dependency builds on their own, for titan's cache timer to keep as a
|
||||
# GC root: the checks' outputs do not reference them, so rooting only the
|
||||
# checks would let the part a PR actually reuses be collected.
|
||||
ci-deps = pkgs.linkFarmFromDrvs "improvise-ci-deps" [nativeDeps wasmCheckDeps bundleDeps];
|
||||
|
||||
# harness/build.sh, unchanged: the three wasm bundles and harness/serve.
|
||||
# No network: wasm-bindgen and wasm-opt come from PATH, so wasm-pack
|
||||
# downloads nothing (see wasmBindgenCli in flake.nix).
|
||||
web = craneLib.mkCargoDerivation (common
|
||||
// {
|
||||
pname = "improvise-web";
|
||||
cargoArtifacts = bundleDeps;
|
||||
doInstallCargoArtifacts = false;
|
||||
nativeBuildInputs = [pkgs.wasm-pack wasmBindgenCli pkgs.binaryen pkgs.nodejs_24];
|
||||
# web/assemble.sh calls scripts/assemble-stamp.sh, whose
|
||||
# `#!/usr/bin/env bash` has no interpreter in the sandbox. Patching
|
||||
# this private copy is harmless: no checker runs here.
|
||||
postPatch = "patchShebangs scripts web harness";
|
||||
buildPhaseCargoCommand = ''
|
||||
export HOME=$TMPDIR
|
||||
./harness/build.sh
|
||||
'';
|
||||
installPhaseCommand = ''
|
||||
mkdir -p $out
|
||||
cp -r harness/serve $out/harness-serve
|
||||
cp -r web/pkg web/worker-pkg $out/
|
||||
'';
|
||||
});
|
||||
|
||||
# The Pages tree, stamped with the commit it was built from. Separate from
|
||||
# `web` so the stamp changing every commit does not rebuild the bundles.
|
||||
site =
|
||||
pkgs.runCommand "improvise-site" {
|
||||
nativeBuildInputs = [pkgs.nodejs_24];
|
||||
} ''
|
||||
cp -r ${src} tree
|
||||
chmod -R u+w tree
|
||||
patchShebangs tree/scripts tree/web
|
||||
cp -r ${packages.web}/pkg ${packages.web}/worker-pkg tree/web/
|
||||
chmod -R u+w tree/web
|
||||
cd tree
|
||||
./web/assemble.sh $out ${rev}
|
||||
'';
|
||||
};
|
||||
}
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# The dead-ID audit: every improvise-<id> the tree cites must be a ticket bd
|
||||
# knows. See AGENTS.md, "Cite a ticket ID you have read back" (improvise-52eq).
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: bd-audit-ids
|
||||
|
||||
Run from anywhere inside the repository. Takes no arguments.
|
||||
|
||||
exit 0 every cited id is a ticket bd lists
|
||||
exit 1 at least one cited id is not; each is printed
|
||||
exit 3 bd could not answer: missing, failing, or listing nothing
|
||||
exit 4 usage error, or the file scan itself failed
|
||||
|
||||
The comparison is POSITIVE: a cited id passes only by appearing in what
|
||||
`bd list --all` returns. The recipe this replaced looked for bd's "not found"
|
||||
lines and reported "all resolve" when there were none, so a bd that never ran
|
||||
read as a clean audit.
|
||||
|
||||
THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same
|
||||
pipeline or `;` list.
|
||||
EOF
|
||||
exit 4
|
||||
}
|
||||
[ $# -eq 0 ] || usage
|
||||
|
||||
BD="${BD_AUDIT_BD:-bd}"
|
||||
ROOTS=(context README.md AGENTS.md CLAUDE.md crates src tests examples web
|
||||
harness docs .agents .claude)
|
||||
# Crate, package, artifact and file names that are shaped like ids. Anchored,
|
||||
# because an unanchored `gp` would silently exclude any real ticket id starting with it.
|
||||
EXCLUDE='^improvise-(core|formula|io|protocol|command|gp|ws|worker|wasm|harness|registry|report|side|web)$'
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
git ls-files -z --cached --others --exclude-standard -- "${ROOTS[@]}" > "$work/files"
|
||||
|
||||
# xargs exits 123 when one grep batch matched nothing, which is not an error.
|
||||
set +e
|
||||
xargs -0 grep -d skip -ohE 'improvise-[a-z0-9]{3,8}\b' < "$work/files" > "$work/raw"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" -eq 0 ] || [ "$rc" -eq 123 ] || { echo "bd-audit-ids: grep failed ($rc)" >&2; exit 4; }
|
||||
sort -u "$work/raw" | { grep -vE "$EXCLUDE" || true; } > "$work/cited"
|
||||
|
||||
if ! command -v "$BD" >/dev/null 2>&1; then
|
||||
echo "bd-audit-ids: '$BD' is not on PATH, so nothing was checked." >&2
|
||||
exit 3
|
||||
fi
|
||||
if ! "$BD" list --all --limit 0 --json > "$work/known.json" 2> "$work/bd.err"; then
|
||||
echo "bd-audit-ids: '$BD list' failed, so nothing was checked:" >&2
|
||||
cat "$work/bd.err" >&2
|
||||
exit 3
|
||||
fi
|
||||
if ! jq -r '.[].id' "$work/known.json" 2>/dev/null | sort -u > "$work/known"; then
|
||||
echo "bd-audit-ids: '$BD list' did not print a JSON list of issues." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [ ! -s "$work/known" ]; then
|
||||
echo "bd-audit-ids: '$BD list' returned no issues; refusing to call that a pass." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
comm -23 "$work/cited" "$work/known" > "$work/dead"
|
||||
if [ -s "$work/dead" ]; then
|
||||
echo "bd-audit-ids: cited but not a ticket bd knows:"
|
||||
sed 's/^/ /' "$work/dead"
|
||||
exit 1
|
||||
fi
|
||||
printf 'bd-audit-ids: all %d cited ids resolve (bd knows %d)\n' \
|
||||
"$(wc -l < "$work/cited" | tr -d ' ')" "$(wc -l < "$work/known" | tr -d ' ')"
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# check_the_checker for scripts/bd-audit-ids. A throwaway git repo and a fake
|
||||
# bd; no network, no real tracker. See context/test-evidence/tooling.md.
|
||||
set -uo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AUDIT="$here/bd-audit-ids"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
|
||||
|
||||
# The fake bd answers `list` from $FAKE_BD_OUT and exits $FAKE_BD_RC; any
|
||||
# other subcommand is an error, so the audit cannot pass by calling `show`.
|
||||
mkdir -p "$work/bin"
|
||||
cat > "$work/bin/bd" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" = list ] || { echo "fake bd: unexpected '$*'" >&2; exit 2; }
|
||||
[ -f "${FAKE_BD_OUT:-}" ] && cat "$FAKE_BD_OUT"
|
||||
exit "${FAKE_BD_RC:-0}"
|
||||
EOF
|
||||
chmod +x "$work/bin/bd"
|
||||
|
||||
repo="$work/repo"
|
||||
mkdir -p "$repo/context" "$repo/harness/serve"
|
||||
git -C "$repo" init -q
|
||||
printf 'serve/\n' > "$repo/harness/.gitignore"
|
||||
# A live id, and a crate name the exclusion list must drop: without the
|
||||
# exclusion, improvise-core would be reported dead and the first case fails.
|
||||
printf 'See improvise-ab12 in improvise-core.\n' > "$repo/context/notes.md"
|
||||
# Ignored build output with a dead id. The audit reads git's file list, so
|
||||
# this must never be reported (improvise-8haj).
|
||||
printf 'improvise-gone9\n' > "$repo/harness/serve/copy.md"
|
||||
|
||||
printf '[{"id":"improvise-ab12"},{"id":"improvise-cd34"}]\n' > "$work/known.json"
|
||||
printf '' > "$work/empty"
|
||||
printf '[]\n' > "$work/none.json"
|
||||
|
||||
# $1 name, $2 expected exit, $3 grep -E pattern the output must contain;
|
||||
# the caller sets FAKE_BD_* and BD_AUDIT_BD in the environment.
|
||||
run_case() {
|
||||
local name="$1" want="$2" pat="$3"
|
||||
(cd "$repo" && "$AUDIT") > "$work/out" 2>&1
|
||||
local got=$?
|
||||
if [ "$got" != "$want" ]; then bad "$name" "exit $got, wanted $want: $(head -c 200 "$work/out")"
|
||||
elif ! grep -Eq "$pat" "$work/out"; then bad "$name" "no /$pat/ in: $(head -c 200 "$work/out")"
|
||||
else ok "$name"; fi
|
||||
}
|
||||
|
||||
export BD_AUDIT_BD="$work/bin/bd"
|
||||
|
||||
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=0 \
|
||||
run_case "every cited id known is a pass" 0 'all 1 cited ids resolve'
|
||||
|
||||
printf 'Also improvise-zzzq.\n' > "$repo/context/dead.md"
|
||||
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=0 \
|
||||
run_case "a dead id is exit 1, and named" 1 '^ improvise-zzzq$'
|
||||
rm -f "$repo/context/dead.md"
|
||||
|
||||
# The three shapes of "bd never answered". The recipe this replaced reported
|
||||
# "all resolve" for every one of them (improvise-52eq).
|
||||
FAKE_BD_OUT="$work/empty" FAKE_BD_RC=0 \
|
||||
run_case "a bd that prints nothing is not a pass" 3 'did not print a JSON list|returned no issues'
|
||||
FAKE_BD_OUT="$work/none.json" FAKE_BD_RC=0 \
|
||||
run_case "a bd that lists no issues is not a pass" 3 'returned no issues'
|
||||
FAKE_BD_OUT="$work/known.json" FAKE_BD_RC=1 \
|
||||
run_case "a bd that fails is not a pass" 3 'list. failed'
|
||||
BD_AUDIT_BD="$work/bin/no-such-bd" \
|
||||
run_case "a missing bd is not a pass" 3 'is not on PATH'
|
||||
|
||||
# The live repo, with the fake bd listing nothing: if this passed, a real
|
||||
# audit whose bd fell silent would pass too.
|
||||
(cd "$here/.." && FAKE_BD_OUT="$work/none.json" "$AUDIT") > "$work/out" 2>&1
|
||||
got=$?
|
||||
[ "$got" = 3 ] && ok "the real tree does not pass against a silent bd" \
|
||||
|| bad "the real tree does not pass against a silent bd" "exit $got"
|
||||
|
||||
"$AUDIT" --help > "$work/out" 2>&1
|
||||
got=$?
|
||||
[ "$got" = 4 ] && grep -q 'POSITIVE' "$work/out" \
|
||||
&& ok "--help is exit 4 and states the positive comparison" \
|
||||
|| bad "--help is exit 4 and states the positive comparison" "exit $got"
|
||||
|
||||
printf '\nbd-audit-ids-test: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -133,9 +133,15 @@ EOF
|
||||
chmod +x "$work/fakebin/bash"
|
||||
# The runner is invoked through a REAL bash, so the fake one below is only
|
||||
# what its own `command -v bash` probe finds -- not its interpreter.
|
||||
# /usr/bin:/bin carries the utilities the runner itself needs and carries no
|
||||
# nix, so the fake bash is the only candidate and the nix fallback is absent.
|
||||
env PATH="$work/fakebin:/usr/bin:/bin" HOME="$HOME" \
|
||||
# utilbin carries only the utilities the runner itself needs, and no nix, so
|
||||
# the fake bash is the only candidate and the nix fallback is absent. Linked
|
||||
# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env`
|
||||
# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3.
|
||||
mkdir -p "$work/utilbin"
|
||||
for tool in dirname; do
|
||||
ln -sf "$(command -v "$tool")" "$work/utilbin/$tool"
|
||||
done
|
||||
env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \
|
||||
"${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1
|
||||
got=$?
|
||||
[ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \
|
||||
|
||||
+6
-2
@@ -9,7 +9,8 @@ usage() {
|
||||
usage: pr-reviews <pr-number> [--wait <sec>] [--interval <sec>]
|
||||
|
||||
--wait <sec> wait up to this long for a review or an inline review
|
||||
comment whose commit is the current head
|
||||
comment whose commit is the current head; any --wait
|
||||
above 0 polls at least once more after the first read
|
||||
(default 0 = print what exists and exit)
|
||||
--interval <sec> seconds between polls (default 30)
|
||||
|
||||
@@ -157,8 +158,10 @@ report "$json"
|
||||
|
||||
if has_head_review "$json"; then exit 0; fi
|
||||
|
||||
# The deadline is checked AFTER each poll: checked first, a second ticking over
|
||||
# between two reads of the clock skipped the only re-poll (improvise-qnpi).
|
||||
deadline=$(( $(date +%s) + WAIT ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
while :; do
|
||||
sleep "$INTERVAL"
|
||||
json=$(fetch)
|
||||
if has_head_review "$json"; then
|
||||
@@ -166,6 +169,7 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
report "$json"
|
||||
exit 0
|
||||
fi
|
||||
[ "$(date +%s)" -lt "$deadline" ] || break
|
||||
done
|
||||
|
||||
printf '\npr-reviews: waited %ss; nothing against the current head.\n' "$WAIT"
|
||||
|
||||
@@ -197,6 +197,59 @@ mk "$d" last.issue.json <<'EOF'
|
||||
EOF
|
||||
run_case "an inline comment arriving mid-wait is seen" 0 "$d" --wait 1 --interval 1
|
||||
|
||||
# The same fixture on a clock that ticks one second per read: a loaded machine,
|
||||
# compressed. A second passing between two reads must not cost the only poll.
|
||||
clock="$work/clock"
|
||||
mkdir -p "$clock"
|
||||
cat > "$clock/date" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" = "+%s" ] || exec /bin/date "$@"
|
||||
n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 ))
|
||||
echo "$n" > "$CLOCK_TICKS"
|
||||
echo $(( 1000 + n ))
|
||||
EOF
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$clock/sleep"
|
||||
chmod +x "$clock/date" "$clock/sleep"
|
||||
rm -f "$d/.poll" "$work/ticks"
|
||||
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$d" \
|
||||
"$REVIEWS" 1 --wait 1 --interval 1 > "$work/out" 2>&1
|
||||
got=$?
|
||||
[ "$got" = 0 ] && ok "a second passing before the first re-poll does not skip it" \
|
||||
|| bad "a second passing before the first re-poll does not skip it" "exit $got, wanted 0"
|
||||
|
||||
# Every fixture above resolves by poll 2, so a loop that stops after one
|
||||
# re-poll passed them all (improvise-7epn). This one resolves on poll 3.
|
||||
d3="$work/arrives-third"
|
||||
for n in 1 2; do
|
||||
mk "$d3" "$n.pr.json" <<EOF
|
||||
{"headRefOid":"$HEAD","reviews":[]}
|
||||
EOF
|
||||
mk "$d3" "$n.inline.json" <<'EOF'
|
||||
[]
|
||||
EOF
|
||||
mk "$d3" "$n.issue.json" <<'EOF'
|
||||
[]
|
||||
EOF
|
||||
done
|
||||
cp -f "$d/last.pr.json" "$d/last.inline.json" "$d/last.issue.json" "$d3/"
|
||||
rm -f "$d3/.poll" "$work/ticks"
|
||||
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$d3" \
|
||||
"$REVIEWS" 1 --wait 10 --interval 1 > "$work/out" 2>&1
|
||||
got=$?
|
||||
polls=$(cat "$d3/.poll" 2>/dev/null || echo 0)
|
||||
[ "$got" = 0 ] && [ "$polls" = 3 ] && ok "a review arriving on the third poll is seen" \
|
||||
|| bad "a review arriving on the third poll is seen" "exit $got after $polls polls, wanted 0 after 3"
|
||||
|
||||
# The timeout path: nothing ever arrives, so --wait 5 on a clock that ticks
|
||||
# once per read must keep polling until the deadline, then report exit 1.
|
||||
rm -f "$work/empty/.poll" "$work/ticks"
|
||||
CLOCK_TICKS="$work/ticks" PATH="$clock:$PATH" PR_REVIEWS_FIXTURE_DIR="$work/empty" \
|
||||
"$REVIEWS" 1 --wait 5 --interval 1 > "$work/out" 2>&1
|
||||
got=$?
|
||||
polls=$(cat "$work/empty/.poll" 2>/dev/null || echo 0)
|
||||
[ "$got" = 1 ] && [ "$polls" -ge 5 ] && ok "--wait keeps polling until its deadline" \
|
||||
|| bad "--wait keeps polling until its deadline" "exit $got after $polls polls, wanted 1 after 5 or more"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `gh api --paginate` emits one array PER PAGE, so a thread past the page size
|
||||
# arrives as several concatenated documents. Passing that straight to
|
||||
|
||||
+27
-15
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Waits for a PR's checks to finish and says what happened. Every rule in here
|
||||
# is one this repo got wrong first; see context/code/pr-watch.md.
|
||||
# is one this repo got wrong first; see context/code/tooling.md section 3.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
@@ -11,10 +11,12 @@ usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>
|
||||
--timeout <sec> give up after this long (default 1800)
|
||||
--interval <sec> seconds between polls (default 20)
|
||||
|
||||
exit 0 every check COMPLETED and none failed
|
||||
exit 1 a check failed, or the PR is not mergeable
|
||||
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 1 a check failed, or the PR is not mergeable (any other known state)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running
|
||||
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
EOF
|
||||
exit 4
|
||||
@@ -86,13 +88,28 @@ while :; do
|
||||
total="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]?] | length')"
|
||||
running="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length')"
|
||||
|
||||
failed="$(printf '%s' "$json" | jqf \
|
||||
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
|
||||
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
|
||||
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
|
||||
|
||||
# UNKNOWN and green-but-BLOCKED are waited on: see context/code/tooling.md,
|
||||
# "pr-watch and mergeability" (improvise-edpl, improvise-zlj2).
|
||||
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
|
||||
break
|
||||
if [ "$failed" -gt 0 ] || [ "$mergeable" = CONFLICTING ]; then break; fi
|
||||
case "$mergeable/$state" in
|
||||
UNKNOWN/*|*/UNKNOWN|MERGEABLE/BLOCKED) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
now=$(date +%s)
|
||||
if [ $((now - started)) -ge "$TIMEOUT" ]; then
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
|
||||
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with mergeability $mergeable/$state" >&2
|
||||
else
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
@@ -100,15 +117,10 @@ done
|
||||
|
||||
printf '%s' "$json" | jqf '.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | " \(.conclusion) \(.name)"'
|
||||
|
||||
failed="$(printf '%s' "$json" | jqf \
|
||||
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
|
||||
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
|
||||
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
|
||||
|
||||
echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
|
||||
|
||||
[ "$failed" -eq 0 ] || exit 1
|
||||
# UNSTABLE means a non-required check is unhappy; it is not a merge blocker on
|
||||
# its own, so only an outright non-mergeable state fails here.
|
||||
[ "$mergeable" != "CONFLICTING" ] || exit 1
|
||||
exit 0
|
||||
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
|
||||
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
|
||||
[ "$mergeable" = MERGEABLE ] || exit 1
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
|
||||
|
||||
@@ -73,5 +73,96 @@ run_case "a head that moves mid-wait is not a pass" 2 "$work/moved" --timeout 60
|
||||
run_case "--expect refuses a different head" 2 "$work/green" --expect bbbbbbbb
|
||||
run_case "--expect accepts a prefix of the head" 0 "$work/green" --expect aaaa
|
||||
|
||||
# improvise-edpl. GitHub computes mergeability lazily: for a while after the
|
||||
# base moves it reports UNKNOWN, and PRs #172 and #179 both exited 0 there.
|
||||
# This is the shape both had: every check green, mergeability unresolved.
|
||||
green_rollup='[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]'
|
||||
red_rollup='[{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]'
|
||||
# $1 dir, $2 file, $3 mergeable, $4 mergeStateStatus, $5 rollup (JSON values)
|
||||
state() {
|
||||
mkdir -p "$1"
|
||||
printf '{"headRefOid":"aaaaaaaaaaaa","mergeable":%s,"mergeStateStatus":%s,"statusCheckRollup":%s}\n' \
|
||||
"$3" "$4" "$5" > "$1/$2"
|
||||
}
|
||||
|
||||
state "$work/unk-conflict" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-conflict" last.json '"CONFLICTING"' '"DIRTY"' "$green_rollup"
|
||||
run_case "UNKNOWN that resolves to CONFLICTING fails" 1 "$work/unk-conflict" --timeout 60
|
||||
|
||||
state "$work/unk-clean" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-clean" 2.json '"MERGEABLE"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-clean" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
|
||||
run_case "UNKNOWN that resolves to MERGEABLE/CLEAN passes" 0 "$work/unk-clean" --timeout 60
|
||||
|
||||
state "$work/unk-forever" last.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
run_case "UNKNOWN until the timeout is a timeout, not a pass" 3 "$work/unk-forever"
|
||||
|
||||
# A failed check fails whatever mergeability says; waiting on it would turn a
|
||||
# known red into a timeout.
|
||||
state "$work/unk-red" last.json '"UNKNOWN"' '"UNKNOWN"' "$red_rollup"
|
||||
run_case "a failed check fails without waiting on mergeability" 1 "$work/unk-red" --timeout 0
|
||||
|
||||
# The whole table, enumerated rather than hand-picked, every check green. The
|
||||
# rule it states is in context/code/tooling.md, "pr-watch and mergeability".
|
||||
for m in MERGEABLE CONFLICTING UNKNOWN; do
|
||||
for s in BEHIND BLOCKED CLEAN DIRTY DRAFT HAS_HOOKS UNKNOWN UNSTABLE SOMETHING_NEW null; do
|
||||
if [ "$m" = CONFLICTING ]; then want=1
|
||||
elif [ "$m" = UNKNOWN ] || [ "$s" = UNKNOWN ]; then want=3
|
||||
else case "$s" in CLEAN|UNSTABLE|HAS_HOOKS) want=0 ;; BLOCKED) want=3 ;; *) want=1 ;; esac
|
||||
fi
|
||||
sv="\"$s\""; [ "$s" = null ] && sv=null
|
||||
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
|
||||
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
|
||||
done
|
||||
done
|
||||
|
||||
# improvise-zlj2. The required "Browser harness" check comes from a job that
|
||||
# `needs: build`, so its check run does not exist until build finishes. In that
|
||||
# gap every registered check is green and GitHub says MERGEABLE/BLOCKED. The
|
||||
# rollup below is PR #189's, verbatim; the gap states drop or rewind entries.
|
||||
cat > "$work/rollup.jsonl" <<'EOF'
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:53Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406461","name":"Build all bundles","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047406233","name":"plan","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482378","name":"build-local-artifacts (${{ join(matrix.targets, ', ') }})","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:42:22Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406506","name":"Test and lint (host)","startedAt":"2026-09-23T04:40:16Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482733","name":"build-global-artifacts","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:40Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406409","name":"wasm32 check (browser bundle crates)","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482801","name":"host","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:25Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406415","name":"Browser JS unit tests (node --test)","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047483313","name":"announce","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406264","name":"Knowledge base consistency","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:39Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406373","name":"Lint the harness crate","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047753280","name":"Browser harness (OPFS contract + shell)","startedAt":"2026-09-23T04:41:56Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:18Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107048259684","name":"Deploy to GitHub Pages","startedAt":"2026-09-23T04:44:18Z","status":"COMPLETED","workflowName":"CI"}
|
||||
EOF
|
||||
harness='Browser harness (OPFS contract + shell)'
|
||||
deploy='Deploy to GitHub Pages'
|
||||
# $1 dir, $2 file, $3 mergeable, $4 state, $5 jq filter over the rollup array
|
||||
gap_state() {
|
||||
state "$1" "$2" "\"$3\"" "\"$4\"" "$(jq -cs "$5" "$work/rollup.jsonl")"
|
||||
}
|
||||
build_running='map(if .name == "Build all bundles" then .status = "IN_PROGRESS" | .conclusion = "" else . end)'
|
||||
not_yet="map(select(.name != \"$harness\" and .name != \"$deploy\"))"
|
||||
harness_running="map(if .name == \"$harness\" then .status = \"IN_PROGRESS\" | .conclusion = \"\" else . end) | map(select(.name != \"$deploy\"))"
|
||||
gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
|
||||
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
|
||||
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
|
||||
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
|
||||
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
|
||||
|
||||
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
|
||||
run_case "a required check that never registers is a timeout" 3 "$work/gap-never"
|
||||
|
||||
# improvise-h3ni. The head must stay pinned across the UNKNOWN wait, or a
|
||||
# verdict about b is reported as a verdict about a.
|
||||
state "$work/unk-moved" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-moved" 2.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-moved" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
|
||||
for f in 2.json last.json; do
|
||||
sed 's/aaaaaaaaaaaa/bbbbbbbbbbbb/' "$work/unk-moved/$f" > "$work/unk-moved/$f.new"
|
||||
mv -f "$work/unk-moved/$f.new" "$work/unk-moved/$f"
|
||||
done
|
||||
run_case "a head that moves during the UNKNOWN wait is not a pass" 2 "$work/unk-moved" --timeout 60
|
||||
|
||||
printf '\npr-watch-test: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
+50
-2
@@ -79,7 +79,7 @@ pub fn run_tui(
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
app.handle_key(convert_key_event(key))?;
|
||||
press(&mut app, convert_key_event(key))?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
app.term_width = w;
|
||||
@@ -99,6 +99,13 @@ pub fn run_tui(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One keypress in the TUI. A message lasts until the next key, so it is drawn
|
||||
/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`).
|
||||
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
|
||||
app.handle_key(key)
|
||||
}
|
||||
|
||||
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
|
||||
|
||||
// The only place crossterm's key types and `command::key` may both appear.
|
||||
@@ -566,7 +573,8 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod bottom_bar_tests {
|
||||
use super::{draw_bottom_bar, minibuffer_line};
|
||||
use super::{draw_bottom_bar, minibuffer_line, press};
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::workbook::Workbook;
|
||||
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
|
||||
@@ -587,6 +595,46 @@ mod bottom_bar_tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A message lasts until the next key, then the derived hint is back
|
||||
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
|
||||
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.status_msg = "Saved to x.improv".to_string();
|
||||
|
||||
press(&mut app, key(KeyCode::Char('T'))).unwrap();
|
||||
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains("Saved to"),
|
||||
"a stale message outlived a key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint in TileSelect: {row:?}"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Char(' '))).unwrap();
|
||||
let said = app.view_state.status_msg.clone();
|
||||
assert!(!said.is_empty(), "the axis cycle must say what it did");
|
||||
assert!(
|
||||
bottom_row(&app, 240).contains(&said),
|
||||
"a message was wiped before it was drawn"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Esc)).unwrap();
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains(&said),
|
||||
"the axis message outlived the next key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint back in Normal: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wizard overlay draws correct per-step hints; the status bar stacked a
|
||||
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user