Merge commit '50e1314d069a4f6e549cd7d9795b6491a89bf903' into adr9-rev-steward
This commit is contained in:
@@ -59,7 +59,8 @@ they mean, rather than testing for the variant
|
||||
**Layering.** `forbidden/2` in `kb.pl` is the authority:
|
||||
`improvise-core` no UI/IO, `improvise-io` no UI/commands, `improvise-command`
|
||||
no ratatui/crossterm. A ratatui type in core is the usual violation — the fix
|
||||
is a semantic enum in core that the render layer maps (see `AxisColor`).
|
||||
is a semantic value the render layer maps; for appearance that is a `Face` in
|
||||
`improvise-protocol`, resolved by `draw.rs::face_style` (ADR 004).
|
||||
|
||||
**`Option`/`Result`/`Box` at the boundaries.** Wrap at parse/lookup/IO, unwrap
|
||||
early, keep core logic on concrete values. Heap-container plumbing should be
|
||||
|
||||
@@ -5,13 +5,19 @@ description: Wait for a GitHub PR's checks to finish before merging or reporting
|
||||
|
||||
# Waiting on a PR
|
||||
|
||||
"Watching a PR" means tracking **both** CI checks **and** GitHub comments/reviews.
|
||||
A PR is not ready to merge just because checks are green — review feedback must be
|
||||
read and answered first.
|
||||
|
||||
## CI Checks
|
||||
|
||||
```
|
||||
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
|
||||
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews]
|
||||
```
|
||||
|
||||
```
|
||||
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 0 every check COMPLETED, none failed, MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS, and reviews fetched (if --reviews)
|
||||
exit 1 a check failed (under any mergeability, BLOCKED included), or the
|
||||
PR is not mergeable (CONFLICTING, DIRTY, BEHIND, DRAFT, or a state
|
||||
it does not know)
|
||||
@@ -21,21 +27,70 @@ exit 3 timed out with checks still running, mergeability still UNKNOWN,
|
||||
exit 4 usage or environment error
|
||||
exit 5 the PR is MERGED or CLOSED, at the head the watch pinned; it ends on
|
||||
the first poll that sees it closed, which may be mid-wait
|
||||
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
|
||||
```
|
||||
|
||||
**`--reviews`** (default): after checks complete, print all reviews and comments
|
||||
via `scripts/pr-reviews`. Use `--no-reviews` to skip this. The reviews are shown
|
||||
but do not change the exit code — answering them is your responsibility.
|
||||
|
||||
**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).
|
||||
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.
|
||||
|
||||
## Reviews and Comments
|
||||
|
||||
Reviews are a **separate API surface** from the comment thread. Codex/ChatGPT
|
||||
reviews arrive as *reviews*, not comments — checking only the comment thread
|
||||
misses them entirely. `pr-watch --reviews` (the default) shows all three:
|
||||
|
||||
| Surface | What it is |
|
||||
|---|---|
|
||||
| `[REVIEW]` | The review's own body (pulls/{n}/reviews) |
|
||||
| `[INLINE]` | A comment on a line of the diff (pulls/{n}/comments) |
|
||||
| `[COMMENT]` | A comment on the PR thread (issues/{n}/comments) |
|
||||
|
||||
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it**. The
|
||||
script's `$?` must be read directly; piping or chaining loses the exit code.
|
||||
Redirect to a file if you need to process it.
|
||||
|
||||
### Review Obligations
|
||||
|
||||
Before treating a PR as done or merging:
|
||||
|
||||
1. **Read all review feedback** — reviews against an earlier head may still apply
|
||||
2. **Answer substantive feedback with attribution** — every reply must include
|
||||
the attribution line (see below)
|
||||
3. **Re-check after each push** that new reviews target the current head SHA
|
||||
4. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows;
|
||||
an absent review and an unrequested one look identical. The adversarial review
|
||||
that gates a merge is dispatched by the orchestrator; external reviews are
|
||||
read *in addition*, not instead.
|
||||
|
||||
### GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write must include an attribution line.** Posts go out under the
|
||||
repository owner's account — an unattributed post reads as the human's words.
|
||||
|
||||
Open the body with: `_Posted by <Agent Stack> (<model>)_`
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
|
||||
A thread is not "answered" until the reply carries this attribution.
|
||||
|
||||
See AGENTS.md "GitHub Attribution" for the full rule and "Shipping a wave" for
|
||||
Copilot request mechanics and how to answer declined findings.
|
||||
|
||||
## Do not write the loop yourself
|
||||
|
||||
Four signals look like "finished" and are not:
|
||||
|
||||
@@ -52,7 +52,10 @@ nobody corrects. The brief adds only what varies per wave:
|
||||
suite, which it is not. The agent still measures its own.
|
||||
3. **Its ground, and the ground that is not.** Name any orchestrator-only file
|
||||
it is exceptionally allowed to edit.
|
||||
4. **Its `BEADS_ACTOR` and its tickets.**
|
||||
4. **Its `BEADS_ACTOR` and its tickets.** The actor also names the agent's
|
||||
commits: it commits through `scripts/agent-git`, which authors each one as
|
||||
`Claude (<BEADS_ACTOR>)` (`agent-rules.md`, "Discipline per ticket"). So
|
||||
give each agent its own actor, and do not tell it to set `user.name`.
|
||||
5. **The attribution line, commit trailers and PR footer**, spelled out.
|
||||
They name the agent (Claude's line opens `_Posted by Claude (<model>)…_`)
|
||||
and this session's URL, which is why the shared `agent-rules.md` cannot
|
||||
|
||||
@@ -216,6 +216,33 @@ out on purpose:
|
||||
- **Claim first:** `export BEADS_ACTOR=<your actor>` in every shell, then
|
||||
`bd update <id> --claim`. Exit 13 means another actor holds it. Pick another;
|
||||
do not retry.
|
||||
- **Author every commit as `Claude (<BEADS_ACTOR>)`.** The user: "agents
|
||||
should override git author name, I think to include an agent identifier
|
||||
(matching BEADS_ACTOR)" (2026-09-25; improvise-1xfb.3). Make every commit
|
||||
through the wrapper, merge commits included:
|
||||
`BEADS_ACTOR=<your actor> scripts/agent-git commit …`, and the same for
|
||||
`merge`. The wrapper sets the author name for that one command, and it refuses (exit 2) when `BEADS_ACTOR` is unset or empty. It
|
||||
reads `BEADS_ACTOR` from its own environment, so the prefix works in a shell
|
||||
that keeps nothing between commands.
|
||||
- **The author email, the committer and the `Co-Authored-By` trailer stay as
|
||||
they are.** The email is the owner's configured one, so GitHub still links
|
||||
the commit.
|
||||
- **Never set `user.name` with `git config`.** Worktrees share
|
||||
`.git/config`, so it renames every agent at once. Do not use
|
||||
`git -c user.name=…` either: `user.name` names the committer as well as
|
||||
the author. Why the wrapper uses `GIT_AUTHOR_NAME`:
|
||||
`context/test-evidence/tooling.md`.
|
||||
- A plain `git commit --amend` keeps the author it amends, so amend with
|
||||
plain git or with the wrapper.
|
||||
- **A cherry-pick keeps its source's author**, through the wrapper or not:
|
||||
git ignores `GIT_AUTHOR_NAME` there. That is right, because rule 8 allows
|
||||
only `cherry-pick -x` of commits that keep whoever wrote them.
|
||||
- Before you push, check `git log --format='%an <%ae> | %cn' <pinned
|
||||
SHA>..HEAD`. Every commit **you made** must name your actor. A
|
||||
cherry-picked commit, or one from a PR you stack on, keeps its own author.
|
||||
- **The format is a proposal.** `Claude (<BEADS_ACTOR>)` is the default
|
||||
proposed on improvise-1xfb.3's PR. The user may change it there. The format
|
||||
lives in `scripts/agent-git` and nowhere else.
|
||||
- **`bd` needs `.envrc` allowed in your worktree.** Without it you get a
|
||||
**version mismatch**, not a clean failure. Run `direnv allow` in the worktree,
|
||||
or rely on the `[whitelist] prefix` in `~/.config/direnv/direnv.toml`.
|
||||
|
||||
+386
-247
File diff suppressed because one or more lines are too long
+46
-257
@@ -1,273 +1,62 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Prepare a Claude Code on the web container for work on this repository.
|
||||
#
|
||||
# Section 0 runs everywhere, cloud or not: it asserts that the `bd` on PATH
|
||||
# is the one flake.nix pins. Everything after it is cloud-only setup.
|
||||
# This hook delegates to the shared `scripts/agent-bootstrap.sh`, which works
|
||||
# for any coding agent stack (Claude Code, Cursor, etc.). This file exists
|
||||
# because Claude Code expects hooks at `.claude/hooks/session-start.sh`.
|
||||
#
|
||||
# Three things are slow or wrong by default in a fresh cloud session:
|
||||
# For local checkouts, only the bd pin assertion runs.
|
||||
# For cloud sessions (CLAUDE_CODE_REMOTE=true), full bootstrap runs.
|
||||
#
|
||||
# 1. `bd` is not installed, so the `bd prime` hook and every `bd` command
|
||||
# in AGENTS.md silently do nothing.
|
||||
# 2. `.beads/issues.jsonl` is a stale export. An agent that reads it picks
|
||||
# work that is already done — this has happened. The authoritative
|
||||
# tracker is the Dolt database on the git remote.
|
||||
# 3. The Rust workspace is uncompiled, so the first `cargo test` pays a
|
||||
# cold build before it reports anything.
|
||||
#
|
||||
# The tracker is re-synced on every session, not just when the database is
|
||||
# missing: cloud containers reuse a cached workspace, so "already present"
|
||||
# does not mean "still current", and a silently ageing tracker is the exact
|
||||
# failure this hook exists to prevent. Installing bd and warming the build
|
||||
# do check first, being genuinely one-time.
|
||||
#
|
||||
# Nothing here writes to a tracked file, stages anything, or creates a commit.
|
||||
# See also: scripts/agent-bootstrap.sh (the stack-agnostic implementation)
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
cd "$ROOT" || exit 0
|
||||
BOOTSTRAP="$ROOT/scripts/agent-bootstrap.sh"
|
||||
|
||||
log() { printf '[session-start] %s\n' "$*"; }
|
||||
|
||||
# ── 0. The bd on PATH must be the bd flake.nix pins ────────────────────────
|
||||
# `flake.nix` pins beads so that one build of `bd` touches this repo. A git
|
||||
# worktree does not load that pin — a fresh `.envrc` is unallowed, so a
|
||||
# process there falls through to whatever `bd` the user's profile happens to
|
||||
# carry. Today those agree; nothing enforces that they keep agreeing.
|
||||
#
|
||||
# The failure that matters is silent: writing .beads/issues.jsonl with a
|
||||
# skewed build empties the dependency graph and reports success. Nobody
|
||||
# notices until they ask what blocks what. So the mismatch is turned into a
|
||||
# loud one here rather than trusted. Reading with a skewed bd is safe — it
|
||||
# is the mutating commands that corrupt — so this blocks nothing, it shouts.
|
||||
#
|
||||
# The expected version is read from flake.nix, not written down twice: a
|
||||
# hard-coded copy would go stale the first time the pin moves, and a stale
|
||||
# assertion is worse than none.
|
||||
beads_pin() {
|
||||
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
|
||||
flake.nix 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
bd_running_version() {
|
||||
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
|
||||
bd_pin_banner() {
|
||||
{
|
||||
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n'
|
||||
printf '[session-start] %s\n' "$@"
|
||||
printf '[session-start] =============================\n\n'
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Prints the banner and returns 1 when the two disagree, or when either one
|
||||
# cannot be read — an unreadable answer is a rotted assertion, not a pass.
|
||||
assert_bd_pin() {
|
||||
command -v bd >/dev/null 2>&1 || return 0 # nothing on PATH to assert about
|
||||
|
||||
pinned=$(beads_pin)
|
||||
running=$(bd_running_version)
|
||||
|
||||
case "$pinned" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*)
|
||||
bd_pin_banner "cannot read the beads pin out of flake.nix" \
|
||||
"read: '${pinned:-<nothing>}'" \
|
||||
"The pin moved to a ref this hook cannot parse (a branch or a" \
|
||||
"commit, perhaps). Teach beads_pin() to read it, or the check" \
|
||||
"silently protects nothing."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$running" ]; then
|
||||
bd_pin_banner "cannot read the version of the bd on PATH" \
|
||||
"bd: $(command -v bd)" \
|
||||
"'bd version' did not answer in the expected shape. Until it does," \
|
||||
"nothing here can tell whether that build matches the pin."
|
||||
return 1
|
||||
fi
|
||||
|
||||
[ "$running" = "$pinned" ] && return 0
|
||||
|
||||
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
|
||||
"pinned (flake.nix): $pinned" \
|
||||
"running: $running ($(command -v bd))" \
|
||||
"" \
|
||||
"Reading is safe. Do NOT run a mutating bd command — create, update," \
|
||||
"close, dep, dolt push. Version skew between builds empties the" \
|
||||
"dependency graph in .beads/issues.jsonl and reports success." \
|
||||
"" \
|
||||
"Fix now: nix develop (in this directory), then re-run the" \
|
||||
" command; or 'direnv allow' here." \
|
||||
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
|
||||
" them one at a time never catches the next one. Put the" \
|
||||
" repo root under [whitelist] prefix in" \
|
||||
" ~/.config/direnv/direnv.toml."
|
||||
return 1
|
||||
}
|
||||
|
||||
pin_status=0
|
||||
assert_bd_pin || pin_status=2
|
||||
|
||||
# Local checkouts have their own setup; this is for the web containers.
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit "$pin_status"
|
||||
fi
|
||||
|
||||
# Install a verified database at .beads/embeddeddolt.
|
||||
#
|
||||
# The staged copy lives on another filesystem, so it is brought alongside the
|
||||
# live one FIRST. Every step after that is a same-filesystem rename, which
|
||||
# either happens or does not — no half-finished copy. The previous database is
|
||||
# removed only once the new one is in place, so no failure path leaves the repo
|
||||
# without a tracker, and every move is checked: an unchecked one would let the
|
||||
# new database land *inside* the old directory and still report success.
|
||||
install_tracker() {
|
||||
staged=$1
|
||||
live=.beads/embeddeddolt
|
||||
incoming=$live.incoming
|
||||
previous=$live.previous
|
||||
|
||||
rm -rf "$incoming" "$previous" || return 1
|
||||
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
|
||||
|
||||
if [ -d "$live" ] && ! mv "$live" "$previous"; then
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv "$incoming" "$live"; then
|
||||
rm -rf "$previous"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Could not put the new one in place; restore what was there.
|
||||
if [ -d "$previous" ] && ! mv "$previous" "$live"; then
|
||||
log "ERROR: tracker left at $previous — move it back to $live by hand"
|
||||
fi
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 1. bd on PATH ──────────────────────────────────────────────────────────
|
||||
# The embedded Dolt engine needs CGO, so this builds from source (~3 min).
|
||||
# The container image is cached after the hook, so it is paid once.
|
||||
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
|
||||
# Pinned to a commit rather than `main`, and downloaded before it is run:
|
||||
# piping a moving URL straight into a shell executes code nobody has seen,
|
||||
# and leaves nothing to inspect afterwards. The installer stays at
|
||||
# /tmp/bd-install.sh so what actually ran can be read.
|
||||
#
|
||||
# To take a newer beads, resolve the peeled commit of its tag:
|
||||
# git ls-remote --tags https://github.com/steveyegge/beads | grep 'v1.3.0^{}'
|
||||
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
|
||||
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
|
||||
|
||||
if ! command -v bd >/dev/null 2>&1; then
|
||||
log "installing bd (beads v1.3.0)…"
|
||||
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
|
||||
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
|
||||
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
|
||||
log "bd installed"
|
||||
else
|
||||
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
|
||||
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
|
||||
fi
|
||||
fi
|
||||
# Forward the Claude env file variable to the generic name the bootstrap uses
|
||||
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
|
||||
# Single quotes are deliberate: $PATH and $HOME must be written literally so
|
||||
# they expand when the session sources this, not now.
|
||||
# shellcheck disable=SC2016
|
||||
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
command -v bd >/dev/null 2>&1 && bd metrics off >/dev/null 2>&1
|
||||
|
||||
# bd may have arrived only just now, in which case §0 had nothing to check.
|
||||
# Re-check, but only if §0 stayed quiet, so the banner is never printed twice.
|
||||
if [ "$pin_status" -eq 0 ]; then
|
||||
assert_bd_pin || pin_status=2
|
||||
export AGENT_ENV_FILE="$CLAUDE_ENV_FILE"
|
||||
fi
|
||||
|
||||
# ── 2. Sync the issue tracker from the Dolt remote ─────────────────────────
|
||||
# `bd init` in-place rewrites .beads/config.yaml and commits, so the sync
|
||||
# happens in a throwaway directory and only the database is moved back.
|
||||
# That directory must be named after the database (see .beads/metadata.json),
|
||||
# since bd derives the database name from it.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
|
||||
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
|
||||
case "$ORIGIN" in
|
||||
git@github.com:*) ORIGIN="https://github.com/${ORIGIN#git@github.com:}" ;;
|
||||
esac
|
||||
ORIGIN="${ORIGIN%.git}"
|
||||
|
||||
# The mktemp is part of the condition on purpose. Unchecked, a failure
|
||||
# leaves TMP empty, "$TMP/$DB_NAME" becomes an absolute path at the
|
||||
# filesystem root, and the cleanup at the end degrades to `rm -rf ""`.
|
||||
# Short-circuiting also means no temp directory is created when there is
|
||||
# no origin to sync from.
|
||||
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
|
||||
log "syncing issue tracker from $ORIGIN …"
|
||||
STAGE="$TMP/$DB_NAME"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
# `bd init` exits non-zero when the remote is unreachable but STILL leaves
|
||||
# a .beads/embeddeddolt behind — an empty database. Testing only for the
|
||||
# directory would swap that over a good tracker, so check the status and
|
||||
# confirm the result actually carries issues. An empty remote tracker is
|
||||
# indistinguishable from a failed clone here; keeping what we already have
|
||||
# is the safe way to be wrong.
|
||||
sync_ok=0
|
||||
if (
|
||||
cd "$STAGE" && git init -q . &&
|
||||
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
|
||||
--remote "git+$ORIGIN"
|
||||
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
|
||||
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
|
||||
case "$listing" in
|
||||
"" | "No issues found."*) ;;
|
||||
*) sync_ok=1 ;;
|
||||
esac
|
||||
# For local checkouts, only verify the bd pin (no install, no sync)
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
# Inline the pin-check only, since the full bootstrap is cloud-only
|
||||
cd "$ROOT" || exit 0
|
||||
beads_pin() {
|
||||
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
|
||||
flake.nix 2>/dev/null | head -1
|
||||
}
|
||||
bd_running_version() {
|
||||
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
|
||||
}
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
pinned=$(beads_pin)
|
||||
running=$(bd_running_version)
|
||||
# Fail closed: if we can't parse either version, warn rather than silently pass
|
||||
if [ -z "$pinned" ]; then
|
||||
printf '[session-start] WARNING: cannot parse beads pin from flake.nix\n' >&2
|
||||
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
|
||||
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
|
||||
elif [ -d .beads/embeddeddolt ]; then
|
||||
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
|
||||
log " It may be out of date — verify any bead against the code."
|
||||
else
|
||||
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
if [ -z "$running" ]; then
|
||||
printf '[session-start] WARNING: cannot parse bd version from "bd version"\n' >&2
|
||||
printf '[session-start] bd: %s\n' "$(command -v bd)" >&2
|
||||
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$running" != "$pinned" ]; then
|
||||
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n' >&2
|
||||
printf '[session-start] pinned (flake.nix): %s\n' "$pinned" >&2
|
||||
printf '[session-start] running: %s (%s)\n' "$running" "$(command -v bd)" >&2
|
||||
printf '[session-start] Reading is safe. Do NOT run mutating bd commands.\n' >&2
|
||||
printf '[session-start] Fix: nix develop, or direnv allow.\n' >&2
|
||||
printf '[session-start] =============================\n\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
rm -rf "$TMP"
|
||||
elif [ -n "$ORIGIN" ]; then
|
||||
log "WARNING: could not create a temporary directory; skipped the tracker sync."
|
||||
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Warm the build so the first cargo test is not a cold compile ────────
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
log "warming cargo build…"
|
||||
if cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
|
||||
log "workspace built"
|
||||
else
|
||||
log "WARNING: cargo build failed; see /tmp/cargo-warm.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Environment facts worth knowing up front ────────────────────────────
|
||||
cat <<'NOTES'
|
||||
[session-start] Environment notes (see context/cloud-agents.md):
|
||||
• Use the preinstalled cargo toolchain for ordinary work. `nix develop`
|
||||
also works, but only after scripts/nix-seed-flake-inputs.sh — GitHub
|
||||
tarballs are not served here, so the locked inputs are seeded over git.
|
||||
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
|
||||
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
|
||||
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
|
||||
• `bd dolt push <remote>` ignores its argument and pushes to origin.
|
||||
NOTES
|
||||
|
||||
# Non-zero only when §0 found version skew; the setup above still ran.
|
||||
exit "$pin_status"
|
||||
# Cloud session: run the full bootstrap
|
||||
exec "$BOOTSTRAP"
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Improvise",
|
||||
"install": "nix-daemon >/tmp/nix-daemon.log 2>&1 & ./scripts/agent-bootstrap.sh",
|
||||
"start": "nix-daemon &"
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads-merge
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/code-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/kb
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pr-watch
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/wave
|
||||
@@ -146,13 +146,39 @@ jobs:
|
||||
# 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.
|
||||
#
|
||||
# The repo-stats pipeline runs here because it needs this job's binary:
|
||||
# `.#repo-stats-tools` is that build plus code-maat and its JRE, which stay
|
||||
# out of the ci shell every other job enters.
|
||||
# Its suite gates; the report on a push to main never fails the build, and
|
||||
# its step fails only when improvise refuses the pipeline's input
|
||||
# (context/code/tooling.md section 7). Full history is for the report.
|
||||
nix-build:
|
||||
name: Nix build (crate2nix)
|
||||
name: Nix build (crate2nix) and the repo-stats pipeline
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: nix build .#improvise
|
||||
run: nix build --print-build-logs .#improvise
|
||||
- name: The repo-stats pipeline computes what it claims to
|
||||
run: nix shell .#repo-stats-tools --command ./scripts/checkers.sh --ci nix-build
|
||||
- name: Churn, coupling and doc-growth report (report only)
|
||||
if: github.event_name == 'push'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
rc=0
|
||||
nix shell .#repo-stats-tools --command ./scripts/repo-stats --out repo-stats > repo-stats.txt || rc=$?
|
||||
{ echo '```'; cat repo-stats.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
exit "$rc"
|
||||
- name: Upload the repo-stats model and its CSVs
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: repo-stats
|
||||
path: repo-stats
|
||||
retention-days: 30
|
||||
|
||||
wasm:
|
||||
name: wasm32 check (browser bundle crates)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Cloud agent smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".claude/**"
|
||||
- ".cursor/**"
|
||||
- "scripts/agent-bootstrap.sh"
|
||||
- "scripts/nix-seed-flake-inputs.sh"
|
||||
- ".github/workflows/cloud-agent-smoke.yml"
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
name: Claude entry point + bead
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Bootstrap through Claude session-start hook
|
||||
shell: bash
|
||||
env:
|
||||
CLAUDE_CODE_REMOTE: "true"
|
||||
run: |
|
||||
if ! ./.claude/hooks/session-start.sh; then
|
||||
echo "=== /tmp/nix-daemon.log ==="
|
||||
cat /tmp/nix-daemon.log 2>/dev/null || true
|
||||
echo "=== /tmp/nix-seed.log ==="
|
||||
cat /tmp/nix-seed.log 2>/dev/null || true
|
||||
echo "=== /tmp/bd-install.log ==="
|
||||
cat /tmp/bd-install.log 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
- name: Create, read back, and close a smoke-test bead
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(bd create "Cloud agent smoke: Claude entry point" --description="Ephemeral local-only bead used to verify the Claude cloud bootstrap entry point." --type=task --priority=4 --json)"
|
||||
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
|
||||
test -n "$id"
|
||||
bd show "$id"
|
||||
bd close "$id" --reason="Cloud-agent smoke test completed"
|
||||
bd show "$id" | grep -qi closed
|
||||
|
||||
cursor:
|
||||
name: Cursor install entry point + bead
|
||||
runs-on: [self-hosted, nixos, microvm]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Bootstrap through Cursor install command
|
||||
shell: bash
|
||||
run: |
|
||||
nix-daemon >/tmp/nix-daemon.log 2>&1 &
|
||||
if ! ./scripts/agent-bootstrap.sh; then
|
||||
echo "=== /tmp/nix-daemon.log ==="
|
||||
cat /tmp/nix-daemon.log 2>/dev/null || true
|
||||
echo "=== /tmp/nix-seed.log ==="
|
||||
cat /tmp/nix-seed.log 2>/dev/null || true
|
||||
echo "=== /tmp/bd-install.log ==="
|
||||
cat /tmp/bd-install.log 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
- name: Create, read back, and close a smoke-test bead
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$(bd create "Cloud agent smoke: Cursor entry point" --description="Ephemeral local-only bead used to verify the Cursor cloud bootstrap entry point." --type=task --priority=4 --json)"
|
||||
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
|
||||
test -n "$id"
|
||||
bd show "$id"
|
||||
bd close "$id" --reason="Cloud-agent smoke test completed"
|
||||
bd show "$id" | grep -qi closed
|
||||
@@ -61,6 +61,11 @@ GSYMS
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# The assembled site, written by web/assemble.sh (and the doc generator's
|
||||
# default OUT_DIR). Build output: CI assembles it from source (improvise-1xfb.4).
|
||||
/site/
|
||||
|
||||
# The release web capture, written to the repo root by
|
||||
# scripts/build-web-release.sh (improvise-oujb).
|
||||
/improvise-web.tar.gz
|
||||
.opencode
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
force-push. The rule, the message convention and the incidents are
|
||||
`.agents/skills/wave/agent-rules.md`, rule 8.
|
||||
- always start responses with bananaS!
|
||||
- there is a prolog MCP available: load a session from `kb` at the root of the repository at the beginning of
|
||||
every session, and query it as you work. **Reading only** — the MCP must never write `kb.pl`.
|
||||
- **Knowledge base: use `scripts/kb`, not Prolog MCP.** The Prolog MCP is optional and not
|
||||
available in all agent stacks (Cursor cloud agents do not have it). All agents must use
|
||||
`scripts/kb add|replace|rm|query|check` for KB operations — see the `kb` skill at
|
||||
`.agents/skills/kb/SKILL.md`. Reading `prolog-sessions/kb.pl` directly is fine for queries.
|
||||
- save structured facts as you go — work done, interesting locations, important decisions — with
|
||||
`scripts/kb add` (see the `kb` skill). Write a fact when you learn it; there is no reason to batch.
|
||||
- query the knowledge base as needed and use Prolog reasoning when it makes sense.
|
||||
@@ -25,6 +27,87 @@
|
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
|
||||
|
||||
## Multi-Stack Agent Support
|
||||
|
||||
This repo supports multiple coding-agent stacks: Claude Code, Cursor (IDE and cloud), and
|
||||
potentially others. All stacks share the same operational setup:
|
||||
|
||||
| Component | Location | Notes |
|
||||
|---|---|---|
|
||||
| Toolchain | `nix develop` / `direnv` | **Required.** Rust from flake, wasm target, beads pin, trealla |
|
||||
| Skills | `.agents/skills/` | Canonical source; `.claude/skills/` and `.cursor/skills/` symlink here |
|
||||
| Session bootstrap | `scripts/agent-bootstrap.sh` | Seeds nix inputs, installs bd, syncs tracker, warms cargo |
|
||||
| Knowledge base | `scripts/kb` | **All stacks use this**, not Prolog MCP |
|
||||
| Issue tracker | `bd` CLI | Synced from Dolt remote at session start |
|
||||
|
||||
### Toolchain Requirement
|
||||
|
||||
**Agents must work inside `nix develop` (or direnv) for this repo.** Bare system cargo that
|
||||
fails `edition2024` or missing wasm target is a misconfigured session. The bootstrap seeds
|
||||
nix flake inputs automatically (GitHub tarballs are blocked in cloud), so `nix develop`
|
||||
should work after install completes.
|
||||
|
||||
Run commands inside the dev shell:
|
||||
```bash
|
||||
nix develop --command cargo test
|
||||
nix develop --command cargo build --workspace
|
||||
```
|
||||
|
||||
Or enter an interactive shell: `nix develop`
|
||||
|
||||
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically.
|
||||
|
||||
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
|
||||
discovered from `.cursor/skills/` (symlinks to `.agents/skills/`).
|
||||
|
||||
**Local agents**: Run `scripts/agent-bootstrap.sh` manually, or use `direnv allow` for
|
||||
automatic dev shell activation.
|
||||
|
||||
See `context/cloud-agents.md` for environment-specific notes (nix, network, etc.).
|
||||
|
||||
### GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write (PR, comment, review reply) must include an attribution line identifying
|
||||
the AI agent.** Posts go out under the repository owner's account, and an unattributed post
|
||||
reads as the human owner's words to everyone who sees it.
|
||||
|
||||
**Format:** Open the body with an italicized attribution line:
|
||||
|
||||
```
|
||||
_Posted by <Agent Stack> (<model>)_
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
- `_Posted by Claude Code_`
|
||||
|
||||
For PR bodies, the `🤖 Generated with …` footer that some tools add covers this. For
|
||||
comments and review replies, add the line yourself — there is no automatic footer.
|
||||
|
||||
**This applies to:** PR creation, PR updates, issue comments, review comments, and review
|
||||
replies. A thread is not "answered" until the reply carries this attribution.
|
||||
|
||||
### PR Tracking: Checks AND Reviews
|
||||
|
||||
For any PR an agent owns or is working on, **track both CI checks and GitHub comments/reviews**.
|
||||
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
|
||||
|
||||
**`scripts/pr-watch`** now prints reviews after checks are green (use `--no-reviews` to skip).
|
||||
Reviews are a separate API surface from comments — Codex/ChatGPT reviews arrive as *reviews*,
|
||||
not comments, and are easy to miss if you only read the comment thread.
|
||||
|
||||
1. **Run `scripts/pr-watch <n>`** — waits for checks, then prints all reviews/comments
|
||||
2. **Read all review feedback** — reviews against an earlier head may still apply
|
||||
3. **Answer substantive feedback with attribution** before merging (see format above)
|
||||
4. **Re-check after each push** that new reviews target the current head SHA
|
||||
5. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows
|
||||
|
||||
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it** (exit code trap).
|
||||
|
||||
See the `pr-watch` skill for the full recipe, and "Shipping a wave" below for Copilot
|
||||
request mechanics and how to answer declined findings.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+37
-29
@@ -854,7 +854,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -886,6 +886,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"siphasher",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1044,9 +1045,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1061,7 +1062,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1605,7 +1606,7 @@ dependencies = [
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
@@ -1892,9 +1893,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -2072,11 +2073,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2092,9 +2093,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2173,7 +2174,7 @@ dependencies = [
|
||||
"log",
|
||||
"rand 0.10.3",
|
||||
"sha1",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2194,6 +2195,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -2282,9 +2289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2295,19 +2302,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.78"
|
||||
version = "0.4.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
|
||||
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2315,9 +2323,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -2328,18 +2336,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -2525,18 +2533,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.57"
|
||||
version = "0.8.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||
checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.57"
|
||||
version = "0.8.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||
checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -2657,7 +2657,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
}
|
||||
{
|
||||
name = "unicode-width";
|
||||
@@ -2668,6 +2668,7 @@ rec {
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-wasm-client";
|
||||
@@ -2751,6 +2752,11 @@ rec {
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
name = "improvise-core";
|
||||
packageId = "improvise-core";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
@@ -2791,6 +2797,10 @@ rec {
|
||||
name = "siphasher";
|
||||
packageId = "siphasher";
|
||||
}
|
||||
{
|
||||
name = "unicase";
|
||||
packageId = "unicase";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
@@ -2802,7 +2812,9 @@ rec {
|
||||
packageId = "serde_json";
|
||||
}
|
||||
];
|
||||
|
||||
features = {
|
||||
};
|
||||
resolvedDefaultFeatures = [ "test-support" ];
|
||||
};
|
||||
"improvise-formula" = rec {
|
||||
crateName = "improvise-formula";
|
||||
@@ -3032,6 +3044,11 @@ rec {
|
||||
name = "improvise-core";
|
||||
packageId = "improvise-core";
|
||||
}
|
||||
{
|
||||
name = "improvise-protocol";
|
||||
packageId = "improvise-protocol";
|
||||
features = [ "test-support" ];
|
||||
}
|
||||
{
|
||||
name = "improvise-ws-server";
|
||||
packageId = "improvise-ws-server";
|
||||
@@ -3293,9 +3310,9 @@ rec {
|
||||
};
|
||||
"js-sys" = rec {
|
||||
crateName = "js-sys";
|
||||
version = "0.3.105";
|
||||
version = "0.3.106";
|
||||
edition = "2021";
|
||||
sha256 = "17pr58p55pxbflkjl2wz6pyz854j80nlgavjq4mcwr583q6x4myf";
|
||||
sha256 = "1icwmpjw54lb7vg5926k5y4y5jbiih0zxhwgjwnzn475v90xk0vq";
|
||||
libName = "js_sys";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -3347,7 +3364,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
];
|
||||
@@ -4896,7 +4913,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
{
|
||||
@@ -5877,9 +5894,9 @@ rec {
|
||||
};
|
||||
"smallvec" = rec {
|
||||
crateName = "smallvec";
|
||||
version = "1.16.1";
|
||||
version = "1.16.2";
|
||||
edition = "2018";
|
||||
sha256 = "14gqvsqdli51r1bii3hfqv5vx1b9r0gic4br0x9fsixmy5b70ims";
|
||||
sha256 = "13iai5hhwyp8z0pbn8r11q4j5956jaxhcbvvf2drm17f1q7myfgr";
|
||||
authors = [
|
||||
"The Servo Project Developers"
|
||||
];
|
||||
@@ -6413,18 +6430,18 @@ rec {
|
||||
];
|
||||
|
||||
};
|
||||
"thiserror 2.0.20" = rec {
|
||||
"thiserror 2.0.21" = rec {
|
||||
crateName = "thiserror";
|
||||
version = "2.0.20";
|
||||
version = "2.0.21";
|
||||
edition = "2021";
|
||||
sha256 = "0kxs6p295jffxhzaxpxv1dwaaf5iqlm6sx8h0djp6ancbxgj71pc";
|
||||
sha256 = "17hq1lh5dyr3bkc7zzjrbrp4qgkvhc48kgq1n5fdxkindaw2rr89";
|
||||
authors = [
|
||||
"David Tolnay <dtolnay@gmail.com>"
|
||||
];
|
||||
dependencies = [
|
||||
{
|
||||
name = "thiserror-impl";
|
||||
packageId = "thiserror-impl 2.0.20";
|
||||
packageId = "thiserror-impl 2.0.21";
|
||||
}
|
||||
];
|
||||
features = {
|
||||
@@ -6458,11 +6475,11 @@ rec {
|
||||
];
|
||||
|
||||
};
|
||||
"thiserror-impl 2.0.20" = rec {
|
||||
"thiserror-impl 2.0.21" = rec {
|
||||
crateName = "thiserror-impl";
|
||||
version = "2.0.20";
|
||||
version = "2.0.21";
|
||||
edition = "2021";
|
||||
sha256 = "1bwjc94gi0xn5jz26h1a8bjj1wdkvvr6jifamyc4mp9n28zcs15w";
|
||||
sha256 = "0945n8agp7kg6n6b35yyjb4g5xv2q22vrw15h6jj1nw76a99flgy";
|
||||
procMacro = true;
|
||||
libName = "thiserror_impl";
|
||||
authors = [
|
||||
@@ -6812,7 +6829,7 @@ rec {
|
||||
}
|
||||
{
|
||||
name = "thiserror";
|
||||
packageId = "thiserror 2.0.20";
|
||||
packageId = "thiserror 2.0.21";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
@@ -6874,6 +6891,17 @@ rec {
|
||||
sha256 = "154smf048k84prsdgh09nkm2n0w0336v84jd4zikyn6v6jrqbspa";
|
||||
|
||||
};
|
||||
"unicase" = rec {
|
||||
crateName = "unicase";
|
||||
version = "2.9.0";
|
||||
edition = "2018";
|
||||
sha256 = "0hh1wrfd7807mfph2q67jsxqgw8hm82xg2fb8ln8cvblkwxbri6v";
|
||||
authors = [
|
||||
"Sean McArthur <sean@seanmonstar.com>"
|
||||
];
|
||||
features = {
|
||||
};
|
||||
};
|
||||
"unicode-ident" = rec {
|
||||
crateName = "unicode-ident";
|
||||
version = "1.0.26";
|
||||
@@ -7114,9 +7142,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen" = rec {
|
||||
crateName = "wasm-bindgen";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "1gsi4ggm03dqr3j9bcc1sbxzmkw6ykm3cqx4g8xmw31v7niqgjxf";
|
||||
sha256 = "02flhqld01jqb6vbfyx1gb8s78g1pnq2164daxad93y6mhrlzdcv";
|
||||
libName = "wasm_bindgen";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7147,12 +7175,6 @@ rec {
|
||||
rename = "rustversion-compat";
|
||||
}
|
||||
];
|
||||
devDependencies = [
|
||||
{
|
||||
name = "once_cell";
|
||||
packageId = "once_cell";
|
||||
}
|
||||
];
|
||||
features = {
|
||||
"default" = [ "std" ];
|
||||
"enable-interning" = [ "std" ];
|
||||
@@ -7165,9 +7187,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-futures" = rec {
|
||||
crateName = "wasm-bindgen-futures";
|
||||
version = "0.4.78";
|
||||
version = "0.4.79";
|
||||
edition = "2021";
|
||||
sha256 = "0a6r1q1v2qvi3ypmy0nva3il422iyml1f61i897wbxfdsb9wbx3f";
|
||||
sha256 = "03k1wcg5j3wqk6vgy6gzcq868vsa9k81dlhqissfk0nrw96v7fiw";
|
||||
libName = "wasm_bindgen_futures";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7178,6 +7200,13 @@ rec {
|
||||
packageId = "js-sys";
|
||||
usesDefaultFeatures = false;
|
||||
}
|
||||
{
|
||||
name = "tokio";
|
||||
packageId = "tokio";
|
||||
usesDefaultFeatures = false;
|
||||
target = { target, features }: (("emscripten" == target."os" or null) && (target."wasm_bindgen_unstable_tokio" or false));
|
||||
features = [ "rt" "time" ];
|
||||
}
|
||||
{
|
||||
name = "wasm-bindgen";
|
||||
packageId = "wasm-bindgen";
|
||||
@@ -7193,9 +7222,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-macro" = rec {
|
||||
crateName = "wasm-bindgen-macro";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "1vdrjrb7yqh8p0r0yr93rlaq9iq05hy1wl9kbsjv7a61wc8xb456";
|
||||
sha256 = "0dc5xq09sy1v9ns1fhb0cnyqz5p1wcjjvkdmxskj9qhnbg1x0a9f";
|
||||
procMacro = true;
|
||||
libName = "wasm_bindgen_macro";
|
||||
authors = [
|
||||
@@ -7217,9 +7246,9 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-macro-support" = rec {
|
||||
crateName = "wasm-bindgen-macro-support";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
sha256 = "12p4jm9dr88h1j5sglycizvvxvr05pgmz7aa2v9g47h7y23lh7j1";
|
||||
sha256 = "1dx6w90f14avmhri7ss9lyz03060g6475r4axy3bm7biqf5ill3g";
|
||||
libName = "wasm_bindgen_macro_support";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7253,10 +7282,10 @@ rec {
|
||||
};
|
||||
"wasm-bindgen-shared" = rec {
|
||||
crateName = "wasm-bindgen-shared";
|
||||
version = "0.2.128";
|
||||
version = "0.2.129";
|
||||
edition = "2021";
|
||||
links = "wasm_bindgen";
|
||||
sha256 = "0ghh8hqx038h9c9zj1rlmzlv2k2shh917q756dn054hcipbir541";
|
||||
sha256 = "1ilmp5d3sl8lrq9djhcs90gk66yvk8mgwk4sfrvpvkd75b2wkw13";
|
||||
libName = "wasm_bindgen_shared";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -7271,9 +7300,9 @@ rec {
|
||||
};
|
||||
"web-sys" = rec {
|
||||
crateName = "web-sys";
|
||||
version = "0.3.105";
|
||||
version = "0.3.106";
|
||||
edition = "2021";
|
||||
sha256 = "1y57whpj5ncrl0cljmfsj5m31jqm66ylad68317yq03g0d5drgcz";
|
||||
sha256 = "195bd1m14w3hsw5fg3sr938mh7y4cb260d0sq6a6brffxjfin9l8";
|
||||
libName = "web_sys";
|
||||
authors = [
|
||||
"The wasm-bindgen Developers"
|
||||
@@ -8440,9 +8469,9 @@ rec {
|
||||
};
|
||||
"zerocopy" = rec {
|
||||
crateName = "zerocopy";
|
||||
version = "0.8.57";
|
||||
version = "0.8.58";
|
||||
edition = "2021";
|
||||
sha256 = "0wr85plmrkz0nh5grbqap23l9ghqq4mq1ip4x759q23dyflh4lfk";
|
||||
sha256 = "0i6vsbj4qfqj62b8qxl7wdcin8rnqz6pw88p6zzl5dc2mnpqyzn1";
|
||||
dependencies = [
|
||||
{
|
||||
name = "zerocopy-derive";
|
||||
@@ -8472,9 +8501,9 @@ rec {
|
||||
};
|
||||
"zerocopy-derive" = rec {
|
||||
crateName = "zerocopy-derive";
|
||||
version = "0.8.57";
|
||||
version = "0.8.58";
|
||||
edition = "2021";
|
||||
sha256 = "1g5js10p1q1d7wn6b74wjvrjznv34x5cfxpj7jj8s9a4mgsh2v0l";
|
||||
sha256 = "1whbf7cjcqyp5nhvma78yy489swr7mfgc9m6r6h4ckyz8kh5cpsr";
|
||||
procMacro = true;
|
||||
libName = "zerocopy_derive";
|
||||
dependencies = [
|
||||
|
||||
@@ -31,6 +31,11 @@ name = "reshape_observation"
|
||||
harness = false
|
||||
test = false
|
||||
|
||||
[[bench]]
|
||||
name = "scale_bisect"
|
||||
harness = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
gp-tree = { path = "crates/gp-tree" }
|
||||
improvise-core = { path = "crates/improvise-core" }
|
||||
|
||||
@@ -40,9 +40,9 @@ improvise import examples/demo.csv \
|
||||
--no-wizard \
|
||||
--category Region --category Product --category Customer \
|
||||
--measure Revenue --measure Cost \
|
||||
--time Date --extract Date:Month \
|
||||
--time Date --extract Date:Month --split-date Date \
|
||||
--axis Region:row --axis Product:row \
|
||||
--axis Date_Month:column --axis Measure:column \
|
||||
--axis Date_Month:column --axis _Measure:column \
|
||||
--axis Customer:page --axis Date:none \
|
||||
--formula "Profit = Revenue - Cost" \
|
||||
--name "Acme Sales Demo" \
|
||||
@@ -102,7 +102,7 @@ rollback target — it is not what the published site is running.
|
||||
|
||||
Every cell lives at the intersection of named categories and items —
|
||||
`(Region=East, Measure=Revenue)` — not at a grid address like `B3`. A model
|
||||
can have up to 12 categories, each with an ordered list of items that can be
|
||||
can have up to 24 categories, each with an ordered list of items that can be
|
||||
organized into collapsible groups (e.g. months grouped into quarters).
|
||||
|
||||
Formulas reference dimension names, not cell addresses:
|
||||
@@ -178,7 +178,14 @@ opening one fails with an error that says so. JSON *import* is unaffected.
|
||||
The import wizard analyzes CSV columns and proposes each as a category
|
||||
(string-valued), measure (numeric), time dimension (date-like, with optional
|
||||
year/month/quarter extraction), or skip. Multiple CSVs merge automatically
|
||||
with a synthetic "File" category.
|
||||
with a synthetic "File" category, even when their headers differ. A record
|
||||
simply has no value for a column its file lacks. A folder argument imports
|
||||
every `*.csv` in it, in name order, even when it holds a single CSV. Each file
|
||||
also gets its own view, filtered to that file and showing only its columns:
|
||||
|
||||
```sh
|
||||
improvise import reports/ --no-wizard -o reports.improv
|
||||
```
|
||||
|
||||
All model mutations go through a typed command registry, so the same
|
||||
operations that back the TUI work headless:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import sys; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
cfgs=[dict(k=k,prune=0) for k in (16,17,18,26,27)]+[dict(k=k,prune=1) for k in (7,11,12,13,14,21,22)]+[dict(k=1000,n=n,mode='records') for n in (1000,1200,1500,1800,2000,4600,14500)]
|
||||
for c in cfgs:
|
||||
c.setdefault('n',100000); c['ops']='open,key,window'
|
||||
v=[run('/tmp/sb',c,timeout=200) for _ in range(3)]
|
||||
f=lambda k:'/'.join(f"{r[k][1]:.1f}" if k in r else '-' for r in v)
|
||||
print({k:c[k] for k in c if k!='ops'},'rows',v[0].get('rows'),'open',f('open'),'key',f('keypress_down'),'win',f('browser_grid_of_top'),'tuiframe',f('tui_frame_grid_of_whole'),flush=True)
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys, json; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
cfgs=[dict(k=9,prune=0),dict(k=10,prune=0),dict(k=17,prune=0,groups=1),dict(k=26,prune=0,groups=1),
|
||||
dict(k=8,prune=1),dict(k=13,prune=1,groups=1),
|
||||
dict(k=1000,n=1300,mode='records'),dict(k=1000,n=4400,mode='records'),dict(k=1000,n=14000,mode='records')]
|
||||
for c in cfgs:
|
||||
c.setdefault('n',100000)
|
||||
for rep in range(3):
|
||||
r=run('/tmp/sb',c,timeout=300)
|
||||
print(json.dumps({'cfg':c,'rep':rep,**r}),flush=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Drive benches/scale_bisect one configuration per process; parse its lines.
|
||||
usage: scale_bisect.py BIN k=.. n=.. [cats=..] [prune=..] [mode=..] [ops=..] [--timeout S]
|
||||
"""
|
||||
import subprocess, sys, re, json
|
||||
|
||||
def run(binary, cfg, timeout=90):
|
||||
args = [binary] + [f"{k}={v}" for k, v in cfg.items()]
|
||||
try:
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
out, status = p.stdout, "ok"
|
||||
except subprocess.TimeoutExpired as e:
|
||||
out, status = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or ""), "timeout"
|
||||
res = {"status": status}
|
||||
for line in out.splitlines():
|
||||
if line.startswith("REFUSED"):
|
||||
res["status"] = "refused"
|
||||
m = re.match(r"(\w+) min=([\d.]+) med=([\d.]+) max=([\d.]+)", line)
|
||||
if m:
|
||||
res[m[1]] = (float(m[2]), float(m[3]), float(m[4]))
|
||||
for kv in re.findall(r"(\w+)=([\d.]+)", line):
|
||||
if kv[0] in ("rows", "cols", "maxrss_mb", "improv_mb", "snapshot_kb", "rss_after_open_mb", "cells"):
|
||||
res[kv[0]] = float(kv[1])
|
||||
m = re.match(r"json_encode_setcell_updates ms=(.*)", line)
|
||||
if m:
|
||||
res["json_setcell"] = m[1]
|
||||
return res
|
||||
|
||||
if __name__ == "__main__":
|
||||
binary = sys.argv[1]
|
||||
cfg, timeout = {}, 90
|
||||
rest = sys.argv[2:]
|
||||
for a in rest:
|
||||
if a.startswith("--timeout"):
|
||||
timeout = int(a.split("=")[1])
|
||||
else:
|
||||
k, v = a.split("=", 1)
|
||||
cfg[k] = v
|
||||
print(json.dumps(run(binary, cfg, timeout)))
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys; sys.path.insert(0,'bench')
|
||||
from scale_bisect import run
|
||||
import itertools
|
||||
OPS=sys.argv[1];
|
||||
cfgs=eval(sys.argv[2])
|
||||
cols=sys.argv[3].split(',')
|
||||
for c in cfgs:
|
||||
c=dict(c); c['ops']=OPS
|
||||
r=run('/tmp/sb',c,timeout=int(sys.argv[4]) if len(sys.argv)>4 else 90)
|
||||
print(c, r['status'], 'rows',r.get('rows'), *[f"{k}={r[k][1]:.1f}" if k in r else f"{k}=-" for k in cols], f"rss={r.get('maxrss_mb')}", flush=True)
|
||||
@@ -84,9 +84,6 @@ fn chain_app(regions: usize, products: usize) -> App {
|
||||
v.set_axis("Region", Axis::Row);
|
||||
v.set_axis("Product", Axis::Row);
|
||||
v.set_axis("_Measure", Axis::Column);
|
||||
for g in 0..regions.div_ceil(10) {
|
||||
v.toggle_group_collapse("Region", &format!("G{g}"));
|
||||
}
|
||||
}
|
||||
let mut n = 0.0f64;
|
||||
for r in 0..regions {
|
||||
@@ -127,9 +124,6 @@ fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize)
|
||||
v.set_axis("Day", Axis::Row);
|
||||
v.set_axis("Product", Axis::Row);
|
||||
v.set_axis("_Measure", Axis::Column);
|
||||
for g in 0..days.div_ceil(per_group) {
|
||||
v.toggle_group_collapse("Day", &format!("G{g}"));
|
||||
}
|
||||
}
|
||||
let mut n = 0.0f64;
|
||||
for d in 0..days {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! One configuration per process: build a sparse document, time the real paths.
|
||||
//! `scale_bisect k=1000 n=100000 cats=3 prune=1 mode=grid`; see context/test-evidence/scale-bisect.md.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
use improvise::model::cell::{CellKey, CellValue};
|
||||
use improvise::view::Axis;
|
||||
use improvise::workbook::Workbook;
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::effect::{self, ViewTarget};
|
||||
use improvise_command::ui::host::Host;
|
||||
use improvise_command::ui::project::{self, Viewport};
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_io::persistence::{format_md, parse_md, save_to};
|
||||
use improvise_io::storage::MemoryFs;
|
||||
use improvise_protocol::{
|
||||
ClientFrame, KeyCode, KeyEvent, KeyModifiers, ModelCommand, RecordHash, render::RecordCell,
|
||||
};
|
||||
|
||||
fn lcg(s: &mut u64, n: usize) -> usize {
|
||||
*s = s
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
(*s >> 33) as usize % n
|
||||
}
|
||||
|
||||
struct Cfg {
|
||||
k: usize,
|
||||
n: usize,
|
||||
cats: usize,
|
||||
prune: bool,
|
||||
mode: String,
|
||||
groups: bool,
|
||||
ops: String,
|
||||
}
|
||||
|
||||
fn arg(args: &HashMap<String, String>, k: &str, d: &str) -> String {
|
||||
args.get(k).cloned().unwrap_or_else(|| d.to_string())
|
||||
}
|
||||
|
||||
fn document(c: &Cfg) -> (Workbook, Vec<CellKey>) {
|
||||
let mut wb = Workbook::new("bench");
|
||||
for ci in 0..c.cats {
|
||||
let cat = format!("C{ci}");
|
||||
wb.add_category(cat.clone()).expect("under the limit");
|
||||
for i in 0..c.k {
|
||||
if c.groups && ci == 0 {
|
||||
wb.add_item_in_group(&cat, format!("i{i}"), format!("G{}", i / 10))
|
||||
.expect("added");
|
||||
} else {
|
||||
wb.add_item(&cat, format!("i{i}")).expect("added");
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in ["Cost", "Rev"] {
|
||||
wb.add_item("_Measure", m).expect("virtual");
|
||||
}
|
||||
let space = (c.k as f64).powi(c.cats as i32);
|
||||
let wanted = c.n.min((space / 2.0).max(1.0) as usize);
|
||||
let (mut s, mut seen, mut recs) = (0x9E37_79B9_7F4A_7C15u64, HashSet::new(), Vec::new());
|
||||
while seen.len() < wanted {
|
||||
let d: Vec<usize> = (0..c.cats).map(|_| lcg(&mut s, c.k)).collect();
|
||||
if !seen.insert(d.clone()) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<(String, String)> = d
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ci, x)| (format!("C{ci}"), format!("i{x}")))
|
||||
.collect();
|
||||
recs.push(CellKey::new(coords.clone()));
|
||||
for (m, v) in [("Cost", 3.0), ("Rev", 5.0)] {
|
||||
let mut key = coords.clone();
|
||||
key.push(("_Measure".to_string(), m.to_string()));
|
||||
wb.model.set_cell(CellKey::new(key), CellValue::Number(v));
|
||||
}
|
||||
}
|
||||
let view = wb.active_view_mut();
|
||||
for ci in 0..c.cats {
|
||||
let axis = if c.mode == "records" || c.mode == "none" {
|
||||
Axis::None
|
||||
} else {
|
||||
Axis::Row
|
||||
};
|
||||
view.set_axis(&format!("C{ci}"), axis);
|
||||
}
|
||||
if c.mode == "records" {
|
||||
view.set_axis("_Index", Axis::Row);
|
||||
view.set_axis("_Dim", Axis::Column);
|
||||
} else {
|
||||
view.set_axis("_Measure", Axis::Column);
|
||||
}
|
||||
view.prune_empty = c.prune;
|
||||
(wb, recs)
|
||||
}
|
||||
|
||||
fn maxrss_mb() -> f64 {
|
||||
let mut u: libc::rusage = unsafe { std::mem::zeroed() };
|
||||
unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) };
|
||||
u.ru_maxrss as f64 / 1048576.0
|
||||
}
|
||||
|
||||
fn rss_mb() -> f64 {
|
||||
let out = std::process::Command::new("ps")
|
||||
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
|
||||
.output()
|
||||
.expect("ps");
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.unwrap_or(0.0)
|
||||
/ 1024.0
|
||||
}
|
||||
|
||||
/// Sample `f` up to `reps` times, stopping once `budget_ms` is spent (one sample minimum).
|
||||
fn sample(name: &str, reps: usize, budget_ms: f64, mut f: impl FnMut(usize)) {
|
||||
let mut v = Vec::new();
|
||||
let start = Instant::now();
|
||||
for r in 0..reps {
|
||||
let t = Instant::now();
|
||||
f(r);
|
||||
v.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if start.elapsed().as_secs_f64() * 1000.0 > budget_ms {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
|
||||
println!(
|
||||
"{name} min={:.3} med={:.3} max={:.3} reps={}",
|
||||
v[0],
|
||||
v[v.len() / 2],
|
||||
v[v.len() - 1],
|
||||
v.len()
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
fn once<T>(name: &str, f: impl FnOnce() -> T) -> T {
|
||||
let t = Instant::now();
|
||||
let v = f();
|
||||
println!(
|
||||
"{name} min={0:.3} med={0:.3} max={0:.3} reps=1",
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
v
|
||||
}
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: HashMap<String, String> = std::env::args()
|
||||
.skip(1)
|
||||
.filter_map(|a| {
|
||||
a.split_once('=')
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let c = Cfg {
|
||||
k: arg(&args, "k", "1000").parse().expect("k"),
|
||||
n: arg(&args, "n", "100000").parse().expect("n"),
|
||||
cats: arg(&args, "cats", "3").parse().expect("cats"),
|
||||
prune: arg(&args, "prune", "1") == "1",
|
||||
mode: arg(&args, "mode", "grid"),
|
||||
groups: arg(&args, "groups", "0") == "1",
|
||||
ops: arg(
|
||||
&args,
|
||||
"ops",
|
||||
"open,key,frame,window,setcell,host,reshape,io",
|
||||
),
|
||||
};
|
||||
let rows_cap: f64 = arg(&args, "rows_cap", "30000000").parse().expect("cap");
|
||||
let product = (c.k as f64).powi(c.cats as i32);
|
||||
if c.mode == "grid" && product > rows_cap {
|
||||
println!(
|
||||
"REFUSED product_rows={product:.3e} (> {rows_cap:.0}: layout would materialize it)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let want = |o: &str| c.ops.split(',').any(|x| x == o);
|
||||
let (wb, recs) = document(&c);
|
||||
println!(
|
||||
"cells={} records={} base_rss_mb={:.0}",
|
||||
wb.model.data.iter_cells().count(),
|
||||
recs.len(),
|
||||
rss_mb()
|
||||
);
|
||||
let mut app = once("open", || App::new(wb, None));
|
||||
println!(
|
||||
"rows={} cols={} rss_after_open_mb={:.0}",
|
||||
app.layout.row_count(),
|
||||
app.layout.col_count(),
|
||||
rss_mb()
|
||||
);
|
||||
app.term_width = 200;
|
||||
app.term_height = 50;
|
||||
let name = app.view_name().to_string();
|
||||
|
||||
if want("key") {
|
||||
sample("keypress_down", 8, 6000.0, |_| {
|
||||
app.dispatch_key(key(KeyCode::Down));
|
||||
});
|
||||
}
|
||||
if want("frame") {
|
||||
let whole = |app: &App| Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: app.layout.row_count(),
|
||||
cols: app.layout.col_count(),
|
||||
};
|
||||
sample("tui_frame_grid_of_whole", 3, 8000.0, |_| {
|
||||
std::hint::black_box(project::grid_of(&app, whole(&app)));
|
||||
});
|
||||
let cache = project::grid_of(&app, whole(&app));
|
||||
let area = ratatui::layout::Rect::new(0, 0, 200, 50);
|
||||
sample("tui_render_gridwidget_buffer", 5, 4000.0, |_| {
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
let w = improvise::ui::grid::GridWidget::new(
|
||||
&cache,
|
||||
app.cursor(),
|
||||
&app.view_state.active_view,
|
||||
&app.layout,
|
||||
&app.view_state.mode,
|
||||
&app.view_state.search_query,
|
||||
&app.view_state.buffers,
|
||||
app.drill_state.as_ref(),
|
||||
);
|
||||
ratatui::widgets::Widget::render(w, area, &mut buf);
|
||||
std::hint::black_box(buf);
|
||||
});
|
||||
}
|
||||
if want("window") {
|
||||
let mid = app.layout.row_count() / 2;
|
||||
for (label, off) in [("top", 0), ("mid", mid)] {
|
||||
let vp = Viewport {
|
||||
row_offset: off,
|
||||
col_offset: 0,
|
||||
rows: 45,
|
||||
cols: 12,
|
||||
};
|
||||
sample(&format!("browser_grid_of_{label}"), 5, 6000.0, |_| {
|
||||
std::hint::black_box(project::grid_of(&app, vp));
|
||||
});
|
||||
}
|
||||
}
|
||||
if want("setcell") && !recs.is_empty() {
|
||||
let existing = recs[recs.len() / 2].clone().with("_Measure", "Rev");
|
||||
sample("setcell_existing", 5, 6000.0, |r| {
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
existing.clone(),
|
||||
CellValue::Number(10.0 + r as f64),
|
||||
))]);
|
||||
});
|
||||
let mut s = 99u64;
|
||||
sample("setcell_new_record", 5, 6000.0, |_| {
|
||||
let mut coords: Vec<(String, String)> = (0..c.cats)
|
||||
.map(|ci| (format!("C{ci}"), format!("i{}", lcg(&mut s, c.k))))
|
||||
.collect();
|
||||
coords.push(("_Measure".into(), "Rev".into()));
|
||||
app.apply_effects(vec![Box::new(effect::SetCell(
|
||||
CellKey::new(coords),
|
||||
CellValue::Number(1.0),
|
||||
))]);
|
||||
});
|
||||
}
|
||||
if want("reshape") && c.mode == "grid" {
|
||||
let v = || ViewTarget::Active;
|
||||
sample("reshape_hide_item", 3, 6000.0, |r| {
|
||||
app.apply_effects(vec![Box::new(effect::HideItem {
|
||||
view: v(),
|
||||
category: "C1".into(),
|
||||
item: format!("i{r}"),
|
||||
})]);
|
||||
});
|
||||
if c.groups {
|
||||
sample("reshape_toggle_group", 3, 6000.0, |_| {
|
||||
app.apply_effects(vec![Box::new(effect::ToggleGroup {
|
||||
view: v(),
|
||||
category: "C0".into(),
|
||||
group: "G0".into(),
|
||||
})]);
|
||||
});
|
||||
}
|
||||
let last = format!("C{}", c.cats - 1);
|
||||
sample("reshape_set_axis", 4, 6000.0, |r| {
|
||||
let axis = if r % 2 == 0 { Axis::Page } else { Axis::Row };
|
||||
app.apply_effects(vec![Box::new(effect::SetAxis {
|
||||
view: v(),
|
||||
category: last.clone(),
|
||||
axis,
|
||||
})]);
|
||||
});
|
||||
sample("reshape_toggle_prune", 2, 6000.0, |_| {
|
||||
app.apply_effects(vec![Box::new(effect::TogglePruneEmpty(v()))]);
|
||||
});
|
||||
}
|
||||
if want("io") {
|
||||
let wbr = &app.model_state.workbook;
|
||||
let text = once("format_md", || format_md(wbr));
|
||||
println!("improv_mb={:.2}", text.len() as f64 / 1048576.0);
|
||||
once("parse_md", || parse_md(&text).expect("parses"));
|
||||
let fs = MemoryFs::new();
|
||||
sample("persist_per_command_save_to", 3, 6000.0, |_| {
|
||||
save_to(&fs, wbr, std::path::Path::new("d.improv")).expect("write");
|
||||
});
|
||||
}
|
||||
if want("host") && !recs.is_empty() {
|
||||
let mut host = Host::new(app);
|
||||
let (id, snap) = once("host_connect_snapshot", || host.connect(45, 12));
|
||||
sample("json_encode_snapshot", 3, 4000.0, |_| {
|
||||
std::hint::black_box(serde_json::to_vec(&snap.msg).expect("json"));
|
||||
});
|
||||
println!(
|
||||
"snapshot_kb={:.1}",
|
||||
serde_json::to_vec(&snap.msg).expect("json").len() as f64 / 1024.0
|
||||
);
|
||||
let rec = recs[recs.len() / 2].clone();
|
||||
let cell = RecordCell {
|
||||
record: RecordHash::of(&rec),
|
||||
measure: Some("Rev".into()),
|
||||
};
|
||||
let mut enc: Vec<(f64, usize)> = Vec::new();
|
||||
sample("host_handle_setcell_and_project", 4, 8000.0, |r| {
|
||||
let wire = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::SetCell {
|
||||
view: name.clone(),
|
||||
cell: cell.clone(),
|
||||
value: CellValue::Number(50.0 + r as f64),
|
||||
},
|
||||
);
|
||||
let out = host.handle(id, ClientFrame::command(wire));
|
||||
let t = Instant::now();
|
||||
let bytes: usize = out
|
||||
.iter()
|
||||
.map(|a| serde_json::to_vec(&a.msg).expect("json").len())
|
||||
.sum();
|
||||
enc.push((t.elapsed().as_secs_f64() * 1000.0, bytes));
|
||||
});
|
||||
println!("json_encode_setcell_updates ms={:?}", enc);
|
||||
}
|
||||
println!("maxrss_mb={:.0}", maxrss_mb());
|
||||
}
|
||||
+82
-13
@@ -1,11 +1,30 @@
|
||||
# Cloud Agents (LLM Reference)
|
||||
|
||||
Environment notes for agents running in Claude Code on the web. Local
|
||||
checkouts are unaffected — the session hook exits early unless
|
||||
Environment notes for agents running in cloud containers — Claude Code on the web,
|
||||
Cursor cloud agents, and similar setups. Local checkouts are unaffected.
|
||||
|
||||
## Session Bootstrap
|
||||
|
||||
All agent stacks use the same bootstrap script: `scripts/agent-bootstrap.sh`. It:
|
||||
|
||||
1. **Seeds nix flake inputs** (GitHub tarballs are blocked; seeds over git instead)
|
||||
2. Verifies the `bd` version matches the flake.nix pin
|
||||
3. Installs `bd` if missing
|
||||
4. Syncs the Dolt issue tracker from the git remote
|
||||
5. Warms the cargo build **inside `nix develop`**
|
||||
|
||||
**After bootstrap, `nix develop` works.** This is the required toolchain — bare system
|
||||
cargo that fails `edition2024` or missing wasm target is a misconfigured session.
|
||||
|
||||
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically when
|
||||
`CLAUDE_CODE_REMOTE=true`.
|
||||
|
||||
`.claude/hooks/session-start.sh` handles the setup automatically. This doc
|
||||
is the "why", and the record of what bites when it doesn't run.
|
||||
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
|
||||
in `.cursor/skills/` (symlinks to `.agents/skills/`).
|
||||
|
||||
**Other stacks**: Run `scripts/agent-bootstrap.sh` manually after checkout.
|
||||
|
||||
This doc is the "why", and the record of what bites when setup doesn't run.
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +79,8 @@ same if you need to re-sync by hand; never run it in the working tree.
|
||||
|
||||
| Thing | Status | Use instead |
|
||||
|---|---|---|
|
||||
| `nix develop` **unseeded** | Fails: flake inputs are GitHub **tarballs**, which are not served here | Run `scripts/nix-seed-flake-inputs.sh` first — see below. Or just use the preinstalled `cargo`/`rustc`, the same stable toolchain the flake pins |
|
||||
| `nix develop` **without bootstrap** | Fails: flake inputs are GitHub **tarballs**, which are blocked | Bootstrap seeds inputs automatically; if it failed, run `scripts/nix-seed-flake-inputs.sh` manually |
|
||||
| Bare system cargo | Fails: `edition2024`, missing wasm target | **Always use `nix develop`** — this is the required toolchain |
|
||||
| `bd dolt push` | Fails: 403 outbound | Nothing; bead status changes stay local. Say so rather than implying the tracker was updated |
|
||||
| `git+ssh://` Dolt remote in `config.yaml` | No ssh client in the container | `git+https://<origin>`, which the hook passes explicitly |
|
||||
| `git push`, GitHub API | **Work normally** | — |
|
||||
@@ -70,13 +90,13 @@ starting by hand). Reads of public GitHub repos over the git protocol work —
|
||||
it is specifically the `/archive/*.tar.gz` and `codeload` endpoints that are
|
||||
refused.
|
||||
|
||||
### Making `nix develop` work
|
||||
### How nix develop works after bootstrap
|
||||
|
||||
`scripts/nix-seed-flake-inputs.sh` makes the devShell work **without touching
|
||||
`flake.nix` or `flake.lock`**. Verified end to end: `rustc 1.98.1`, `cargo`,
|
||||
`crate2nix` in the shell, `git status` clean.
|
||||
The bootstrap runs `scripts/nix-seed-flake-inputs.sh` automatically, which seeds
|
||||
the flake inputs over git **without touching `flake.nix` or `flake.lock`**.
|
||||
After bootstrap, `nix develop` should work. If it fails, check `/tmp/nix-seed.log`.
|
||||
|
||||
Why it is needed, and why the obvious fixes are not available:
|
||||
Why the seeding is needed:
|
||||
|
||||
- Nix's `github:` fetcher wants a tarball. GitHub traffic **bypasses the
|
||||
environment's domain allowlist entirely** and goes through a proxy that
|
||||
@@ -105,9 +125,9 @@ around it — a mismatch fails loudly. Do not instead rewrite the lock's nodes
|
||||
to `git` type: carrying the old `narHash` onto a different fetcher is exactly
|
||||
the claim the lock exists to make.
|
||||
|
||||
It is not in the session-start hook because it is expensive (minutes, plus
|
||||
several GB of store once the devShell builds) and most work does not need the
|
||||
devShell. Run it when you do.
|
||||
The bootstrap runs this automatically. It takes a few minutes and several GB of
|
||||
store once the devShell builds. If bootstrap reported a seed failure, check
|
||||
`/tmp/nix-seed.log` and re-run `scripts/nix-seed-flake-inputs.sh` manually.
|
||||
|
||||
Two alternatives if per-container seeding is unattractive: push the inputs to
|
||||
a binary cache with `nix flake archive` and point sessions at it with
|
||||
@@ -143,3 +163,52 @@ environment, where egress leaves through your own network boundary.
|
||||
reorder or fold commits (`.agents/skills/wave/agent-rules.md`, rule 8).
|
||||
- Keep `context/repo-map.md` and `context/design-principles.md` current —
|
||||
they are the two docs agents are expected to maintain by hand.
|
||||
|
||||
---
|
||||
|
||||
## PR Tracking
|
||||
|
||||
For any PR you own, **track both CI checks and GitHub comments/reviews**:
|
||||
|
||||
- **`scripts/pr-watch <n>`**: Waits for checks, then prints all reviews/comments (default).
|
||||
Reviews are a separate API surface — Codex/ChatGPT reviews arrive as *reviews* not comments.
|
||||
- **Answer feedback with attribution** before marking work done (see below).
|
||||
- **Re-check after push** that reviews target the current head SHA.
|
||||
|
||||
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
|
||||
See AGENTS.md "PR Tracking: Checks AND Reviews" and "Shipping a wave" for the full workflow.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Attribution (Hard Rule)
|
||||
|
||||
**Every GitHub write (PR, comment, review reply) must include an attribution line.**
|
||||
Posts go out under the repository owner's account — an unattributed post reads as the
|
||||
human's words.
|
||||
|
||||
Open the body with: `_Posted by <Agent Stack> (<model>)_`
|
||||
|
||||
Examples:
|
||||
- `_Posted by Claude (Opus 4.5)_`
|
||||
- `_Posted by Cursor Agent (claude-sonnet-5)_`
|
||||
|
||||
A thread is not "answered" until the reply carries this attribution. See AGENTS.md
|
||||
"GitHub Attribution" for the full rule.
|
||||
|
||||
---
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
**Use `scripts/kb`, not Prolog MCP.** The Prolog MCP is not available in all agent stacks
|
||||
(Cursor cloud agents do not have it). All agents must use `scripts/kb` for KB operations:
|
||||
|
||||
```bash
|
||||
scripts/kb check # kb_consistent, exactly what CI runs
|
||||
scripts/kb query '<goal>' # run a goal; exit 1 if it fails
|
||||
scripts/kb add <delta.pl> # add clauses; refuses if a key already exists
|
||||
scripts/kb replace <delta.pl> # add, overwriting existing keys
|
||||
scripts/kb rm <keys.pl> # remove by key; refuses if a key is absent
|
||||
```
|
||||
|
||||
Reading `prolog-sessions/kb.pl` directly for queries is fine. See the `kb` skill at
|
||||
`.agents/skills/kb/SKILL.md` for the full interface.
|
||||
|
||||
@@ -369,3 +369,172 @@ non-zero and writes nothing. It is checked before the build rather than read
|
||||
off the build's error, so the listing is structured data and not a parsed
|
||||
sentence. `separate` uses `ImportPipeline::suggested_duplicate_category`, and
|
||||
there is deliberately no flag to name the category yet.
|
||||
|
||||
## 12 — `import` takes folders, and the CLI only says which arguments are folders (improvise-lpzu)
|
||||
|
||||
`improvise import` takes any mix of files and folders. A folder stands for its
|
||||
`*.csv` files, in name order. A folder always merges, even when it holds a
|
||||
single CSV, so that file keeps its `File` item and view. A single file named
|
||||
directly still imports alone (the user's ruling on the PR). The rules
|
||||
and their reasons are in `context/code/formula-and-io.md`, "A folder of CSVs is
|
||||
one sparse model, with a view per file". The CLI's part is the one question
|
||||
`Storage` cannot answer: whether a path is a folder. `get_import_data` asks
|
||||
`Path::is_dir` for each argument, hands the resulting `ImportPath`s to
|
||||
`expand_import_paths` over `PhysicalFs`, and from there takes the path a list
|
||||
of files always took. A folder with no CSV in it is refused before anything is
|
||||
read. The message names the folder, and the run exits non-zero and writes
|
||||
nothing.
|
||||
|
||||
A merge now reaches the build as an `ImportPipeline` carrying the merged files'
|
||||
`sources`, rather than as a bare JSON value. Both `--no-wizard` and the TUI
|
||||
wizard (`run_tui` → `App::start_import`) therefore build the per-file views.
|
||||
The headless path matters most, because the code-maat pipeline
|
||||
(improvise-1xfb.5.1) runs there. `a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte`
|
||||
pins the views, the sparse Data lines and the byte-identical second run, with
|
||||
the first run's output sitting in the folder it reads.
|
||||
|
||||
## 13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6)
|
||||
|
||||
The user treats the headless command and model layer as a product of its own,
|
||||
and the scripted `import --no-wizard` is where it meets a pipeline. The
|
||||
repo-stats pipeline (improvise-1xfb.5.1) found three ways that import saved
|
||||
something other than what the flags asked for, and still exited 0. The
|
||||
reasoning for each is in `context/code/formula-and-io.md`, "A CSV field is
|
||||
text, and the field's role reads it" and "An import refuses what it cannot
|
||||
keep". The CLI's contract:
|
||||
|
||||
- **`--category` keeps the CSV text.** A forced category's items are the
|
||||
file's text, verbatim: `00565796` stays `00565796`, and `220944e2` stays
|
||||
`220944e2`. Measures still read as numbers. A JSON number has no source
|
||||
text, so it is named as JSON writes it (`1e3` is `1000.0`).
|
||||
- **An unparseable `--formula` is a refusal.** It exits non-zero and writes
|
||||
nothing. Every bad formula is named on stderr, each with its parse error.
|
||||
This is §10's rule: the request was not met, so the exit code says so. The
|
||||
wizard refuses the same text at the formula step, with the same wording.
|
||||
- **An import that kept no record is a refusal.** It exits non-zero, writes
|
||||
nothing, and says why: every record was skipped as not a JSON object, or
|
||||
the source had none.
|
||||
- **An import that skipped *some* records exits 0 by default.** It writes the
|
||||
model and warns with a count. With `--strict` (the user's ruling on PR #321)
|
||||
any skipped record is a refusal instead: exit 1, the same count named, and
|
||||
nothing written. The argument is in `formula-and-io.md`.
|
||||
|
||||
Two refusals bail in `run_headless_import` before the build: the `--strict`
|
||||
skip check (`src/main.rs:305`) and the `--on-duplicate refuse` collision
|
||||
check. The rest come from `build_model_with_warnings` as an `Err`. All of
|
||||
them reach `main` as exit 1 before `persistence::save`. So "writes nothing"
|
||||
holds by statement order, not by where the refusal is raised. The headless
|
||||
tests check it by asserting that the output path does not exist.
|
||||
|
||||
## 14 — the command language reads `|pipe quoting|`, and a surplus argument is refused (improvise-31oe.3)
|
||||
|
||||
`create-view |By area|` used to make a view named `|By` and drop `area|` without
|
||||
a word. Two separate defects, fixed separately.
|
||||
|
||||
**The quoting.** `parse.rs` now has one lexer, `lex`, and everything else is
|
||||
built on it: `parse_line_with`, `segments` and `parse_segment_interactive`. The
|
||||
rules are:
|
||||
|
||||
- A `|…|` piece is read by `improvise_formula::quote::unescape_pipe_body`, the
|
||||
one home of pipe quoting (gotcha 34). So `\|`, `\\` and `\n` mean exactly what
|
||||
they mean in `.improv` and in formulas. The lexer only finds the closing pipe:
|
||||
a backslash takes the next character with it, so `\|` does not close.
|
||||
- A piece may sit **anywhere in a word**, and the pieces and bare text are
|
||||
joined. So a coordinate quotes each half, `|Sales Region|/|East Coast|`, the
|
||||
way a `.improv` data line quotes each name. The alternative, a pipe only at
|
||||
the start of a word, would have left no way to spell a coordinate with a
|
||||
space in it.
|
||||
- `"…"` is **kept**, verbatim, with no escapes, and a `|` inside it stays a
|
||||
`|`. It is not a workaround to retire. Formula text carries its own pipes:
|
||||
`add-formula "Net = |Gross Rev| - Cost"` must reach `parse_formula` with the
|
||||
pipes on. If the line had pipes only, every pipe in a formula would need
|
||||
escaping. Its old leniency is kept too: a `"` that is never closed runs to
|
||||
the end of the line. A `|` that is never closed is refused, naming the text
|
||||
from the open pipe on, because a name is never meant to run to the end.
|
||||
- The separator is a **bare** `.` word. A quoted `|.|` or `"."` is an argument,
|
||||
and a `.` inside `|a . b|` does not split the line. The old `split_on_dot`
|
||||
was a second scanner that knew double quotes and not pipes. With one lexer
|
||||
there is no second scanner to disagree.
|
||||
- A comment line (`#`, `//`) is not lexed, so an unmatched pipe in a comment is
|
||||
not an error.
|
||||
|
||||
`segments` now returns `Result`, because an unterminated pipe is a fact about
|
||||
the whole line. `main.rs` refuses the line before running any of it, as §7 does
|
||||
for a parse error, and `ExecuteCommand` refuses it on the status line.
|
||||
|
||||
What is still not spellable: an item whose name contains `/`. The coordinate is
|
||||
split on the first `/` **after** unquoting, so `|a/b|/x` is still read as
|
||||
category `a` and item `b/x`. Closing that needs the lexer to hand structured
|
||||
coordinates to the commands, which is a larger change than this ticket.
|
||||
|
||||
**The arity.** Every registration now declares `MaxArgs::AtMost(n)` or
|
||||
`MaxArgs::Any` beside its prototype. `CmdRegistry::parse` and
|
||||
`CmdRegistry::interactive` apply it after construction succeeds. So the rule is
|
||||
written once and every command gets it, and a new registration cannot compile
|
||||
without a declaration. The refusal names the command, the limit and every
|
||||
surplus argument (`create-view: takes at most 1 argument(s), got 2; unexpected
|
||||
"area"`). A headless run exits non-zero through the §7 parse gate, and the `:`
|
||||
line refuses on the status line.
|
||||
|
||||
It runs **after** the command's own parse, deliberately. A command whose own
|
||||
refusal states its shape keeps it: `add-formula`'s "quote the formula"
|
||||
(improvise-z05h) is more use than an arity, and it is what `formula Product
|
||||
Total = Widget + Gadget` still says.
|
||||
|
||||
`MaxArgs::Any` is a claim, and the list of commands making it is short:
|
||||
`add-items`, `set-cell`, `set-format` (it joins its words), `clear-cell`,
|
||||
`yank`, `paste`, `drill-into-cell` and `commit-cell-edit`. Each takes a list of
|
||||
items or coordinates. `no_command_silently_drops_an_argument_it_was_given` names
|
||||
that list, so widening another command to `Any` has to add it there.
|
||||
|
||||
## 15 — a command that names something the document lacks is refused (improvise-31oe.2)
|
||||
|
||||
`set-axis Regoin row`, `set-page Region Nowhere` and `set-axis Region sideways`
|
||||
all exited 0 and said nothing. §10's rule applies: nothing changed, so the
|
||||
request was not met.
|
||||
|
||||
- **An unreadable axis is an argument error.** `set-axis` now checks its axis
|
||||
with `parse_axis` when it is parsed, so a script is refused before any of it
|
||||
runs. `execute` still refuses an axis it cannot read rather than returning
|
||||
nothing. That arm cannot be reached through the registry.
|
||||
- **An unknown category, item, group or view is a refusal**, from `RequireKnown`
|
||||
(below), because only the document knows what it holds. `effect::unknown_name`
|
||||
asks the active view (`try_axis_of`) for a category. That is exactly the
|
||||
condition under which `View::set_axis` was a silent no-op. It asks
|
||||
`effective_item_names` for an item, so a formula target is a known `_Measure`
|
||||
item. It asks both an item's own group and the category's `groups` for a
|
||||
group. `add_item_in_group` registers no `Group`, and a parent group has no
|
||||
items, so either one alone misses a real group. A view is asked of
|
||||
`workbook.views`.
|
||||
|
||||
The siblings fixed with it: `hide-item` and `show-item` (hiding an unknown item
|
||||
wrote it into `hidden_items`), `toggle-group`, and `switch-view` (the effect
|
||||
ignores a view it lacks, and says nothing). `set-page` with an unknown item was
|
||||
worse than a no-op: it wrote the name into `page_selections`.
|
||||
|
||||
The check runs at **apply** time, as an effect of its own. `execute` runs
|
||||
against one context built before the line ran, and a `:` line builds every
|
||||
segment up front, so a check in `execute` refused
|
||||
`create-view Second . switch-view Second` and
|
||||
`add-item Type Tools . set-page Type Tools` after their first halves had landed,
|
||||
while `improvise cmd` (which rebuilds the context per segment) accepted them.
|
||||
Each of these commands therefore returns `RequireKnown(needs)` followed by the
|
||||
real effect. `RequireKnown` refuses and aborts the batch (gotcha 21), so the
|
||||
real effect never applies. It is a `ViewOnly` effect that writes nothing and is
|
||||
off the wire on purpose: the wire's own effects (`model_effects`, gotcha 38)
|
||||
stay non-refusing, since a refusing effect there becomes a frame rejection
|
||||
(`MutationRefused`). That is arguably right, but it changes the protocol, and
|
||||
a browser composes these only from names it was sent. It is left for a ticket
|
||||
of its own.
|
||||
|
||||
A refused name stops the rest of a `:` line, because the abort ends the one batch
|
||||
the whole line runs as. Headless runs each segment as its own batch, so an abort
|
||||
never crosses a `.` there: `:set-page Type Nowhere . add-item Type X` adds
|
||||
nothing, while `improvise cmd` on the same line exits 1 and still adds `X`.
|
||||
`load`/`Save` already behaved this way. The two front ends' chain semantics are
|
||||
left for a ticket of their own.
|
||||
|
||||
The interactive cursor fallbacks of `clear-cell`, `yank`, `paste` and
|
||||
`drill-into-cell` answer only for coordinates that were OMITTED
|
||||
(`only_when_omitted`). Coordinates that were given and are malformed are
|
||||
refused, never replaced by the cursor's cell.
|
||||
|
||||
@@ -831,7 +831,7 @@ The classes that remain:
|
||||
column renumbers every index to its right and the advance was computed from
|
||||
the pre-commit layout (gotcha 23), so `CommitAndAdvance` returns straight
|
||||
after it and a client that advanced anyway would land one column off,
|
||||
silently. `AddCategory` can also be refused by the 12-category limit and sets
|
||||
silently. `AddCategory` can also be refused by the category limit and sets
|
||||
`abort_effects`; the mode change therefore goes **first**, where an abort
|
||||
cannot reach it (gotcha 21). **Since improvise-bd9o that exception is
|
||||
unreachable**, and this bullet describes `commit` alone: the records decline
|
||||
@@ -1652,7 +1652,7 @@ through the server's own types; with one definition there is nothing left for
|
||||
them to compare, which is what improvise-xks6 was for.
|
||||
|
||||
**`rejection_message` is spelled here rather than on `Rejection`** for the
|
||||
reason `AxisColor` is not a ratatui colour: the protocol carries the *fact*, and
|
||||
reason a `Face` is not a ratatui colour: the protocol carries the *fact*, and
|
||||
each front end words it. The wording tracks ADR 001's force table, because "try
|
||||
again" and "this cannot be forced" are the two different things a user must be
|
||||
able to tell apart — a `RecordGone` has nothing to write to, and offering force
|
||||
@@ -1877,8 +1877,8 @@ Four smaller decisions that would be re-derived wrongly:
|
||||
`improvise_protocol::read_buffer` so the `"search"` special case keeps its one
|
||||
home (gotcha 11); `None` is "no text-entry mode is open", so the page asks
|
||||
nothing about the mode. The colour stays semantic — `MinibufColor` crosses and
|
||||
the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg`
|
||||
is the TUI's (gotcha 26). It is drawn as a surface of its own rather than *in
|
||||
the CSS class is the page's last translation. (The TUI no longer reads it: it
|
||||
draws a face per text-entry mode, and improvise-nn4s.4 moves the page there.) It is drawn as a surface of its own rather than *in
|
||||
place of* `#status` as the TUI's bottom bar does, because `#status` also
|
||||
carries the transport's failures and a repaint must not erase one (gotcha 48).
|
||||
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
|
||||
@@ -1920,7 +1920,7 @@ home for it.
|
||||
The rows themselves are `improvise_protocol::help::pages()`, the same ones
|
||||
`src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string
|
||||
(`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a
|
||||
CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can
|
||||
CSS class — the `Face → draw.rs::face_style` shape again, and the reason the page can
|
||||
style a heading without knowing what makes a line a heading. Where the content
|
||||
comes from and what is derived from the keymap is `context/code/protocol.md` §8.
|
||||
|
||||
@@ -2353,7 +2353,7 @@ truncation. A browser has no column to fit, so the abbreviation buys nothing and
|
||||
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
|
||||
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
|
||||
vocabulary already on `Axis`: the decision stays in the shared layer and only
|
||||
the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color`
|
||||
the *choice* of vocabulary is local, exactly the `Face → ratatui::Style`
|
||||
shape §5 blesses. `short_label` is untouched, because the TUI's width is
|
||||
load-bearing.
|
||||
|
||||
@@ -2439,8 +2439,8 @@ So it is now a raised bar: fixed, centred over the grid, larger, bordered in the
|
||||
mode's own colour and shadowed. **Every part of that is paint.** The content is
|
||||
`RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` —
|
||||
the same semantic value the TUI reads — and the colour is `MinibufColor`
|
||||
resolved to a concrete colour exactly as `draw.rs::minibuf_fg` resolves it for
|
||||
the terminal. Nothing here asks which mode is open, which buffer is being typed
|
||||
resolved to a concrete colour by `style.css`. (The TUI now draws a face per
|
||||
text-entry mode instead; improvise-nn4s.4 moves the page onto the same faces.) Nothing here asks which mode is open, which buffer is being typed
|
||||
into, or what the prompt says; a second answer to any of those is the disease
|
||||
design-principles §5 is about. The caret is drawn as a CSS `::after` rather than
|
||||
appended to the text for the same reason: the text is the host's, and the page
|
||||
|
||||
+18
-2
@@ -2996,8 +2996,10 @@ ride in `ViewState::transient_prefix`, so they must live where the wire types do
|
||||
and the re-export keeps `crate::command::key::…` paths working.
|
||||
|
||||
`parse.rs` is the quasi-lisp prefix parser: `word arg1 arg2 …`, several commands per
|
||||
line separated by `.`, coordinate pairs written `Category/Item`, and quoted strings
|
||||
(`"Profit = Revenue - Cost"`).
|
||||
line separated by `.`, coordinate pairs written `Category/Item`, verbatim quoted
|
||||
strings (`"Profit = Revenue - Cost"`) and `|pipe-quoted names|` read by
|
||||
`improvise_formula::quote`. One lexer serves every entry point, so the separator
|
||||
and the quoting cannot disagree (improvise-31oe.3, `context/code/cli.md` §14).
|
||||
|
||||
### `cmd/core.rs`
|
||||
|
||||
@@ -3014,6 +3016,12 @@ 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`.
|
||||
|
||||
Every registration also declares its `MaxArgs`, and the registry refuses surplus
|
||||
arguments on both arms after the command is built. `register_nullary` declares
|
||||
`AtMost(0)` for itself, and an `effect_cmd!` declares its limit in the macro
|
||||
call as `MAX_ARGS`. So the arity rule is written once and is total over the
|
||||
registry (improvise-31oe.3, `context/code/cli.md` §14).
|
||||
|
||||
`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
|
||||
map but its own `ViewState` field (improvise-gdl, gotcha 11). That knowledge used to
|
||||
@@ -3372,6 +3380,14 @@ The parseable model-mutation commands are thin `Cmd` wrappers around effects,
|
||||
constructible from string args, sharing the same execution path as
|
||||
keymap-dispatched commands (gotcha 16).
|
||||
|
||||
A wrapper that names a category, item, group or view returns
|
||||
`effect::RequireKnown(Needs)` followed by its real effect (improvise-31oe.2).
|
||||
`RequireKnown` asks `unknown_name` against the live app when the batch applies
|
||||
and refuses and aborts if the document lacks the name, so a `:` line sees what
|
||||
its earlier segments made. The effects themselves stay silent about unknown
|
||||
names, because they are also the wire's mutation path. Why the check is a
|
||||
separate effect: `context/code/cli.md` §15.
|
||||
|
||||
### `cmd/wizard.rs` (improvise-2wm, improvise-907)
|
||||
|
||||
Import-wizard key handling. The wizard is a small step machine, and which key means
|
||||
|
||||
+335
-10
@@ -258,9 +258,11 @@ user drilled in to see all the raw data.
|
||||
|
||||
On an axis with two grouped categories the runs **nest**: `North` in `Upper`
|
||||
holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a
|
||||
marker names its level. `GroupHeader { level, .. }` opens a run at `level` and
|
||||
closes every deeper one; `GroupEnd { level }` closes `level` and every deeper
|
||||
one. `expand_category` reads the level off its prefix, which holds one item per
|
||||
marker names its level. `GroupHeader { level, depth, .. }` opens a run at
|
||||
`(level, depth)` and closes every deeper one; `GroupEnd { level, depth }` closes
|
||||
that run and every deeper one. "Deeper" is the ordering of the pair: the level
|
||||
first, then the depth inside one category's hierarchy (§19, improvise-felo.6).
|
||||
`expand_category` reads the level off its prefix, which holds one item per
|
||||
outer category, so it is derived rather than passed. A row's group, for
|
||||
`row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open
|
||||
at it. That is the user's ruling on the ticket: membership is per level, and
|
||||
@@ -357,12 +359,14 @@ browser symptom and silently break drill.
|
||||
`Axis` also owns four presentation vocabularies — `plain_label()`, `glyph()`
|
||||
(which `Display` appends, giving "Row ↕"), `short_label()` (three columns for
|
||||
the tile bar; `None` is "·", **not** a truncation of "None") and `initial()`
|
||||
(one letter for the view panel; `None` is empty) — plus `color() -> AxisColor`.
|
||||
`AxisColor` is a *semantic* palette because `improvise-core` must not name a
|
||||
ratatui type; `draw.rs::axis_fg` does the one mapping, exactly as `minibuf_fg`
|
||||
does for `MinibufColor` (improvise-rml). The tile bar and the category panel each
|
||||
carried a copy of the colour table before, with nothing keeping them in
|
||||
agreement.
|
||||
(one letter for the view panel; `None` is empty). Its **colour is no longer
|
||||
here**. It was `color() -> AxisColor` (improvise-rml), a semantic palette that
|
||||
lived in core only because it was a method on a core type; ADR 004 clause 6
|
||||
moved it to `improvise-protocol` as `Face::Axis(axis)`, because a face is UI and
|
||||
this crate is the one that must have none. The TUI resolves it through
|
||||
`draw.rs::face_style` (`context/code/tui.md`). The tile bar and the category
|
||||
panel each carried a copy of the colour table before improvise-rml, with nothing
|
||||
keeping them in agreement, and they still share one: the face.
|
||||
|
||||
### `on_category_added`: a virtual on an axis is a configuration, not a vacancy
|
||||
|
||||
@@ -518,12 +522,129 @@ explicitly would be redundant in the file format and confusing in the UI.
|
||||
|
||||
`Label` is a high-cardinality per-row field (description, id, note): stored
|
||||
alongside the data so it shows up in record/drill views, but defaulting to
|
||||
`Axis::None` and excluded from the `MAX_CATEGORIES = 12` pivot limit and from
|
||||
`Axis::None` and excluded from the `MAX_CATEGORIES` pivot limit and from
|
||||
auto Row/Column assignment.
|
||||
|
||||
`Category::add_item` deduplicates by name and assigns IDs from a private
|
||||
counter, so external code cannot fabricate an `ItemId`.
|
||||
|
||||
### The limit is 24, and nothing is exponential in it (improvise-felo.1)
|
||||
|
||||
`MAX_CATEGORIES` counts **regular** categories only. It is `pub` in
|
||||
`model/types.rs` and re-exported as `improvise_core::model::MAX_CATEGORIES`,
|
||||
and it has one home: the import wizard's precondition reads it rather than
|
||||
keeping a copy. It had a copy until felo.1, a private `const` of its own, and
|
||||
raising the model's number alone would have left the wizard refusing a 13th
|
||||
category the model accepts. Tests that fill to the cap derive it from the
|
||||
constant; `add_category_max_limit` is the one place the number 24 is spelled.
|
||||
|
||||
**Why 12.** Nothing recorded says. The limit arrived in the initial commit
|
||||
with no comment, ADR or ticket behind it, and nothing in `context/` or `kb.pl`
|
||||
argues for it. The likeliest source is Lotus Improv, which this tool follows
|
||||
by name (`context/plan.md`) and whose worksheets are remembered as capped at
|
||||
12 categories. That is not verified here. It was never a cost bound. The user raised it on 2026-09-25: "12 categories is
|
||||
feeling small, we should increase to 24". An E*Trade import hit exactly 12,
|
||||
leaving no room for improvise-cgqt's disambiguating category.
|
||||
|
||||
**What scales with the number of categories**, read off the code:
|
||||
|
||||
- `CellKey` is a sorted `Vec` of pairs: building one is O(k log k), and
|
||||
comparing or hashing one is O(k).
|
||||
- The `DataStore` secondary index holds one `(category, item)` entry per
|
||||
coordinate per cell. That is O(k) per insert and O(cells × k) in memory.
|
||||
- Records mode has one column per regular category, so it is linear in k.
|
||||
- `cross_product` is the product of the item counts of the categories **on
|
||||
an axis**. That is exponential in what the user places on the axis, and the
|
||||
cap never bounded it: 12 categories of 4 items on rows is already 16M rows.
|
||||
`prune_empty` is what keeps it drawable.
|
||||
- `improvise-gp` bounds its encoded width separately (`MAX_FEATURES = 64`),
|
||||
so a wide fit is refused by name rather than slowed.
|
||||
|
||||
**Measured**, `cargo run --release -p improvise-core --example
|
||||
category_width_bench`, on an Apple M4 Max. Each category has 4 items and each
|
||||
record carries 2 measures. The pivot is one category on rows, `_Measure` on
|
||||
columns and the rest aggregated away. Figures are the best of 5 (the store
|
||||
build is the best of 3), and they held across two runs.
|
||||
|
||||
| categories | records | build store | pivot, every cell | records build | records draw |
|
||||
|---|---|---|---|---|---|
|
||||
| 12 | 2,500 | 26–30 ms | 1.6 ms | 79 ms | 1.6 ms |
|
||||
| 24 | 2,500 | 80–83 ms | 3.0–3.7 ms | 82 ms | 3.7 ms |
|
||||
| 12 | 5,000 | 54–57 ms | 3.2–3.9 ms | 302 ms | 3.1 ms |
|
||||
| 24 | 5,000 | 163–172 ms | 6.3–8.0 ms | 321 ms | 7.0 ms |
|
||||
|
||||
Doubling the width doubles the per-cell work and triples loading the store,
|
||||
which also builds and hashes CellKeys twice as long. The records-mode build is
|
||||
the outlier, and width is not its cause: it grows 4× when the **records**
|
||||
double, at either width. `RecordRow::group` finds each cell's row with a linear
|
||||
scan (improvise-sk32).
|
||||
|
||||
#### Coupling found
|
||||
|
||||
Raising the constant was used to look for code that assumes few categories.
|
||||
This inventory was taken at felo.1's merge of `gh/main` `4f38a35e`. Line
|
||||
numbers are dated, so re-derive them before citing.
|
||||
|
||||
**Reads the constant (fixed in felo.1).** The wizard's precondition, and the
|
||||
duplicate step's cap check that improvise-cgqt added (`separate_blocked`). A
|
||||
fixture that fills to the cap either derives it or fills until
|
||||
`add_category` refuses.
|
||||
|
||||
**Separate limits, left alone.** None of these is a category count:
|
||||
|
||||
- `MAX_ROW_HEADER_W = 24` (`improvise-protocol/src/render.rs`) is the widest
|
||||
one header level is drawn, in cells. It equals the new cap by coincidence.
|
||||
- `MAX_FEATURES = 64` (`improvise-gp/src/fit.rs`) is the encoded-width budget
|
||||
of one fit.
|
||||
- `CATEGORY_THRESHOLD = 20` (`import/analyzer.rs`) is the distinct-value count
|
||||
at which a text field becomes a `Label`.
|
||||
|
||||
**Degrades as categories grow.** These are not fixed here; each is a place
|
||||
where 24 is worse than 12. Most were already reachable at 12.
|
||||
|
||||
- **The TUI row header is the sum of its levels** (improvise-gtn7.5). In `src/ui/grid.rs`, around
|
||||
`row_header_width`/`data_area_width` (and
|
||||
`ui::viewport::compute_row_header_width`), each level takes 4 to 24 cells.
|
||||
Categories on Row therefore cost at least 4 cells each, and the data area
|
||||
saturates to zero width with no data drawn.
|
||||
- **Column headers take one line per level, but the keypress path does not
|
||||
count them** (improvise-gtn7.6). `App`'s `visible_rows` is `term_height − 8`, a fixed allowance
|
||||
that ignores the column levels, the group row and the page bar. So with
|
||||
several categories on Column, the cursor can scroll below the drawn grid.
|
||||
- **The page bar is one unclipped line** (improvise-gtn7.7). It is
|
||||
`GridWidget::render`'s `page_coords` join, drawn with a bare `set_string`
|
||||
that is not clipped to the inner rectangle, so a long bar can overwrite the
|
||||
block's right border. Default seating (`View::on_category_added`) sends every
|
||||
regular category after the second to Page. A 24-category import therefore
|
||||
puts 22 selectors on one line, clips most of them without a mark, and opens
|
||||
on a single slice.
|
||||
- **Side panels do not scroll** (improvise-gtn7.8). In `src/ui/panel.rs`, an item past
|
||||
`item_height` is not drawn, and the cursor can move onto it unseen. The
|
||||
category panel lists every category plus its expanded items.
|
||||
- **The wizard's field list does not scroll** (improvise-8jk6.1). Its `ReviewProposals` arm in
|
||||
`src/ui/import_wizard_ui.rs` breaks at the popup's bottom (the popup is at
|
||||
most 30 rows high), unlike the steps that use `scrolling`. About 22 fields
|
||||
fit, so the over-the-cap case cannot show every field it asks the user to
|
||||
fix.
|
||||
- **Loading the store is O(k log k) per cell and O(cells × k) in memory**
|
||||
(improvise-felo.2). It
|
||||
measured 3× from 12 to 24 (the table above).
|
||||
- **A fit declines more columns.** `unaffordable_categories` drops the widest
|
||||
category columns once the encoding passes `MAX_FEATURES`. A wide document
|
||||
is fit on fewer of its categories, and the fit says so.
|
||||
- **`cross_product` is exponential in the categories placed on one axis**, and
|
||||
`prune_empty`'s `has_value` matrix is rows × columns. The cap never bounded
|
||||
either. Virtualizing the grid is improvise-felo.3.
|
||||
- **Header repeat suppression costs O(levels) per position and level**
|
||||
(`render.rs::shows`). That is O(rows × levels²) per frame, which is small at
|
||||
24.
|
||||
|
||||
The records-mode build's quadratic (improvise-sk32) is not category coupling;
|
||||
it is named in the measurement paragraph above because this measurement found it.
|
||||
|
||||
The browser adapts where the TUI does not. `#tiles` and `#categories` scroll,
|
||||
and `web/shell.js` charges each column level to the extent.
|
||||
|
||||
### Seating takes the kind, because a name cannot carry it (improvise-cj4u)
|
||||
|
||||
`View::on_category_added` takes a `CategoryKind` beside the name. It used to
|
||||
@@ -1439,3 +1560,207 @@ 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.
|
||||
|
||||
|
||||
## 19. Group hierarchies: parents nest runs, and every group starts closed (improvise-felo.6)
|
||||
|
||||
`Group::parent` was parsed (`> Q1[2024]`), written, round-tripped and hashed,
|
||||
and nothing read it: `Q1` was never drawn inside `2024`. It is now the
|
||||
hierarchy.
|
||||
|
||||
### The user's rulings
|
||||
|
||||
- "I think hierarchies should start closed unless we somehow opt into a
|
||||
specific one opened to a certain depth (view config maybe?)" — and the
|
||||
reason: "in most cases, the closed by default start is preferable because it
|
||||
makes the grid more dense."
|
||||
- "Everything opens closed, including old files." There is no version gate: a
|
||||
file written before this change opens with its groups closed, and its
|
||||
`collapsed:` lines simply agree with the default.
|
||||
- "I think we'll need a depth for expanded, something like expanded(2): ...".
|
||||
- "expanded+collapsed in the same group is an error".
|
||||
|
||||
### The model
|
||||
|
||||
- **A group's chain** is the group and its ancestors, root first
|
||||
(`Category::group_chain`). An item's chain is its group's
|
||||
(`Category::item_chain`); an ungrouped item has none. A group's depth is its
|
||||
number of ancestors.
|
||||
- **A group covers every leaf beneath it.** `items_in_group` reads each item's
|
||||
chain, so `2024` covers the months of `Q1-2024` and `Q2-2024`. That is the
|
||||
whole change to evaluation: `evaluate_covering` already expanded a group
|
||||
coordinate through `items_in_group` (§14), so a year's fold is the fold over
|
||||
its months with no second path.
|
||||
- **An item may sit directly in a parent group, beside its child groups.**
|
||||
Chosen because nothing is simpler: the chain handles it (`Jun[H1]` after a
|
||||
run of `Q2` inside `H1` ends the `Q2` run and stays in `H1`), and refusing it
|
||||
would be a rule with nothing to protect. A date import never produces one.
|
||||
- **A cycle is refused on load, naming its members** (`Refusal::HierarchyCycle`,
|
||||
`persistence/parse.rs`). A cycle built in code cannot hang anything either:
|
||||
`group_chain` stops where it would repeat.
|
||||
- **There is no such thing as a dangling parent in this format.** A root group
|
||||
is declared *only* by being named as a parent: the writer emits `>` lines
|
||||
only for groups that have a parent, and nothing else can name `2024`. So
|
||||
"a parent that names no group" and "a root" are the same line, and refusing
|
||||
one refuses the other.
|
||||
|
||||
### Which groups are open
|
||||
|
||||
State lives on the `View`, in two places that cannot disagree about one group:
|
||||
|
||||
- `expand_depth: cat → N` — `expanded(N): Cat`, the category's top N levels
|
||||
open.
|
||||
- `group_folds: cat → group → GroupFold` — ONE map, so a group has at most one
|
||||
fold. `GroupFold::Collapsed` is `collapsed: Cat/Group`;
|
||||
`GroupFold::Expanded(N)` is `expanded(N): Cat/Group`, the group open and
|
||||
N-1 levels beneath it. Two sets would have made "both" representable, which
|
||||
is the state the user ruled an error
|
||||
(`doctrine(an_invariant_is_made_unrepresentable_not_validated)`).
|
||||
|
||||
`View::is_group_collapsed(cat, chain)` answers, in order:
|
||||
|
||||
1. the group's own fold, if it has one — so a `collapsed:` group inside an
|
||||
`expanded(N)` region stays collapsed, and an `expanded:` group below the
|
||||
category's depth opens;
|
||||
2. open, if an ancestor's `Expanded(n)` reaches it (the ancestor `k` levels up
|
||||
reaches it when `k < n`), or the category's depth does (`depth < N`);
|
||||
3. closed.
|
||||
|
||||
**`z` writes one fold, or none.** `toggle_group_collapse` flips the answer and
|
||||
records the flip as `Collapsed` or `Expanded(1)` — unless the flip lands on
|
||||
what the group would inherit from rule 2, in which case it removes the group's
|
||||
fold. So `z z` leaves the view as it was, and no sequence of toggles writes a
|
||||
file that names one group twice
|
||||
(`no_sequence_of_toggles_writes_a_conflicting_file`). Closing a group that
|
||||
carried `Expanded(3)` drops the 3: reopening it with `z` opens one level.
|
||||
|
||||
### The layout
|
||||
|
||||
`expand_category` walks each drawn item's chain. The **outermost** collapsed
|
||||
group in the chain folds everything beneath it: the row's tuple is that group's
|
||||
name, it folds once however non-contiguous its members are (§14), and the
|
||||
headers above it are drawn. The run markers move from item to item exactly as
|
||||
`remark` moves them after pruning — past the shared prefix of the two chains,
|
||||
end the deeper runs innermost-out, then open what differs — so an unpruned axis
|
||||
survives `prune_axis_items` marker for marker, and the enumeration in
|
||||
`group_membership` (now with a `NESTED` fixture: a non-contiguous quarter, an
|
||||
item directly in a half-year, an outer grouped category) asserts it.
|
||||
|
||||
A header carries its own `collapsed`, computed once in `expand_category`, so
|
||||
the frame projection and the frame hash read it rather than asking the view
|
||||
again with a chain they would have to rebuild.
|
||||
|
||||
### Persistence
|
||||
|
||||
`expanded(N): Cat`, `expanded(N): Cat/Group`, bare `expanded:` for N = 1, and
|
||||
the existing `collapsed: Cat/Group` (`improv.pest`, `expanded_line`). N is at
|
||||
least 1 by the grammar (`'1'..'9' ~ ASCII_DIGIT*`), so `expanded(0)` is a parse
|
||||
error rather than a value to explain. Two further decisions:
|
||||
|
||||
- **The writer keeps what the view holds.** An old file's `collapsed:` lines
|
||||
load as `Collapsed` folds and are written back verbatim, though they agree
|
||||
with the default. They are the user's explicit closes, and they survive a
|
||||
later `expanded(N): Cat` that would otherwise open those groups; dropping
|
||||
them on save would change what that later line means. A `z` round trip
|
||||
removes such a line, because `z` normalises (above).
|
||||
- **A category depth is never written bare.** `axis_line` is tried before
|
||||
`expanded_line`, so a category literally named `expanded` keeps its
|
||||
`expanded: row`. The price is that `expanded: row` meaning "category `row`,
|
||||
depth 1" would read as that axis line, so the writer always spells the
|
||||
category form `expanded(N): Cat`. The group form has a `/` and is never
|
||||
ambiguous.
|
||||
|
||||
A view naming one group with two different folds, or one category with two
|
||||
depths, is refused on load, naming the view and the group
|
||||
(`Refusal::FoldConflict`). The same line twice is not a conflict.
|
||||
|
||||
The grammar-walking generator emits cycles and conflicts with ordinary
|
||||
probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a
|
||||
`Refusal` — a typed error, so it cannot accept a parse failure by accident.
|
||||
|
||||
### Known limits
|
||||
|
||||
- The TUI draws nested headers exactly as it draws any other: one line per
|
||||
header, not indented by depth, and the column band shows each column's
|
||||
innermost group only. Both are pre-existing presentation choices; the frame
|
||||
carries `depth` for the day a front end wants to indent.
|
||||
- Two `>` lines giving one group two different parents: the last wins, as it
|
||||
did before this change. That makes the hierarchy a function of line order.
|
||||
|
||||
## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
|
||||
|
||||
`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure
|
||||
functions of the display and the allocator's own history. Nothing calls them
|
||||
yet: the creation sites move onto them in the re-key step (improvise-cq1q.4).
|
||||
Each rule traces to a numbered answer in the ADR's "What the user decided".
|
||||
|
||||
**Case folding is full Unicode folding, and it comes from `unicase`.**
|
||||
`str::to_lowercase` is not folding: `ß` lowercases to itself and is then
|
||||
stripped, so `Straße` would slug to `strae` (answer 26). The same goes for `ſ`
|
||||
(long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no
|
||||
dependencies, so it passes the wasm gate. Its `to_folded_case` applies the
|
||||
C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` →
|
||||
`masse`. Most of what folding produces is stripped anyway: only an ASCII
|
||||
letter survives step 3. Deriving that ASCII-producing subset of the table by
|
||||
hand was rejected. It is a second copy of the Unicode data, and nothing would
|
||||
say when it drifted.
|
||||
|
||||
**A "space" is U+0020.** Answer 18 says "replace space with `_`", and answer 21
|
||||
says runs collapse. A tab or a no-break space is not a space here, so it is
|
||||
stripped by step 3. Only a run of U+0020 becomes one `_`. An underscore the
|
||||
display already had is never merged with the one a space run makes. So `a_ b`
|
||||
slugs to `a__b`: the steps are applied as written, and no extra
|
||||
underscore-collapsing step exists. A trailing run survives, so `East ` slugs
|
||||
to `east_`. Only *leading* underscores are removed (step 4).
|
||||
|
||||
**The allocator records every id it admits and releases none.** That is the
|
||||
whole of retirement (clause 4, "within a session a removed id is never handed
|
||||
out again"). No release operation exists, so a removed id cannot be reissued
|
||||
by construction; no check refuses it. The counter starts at 1, is shared by
|
||||
every kind, and advances each time a suffix is tried, including a candidate
|
||||
that turns out to be taken by a hand-written id (answers 28 and 29). So
|
||||
`east-7` may follow `east` directly, and no order is enforced among suffixes.
|
||||
**The walk past taken candidates is a loop, never recursion.** A
|
||||
new-version file saved from a non-ASCII import holds `item-1` … `item-N`, and
|
||||
the counter restarts at 1 on load, so one generation walks all N. The first
|
||||
version recursed once per candidate, not in tail position, and aborted the
|
||||
process on a stack overflow: in debug at N ≈ 11,000, in release at 50,000.
|
||||
An abort, not a panic, so the TUI lost unsaved edits (Codex P2 on PR #318).
|
||||
An empty slug always takes a suffix (`category-1`), even when the bare kind
|
||||
name happens to be free, because answer 16(e) spells the result
|
||||
`<kind>-<n>`.
|
||||
|
||||
**`claim` is the only door for a hand-written id.** It checks, in this order:
|
||||
the alphabet (non-empty, `a`–`z`, `0`–`9`, `-`, `_`), the reserved leading
|
||||
`_` (only `_index`, `_dim` and `_measure` may carry one, per answer 23, and
|
||||
the match is exact: `_indexer` and `_measure-1` are Reserved, since "no other
|
||||
id may start with `_`"), and
|
||||
duplicates. It never renames. Refusing is the loader's job, and the loader
|
||||
must name both lines of a duplicate; `ClaimError` carries only the id, so the
|
||||
loader keeps the line numbers. The virtual views `_records` and `_drill` are
|
||||
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
|
||||
with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
|
||||
|
||||
## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3)
|
||||
|
||||
`Category`, `Item` and `View` each carry a private `display`. It starts equal to
|
||||
the key and changes only through `set_display` / `set_item_display`, which move
|
||||
no key. Until the re-key (improvise-cq1q.4), nothing in production calls those
|
||||
setters, so `display == name` everywhere and behaviour does not change. The
|
||||
split exists so that the re-key changes what goes *into* the key, and nothing a
|
||||
person sees moves with it.
|
||||
|
||||
**The key travels beside the display, never in place of it.** `CategoryRow`,
|
||||
`TileEntry` and `ViewSummary` each gain a `display` field. The key field
|
||||
(`name`, `category`) stays exactly what it was, because both front ends compose
|
||||
commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of
|
||||
those strings. Carrying the display *in* the key field would make every
|
||||
command name a key that does not exist once the two differ. That is the gap
|
||||
improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The
|
||||
active-view marker in both view panels still compares keys for the same reason.
|
||||
|
||||
**What this first slice covers:** the category panel's headers, the tile bar and
|
||||
the view list, in both front ends. Item rows, the grid frame, the page bar,
|
||||
records-mode labels, CSV export headers, status messages and the view title
|
||||
still show keys. They follow in later slices of this step, because the grid's
|
||||
label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their
|
||||
display has to be added at projection time rather than in place.
|
||||
|
||||
@@ -181,6 +181,15 @@ The general form: when a check misses a defect, add the check that could have
|
||||
seen it rather than only fixing the defect, and ask what *question* the
|
||||
existing check was answering.
|
||||
|
||||
**`site/` is build output and is not tracked** (improvise-1xfb.4). It is
|
||||
`web/assemble.sh`'s default `OUT_DIR`, and `site/docs` is this script's.
|
||||
Every reader builds its own copy from source: CI's `build` job runs
|
||||
`nix build .#site`, which assembles into the store; the harness assembles
|
||||
into `harness/serve/`; `scripts/build-web-release.sh` assembles into a
|
||||
temporary stage. For a while 27 generated pages under `site/docs` were
|
||||
committed by accident. They went stale, still saying 12 categories after the
|
||||
cap was raised, and nothing read them.
|
||||
|
||||
## 5. Why a node script and not a generator
|
||||
|
||||
The landing page is hand-written HTML with no build step and one pinned CDN
|
||||
|
||||
+371
-12
@@ -165,7 +165,7 @@ share, which is why the existing suite was silent — the same shape as the
|
||||
`Category::kind` rather than the emitted text.
|
||||
|
||||
The consequence was a lost document, not a lost flag. `Label` sits outside
|
||||
`MAX_CATEGORIES = 12` on purpose, and the import wizard's own precondition
|
||||
`MAX_CATEGORIES` on purpose (12 then, 24 since improvise-felo.1), and the import wizard's own precondition
|
||||
scores a label as zero, so it will happily build twelve categories plus a
|
||||
label. On load every label came back `Regular`, the thirteenth was refused, and
|
||||
`parse_md` failed: **any document where regular + label exceeded twelve was
|
||||
@@ -218,6 +218,32 @@ times. The probe was removed rather than kept: its rate depends on proptest's
|
||||
internal RNG, so as a permanent assertion it would fail on a dependency bump
|
||||
for a reason unrelated to the format.
|
||||
|
||||
### Folds are view lines: `collapsed:` and `expanded(N):` (improvise-felo.6)
|
||||
|
||||
Groups start collapsed, so a view persists what it opens. `expanded(N): Cat`
|
||||
opens a category's top N levels, `expanded(N): Cat/Group` opens that group and
|
||||
N-1 levels beneath it, a bare `expanded:` is N = 1, and `collapsed: Cat/Group`
|
||||
keeps its old meaning. The semantics, the precedence and the refusals are
|
||||
`context/code/core.md` §19; what is particular to the format:
|
||||
|
||||
- **No version bump.** The line is additive, and the user ruled that old files
|
||||
open closed like new ones, so `v2025-04-09` still reads every file it did.
|
||||
- **N >= 1 is grammar, not validation**: `depth_number = @{ '1'..'9' ~
|
||||
ASCII_DIGIT* }`. Spelled with a `Range` and `ASCII_DIGIT` rather than
|
||||
`ASCII_NONZERO_DIGIT`, because the generator knows the first two and would
|
||||
emit nothing for the third — `expanded(): ` — a file its own grammar refuses.
|
||||
- **`axis_line` comes before `expanded_line`** in `view_entry`, so a category
|
||||
named `expanded` keeps its axis line, and the writer never spells a category
|
||||
depth bare (`expanded: row` would be that axis line).
|
||||
- **Two refusals the grammar cannot express** — a cycle among `>` lines and one
|
||||
group given two folds — are a typed `Refusal`, which `generated_file_parses`
|
||||
accepts and nothing else does. The generator produces both at ordinary rates.
|
||||
- **The generator reaches the new rule**, asserted by
|
||||
`the_generator_writes_expanded_lines_with_and_without_a_depth` over 512
|
||||
deterministic files. Unlike the kind-line probe above, it asserts that each
|
||||
spelling occurs at all, not a rate, so a proptest bump fails it only if the
|
||||
walk stopped reaching the rule.
|
||||
|
||||
### The tree walkers are infallible by construction
|
||||
|
||||
Every `build_*` in `parser.rs` operates on a `Pair` pest has already
|
||||
@@ -587,6 +613,59 @@ different row order on screen, and a different detected date format, since
|
||||
varied. The `HashSet` in `analyze_records` is there to deduplicate and **never
|
||||
to iterate**.
|
||||
|
||||
### A date column imports as ONE hierarchical category (improvise-8jk6.3)
|
||||
|
||||
The user: "I think dates should usually be one category and we can use
|
||||
formulas to crosstab." So the default `DateShape::Hierarchy` builds one
|
||||
category named after the column, where the old import built up to three flat
|
||||
ones beside it (`Date_Year`, `Date_Quarter`, `Date_Month`):
|
||||
|
||||
- **Its items are the finest chosen component**, spelled so they stay unique
|
||||
across years: `2024-01`, `2024-Q1`, `2024`, which are
|
||||
`extract_date_component`'s existing spellings. A month is never `Jan`.
|
||||
- **The coarser chosen components are its groups**, coarsest first
|
||||
(`DateComponent::COARSE_TO_FINE`): a month sits in its quarter, and the
|
||||
quarter's `>` line names its year. A level left unchosen is skipped rather
|
||||
than drawn empty, so Year + Month groups months straight into years.
|
||||
- **It opens folded**, one row per year, with nothing to set, because every
|
||||
group starts closed (`context/code/core.md` §19).
|
||||
- **It costs one of the category slots**, not up to four, and
|
||||
`proposed_category_count` counts it that way.
|
||||
- **The raw dates are gone from that category.** The day is not a component, so
|
||||
two records of one month land on one cell and the collision step asks, as it
|
||||
does for any other collision. `Split` keeps the day.
|
||||
- **A value the detected format cannot read keeps its raw text** as an
|
||||
ungrouped item, with a warning naming the column and the row count, rather
|
||||
than being dropped. The warning is separate from the split shape's, because
|
||||
that one reports an absent coordinate and this one a coordinate that is there
|
||||
and ungrouped. **Unless its spelling is one the hierarchy generates**
|
||||
(improvise-d0n6): a raw `2024-01` or `2024` is the same name as the generated
|
||||
month or year, so it would join the grouped item and the warning would be
|
||||
false. The build **refuses**, naming the column and the spellings and pointing
|
||||
at the split shape. That follows the collision rule below: where two records
|
||||
would silently be one thing, the user is asked and nothing is defaulted. A
|
||||
fallback to the split shape was rejected as a silent change of the user's
|
||||
choice, and a renamed raw item as altering the data. The check is
|
||||
`Plan::spelling_clash`.
|
||||
- **Item order is first-seen, not chronological.** Order is a correctness
|
||||
property here (gotcha 18), and a group need not be contiguous, so a CSV that
|
||||
is not in date order still nests correctly. It is just drawn in the order
|
||||
it was read.
|
||||
|
||||
`DateShape::Split` is the old behaviour, unchanged, and stays a choice. `s` on
|
||||
the wizard's date step toggles the field under the cursor, and the header line
|
||||
for each field says which it will get ("one category: Year > Quarter > Month"
|
||||
or "split: Date, Date_Year, …"). It is worded once, in
|
||||
`project.rs::date_shape_sentence`. Headless, `--split-date <field>` does the
|
||||
same, and `--extract` keeps its meaning: which components to build.
|
||||
|
||||
**Crosstabs by formula are not available yet.** A formula can name a month
|
||||
(`SUM(Amount WHERE Date = "2024-01")`, `Date IS "2024-01"`), but a `WHERE` or
|
||||
`IS` naming a GROUP matches no record: both compare the coordinate to the name
|
||||
exactly. `SUM(Amount WHERE Date = "2024")` is an empty sum, and `Date IS "2024"`
|
||||
is 0 on every month of 2024. That was measured on this branch and reported for
|
||||
filing, not built here.
|
||||
|
||||
### The ambiguous-date heuristic reads American-first, deliberately
|
||||
|
||||
`01/02/2025` parses under both `%m/%d/%Y` and `%d/%m/%Y` and nothing in the data
|
||||
@@ -632,6 +711,256 @@ one proves nothing.
|
||||
backend rather than only a file (improvise-fuj); the path-taking wrappers remain
|
||||
for the native CLI.
|
||||
|
||||
### A CSV field is text, and the field's role reads it (improvise-8jk6.4)
|
||||
|
||||
The parser used to type every field as it read it: text that parsed as an
|
||||
`i64` or a finite `f64` became a `Value::Number`. That is a lossy step, and it
|
||||
ran before anyone knew what the column was for. A column the user then forced
|
||||
to Category (`--category`, or the wizard's `c`) took its item names from the
|
||||
number's display, not from the file. So commit `00565796` became item `565796`,
|
||||
and `220944e2` was read as scientific notation and became `22094400.0`. The
|
||||
repo-stats pipeline (improvise-1xfb.5.1) found it.
|
||||
|
||||
CSV has no types. Whether `00565796` is a number depends on whether its column
|
||||
is a measure, and the parser cannot know that. So the parser now yields every
|
||||
non-empty field as a `Value::String` of its text (blank is still `Null`), and
|
||||
two readers apply the old numeric rule, `csv_parser::number_in`, only where a
|
||||
number is wanted:
|
||||
|
||||
- **Analysis** proposes a kind from a typed copy of the records
|
||||
(`Scalars::typed_record`), so a numeric CSV column is still proposed as a
|
||||
Measure, exactly as before.
|
||||
- **A measure** takes its value through `Scalars::number`.
|
||||
|
||||
A category or a label is named from the text itself, so it is verbatim.
|
||||
|
||||
`ImportPipeline` carries the reading as a private `scalars: Scalars`:
|
||||
`Typed` for JSON and `Text` for CSV. JSON cannot share the text rule, because
|
||||
a JSON string is text by the file's own say. `"02139"` in JSON is a zip code,
|
||||
and reading it as a number would propose it as a Measure. A JSON number
|
||||
forced to Category has no source text to keep, because serde_json does not
|
||||
keep one. It is named by `Value`'s own display, which is what writing it back
|
||||
as JSON would say: `42`, `1.5`, and `1000.0` for `1e3`.
|
||||
|
||||
**The parser returns `CsvRecords`, not `Vec<Value>`.** It derefs to
|
||||
`[Value]`, so a caller can read it. It converts to a `Vec` only inside the
|
||||
crate, and the only public way into a pipeline is `ImportPipeline::from_csv`.
|
||||
This is not caution for its own sake. When the parser first changed, eleven
|
||||
tests in four files failed. Every one handed parser output to
|
||||
`ImportPipeline::new`, the JSON constructor, which would read every column as
|
||||
text and propose `Revenue` as a Category. Production had the same shape at all
|
||||
three CSV entry points: `main.rs`'s single file, `ImportPipeline::from_merge`
|
||||
(the merge path, from PR #314), and `ImportJsonHeadless`. `from_merge` merged
|
||||
in with `Self::new` and now calls `Self::from_csv`. Now that mistake does not
|
||||
compile. Two holes remain, and
|
||||
both take a deliberate step. `ImportPipeline::records` is still a public
|
||||
`Vec<Value>`, and a test helper once copied it out of a CSV pipeline into a
|
||||
JSON one. And `CsvRecords` derefs to a slice that `.to_vec()` will copy.
|
||||
|
||||
### An import refuses what it cannot keep (improvise-8jk6.5, improvise-8jk6.6)
|
||||
|
||||
Two failures used to end in an import that saved and reported success.
|
||||
|
||||
**A formula that does not parse.** `build_model_with_warnings` ran
|
||||
`if let Ok(formula) = parse_formula(..)`, so a bad `--formula` vanished. The
|
||||
headless run then saved and exited 0, and the wizard kept the bad text in its
|
||||
list and dropped it the same way at build. Both now go through one
|
||||
function, `import_formula`, whose error names the formula and carries pest's
|
||||
parse error: `Formula error in 'Y = x +': …`. The prefix matches the TUI
|
||||
formula minibuffer's `Formula error:`.
|
||||
|
||||
- `ImportPipeline::parsed_formulas` parses them all. If any fails, it refuses
|
||||
and names **every** formula that failed, one per line (a message the user
|
||||
needs is never truncated). The build calls it before creating anything, so a
|
||||
refusal writes nothing.
|
||||
- The wizard checks at `confirm_formula`, the one place the user can still fix
|
||||
the text (gotcha 22). A formula that does not parse stays in the buffer with
|
||||
the error as the step's message, and never enters the list.
|
||||
|
||||
**Nothing kept.** The one skip left after improvise-8jk6.2 is a record that
|
||||
is not a JSON object. When every record is skipped, there are no fields, so
|
||||
there are no proposals and no categories. The build did refuse that case, but
|
||||
by accident, with "At least one category must be accepted". That tells the
|
||||
user to change a field's kind when there are no fields. The ticket's own repro
|
||||
(`[1, 2, "three"]`) already exited 1 on the lpzu base, so the headline did not
|
||||
reproduce there. What did reproduce was the reason. `nothing_to_import` now
|
||||
states the rule and runs before the category check: `Nothing to import: all 3
|
||||
records were skipped as not JSON objects`, or `the source has no records`. The
|
||||
wizard asks it first on `ReviewProposals`, where another array path can still
|
||||
be chosen.
|
||||
|
||||
One case meets PR #314's "a header-only file is still a file". A folder whose
|
||||
CSVs are **all** header-only has no records, only sources. Built from
|
||||
`from_merge`'s seeded proposals alone, it would save `File` items, empty
|
||||
columns and one empty records view per file. It is refused here, because it
|
||||
kept no record. A header-only file *beside* files with rows is unaffected,
|
||||
since some record was kept. The user confirmed this on PR #321: "Refuse (as
|
||||
#321 does)".
|
||||
|
||||
**A partial skip stays a warning.** Some records kept and some skipped still
|
||||
builds, exits 0, and warns with a count: `Skipped 1 of 3 records that were not
|
||||
JSON objects`. The argument: a non-object in an array of records is not a
|
||||
record that failed to import. It is structurally not a record, such as a
|
||||
trailing `null` or a stray scalar in a hand-edited file. Every record that
|
||||
*is* a record was kept, and the count is on stderr for a script to read.
|
||||
Making it fatal would fail every import that carries one stray element.
|
||||
Making it a distinct non-zero exit that still writes gives a contract where
|
||||
`import … && next` stops even though the file was written.
|
||||
|
||||
The user ruled on PR #321: "Add a --strict flag". The default stays exit 0
|
||||
with the counted warning, pinned by
|
||||
`an_import_that_skips_some_records_warns_with_a_count_and_still_writes`.
|
||||
`import --no-wizard --strict` turns any skipped record into a refusal:
|
||||
`--strict: Skipped 1 of 3 records that were not JSON objects`, exit 1.
|
||||
|
||||
**A strict refusal writes nothing.** A refusal that still wrote the file would
|
||||
be the half-met contract argued against above: `&&` stops while the output
|
||||
sits there, and a script that checks only the file gets a model that `--strict`
|
||||
existed to prevent. So the check runs in `run_headless_import` before the
|
||||
build. It reads `ImportPipeline::skip_warning`, the same function the build's
|
||||
warning comes from, so the two cannot disagree about what was skipped.
|
||||
"Skipped" here means a record that is not an object, the only skip left after
|
||||
8jk6.2. An unparsed date or a short CSV row is a warning about a record that
|
||||
was kept, and `--strict` does not refuse it.
|
||||
|
||||
### A folder of CSVs is one sparse model, with a view per file (improvise-lpzu)
|
||||
|
||||
`improvise import <folder>` exists for improvise-1xfb.5.1. code-maat writes one
|
||||
CSV per analysis into a directory, and the schemas differ: coupling has
|
||||
`entity,coupled,degree,average-revs`, age has `entity,age-months`, and
|
||||
abs-churn has `date,added,deleted,commits`. The user ruled (2026-09-25):
|
||||
"Records are already sparse: the model can just leave null/empty values for
|
||||
columns not in a given CSV file." So files with different headers merge into
|
||||
**one** model. It is not refused, and it is not split into one model per schema.
|
||||
|
||||
**A folder argument means exactly its `*.csv` files.** `expand_import_paths`
|
||||
takes the arguments in order and replaces each folder with its `*.csv`
|
||||
entries. The extension check is case-insensitive, the listing is not recursive,
|
||||
and the order is `Storage::list`'s, which is sorted by contract. Then the
|
||||
existing path runs, with one difference: **a folder always merges**. Two or more
|
||||
files merge through `merge_csvs_from` with a `File` category, and so does a
|
||||
folder holding a single CSV. That gives the one file its `File` item and its
|
||||
per-file view.
|
||||
|
||||
The single-CSV rule is **the user's ruling (2026-09-25)**, which replaced this
|
||||
PR's first answer, "`import dir/` is exactly `import dir/*.csv`". Their words:
|
||||
"yes, we might want to have some kind of update import eventually (don't plan
|
||||
this yet, but we'd want file identity for this)". A folder names a collection
|
||||
that may grow, so a document built from it should record which file each
|
||||
record came from from the start.
|
||||
|
||||
**A single CSV named directly, not through a folder, still imports alone,
|
||||
without `File`.** That is deliberate. Naming one file asks for that file, not
|
||||
for a collection, and the shipped recipes (`examples/demo.improv` from
|
||||
`examples/demo.csv`) and every existing single-file script would otherwise gain
|
||||
a `File` category and a second view. File identity for update-import can be
|
||||
asked for by naming a folder. The test is
|
||||
`a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not`.
|
||||
|
||||
Apart from that, a folder still behaves as the glob would, and four cases follow
|
||||
from that rather than being chosen one by one:
|
||||
|
||||
- **Non-CSV entries are skipped**, not refused, because a glob skips them. This
|
||||
includes the output `.improv`, which a second run finds in the folder.
|
||||
- **A folder with no CSV at all is refused**, naming the folder. A glob that
|
||||
matches nothing is an error too, and importing nothing would write an empty
|
||||
document.
|
||||
- **Files and folders mix freely**, in argument order.
|
||||
- **A file named twice is read twice.** For example, `dir/a.csv dir/` reads
|
||||
`a.csv` twice, and its records collide with themselves. The headless path
|
||||
refuses that like any other collision. Nothing deduplicates, because the
|
||||
shell glob would not either.
|
||||
|
||||
The listing goes through `Storage::list` so the browser host can reuse it
|
||||
(gotcha 2). `Storage` knows documents, not folders, so **whether an argument
|
||||
is a folder is the caller's question**. The CLI answers it at the edge with
|
||||
`Path::is_dir` and passes an `ImportPath::{File, Folder}`. An OPFS host would
|
||||
answer it from its own UI.
|
||||
|
||||
**Sparse merge.** A record from a file without some column has no key for it,
|
||||
so `Plan::place` gives it no coordinate there (improvise-8jk6.2 above), and no
|
||||
measure cell. Nothing is fabricated: no `0` and no `""`. The analyzer already
|
||||
classified each column over the values present (improvise-l1f5), so a column
|
||||
only one file has is judged by that file's values alone. The category cap
|
||||
counts the **union** of every file's categories, header-only files included
|
||||
(see the next paragraph). Rows from different files cannot collide, because
|
||||
`File` differs. One caveat on "judged by its values": a JSON `""` is not yet
|
||||
dropped by the analyzer the way `null` is, so a JSON numeric column holding
|
||||
`""` is proposed as a Label (improvise-8jk6.11). CSV is unaffected, because
|
||||
its parser turns a blank field into `null`.
|
||||
|
||||
**A header-only file is still a file** (the Codex P2 on PR #314). code-maat
|
||||
writes a header and no rows whenever nothing passes its threshold, so
|
||||
`coupling.csv` is often exactly that. The pipeline is analyzed from *records*,
|
||||
and such a file has none, so it had no `File` item, its columns never reached
|
||||
the model, and its view filtered to an item that did not exist.
|
||||
`ImportPipeline::from_merge` therefore **seeds from `Merged::sources`**:
|
||||
|
||||
- the `File` proposal's items are the sources' items, in file order, so every
|
||||
file has its item whether it has rows or not;
|
||||
- a source column that no record has a value in is proposed as a **Label**.
|
||||
That is the analyzer's own verdict for an all-null column (improvise-l1f5),
|
||||
and a column with no values is exactly that: nothing can be classified, so it
|
||||
takes no pivot slot and does not count toward the category cap until the user
|
||||
reclassifies it in the wizard or with `--category`/`--measure`.
|
||||
|
||||
This is also the file identity the user wants kept for a future update-import.
|
||||
|
||||
**A file with no measure column gets a records view** (improvise-8jk6.13). A
|
||||
record with no measure makes no cell, so such a file contributes items and no
|
||||
data. Its view had nothing to page `_Measure` to, and paged onto another file's
|
||||
measure, which the same view hides; the cell there was a `Record`, editable,
|
||||
under this file's `File`. Now `source_view` gives such a file the records
|
||||
layout (`view::records_steps`, the one home of what a records view is) with
|
||||
`File` still on Filter at the file's item. It draws zero rows, because there
|
||||
are no cells. A header-only file whose columns are
|
||||
all Labels is the same case. In records mode the columns are the whole model's
|
||||
categories and measures, not just this file's: records mode has no per-view
|
||||
column filter. With no rows that is cosmetic.
|
||||
|
||||
**Each file gets a view** (the user, 2026-09-25: "dir import should do this:
|
||||
one view filtered by the file column and projected to the columns in that
|
||||
file"). `merge_csvs_from` returns a `Merged` whose `sources` list each `File`
|
||||
item with the columns its header named. Two files with one stem share an item,
|
||||
so their columns are unioned. The pipeline carries `sources`, and the build
|
||||
adds one view per source after the merged `Default` view, which stays the
|
||||
initial view. Each per-file view:
|
||||
|
||||
- **Is named after the file's stem.** A name already taken, including the
|
||||
virtual `_Records` and `_Drill`, gets ` 2`, ` 3` and so on. A view named
|
||||
`_Drill` would never be written and would be dropped on load.
|
||||
- **Puts `File` on `Axis::Filter`, fixed at that file's item.** Filter and
|
||||
not Page, because the view *is* that file's projection. Paging to another
|
||||
file would show that file's records through this file's columns and hidden
|
||||
measures, which is wrong on every page but one. Filter constrains without
|
||||
being navigable (gotcha 27). Until now it appeared only in the never-saved
|
||||
`_Drill` view, but the `.improv` grammar has always round-tripped it.
|
||||
`per_file_views_survive_a_round_trip_and_repeat_byte_for_byte` proves that on
|
||||
a saved per-file view, and `a_saved_per_file_view_projects_only_its_file`
|
||||
proves the shared `grid_in` projection, which both the TUI and the browser
|
||||
draw from, carries it as a fixed coordinate with nothing to cycle.
|
||||
- **Seats the file's own categories** (its columns, plus date parts derived
|
||||
from them) through `View::on_category_added`, in model order: the rule a
|
||||
single-file import uses. Every other category goes on `None`. The file's
|
||||
records lack those, so nothing splits.
|
||||
- **Puts `_Measure` on Page**, as `Workbook::new` does, **with other files'
|
||||
measures hidden** and the page selection on the file's first measure. A
|
||||
formula target comes from no file, so it stays visible. A file with no measure
|
||||
gets a records view instead (above).
|
||||
- **Is never named like another view from the same import.** `Default.csv` and
|
||||
`Default 2.csv` become `Default 2` and `Default 2 2`; without the same-batch
|
||||
check both would be named `Default 2` and one view would silently replace the
|
||||
other.
|
||||
|
||||
A per-file view is made only while `File` is an accepted category. A user who
|
||||
skipped or reclassified it has no filter to build views from.
|
||||
|
||||
Two things this does not do. Page cycling still steps onto a hidden item and
|
||||
draws an empty grid: `[`/`]` in a per-file view can reach another file's
|
||||
measure (improvise-yd2t; the Default view already behaved this way). And a
|
||||
CSV with its own `File` column has it overwritten by the merge's, which is the
|
||||
existing behaviour of `merge_csvs`.
|
||||
|
||||
### The wizard's back path is a history stack
|
||||
|
||||
`advance` pushes the step it leaves; `previous` pops it. Keeping the path
|
||||
@@ -643,8 +972,9 @@ Preconditions live in `validation_error()` and are checked **on the step where
|
||||
the user can still fix them** — never deferred to `build_model` after the final
|
||||
confirm. Two gates earn that: too many categories (improvise-mzv) and no
|
||||
category at all (improvise-gfq); both are fixable with `c` at `ReviewProposals`,
|
||||
and both used to refuse only at the last keystroke. `MAX_CATEGORIES` here mirrors
|
||||
the model's private limit and counts regular categories only.
|
||||
and both used to refuse only at the last keystroke. `MAX_CATEGORIES` here is the
|
||||
model's own constant, which counts regular categories only. Until improvise-felo.1
|
||||
it was a private copy (core.md §9).
|
||||
|
||||
`NameModel` is terminal for `advance`: leaving it is a **build**, not a step
|
||||
change. `build()` parks the workbook in `built` on `Done`; a build failure leaves
|
||||
@@ -657,10 +987,35 @@ and a blank string are **absences, not names**; stringifying them used to invent
|
||||
an item literally called "null" (improvise-ych). Every other value keeps its
|
||||
printed form.
|
||||
|
||||
A record missing a value for an accepted Category field has no place in the grid
|
||||
and is dropped; `build_model_with_warnings` counts it against the *first*
|
||||
category it is missing and reports the total, because without a warning that
|
||||
loss is invisible in a thousand-row import (improvise-5j9).
|
||||
**A record with no value for a category keeps its cell, without that
|
||||
coordinate** (improvise-8jk6.2). Records are sparse: a `CellKey` names only the
|
||||
coordinates it has. So a missing field, a `null` and a blank string all mean
|
||||
"no coordinate here". None of them makes an item: not `"null"`, not `""`, and
|
||||
no synthetic placeholder. The same rule covers labels, which used to become an
|
||||
item named `""`. The user's words, 2026-09-25: "this limit in import should be
|
||||
fixed, it dates to when this wasn't sparse." That limit was improvise-5j9's
|
||||
rule: such a record "has no place in the grid" and was dropped, counted and
|
||||
reported as `Skipped N of M records missing category values`. 5j9's warning
|
||||
made the loss visible, and the loss is now gone, so the warning went with it.
|
||||
The only record still skipped is one that is not a JSON object.
|
||||
|
||||
Where such a record shows up is decided by the axes, not by the import. The
|
||||
test is `a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid`:
|
||||
|
||||
- **The category on Row or Column:** no position holds the record. Its other
|
||||
coordinates may still draw a row, but the cell under the category's items is
|
||||
empty, and the column total leaves it out.
|
||||
- **The category on Page:** no selection shows it.
|
||||
- **The category on None:** it is aggregated like any other record.
|
||||
- **Records mode:** its row shows a blank cell in that category's column.
|
||||
- **`.improv`:** its Data line omits the category, and it round-trips.
|
||||
- **Drill:** a drill that does not constrain the category reaches it.
|
||||
|
||||
**No "(none)" position, by the user's decision (2026-09-25).** They were asked
|
||||
whether Row, Column or Page should give such records a place of their own. Their
|
||||
answer: "I think leaving it out for now is fine, maybe a UI element somewhere
|
||||
that shows the number of records underneath the current view?" That indicator
|
||||
is improvise-gtn7.10, and it is not an import concern.
|
||||
|
||||
### Colliding records are a question, not a default (improvise-cgqt)
|
||||
|
||||
@@ -677,7 +1032,9 @@ made it choose too. It chose `Refuse` and points at `:import`.
|
||||
|
||||
**Detection comes before the build and shares the build's placement.** Where a
|
||||
record lands is `Plan::place`: its categories, then its date parts, then its
|
||||
labels, then its measure values, or a reason it is dropped. `collisions()` and
|
||||
labels, then its measure values. Only a record that is not an object is
|
||||
dropped, since a missing value is no coordinate rather than a reason to drop
|
||||
(improvise-8jk6.2). `collisions()` and
|
||||
`build_model_with_warnings` both call it, so the question the wizard asks and
|
||||
the thing the build does cannot disagree. Neither has a second walk of the
|
||||
record. **A collision is a cell that more than one record writes**, meaning a
|
||||
@@ -735,14 +1092,16 @@ is already a category. `suggested_duplicate_category` lives on the pipeline, so
|
||||
the CLI's `separate` uses the same rule.
|
||||
|
||||
**At the category cap, Separate is shown and disabled.** The reported import
|
||||
already had 12 regular categories, which is `MAX_CATEGORIES`. Option (b) adds a
|
||||
had 12 regular categories, which was `MAX_CATEGORIES` when this was written.
|
||||
Option (b) adds a
|
||||
**regular** category, and the user declined a hidden virtual one. So at the cap
|
||||
the option is drawn "(unavailable)" with its reason and remedy: go back and
|
||||
Skip a column or make it a Measure, or choose Sum. The reason is decided in
|
||||
`ImportWizard::duplicate_options`, so every front end words it the same way.
|
||||
This is the conservative reading, and it is **an open question for the user**:
|
||||
lifting the cap for this one category, or counting it outside the cap, is a
|
||||
model-layer decision this ticket did not take.
|
||||
This was the conservative reading, and it left an open question for the user.
|
||||
**The user answered it on 2026-09-25 by raising the cap to 24**
|
||||
(improvise-felo.1), so "keep separate" is available for the reported import
|
||||
now. The option is still disabled for any import that fills the new cap.
|
||||
|
||||
**Headless paths refuse by default.** `improvise import --no-wizard` has
|
||||
nobody to ask, so `--on-duplicate refuse|sum|separate` defaults to `refuse`.
|
||||
|
||||
+110
-7
@@ -1311,8 +1311,8 @@ re-derived from `kind`, so the two ends cannot disagree about what "Measure
|
||||
rather than re-exported, because this crate must not depend on `improvise-io`
|
||||
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
|
||||
still be able to name the step it is on. `WizardFieldKind` is the semantic
|
||||
half of a colour decision — the terminal mapping lives in `draw.rs`, exactly
|
||||
as `AxisColor`/`axis_fg` does (gotcha 26).
|
||||
half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the
|
||||
terminal mapping is `draw.rs::face_style`.
|
||||
|
||||
### `Projection` carries no multi-widget variant
|
||||
|
||||
@@ -1474,6 +1474,22 @@ sequence. The header's level also gives a client the group's category,
|
||||
the dead field improvise-7xyx removed: `level` is read by `groups()`, and the
|
||||
category name is not carried twice.
|
||||
|
||||
**Within one category runs nest by `depth` (improvise-felo.6).** A group's
|
||||
parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME
|
||||
level, so both markers also carry `depth`, the group's number of ancestors.
|
||||
A run's key is `(level, depth)`, compared in that order: a header at a key
|
||||
closes every run with a greater key, and so does a `GroupEnd`. With `Date`
|
||||
alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after
|
||||
`2024` ends in nothing. Depth rides beside `level` rather than being folded
|
||||
into one flat index, because a flat index would need every category's tree
|
||||
height to be known before the first marker, and `level` would stop naming
|
||||
`cats[level]`. Both front ends draw a nested header exactly as they draw any
|
||||
other: one inline row per `Group` entry, the innermost group in the column
|
||||
band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and
|
||||
`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and
|
||||
`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the
|
||||
windowed frame against the layout.
|
||||
|
||||
`group_runs` is unchanged. It coalesces whatever `groups()` answers, by header
|
||||
identity (improvise-h0iv), so the band still paints one group per position, now
|
||||
the innermost. One band per level would be a separate feature.
|
||||
@@ -1554,14 +1570,19 @@ runs. Reversing it is one comparison, and the test is renamed
|
||||
|
||||
## §3b. A frame's levels are checked as it arrives
|
||||
|
||||
A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index
|
||||
`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack
|
||||
with `open.resize(g.level, None)`. So a level arriving over the wire decides how
|
||||
much it allocates. A frame whose level is `4294967295` would ask for a
|
||||
A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index
|
||||
`cats`. `AxisFrame::groups` used to keep one open run per level and size that
|
||||
stack with `open.resize(g.level, None)`, so a level arriving over the wire
|
||||
decided how much it allocated. A frame whose level is `4294967295` would ask for a
|
||||
multi-gigabyte vector, and the client would be taken down instead of the frame
|
||||
being declined. ChatGPT Codex found this on #250, the PR that gave levels to the
|
||||
wire (improvise-e265).
|
||||
|
||||
Since improvise-felo.6 `groups()` keeps only the headers it has received,
|
||||
ordered by `(level, depth)`, so no number on the wire sizes anything and the
|
||||
new `depth` field needs no bound. The level check stays for its other reason:
|
||||
`cats[level]` names the group's category, which a browser `z` reads.
|
||||
|
||||
The bound is enforced at deserialization, by `#[serde(try_from =
|
||||
"UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A
|
||||
level that is not below `cats.len()` fails to parse, with an error that names the
|
||||
@@ -2353,7 +2374,7 @@ deserializes now carries no English at all.
|
||||
`Hint { keys, command, caption }` is the unit: `keys` come off
|
||||
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
|
||||
non-terminal front end keys off, and `caption` is the default English — exactly
|
||||
the `AxisColor`/`draw.rs::axis_fg` shape, with the decision shared and only the
|
||||
the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the
|
||||
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
|
||||
|
||||
### What that bought, and what it cost
|
||||
@@ -2441,3 +2462,85 @@ the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
|
||||
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
|
||||
~310 lines of content that is now shared. Neither end restates what the keys
|
||||
are.
|
||||
|
||||
## 10. `face.rs` — appearance named by role (ADR 004)
|
||||
|
||||
ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section
|
||||
is where its first two migration steps landed and what they settled that the
|
||||
ADR left open (improvise-nn4s.1, improvise-nn4s.2).
|
||||
|
||||
### What is here
|
||||
|
||||
- `Face`: one variant per role. Where a domain enum already exists the face is
|
||||
**parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`,
|
||||
`Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`,
|
||||
`Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by
|
||||
construction (clause 2). `Face::name` spells each one as a theme file keys it
|
||||
(`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those
|
||||
names are a compatibility surface once a theme can be downloaded (clause 9).
|
||||
- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated
|
||||
by the theme**. A theme that omits a face must still get the face's parent's
|
||||
values ("a theme written today keeps working when a face is added tomorrow",
|
||||
clause 9), and that is only true if the chain does not depend on what the
|
||||
theme says.
|
||||
- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`,
|
||||
and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and
|
||||
`Some(Colour::Default)` states "the surface" and stops inheritance. The two
|
||||
are different answers and the type keeps them apart.
|
||||
- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the
|
||||
chain once and returns every attribute concrete. **No front end walks the
|
||||
chain** (clause 3). `resolve` takes a face and nothing else.
|
||||
- `Theme::shipped()`: today's terminal rendering, palette-relative throughout
|
||||
(clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line.
|
||||
- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both
|
||||
exhaustive over `AppMode`, so a new mode fails the build here as it used to
|
||||
in `draw.rs::mode_style` (improvise-zx8t).
|
||||
|
||||
### The JSON shape
|
||||
|
||||
Face names are the keys and a colour is spelled for a person: `"default"`, a
|
||||
slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`),
|
||||
`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not
|
||||
ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown
|
||||
name): a file the user edits by hand should say where it is wrong rather than
|
||||
silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s
|
||||
derived order, so a re-download after an edit is a reviewable diff (clause 9).
|
||||
The protocol crate derives and the front ends encode, so `serde_json` stays a
|
||||
dev-dependency here.
|
||||
|
||||
### Choices the ADR did not make, made here
|
||||
|
||||
- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and
|
||||
the shipped theme pays for it.** The mode indicator is black text on an
|
||||
accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under
|
||||
this chain every `minibuffer.<entry>` in the shipped theme states all three of
|
||||
its attributes and inherits none of them from its parent. A first version gave
|
||||
them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a
|
||||
published parent is compatibility behaviour, so a theme written to the
|
||||
accepted ADR that restyles `mode.text-entry` would have silently missed every
|
||||
prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the
|
||||
accepted chain. Changing it is the user's call and belongs in the ADR, not in
|
||||
this file.
|
||||
- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition
|
||||
covers the thirteen DarkGray modes and the text-entry chain, and does not
|
||||
place `TileSelect`.
|
||||
- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve
|
||||
`fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is
|
||||
not a no-op on a `TestBackend` buffer, and clause 8 names only one exception.
|
||||
So it waits for improvise-nn4s.6 and a ruling.
|
||||
- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they
|
||||
are the browser's substitute for a terminal palette (clause 4), and the first
|
||||
consumer is improvise-nn4s.5. Adding them then is a field with a serde
|
||||
default, not a format break.
|
||||
- **Only the faces some call site draws with exist.** The ADR's full vocabulary
|
||||
(`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the
|
||||
call sites that use it, because a face with no caller is a published name
|
||||
with no evidence behind it.
|
||||
|
||||
### The category-add exception
|
||||
|
||||
`minibuffer.category-add` resolves to green in the shipped theme, where the old
|
||||
literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it
|
||||
is clause 8's one declared exception. `AppMode::category_add` now carries
|
||||
`MinibufColor::Green` too, so the browser, which still reads the wire colour
|
||||
until improvise-nn4s.4, agrees with the TUI rather than drifting from it.
|
||||
|
||||
@@ -1032,3 +1032,212 @@ changes. Two rivals were run:
|
||||
- reading every failure as stale fails the offline case (exit 1, wanted 2);
|
||||
- dropping `--locked` fails the stale case (exit 0, wanted 1). Without
|
||||
`--locked`, cargo quietly rewrites the lock and reports success.
|
||||
|
||||
## 7. The repo-stats pipeline (improvise-1xfb.5.1)
|
||||
|
||||
`scripts/repo-stats` reports churn, change coupling and doc growth over a window
|
||||
of history: by default the last 7 days of `gh/main`, falling back to `HEAD`,
|
||||
without merges. It reports and never gates. The user asked for code-maat to do
|
||||
the analyses it has, and for improvise to be the engine for the rest. So the
|
||||
pipeline is also an end-to-end run of improvise's headless product: `import`,
|
||||
`script` and `export`.
|
||||
|
||||
### The shape
|
||||
|
||||
1. `git log --numstat --no-renames --no-merges` in code-maat's `git2` format.
|
||||
2. code-maat runs `coupling` (with `--min-revs`/`--min-shared-revs`), `abs-churn`,
|
||||
`age` and `soc`. Each writes one CSV into the output directory, named after
|
||||
the analysis. code-maat is pinned in `flake.nix` as a `fetchurl` of the
|
||||
1.0.4 standalone jar, run by a JRE. It is in the dev shell and in the
|
||||
`.#repo-stats-tools` package, but not in the `ci` shell: the JRE is about
|
||||
0.6 GB on Linux, and every CI job enters that shell.
|
||||
3. The script adds `churn.csv`, one row per (commit, file) with the date, the ISO
|
||||
week, the path (`entity`, code-maat's own name for it), the area, the kind
|
||||
(prose, code or other), and the lines added and deleted. It also adds two
|
||||
flags to code-maat's `coupling.csv`: `crosses-crate` and `agent-rules`.
|
||||
4. `improvise import <dir>` (improvise-lpzu) merges the directory's five CSVs
|
||||
into **one** sparse model, `repo-stats.improv`. The merge's `File` category
|
||||
names each source, and the import makes one view per file, named after its
|
||||
stem and filtered to it. That is the user's ruling: "Records are already
|
||||
sparse: the model can just leave null/empty values for columns not in a
|
||||
given CSV file." A record keeps no coordinate in a category its file lacks.
|
||||
5. `improvise script` runs the checked-in `scripts/repo-stats.improvise`. It
|
||||
adds the prose-to-code formula, builds four views on top of the per-file
|
||||
ones and `export`s each to `report/*.csv`. The four names (`By area`,
|
||||
`By kind`, `Area by week`, `By day`) must never be a file's stem:
|
||||
`create-view` of an existing name replaces that view silently (gap 17). The
|
||||
suite's view count, 10, fails if one does. None of the report reads the
|
||||
merged `Default` view, which can be empty for a typical code-maat pair
|
||||
(improvise-8jk6.15).
|
||||
6. The text report is those exports laid out as tables. The code-maat rankings,
|
||||
doc sizes and the coupling flags are added to it. `--json` gives the same
|
||||
data as JSON.
|
||||
|
||||
The `.improv` file is the artifact. It opens in the TUI, and CI uploads it with
|
||||
the CSVs.
|
||||
|
||||
**A window in which no file changed builds no model.** Its commits are all
|
||||
empty (`--allow-empty`), so there is no churn to import and nothing for
|
||||
code-maat to analyse. The script writes `churn.csv` with its header alone, skips
|
||||
code-maat and improvise, and the report says so. It exits 0. Running improvise
|
||||
over an empty CSV was the other option. It was not taken, because it would test
|
||||
only how improvise treats a CSV with no records, which is not what this pipeline
|
||||
exists to exercise.
|
||||
|
||||
**`--out` must hold no CSV the script did not write.** `improvise import .`
|
||||
merges every CSV in the directory, so a stale or foreign one would silently
|
||||
join the model. The script refuses one with exit 2 and names it. It refuses
|
||||
rather than deleting, because deleting files in a directory the caller named is
|
||||
the wrong default. Its own five CSVs are overwritten on every run, so a rerun
|
||||
into the same `--out` works.
|
||||
|
||||
**No collisions, by construction.** A `churn.csv` record is keyed by commit and
|
||||
file, which `--no-renames` makes unique. code-maat's records are keyed by
|
||||
entity, by entity pair or by date. `File` separates the sources. So the import
|
||||
keeps its default, `--on-duplicate refuse`. A collision would be a bug in this
|
||||
script, and the import refuses it.
|
||||
|
||||
**What improvise computes and what it does not.** Everything that is a sum or a
|
||||
ratio of sums is a view:
|
||||
- churn by area, with `DeletionRatio = deleted / added` as a `_Measure` formula;
|
||||
- prose, code and other, with `ProseToCode = prose / code` as a formula on the
|
||||
`kind` category;
|
||||
- area by ISO week;
|
||||
- lines added per day from `churn.csv` next to code-maat's `abs-churn`. The two
|
||||
columns must agree, and the suite checks that they do.
|
||||
|
||||
Two kinds of output stay in Python. The ranked code-maat tables (coupling, soc
|
||||
and age) are read from code-maat's own CSVs, because improvise cannot sort rows
|
||||
(gap 11 below). Doc sizes are a property of files at one ref, not of the window.
|
||||
|
||||
### Definitions
|
||||
|
||||
- **Areas**: first match wins, in the order of `AREAS` in the script.
|
||||
`generated` comes first, so `harness/opfs-contract/Cargo.lock` counts as
|
||||
generated and not as harness. `.beads/` and `site/` are generated too.
|
||||
`docs/*.md`, `README.md` and `RELEASING.md` are "other docs", and `.codex/`
|
||||
is agent docs and skills. A Rust test is a `.rs` file under a `tests/` or
|
||||
`benches/` directory, or one named `tests.rs` or `*_test(s).rs`. A
|
||||
`#[cfg(test)] mod tests` inside a source file counts as Rust src, because no
|
||||
path can tell the two apart.
|
||||
- **Prose** is `context/code`, `context/test-evidence`, other `context/`, other
|
||||
docs, `repo-map`, and the agent docs and skills. **Code** is Rust src, Rust tests,
|
||||
scripts/CI, and web and harness. `kb.pl`, generated files and other files are
|
||||
neither.
|
||||
- **The deletion ratio** is deleted lines over added lines. It is 0 when nothing
|
||||
was deleted, and blank when nothing was added.
|
||||
- **A crate crossing**: both paths are in a crate (`crates/<name>/`, the root
|
||||
crate's `src/`, `tests/`, `benches/`, `examples/` or `Cargo.toml`, or
|
||||
`harness/opfs-contract/`), and the two crates differ. A pair with one side
|
||||
outside every crate is not flagged.
|
||||
- **Agent rules**: both paths are in the agent docs and skills area. Such a pair
|
||||
means one rule stated in several places.
|
||||
- **Gotchas** are the numbered items between `## Gotchas` and the next `---` or
|
||||
`##`. The longest gotcha is measured in characters. Most gotchas are one long
|
||||
line, so a line count would say little.
|
||||
- **Test-evidence entries** are the `###` and `####` headings.
|
||||
|
||||
### What fails, and what never does
|
||||
|
||||
- **The numbers never fail anything.** No budget is checked. Budgets are a
|
||||
separate decision under the compaction epic, improvise-1xfb.5.
|
||||
- **improvise refusing the pipeline's input exits 3.** That covers a non-zero
|
||||
exit from `import` or `script`, and any `warning:` on their stderr. The
|
||||
warning rule is there because a skipped record is data loss that exits 0
|
||||
(gaps 3 and 4). It is a product regression, not a statistic.
|
||||
- **In CI**, the suite runs on every PR in `nix-build` and gates like any other
|
||||
suite. The report runs only on a push to `main`. Its step has
|
||||
`continue-on-error`, so an improvise failure there marks the step failed and
|
||||
the job and the deploy go on. Whether that failure should block the deploy
|
||||
is a question for the user on the PR. The report appends to
|
||||
`$GITHUB_STEP_SUMMARY`, and the directory is uploaded as the `repo-stats`
|
||||
artifact.
|
||||
- The report runs in `nix-build` because that job already builds
|
||||
`.#improvise`. Its steps run under `nix shell .#repo-stats-tools`, which joins
|
||||
that same build with code-maat, so nothing is built twice. It is one package
|
||||
rather than two installables because `check-ci-graph.py` reads a counted
|
||||
step as `nix shell <one> --command`. The job's checkout fetches full history
|
||||
(`fetch-depth: 0`) for the report.
|
||||
|
||||
### Gaps in the headless product, and the workarounds used
|
||||
|
||||
Each of these was found by running the pipeline, and each is a finding against
|
||||
improvise's command and model layer. Where a workaround exists, the script
|
||||
names it.
|
||||
|
||||
1. **A forced `--category` value is still parsed as a number.** The commit
|
||||
`00565796` imported as item `565796`, and `220944e2` imported as
|
||||
`22094400.0`. Workaround: `churn.csv` writes commits as `g<sha>`.
|
||||
improvise-8jk6.4.
|
||||
2. **`import --formula` drops a formula that fails to parse, and exits 0.**
|
||||
`--formula 'X = ('` saves a model with no formula and says nothing.
|
||||
`add-formula` in a script refuses it and exits 1, so the pipeline's own
|
||||
category formula is added there. improvise-8jk6.5.
|
||||
3. **A record that lacks any category's value is skipped.** A merge of CSVs with
|
||||
different headers, which is the user's sparse model, imports **no records**.
|
||||
Closed by improvise-lpzu, whose sparse import this pipeline now uses. Until
|
||||
it landed, every CSV was padded with `-` for the categories it lacked. A
|
||||
related hole is still open: a record with no measure value stores nothing
|
||||
(improvise-8jk6.14).
|
||||
4. **An import that skipped records exits 0**, even when it skipped all of
|
||||
them. It saves an empty model and prints only a warning. improvise-8jk6.6.
|
||||
5. **`set-axis` and `set-page` accept names that do not exist, and exit 0.** An
|
||||
unknown category, an unknown axis name (`rwo`) and an unknown item are all
|
||||
silent no-ops. improvise-31oe.2.
|
||||
6. **The command language does not read `|pipe quoting|`.**
|
||||
`create-view |By area|` made a view named `|By`, and the second word was
|
||||
ignored. Commands that take a fixed number of arguments ignore extra ones
|
||||
without saying so. Workaround: `"double quotes"`. improvise-31oe.3.
|
||||
7. **`create-view` puts every category it is not told about on Page**, with its
|
||||
first item selected. That silently filters the view. Every view has to set
|
||||
every category's axis, one `set-axis` line each. improvise-31oe.4.
|
||||
8. **A view cannot hide a category's column.** A per-file records view shows
|
||||
every category and measure in the model. improvise-31oe.5.
|
||||
9. **`export` of a records view writes the editor's `+cat` and `+` columns.**
|
||||
improvise-8jk6.7.
|
||||
10. **`export` writes display text.** It uses the view's number format, so the
|
||||
import's default `,.2` gives `"17,512.00"`. Workaround: `set-format .0` on
|
||||
every exported view, and `FORMAT .3` on the ratio formulas.
|
||||
improvise-8jk6.8.
|
||||
11. **Nothing sorts.** Items stay in first-seen order, so weeks come out
|
||||
newest first. A top-N table cannot be produced, so code-maat's rankings are
|
||||
read from its CSVs. improvise-31oe.6.
|
||||
12. **`--extract` has no ISO week.** It has year, month and quarter. Workaround:
|
||||
`churn.csv` carries a `week` column. improvise-8jk6.9.
|
||||
13. **`import --formula` targets only `_Measure`.** A formula on another
|
||||
category needs `add-formula <category> …` in a script. improvise-8jk6.10.
|
||||
14. **The saved model opens on the import's `Default` view**, which puts `File`
|
||||
on rows and `area` on columns, and not on the view the script left active.
|
||||
That is improvise-azhb.
|
||||
15. **No folder import.** Closed by improvise-lpzu (PR #314), which this
|
||||
pipeline now uses. Until it landed, the script named the five files and
|
||||
generated its own per-file views.
|
||||
16. **There is no headless output but a file.** `export` writes a CSV, and the
|
||||
tables are laid out in Python. improvise-31oe.7.
|
||||
17. **`create-view` of an existing name replaces that view, and exits 0.**
|
||||
`Workbook::create_view` inserts into an `IndexMap`, so it overwrites without
|
||||
a word. Folder import makes a view per file stem, so a report view named
|
||||
like a stem would replace that file's view.
|
||||
improvise-31oe.8.
|
||||
|
||||
### The suite
|
||||
|
||||
`scripts/repo-stats-test.sh` builds a fixture repository whose every number is
|
||||
known, runs the whole pipeline, code-maat and improvise included, and asserts
|
||||
improvise's exported tables. It runs in `nix-build`, which puts improvise and
|
||||
code-maat on `PATH`. Locally it uses improvise from `PATH`, or else
|
||||
`target/debug/improvise`, and it fails with a message when there is neither.
|
||||
The fallback does not check how fresh that binary is, so locally the suite can
|
||||
test a `target/debug/improvise` older than the tree. Rebuild after changing
|
||||
improvise.
|
||||
|
||||
It does not build improvise itself. `checkers.sh` scrubs the environment to an
|
||||
allowlist, and on macOS that removes what the linker needs: a `cargo build`
|
||||
inside the suite failed with `library not found for -liconv`. That is a bug in
|
||||
`checkers.sh`: its allowlist drops the Nix linker variables, and the
|
||||
orchestrator has filed it separately. Build first with
|
||||
`cargo build --bin improvise`; `cargo test --workspace` also leaves the binary
|
||||
there. The suite is the slowest in `checkers.sh`, at about 25 seconds, most of
|
||||
it JVM start-up for code-maat's four analyses per run.
|
||||
|
||||
The evidence and the rivals are in `context/test-evidence/tooling.md`.
|
||||
|
||||
+43
-38
@@ -104,46 +104,49 @@ appear. Three rules:
|
||||
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
|
||||
`META`) is dropped.
|
||||
|
||||
### Semantic-to-terminal colour, and the one that is wrong
|
||||
### `face_style` — the one place a face becomes a terminal style
|
||||
|
||||
This file holds the mapping design-principles §5 names as the shape everything
|
||||
else should imitate: **the decision lives in the shared layer as a semantic
|
||||
enum, and only the last translation is local.**
|
||||
ADR 004 replaced the four per-family maps this section used to describe
|
||||
(`axis_fg`, `minibuf_fg`, `field_kind_fg`, `mode_style`) with one function:
|
||||
`face_style(Face) -> Style` resolves the face against the shipped theme
|
||||
(`Theme::resolve`, in `improvise-protocol`) and translates the result. That is
|
||||
design-principles §5's shape with nothing left over: **the decision is shared,
|
||||
and only the last translation is local** — `Colour` → `ratatui::Color` and the
|
||||
six attributes → `Modifier`. The chain is never walked here (ADR 004 clause 3).
|
||||
What the vocabulary is and why is `context/code/protocol.md` §10.
|
||||
|
||||
- `axis_fg(AxisColor) -> Color` — `Axis::color` is the single place each axis's
|
||||
accent is decided; this is the single place axis colouring is *rendered*
|
||||
(improvise-rml, gotcha 26). Before it, the tile bar and the category panel
|
||||
each carried their own copy of the colour table with nothing keeping them in
|
||||
agreement.
|
||||
- `minibuf_fg(MinibufColor) -> Color` — the same shape for the minibuffer.
|
||||
- `src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color` — the
|
||||
same shape again. `improvise-protocol` must not name a ratatui `Color`.
|
||||
Three things to know.
|
||||
|
||||
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
|
||||
`Color` are ratatui types and `improvise-command`, where `AppMode` lives,
|
||||
carries no rendering-framework dependency), and since `improvise-zx8t` it is
|
||||
**exhaustive**: the thirteen variants that take the default chrome are spelled
|
||||
out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the
|
||||
build here until someone chooses its colour. The arithmetic is the check a
|
||||
prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` +
|
||||
`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until
|
||||
2026-09-22, and nothing could have caught that — a count written in prose is
|
||||
checked by no one.
|
||||
- **`Colour::Default` sets nothing.** It maps to "no `fg`/`bg` on the `Style`",
|
||||
so the terminal's own colour shows through. That is also why a face with no
|
||||
stated colour produces `Style::default()` and not a `Reset`.
|
||||
- **The slot names follow ANSI, not ratatui.** ANSI slot 7 is `White`, which
|
||||
ratatui calls `Gray`; slot 8 is `BrightBlack`, ratatui's `DarkGray`; slot 15
|
||||
is `BrightWhite`, ratatui's `White`. `terminal_colour` is the one place that
|
||||
translation is written.
|
||||
- **The shipped theme is resolved once**, into a `OnceLock`, because there is no
|
||||
theme picker yet (ADR 004 "Deferred"). When one arrives the theme becomes the
|
||||
TUI's own state rather than a static, and a theme is still never on `App`'s
|
||||
`ViewState` (clause 7).
|
||||
|
||||
It is still not quite the same shape as `axis_fg` and its two siblings, and the
|
||||
difference is deliberate rather than pending. Those three map a *semantic enum*
|
||||
the shared layer owns; `mode_style` matches `AppMode` itself, because there is
|
||||
no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here
|
||||
for two reasons: the colour is the only thing at stake, and the ticket says to
|
||||
decide it together with `improvise-k099`, which asks the same question of
|
||||
`hint_text`/`label` and is not in this unit. Exhaustiveness is what the
|
||||
repo map's "the compiler names every one you must fill in" promise needed; a
|
||||
semantic enum is a separate, larger answer and is still open.
|
||||
**The mode indicator is still exhaustive over `AppMode`**, now in
|
||||
`Face::of_mode` rather than here: adding a variant fails the build there
|
||||
(improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants
|
||||
are no longer one arm: they are the five faces ADR 004 derives
|
||||
(`mode.resting`, `mode.text-entry`, `mode.panel.*`, `mode.overlay`,
|
||||
`mode.transient`), which all still inherit the same colours from `mode`.
|
||||
|
||||
**The guard is the compiler, not a test.** Nothing asserts the colours, and a
|
||||
test enumerating the variants would not fail when a fifteenth is added — only
|
||||
the missing match arm does. Verified by deleting the `AppMode::Help` arm:
|
||||
`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`.
|
||||
**No-op evidence.** `face_tests::the_shipped_theme_reproduces_every_migrated_literal`
|
||||
holds the old literals as a table and asserts the shipped theme draws each one,
|
||||
which is clause 8's property. The one deliberate difference, the category-add
|
||||
prompt turning green, is asserted by
|
||||
`the_category_add_prompt_draws_as_the_item_add_prompt_does` rather than being
|
||||
left out of the table silently.
|
||||
|
||||
**Still literal**: every other `Color::` in `src/` (the selection sites, the
|
||||
panels' accents, the grid's headers, help, the welcome popup). They move as
|
||||
their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch
|
||||
`src/ui/grid.rs` for this while improvise-felo.6 is in flight.
|
||||
|
||||
### The minibuffer shares the bottom row with the status line
|
||||
|
||||
@@ -580,9 +583,11 @@ to make room for a marker. `ImportWizard::scroll` clamps to `len - 1`, so the la
|
||||
and the list can never scroll away. It is by item, not by line, because the
|
||||
wizard state cannot know the panel width that line-level clamping needs. The
|
||||
residual is honest. A **single** item taller than the whole list area, roughly
|
||||
1,500 characters at 80 columns, is still clipped at the panel's bottom. No
|
||||
wizard warning is that long today: a collision tuple is bounded by twelve
|
||||
categories plus labels, but a label value is not bounded.
|
||||
1,500 characters at 80 columns, is still clipped at the panel's bottom. A
|
||||
collision tuple has at most `MAX_CATEGORIES` coordinates (24 since
|
||||
improvise-felo.1, up from 12) plus labels. With short names that is a few
|
||||
hundred characters. Neither the names nor a label value is bounded, though, so
|
||||
reaching the residual got easier when the cap doubled.
|
||||
|
||||
**Every write is clipped to the panel.** The widget draws through `Panel`,
|
||||
whose `put` writes only inside the panel's inner rectangle, and its height
|
||||
|
||||
@@ -69,7 +69,7 @@ same effect without the early return.
|
||||
three shapes; dispatch pattern-matches exhaustively.
|
||||
|
||||
- `CategoryKind` is `Regular | VirtualIndex | VirtualDim | VirtualMeasure | Label`.
|
||||
Business rules (e.g., the 12-category limit counts only `Regular`) are
|
||||
Business rules (e.g., the 24-category limit counts only `Regular`) are
|
||||
enforced by matching on the enum, not by checking name prefixes. Virtual
|
||||
categories (`_Index`, `_Dim`, `_Measure`) always exist: `_Index` and `_Dim`
|
||||
support drill-down/records mode; `_Measure` holds numeric data fields and
|
||||
@@ -99,7 +99,8 @@ sort. Property tests verify this invariant.
|
||||
- `CellKey::new()` is the only way to build a key — it always sorts.
|
||||
- `Category::add_item()` deduplicates by name and auto-assigns IDs via a private
|
||||
counter. External code cannot fabricate an `ItemId`.
|
||||
- `Model::add_category()` checks the 12-category limit before insertion.
|
||||
- `Model::add_category()` checks the category limit (`MAX_CATEGORIES`, 24)
|
||||
before insertion.
|
||||
- `Formula::new()` takes all required fields; there is no default/empty formula
|
||||
to accidentally leave half-initialized.
|
||||
|
||||
@@ -268,8 +269,8 @@ why `doctrine(mirror_the_cmd_line_for_line)` is a rule about *resolvers* — the
|
||||
narrow, unavoidable residue — and never a licence to restate a table.
|
||||
|
||||
**What legitimately stays per-framework.** Painting, event wiring, and the
|
||||
mapping from a semantic value to a framework type: `AxisColor → ratatui::Color`
|
||||
in `draw.rs::axis_fg`, `MinibufColor → minibuf_fg`. That mapping is the shape
|
||||
mapping from a semantic value to a framework type: a `Face`, resolved by the
|
||||
shared `Theme`, becomes a `ratatui::Style` in `draw.rs::face_style` (ADR 004). That mapping is the shape
|
||||
to imitate — the *decision* is in the shared layer as a semantic enum, and only
|
||||
the last translation is local. Anything larger than a lookup table is a
|
||||
candidate for the store.
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ The second weekend (or the second half of the first weekend) is reserved for wha
|
||||
|
||||
The following are real issues that have been identified but are explicitly not launch-blocking. Do not start any of them as part of this plan. They can be addressed after launch based on what real users actually trip over.
|
||||
|
||||
- **Do not** raise or restructure the `MAX_CATEGORIES = 12` limit, even though it's tight for some real use cases.
|
||||
- ~~**Do not** raise or restructure the `MAX_CATEGORIES = 12` limit, even though it's tight for some real use cases.~~ **Superseded** by the user's decision of 2026-09-25 ("12 categories is feeling small, we should increase to 24"). The limit is 24 (improvise-felo.1).
|
||||
- **Do not** refactor the `ExecuteCommand` vim command match ladder, even though it duplicates registry logic.
|
||||
- **Do not** fix the hardcoded `"Measure"` category name in `evaluate_aggregated`, even though it's the one place in the code that assumes a specific user-facing name.
|
||||
- **Do not** change `Model::evaluate` from a linear formula walk to a HashMap lookup, even though it's O(N·M·F) in the render hot path.
|
||||
|
||||
+55
-29
File diff suppressed because one or more lines are too long
@@ -407,3 +407,254 @@ named, and no output file. It has two siblings. `..._sums_duplicates_when_told_t
|
||||
asserts `= 30` and no `Occurrence`. `..._keeps_duplicates_separate_when_told_to`
|
||||
asserts the category, `= 10` and `= 20`, and no `= 30`. So the two flags cannot
|
||||
both be summing.
|
||||
|
||||
### a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte
|
||||
|
||||
improvise-lpzu, at the process boundary. The folder holds code-maat's three
|
||||
schemas (coupling, age and abs-churn) and a `README.txt`. At `e65d87fd`'s
|
||||
parent the run failed with `Cannot read '…/maat': Is a directory (os error
|
||||
21)`, because a single path went straight to `read_to_string`. The test
|
||||
asserts:
|
||||
|
||||
- sparse Data lines for all three schemas, with no README;
|
||||
- the four views, `Default` first and initial, then one per file;
|
||||
- `File: filter, coupling`;
|
||||
- a second run, with the first run's `maat.improv` now inside the folder,
|
||||
writing the same bytes.
|
||||
|
||||
Rivals R2, R5, R6 and R7 each fail it.
|
||||
|
||||
### a_headless_import_takes_files_and_folders_together_and_refuses_an_empty_folder
|
||||
|
||||
improvise-lpzu. A file argument and a folder argument together are one merge,
|
||||
and the extra file's records carry its own `File` item. Before the fix this
|
||||
failed with `Multi-file import only supports CSV files`, because the folder was
|
||||
not a `.csv` path. An empty folder exits non-zero, writes nothing, and names
|
||||
itself: `no CSV files in folder …/empty`. Rivals R2, R4 and R5 each fail it.
|
||||
|
||||
### a_saved_per_file_view_projects_only_its_file
|
||||
|
||||
improvise-lpzu (`tests/folder_import_views.rs`). A folder import is saved,
|
||||
reloaded and projected through `project::grid_in`, the one derivation the TUI
|
||||
grid and the browser both draw from (gotcha 44). This is why no separate TUI
|
||||
or browser test was written. For each file's view:
|
||||
|
||||
- the page bar carries `File` fixed at that file with nothing to cycle
|
||||
(`items: None`, gotcha 27);
|
||||
- the drawn cells are that file's alone: `2.00, 7.00` for age and
|
||||
`5.00, 3.00` for coupling.
|
||||
|
||||
Rivals R2, R7 and R9 each fail it. Since the review of `4e68372c` the fixture
|
||||
also holds a header-only `soc.csv`. Its view must carry `File` at `soc`, be a
|
||||
records view, and draw zero rows. Rivals F7 and F8 fail that half.
|
||||
|
||||
### a_csv_column_forced_to_category_keeps_its_text_verbatim
|
||||
|
||||
improvise-8jk6.4. Before the fix, `commit,lines` with `00565796` and
|
||||
`220944e2` forced to Category imported as items `565796` and `22094400.0`.
|
||||
The CSV parser had typed both as numbers, and the category was named from the
|
||||
number. On the base this failed with the items line `- |565796|, |22094400.0|`.
|
||||
The test also asserts both Data lines, which proves the measure `lines` still
|
||||
reads 3 and 5 through the same records.
|
||||
|
||||
- **R1**, a category named from the typed value, fails it.
|
||||
- **R2**, a CSV measure read as a JSON value (`as_f64` on the text), fails it
|
||||
on the Data lines.
|
||||
|
||||
### a_json_number_forced_to_category_is_named_as_json_writes_it
|
||||
|
||||
improvise-8jk6.4, the JSON half. This is not a bug demonstration: it passed
|
||||
on the base, and it pins that the fix left JSON alone. JSON keeps no source
|
||||
text for a number, so `42`, `1.5` and `1e3` name items `42`, `1.5` and
|
||||
`1000.0`. `1e3` is the discriminating value, because its source text, its f64
|
||||
display (`1000`) and its JSON display (`1000.0`) all differ. **R10**, naming a
|
||||
number by its `f64` display, fails it on `1000.0`.
|
||||
|
||||
### an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing
|
||||
|
||||
improvise-8jk6.5. Before the fix, `--formula 'Y = x +'` was dropped: the run
|
||||
printed `Saved to …` and exited 0. That is the base failure (`a dropped
|
||||
formula exited 0`). The test passes a good formula too (`Z = x`) and asserts
|
||||
it is not named, so a refusal that lists every formula cannot pass.
|
||||
`expected` comes from pest's error, which proves the parse error is carried
|
||||
and not only the formula text. **R4**, a build that drops what does not
|
||||
parse, fails it. Since improvise-8jk6.17 it passes two bad formulas, so
|
||||
**R5**, naming only the first, fails it here too. At `efaa5050` only the io
|
||||
unit test saw R5.
|
||||
|
||||
### an_import_that_keeps_no_record_refuses_and_says_why
|
||||
|
||||
improvise-8jk6.6. On the lpzu base `[1, 2, "three"]` already exited 1, so the
|
||||
exit-code half was not the bug. The reason was: `At least one category must
|
||||
be accepted`, which is true only because no fields exist, and which points
|
||||
the user at a fix that cannot help. The base failure was on the message.
|
||||
**R7**, removing `nothing_to_import` from the build, fails it the same way.
|
||||
|
||||
### an_import_that_skips_some_records_warns_with_a_count_and_still_writes
|
||||
|
||||
improvise-8jk6.6, the partial case. It passed on the base. It pins the
|
||||
default the user kept on PR #321 ("Add a --strict flag"), so that changing
|
||||
the default is a decision and not a drift. It asserts the warning's count (`1 of 3`), exit 0,
|
||||
and a Data line from the record *after* the skipped one. That last check
|
||||
shows the skip did not end the import early. **R9**, making any skip fatal,
|
||||
fails it.
|
||||
|
||||
### a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not
|
||||
|
||||
improvise-lpzu, after the user's ruling on PR #314 ("we'd want file identity").
|
||||
At `4e68372c`, a folder holding one CSV took the single-file path, because
|
||||
`import dir/` was exactly `dir/*.csv`. That document had no `File` category and
|
||||
no per-file view, and the test failed at the `## Category: File` assertion. The
|
||||
other half pins the direction the ruling did *not* change: the same CSV named
|
||||
directly imports without any `File`. The rival that merges every all-CSV
|
||||
import, lone files included, fails that half ("a lone file keeps today's
|
||||
shape"). So the test separates "a folder merges" from "everything merges".
|
||||
|
||||
### a_pipe_quoted_argument_is_one_argument_without_its_pipes
|
||||
|
||||
improvise-31oe.3. Red before the fix: the old tokenizer split on whitespace
|
||||
only, so `create-view |By area|` built `CreateViewCmd(["|By", "area|"])`. It
|
||||
asserts the whole built command, so a lexer that joined the words but kept the
|
||||
pipes fails as well as one that split them. The second case has two quoted
|
||||
arguments side by side, so a lexer that reads one pipe piece per line fails.
|
||||
Rival: `bare_word` pushing `|` as a plain character. Fails here and in
|
||||
`a_coordinate_may_pipe_quote_either_half`.
|
||||
|
||||
### a_pipe_quoted_argument_reads_the_escapes_improv_does
|
||||
|
||||
The three escapes `pipe_quote` writes, `\|`, `\\` and `\n`, each in its own
|
||||
case. Red before the fix. Rival: `pipe_body` returning the raw body without
|
||||
`unescape_pipe_body`. Fails here, at the first case, and nowhere else in the
|
||||
crate, which is why the case exists. A lexer that treated `\|` as a closing
|
||||
pipe would also fail the first case, because the pipe after `b` then opens a
|
||||
piece that never closes.
|
||||
|
||||
### a_command_given_more_arguments_than_it_takes_is_refused
|
||||
|
||||
improvise-31oe.3's second half. Red before the fix: `create-view By area`
|
||||
parsed and dropped `area`. It asserts the refusal opens with the command's name
|
||||
and quotes the surplus word, and that the pipe-quoted spelling of the same line
|
||||
is the control that parses. Rival: `MaxArgs::admit` answering `Ok` always.
|
||||
Fails here, in `no_command_silently_drops_an_argument_it_was_given`, and in
|
||||
`a_colon_line_naming_something_missing_refuses_on_the_status_line`.
|
||||
|
||||
### no_command_silently_drops_an_argument_it_was_given
|
||||
|
||||
The rule is one rule only if every registration obeys it, so this enumerates
|
||||
every registered name against every candidate argument list, on both arms. For
|
||||
each list a command accepts, it appends **four** surplus words. One would be
|
||||
absorbed by an optional argument (`add-formula ["1"]` plus one more is a valid
|
||||
two-argument call), and four is more than any optional span in the registry.
|
||||
The last word must be named in the refusal. The exceptions are stated, not
|
||||
inferred: the `Any` list, and three commands whose own shape refusal runs
|
||||
first. Rivals, each run: declaring `add-item-in-group` as `Any` fails here,
|
||||
naming the dropped list. Removing the check from the interactive arm alone
|
||||
fails here and in the `:` line test. A test of the pure arm alone would have
|
||||
missed that.
|
||||
|
||||
### a_command_naming_something_the_document_lacks_is_a_refusal
|
||||
|
||||
improvise-31oe.2 and its siblings: `set-axis`, `set-page`, `hide-item`,
|
||||
`show-item`, `toggle-group` and `switch-view`, each naming a missing category,
|
||||
item, group or view. Red before the fix at the first row, `set-axis Regoin
|
||||
column`, with an empty status. Each row asserts the refusal flag, the missing
|
||||
name in the message, and that the document's `.improv` text and the active view
|
||||
are unchanged. The last two catch a refusal written after the write. The
|
||||
control is `a_command_naming_what_the_document_has_is_not_refused`, whose group
|
||||
row is the one that caught a group check reading only `Category::groups`:
|
||||
`add_item_in_group` registers no `Group`. The checks are `effect::unknown_name`,
|
||||
run by `RequireKnown` when the batch applies. Rivals, each run: the
|
||||
`Needs::Item` arm answering `None` fails at `set-page ["Type", "Nowhere"]`. The
|
||||
category test (`try_axis_of(..).is_none()`) never firing fails at the first
|
||||
row. The `Needs::View` arm answering `None` fails at the `switch-view` row.
|
||||
`RequireKnown` placed after the write, or refusing without `refuse_and_stop`'s
|
||||
abort, fails the document-unchanged assertion.
|
||||
|
||||
### a_colon_line_naming_something_missing_refuses_on_the_status_line
|
||||
|
||||
The TUI path for both tickets, through `ExecuteCommand` and `App::apply_effects`,
|
||||
not through the command alone. It asserts the refusal flag, the name on the
|
||||
status line, and the mode back at Normal. Rivals: an unknown category accepted
|
||||
fails at `:set-axis Regoin row`, and the arity check removed from the
|
||||
interactive arm fails at `:create-view By area`.
|
||||
|
||||
### an_unknown_category_axis_or_item_exits_non_zero_and_names_it
|
||||
|
||||
The three repros from improvise-31oe.2, at the process boundary. Red against
|
||||
the `gh/main` source with this test file: `set-axis Regoin row` exited 0 with an
|
||||
empty stderr. The control, `set-page Region East`, must exit 0. Without it, a
|
||||
fix that refused every `set-page` would pass.
|
||||
|
||||
### a_pipe_quoted_name_reaches_the_document_whole
|
||||
|
||||
improvise-31oe.3's repro at the process boundary: the saved document must carry
|
||||
`## View: By area` and no `|By`. The second half, `create-view By area`, must
|
||||
exit non-zero naming `"area"`. Red against the `gh/main` source: the document
|
||||
carried the view `|By`.
|
||||
|
||||
### command::cmd::registry::tests::a_malformed_coordinate_is_refused_not_dropped
|
||||
|
||||
Codex's P2 on PR #316: `set-cell` and `parse_cell_key_from_args` (`clear-cell`,
|
||||
`yank`, `paste`, `drill-into-cell`, `commit-cell-edit`) built coordinates with
|
||||
`filter_map(split_once('/'))`, so `set-cell 1 Type/Food unexpected` dropped
|
||||
`unexpected` without a word. The test names every caller, a half-empty
|
||||
coordinate, and a control line that must still parse. Rival: the `filter_map`
|
||||
restored in `parse_cell_key_from_args` fails at the first line,
|
||||
`set-cell 1 Type/Food unexpected dropped "unexpected" without a word`.
|
||||
|
||||
### command::cmd::text_buffer::tests::colon_refuses_a_malformed_coordinate_and_leaves_the_cursor_cell_alone
|
||||
|
||||
The PR #316 review's blocker: on `:`, `parse_cell_key_from_args` failing sent
|
||||
`clear-cell`, `yank`, `paste` and `drill-into-cell` into the cursor fallback,
|
||||
which ignores its arguments. `:clear-cell Type/Clothing Month/Feb junk` cleared
|
||||
the cursor's Food/Jan without a word. The test runs each line through the full
|
||||
`:` path and asserts a refusal naming `"junk"`, both cells intact, nothing
|
||||
yanked and no drill opened. Rival: `only_when_omitted` made unconditionally
|
||||
`Ok` fails it at the first line.
|
||||
|
||||
### command::cmd::text_buffer::tests::colon_lets_a_later_segment_use_what_an_earlier_one_created
|
||||
|
||||
The review's second blocker: a `:` line is built before any segment runs, so the
|
||||
name checks in `execute` refused `create-view Second . switch-view Second` and
|
||||
`add-item Type Tools . set-page Type Tools` after the first half landed. The
|
||||
checks now run when the effect applies. Rival: the checks back in `execute`
|
||||
fails both lines with `No view named "Second"` and `Unknown item 'Tools'`.
|
||||
|
||||
### a_strict_import_that_skips_a_record_refuses_and_writes_nothing
|
||||
|
||||
improvise-8jk6.6, the user's ruling on PR #321. Written before `--strict`
|
||||
existed, it failed on clap's unknown-argument error. It asserts the refusal
|
||||
names the flag and the same count as the default warning, and that the output
|
||||
path does not exist.
|
||||
|
||||
- **S1**, the flag parsed but ignored, fails it on the exit status.
|
||||
- **S3**, refusing only after `persistence::save`, fails it on the output
|
||||
path. That is the "a strict refusal still writes" contract this PR decided
|
||||
against.
|
||||
|
||||
### a_strict_import_that_skips_nothing_still_writes
|
||||
|
||||
The control for the entry above. Without it, a `--strict` that refuses
|
||||
**every** import passes that test. **S2** is that rival, and it fails here.
|
||||
It failed before the flag existed too, but only on clap's error, which is not
|
||||
the discriminating failure. The discriminating one is S2.
|
||||
|
||||
### a_csv_decimal_measure_imports_as_its_number
|
||||
|
||||
improvise-8jk6.17 (F3), from the adversarial review of #321. The fixture has
|
||||
decimal CSV measures (`1000.50`, `-2.25`). With `number_in`'s `f64` branch
|
||||
disabled (integers only), no headless test failed at `efaa5050`, and a decimal
|
||||
measure was silently dropped. **F3** now fails this test and the next one.
|
||||
This is coverage, not a bug: it passed when written.
|
||||
|
||||
### import_json_on_a_csv_reads_its_numeric_column_as_a_measure
|
||||
|
||||
improvise-8jk6.17 (F1). `improvise cmd 'import-json f.csv'` reaches
|
||||
`ImportJsonHeadless`, a third CSV entry point besides `import`. **F1**
|
||||
restores its CSV branch to the JSON constructor via
|
||||
`ImportPipeline::new(Value::Array(recs.to_vec()))`. That still compiles,
|
||||
because `CsvRecords` derefs to a slice. `Revenue` then becomes a category of
|
||||
text items, and this test fails on its Data lines. At `efaa5050` only
|
||||
`import_headless_refuses_records_that_collide` failed under F1, and only by
|
||||
accident. The `## Category: Revenue` assertion names the symptom directly.
|
||||
This is coverage: it passed when written.
|
||||
|
||||
@@ -6605,3 +6605,19 @@ commits.
|
||||
`target.view`), run across the whole workspace: fails **only** this test.
|
||||
- A composer that hard-codes `"Default"` fails here as well. Neither view in
|
||||
this test is `Default`, so the default cannot satisfy it by accident.
|
||||
|
||||
### a_download_commit_keeps_the_host_session_mirror_in_lockstep
|
||||
|
||||
Codex on PR #329: `LocalThenRequest` closed and cleared the browser's download
|
||||
prompt, but the bytes-only request moved no corresponding state in the host's
|
||||
session mirror. The test opens SavePrompt through the real browser key path,
|
||||
types a nonempty filename, and commits it. It asserts both that the response is
|
||||
still `DocumentBytes` and that the host mirror is Normal with an empty save
|
||||
buffer. Another session then writes a cell; the requester folds that later
|
||||
projection and must still equal its host mirror.
|
||||
|
||||
- **Rival** (the pre-fix download handlers, which returned bytes without
|
||||
closing the mirrored prompt): fails at the host-mode assertion with
|
||||
`SavePrompt` versus `Normal`, before the later projection.
|
||||
- The nonempty filename proves Enter commits rather than taking the empty-name
|
||||
refusal, and matching `DocumentBytes` preserves the bytes-download ruling.
|
||||
|
||||
@@ -2479,8 +2479,8 @@ from `DocumentSnapshot` — it is no longer only the document.
|
||||
|
||||
**The fixture detail that matters**: a *duplicate* `AddCategory` is idempotent, so
|
||||
the obvious way to reach the refusal proves nothing. This fills to
|
||||
`MAX_CATEGORIES` (12) and overflows. And the predecessor is an `AddItem` rather
|
||||
than a `SetCell`, because adding eleven categories reshapes the view enough that
|
||||
`MAX_CATEGORIES` and overflows. And the predecessor is an `AddItem` rather
|
||||
than a `SetCell`, because filling to the limit reshapes the view enough that
|
||||
a record-addressed predecessor is refused `RecordGone` **first** — hiding the arm
|
||||
under test behind an earlier one.
|
||||
|
||||
@@ -3505,7 +3505,7 @@ handshake. The stronger form fails under both injections.
|
||||
improvise-75ld. `App::apply_effects` bumped `layout_version` and raised
|
||||
`model_state.dirty` off the `EffectKind` **before** the `abort_effects` check, so
|
||||
the refusing effect counted itself. Two arms, and the second is the reachable one:
|
||||
a 13th category is refused (`MAX_CATEGORIES`) and aborts, while a *duplicate*
|
||||
a category past `MAX_CATEGORIES` is refused and aborts, while a *duplicate*
|
||||
`add-cat Region` returns `Ok` and does not abort at all — a pure no-op that still
|
||||
refused every other client's next `Command::Model` with `Rejection::LayoutMoved`
|
||||
and made the browser worker rewrite the whole OPFS document (gotcha 41).
|
||||
@@ -7214,3 +7214,16 @@ cleared the cell cannot pass.
|
||||
`the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable` wrote with
|
||||
`view: "Default"` against `web/demo.improv`, whose view is `Plan`. It passed
|
||||
only because a stored record ignored the view name. It now names `Plan`.
|
||||
|
||||
### the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy
|
||||
|
||||
improvise-felo.6. The windowed frame carries the runs open above the window;
|
||||
those are now keyed by `(level, depth)`. Rival: `axis_frame` closing carried
|
||||
runs by level alone. Only this test fails; the existing agreement test has one
|
||||
group per level, where the two keyings coincide.
|
||||
|
||||
### z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again
|
||||
|
||||
`z` through a closed Year > Quarter > Month hierarchy, cursor at row 1. Rival:
|
||||
`toggle_group_collapse` never removing a fold; the final assertion (no fold left
|
||||
behind once the quarter is closed again) fails.
|
||||
|
||||
@@ -710,11 +710,6 @@ Applying `next` four times is the identity from every starting point and no
|
||||
navigable axis is a fixed point, so no variant is stranded or skipped. Together
|
||||
with the entry above this pins the rotation without enumerating it twice.
|
||||
|
||||
### every_axis_gets_its_own_color
|
||||
|
||||
Distinct axes must be distinguishable at a glance; `color()` is the only reason
|
||||
`AxisColor` exists, so a collision defeats it.
|
||||
|
||||
### a_routing_formula_over_a_group_sums_the_members_answers
|
||||
|
||||
improvise-4zl7, and the hand-computed half of its evidence. Every number in
|
||||
@@ -1865,5 +1860,167 @@ were run against the whole `improvise-core` suite:
|
||||
**What it cannot see is the allocation itself.** A visitor that clones the
|
||||
stack at every row returns the same answers, so no assertion here can tell the
|
||||
two apart without a counting allocator. The copy-free property is held by the
|
||||
shape: the visitor receives a borrowed `&[Option<usize>]`, and no owned stack
|
||||
shape: the visitor receives a borrowed slice of open runs (`&[OpenRun]` since
|
||||
improvise-felo.6), and no owned stack
|
||||
leaves the walk. It is not held by a test.
|
||||
|
||||
### a_parent_group_covers_every_leaf_beneath_its_child_groups
|
||||
|
||||
improvise-felo.6. `items_in_group` read only the items whose own `group` named
|
||||
the group, so a year covered nothing: its months sit in quarters. Rival run
|
||||
against `improvise-core`: the direct-only filter restored. Four tests fail —
|
||||
this one, `a_hierarchy_opens_closed_as_one_fold_per_root` (the 2024 fold reads
|
||||
empty), `collapsing_a_parent_folds_its_children_whatever_their_state` and
|
||||
`hidden_items_leave_every_fold_above_them`. The fixture adds `2024-03` last, so
|
||||
the year and its first quarter are non-contiguous in insertion order.
|
||||
|
||||
### a_year_quarter_month_hierarchy_nests_its_runs
|
||||
|
||||
improvise-felo.6: the whole marker sequence of a Year > Quarter > Month axis
|
||||
opened to depth 2, compared exactly. Rival: every header inside a category
|
||||
emitted at depth 0 (a flat hierarchy). This test fails, and so do
|
||||
`pruning_keeps_every_surviving_rows_runs_at_every_level` (the `NESTED` fixture),
|
||||
`the_nested_fixture_draws_runs_inside_one_category` and
|
||||
`pruning_drops_an_empty_inner_group_and_keeps_its_parent`.
|
||||
|
||||
### a_hierarchy_opens_closed_as_one_fold_per_root
|
||||
|
||||
The user's ruling on improvise-felo.6: groups start closed. Two rivals fail it.
|
||||
The old open default (`inherits_collapsed` always false) fails twelve tests
|
||||
across `improvise-core` and `improvise-io`, this one among them. The INNERMOST
|
||||
collapsed group folding instead of the outermost (`.rev()` on the fold search)
|
||||
fails this one, `hidden_items_leave_every_fold_above_them` and
|
||||
`the_group_under_the_cursor_is_the_innermost_at_every_depth`: under it a closed
|
||||
year shows its quarters as rows.
|
||||
|
||||
### a_groups_depth_opens_it_and_n_minus_one_levels_beneath
|
||||
|
||||
`expanded(2): Time/2024` opens 2024 and its quarters, not its months. Rival: an
|
||||
ancestor reaching `k <= n` levels instead of `k < n`. Only this test fails, which
|
||||
is why it exists: the boundary is the whole semantics of N.
|
||||
|
||||
### a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it
|
||||
|
||||
`z` writes `Collapsed` or `Expanded(1)`, or removes the fold where the flip
|
||||
lands on what the group inherits. Rival: never remove. This test and
|
||||
`command::cmd::grid::tests::z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again`
|
||||
fail; every layout test passes, since a redundant fold draws the same grid. What
|
||||
the rival breaks is the file (a line per toggle, forever) and `z z` being the
|
||||
identity on the view.
|
||||
|
||||
### pruning_drops_an_empty_inner_group_and_keeps_its_parent
|
||||
|
||||
`prune_empty` at depth: an empty quarter goes, header and all, and its year's
|
||||
header stays for the sibling quarter that has data. Rival: `prune_axis_items`
|
||||
keying open runs by level alone. This test and the `NESTED` enumeration fail.
|
||||
|
||||
## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2)
|
||||
|
||||
These tests were written before `slugify` and `IdAllocator` had bodies. With
|
||||
`todo!()` bodies all eleven failed. Each rival below was then injected into the
|
||||
finished module, run against `cargo test -p improvise-core --lib model::id`, and
|
||||
reverted. The reasoning is `context/code/core.md` §20.
|
||||
|
||||
### model::id::tests::slugify_case_folds_rather_than_lowercases
|
||||
|
||||
Rival: `display.to_lowercase()` in place of `unicase`'s full folding. Only
|
||||
this test fails: `left: "strae"`, `right: "strasse"`. `ß` lowercases to itself
|
||||
and step 3 strips it. `slugify_follows_the_adrs_examples` cannot see this rival,
|
||||
because each of its displays lowercases and folds alike.
|
||||
|
||||
### model::id::tests::slugify_collapses_a_run_of_spaces_to_one_underscore
|
||||
|
||||
Rival: every space becomes `_`, with runs not collapsed. Only this test fails:
|
||||
`"time___period"`. No other fixture has two spaces in a row.
|
||||
|
||||
### model::id::tests::slugify_keeps_an_underscore_the_display_already_had
|
||||
|
||||
Rival: `.replace("__", "_")` after the space step, which reads answer 21 as
|
||||
"no doubled underscores". Only this test fails: `left: "a_b"`. The rule
|
||||
collapses runs of *spaces*. An underscore that was in the display is kept.
|
||||
|
||||
### model::id::tests::the_suffix_is_the_next_counter_value_not_the_lowest_free_number
|
||||
|
||||
Rival: the lowest free `<base>-<n>` for each base, which revision 5 of the ADR
|
||||
proposed and answer 28 replaced. Three tests fail: this one (`"west-1"` against
|
||||
`"west-2"`), `an_empty_slug_takes_its_kind_and_the_shared_counter` (`"item-1"`)
|
||||
and `a_slug_taken_by_another_kind_takes_a_suffix` (`"time_period-1"`). Only
|
||||
interleaving two bases separates one shared counter from per-base numbering,
|
||||
and this test is the one that does it on purpose.
|
||||
|
||||
### model::id::tests::a_claimed_id_keeps_its_spelling_and_generation_steps_past_it
|
||||
|
||||
Rival: `claim` validates the id but does not record it. This test and
|
||||
`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` fail. The second
|
||||
`East` is issued `east-1`, the file's own id, which is the collision answer 29
|
||||
forbids: "doing otherwise will corrupt the data section".
|
||||
|
||||
### model::id::tests::generated_ids_are_distinct_well_formed_and_never_reserved
|
||||
|
||||
Rival: `generate` does not record the id it issues, so nothing is retired. Five
|
||||
tests fail, this proptest among them (`reissued`), along with every
|
||||
suffix-order test. The proptest is the one that states the property itself: no
|
||||
id is issued twice in a session, whatever the displays.
|
||||
|
||||
### model::id::tests::two_allocators_fed_alike_generate_alike
|
||||
|
||||
This test builds two allocators independently, so they share no hash state,
|
||||
rather than calling one twice. `seen` is a `HashSet`, but it is only ever
|
||||
queried and never iterated, so its order cannot reach an id (gotcha 18). No
|
||||
rival of the current shape breaks this test. It guards against a future
|
||||
change that picks a candidate by walking `seen`.
|
||||
|
||||
### model::id::tests::a_long_claimed_run_of_suffixes_is_walked_without_recursion
|
||||
|
||||
This is the Codex P2 on PR #318 (review of `a2ddb06e`), reproduced by the adversarial review of
|
||||
`689480bf`. `IdAllocator::suffixed` recursed once per taken candidate, and the
|
||||
call was not in tail position. A new-version file saved from a non-ASCII import
|
||||
claims `item-1` … `item-N`, and the counter restarts at 1 on load, so the
|
||||
next empty-slug generation walks all N. Against the recursive version this test
|
||||
(N = 20,000) aborted the whole test binary: `has overflowed its stack` /
|
||||
`SIGABRT`. It is an abort, not a failed assertion, so no test could catch it.
|
||||
The loop passes, and still advances the counter on every candidate it tries.
|
||||
The second assertion, `category-20002`, pins that: a loop that restarted from
|
||||
a local counter on each call would answer `category-1`.
|
||||
|
||||
### model::id::tests::claim_refuses_an_id_that_only_begins_like_a_virtual_one
|
||||
|
||||
Found by the adversarial review of `689480bf`. With `claim` rewritten to
|
||||
exempt any id that begins with a virtual id (`VIRTUAL_IDS.iter().any(|v|
|
||||
id.starts_with(v))`), all 11 earlier tests passed, because
|
||||
`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` tries only `_draft`.
|
||||
This test fails on that rival (`left: Ok(())` for `_indexer`). The match is
|
||||
exact by the ADR's own wording: answer 23 says no other id may begin with `_`.
|
||||
|
||||
### model::id::tests::a_display_with_leading_spaces_never_slugs_into_the_reserved_space
|
||||
|
||||
Rival, from the same review: strip the leading `_` before the space step, so
|
||||
that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does
|
||||
`generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test,
|
||||
the proptest was the only thing that saw the rival.
|
||||
|
||||
## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice)
|
||||
|
||||
Each renderer test below builds a projection whose display differs from its key.
|
||||
The test-only way to get one is `set_display`, since no production path makes
|
||||
them differ yet. Every test failed before the change, with the key drawn where
|
||||
the display should be: `left: "Region"` against `right: "Sales Area"` in the
|
||||
projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI
|
||||
buffers.
|
||||
|
||||
### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name
|
||||
|
||||
It finds the row by `name == "Region"` before asserting on the display. A rival
|
||||
that writes the display into `name` fails the lookup, which covers the half that
|
||||
keeps commands working. `_Measure` is checked to fall back to its own key.
|
||||
|
||||
### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key
|
||||
|
||||
Rival: the active marker compares `summary.display == active_view`. This test
|
||||
fails on that rival (no `▶ Overview`), because the active view is a key
|
||||
(`ViewState::active_view`) and its display differs from it.
|
||||
|
||||
### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names
|
||||
|
||||
The browser half of the same three panels. It also asserts that the view row is
|
||||
still `active` when found by key.
|
||||
|
||||
@@ -882,6 +882,11 @@ The control: a **lone** `\r` is itself a record terminator to this reader, so
|
||||
it is not part of a CRLF pair, and it is why "two records, clean values" is a
|
||||
real observation rather than a coincidence.
|
||||
|
||||
Since improvise-8jk6.4 the parser yields text, so the assertion is that
|
||||
`Revenue` is the text `1`. A kept `\r` would make it `1\r`, which fails that
|
||||
assertion, and which `number_in` would not read as a number where a measure
|
||||
wanted one.
|
||||
|
||||
### an_empty_file_is_no_records_and_no_warnings
|
||||
|
||||
An empty file is no records and no complaint. The wizard's own preconditions
|
||||
@@ -919,12 +924,17 @@ The consequence downstream is deliberate and worth stating: a column carrying
|
||||
Label rather than a Measure. That is the honest answer — the column is not
|
||||
numeric — and it is visible in the wizard, where a fabricated zero was not.
|
||||
|
||||
Since improvise-8jk6.4 the numeric rule is `csv_parser::number_in`, applied
|
||||
through `typed` only where a number is wanted, and the parser itself yields
|
||||
text. So this test asserts that `typed` leaves each token as text. The
|
||||
`unwrap_or` fault would be injected into `number_in` now.
|
||||
|
||||
### an_ordinary_float_is_still_a_number
|
||||
|
||||
The control for the entry above. Guarding non-finiteness by deleting the float
|
||||
branch entirely would also pass that test, so this one pins that `1000.50` is
|
||||
still a `Number` and that `0` — the value the bug invented — is still reachable
|
||||
when the file really says so.
|
||||
when the file really says so. Since improvise-8jk6.4 both go through `typed`.
|
||||
|
||||
### a_repeated_header_is_renamed_rather_than_overwriting_its_twin
|
||||
|
||||
@@ -1002,7 +1012,7 @@ the error, rather than moving to a `Done` step with nothing to confirm.
|
||||
|
||||
### wizard_blocks_advance_when_over_category_limit
|
||||
|
||||
Bug improvise-mzv: with more than `MAX_CATEGORIES` (12) accepted Category
|
||||
Bug improvise-mzv: with more than `MAX_CATEGORIES` (12 then, 24 since improvise-felo.1) accepted Category
|
||||
proposals, the wizard let the user advance through every remaining step and
|
||||
`build_model()` only failed at the final confirm. Advancing past
|
||||
`ReviewProposals` must be blocked, with a message telling the user to mark
|
||||
@@ -1026,6 +1036,14 @@ every blank column value in a CSV import grew a phantom "null" item. Null means
|
||||
*absent*: the record must be skipped and counted, exactly as if the key were
|
||||
missing.
|
||||
|
||||
**The skip is superseded by improvise-8jk6.2.** Null still means absent, and
|
||||
still invents no item. But an absent coordinate no longer drops the record: it
|
||||
keeps its cell without that coordinate. So the test now asserts both cells
|
||||
(`region=East` and the bare `_Measure=revenue`) and no warning. The rival that
|
||||
restores the skip (a present-but-blank category returns the record as
|
||||
unplaceable) fails here, beside
|
||||
`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`.
|
||||
|
||||
### build_model_literal_string_null_is_a_real_category_item
|
||||
|
||||
The other side of improvise-ych: the *string* `"null"` is an ordinary value — a
|
||||
@@ -1038,7 +1056,9 @@ null" would pass the test above.
|
||||
improvise-ych, third arm. An empty string is the JSON spelling of a blank cell —
|
||||
the CSV parser already turns a blank field into `Value::Null`, so the two inputs
|
||||
must agree. An item with an empty name is unnameable in the grid, so it is
|
||||
treated as missing rather than created.
|
||||
treated as missing rather than created. Since improvise-8jk6.2 "missing"
|
||||
means the record keeps its cell without a `region` coordinate. It is no longer
|
||||
skipped, and the same skip-restoring rival fails here.
|
||||
|
||||
### build_model_sums_records_that_share_every_coordinate
|
||||
|
||||
@@ -1107,12 +1127,19 @@ and scrolls them. It was red at compile time against the old `Option<String>`
|
||||
signature, then red against a `Vec` that still held one elided sentence. The
|
||||
rival **elided after three** fails it.
|
||||
|
||||
### build_model_warns_about_records_skipped_for_missing_category
|
||||
### improvise-5j9's skip warning, retired by improvise-8jk6.2
|
||||
|
||||
improvise-5j9: skipping a record for a missing category value used to be silent,
|
||||
so a 1000-row import could drop rows with no user-visible sign. The warning must
|
||||
name the count, the total, and the offending field(s) — a bare "some records were
|
||||
skipped" is not actionable.
|
||||
so a 1000-row import could drop rows with no user-visible sign. The warning had
|
||||
to name the count, the total, and the offending field(s); a bare "some records
|
||||
were skipped" is not actionable. Its test,
|
||||
`build_model_warns_about_records_skipped_for_missing_category`, was **deleted**
|
||||
by improvise-8jk6.2. A record missing a category value is now kept without that
|
||||
coordinate, so there is no skip left to report. Its successor is
|
||||
`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`, which
|
||||
asserts no warning at all. `build_model_record_with_missing_category_value_skipped`
|
||||
became `build_model_record_with_missing_category_field_is_kept` and now asserts
|
||||
the kept cell rather than only the East one.
|
||||
|
||||
### is_does_not_swallow_the_start_of_a_longer_word
|
||||
|
||||
@@ -1355,7 +1382,7 @@ Faults injected, both against the fixed tree:
|
||||
### a_document_at_the_category_limit_plus_a_label_reloads
|
||||
|
||||
The P1 half, and the reason improvise-dy3j was not merely a lost flag. `Label`
|
||||
is outside `MAX_CATEGORIES = 12` by design — `Model::add_label_category` has no
|
||||
is outside `MAX_CATEGORIES` (12 then) by design — `Model::add_label_category` has no
|
||||
limit check, and the import wizard's `proposed_category_count` scores a label
|
||||
as zero — so a document with twelve categories and one label is legally
|
||||
constructible and the wizard will build one from any CSV carrying a free-text
|
||||
@@ -1364,7 +1391,7 @@ refused, and `parse_md` returned
|
||||
`Err("Maximum of 12 categories reached")`: **the document was permanently
|
||||
unloadable.** Reproduced at `d46ec59` with exactly that error.
|
||||
|
||||
The second assertion (`regular_category_names().len() == 12`) is what keeps the
|
||||
The second assertion (`regular_category_names().len() == crate::model::MAX_CATEGORIES`) is what keeps the
|
||||
first honest. A "fix" that simply raised or removed the limit would let the
|
||||
document load while putting a high-cardinality label into the pivot count, and
|
||||
this assertion fails on it.
|
||||
@@ -1436,7 +1463,16 @@ The fix reports and does not relocate: the row still lands with the coordinates
|
||||
it does have, exactly as before. Changing where such a row is placed is a
|
||||
behaviour change the ticket did not ask for.
|
||||
|
||||
### import::wizard::tests::a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally
|
||||
### import::wizard::tests::a_row_kept_without_a_category_is_charged_to_the_date_tally
|
||||
|
||||
**Renamed from `a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally`
|
||||
by improvise-8jk6.2.** Its premise went with the skip. A row missing `Region`
|
||||
now lands, so its unparseable `Date` *is* a derived column's loss, and it is
|
||||
charged once to each of `Date_Month` and `Date_Year`. The test asserts the
|
||||
landed cell (`Date=2025-02-05, _Measure=Amount = 200`, with no `Region` and no
|
||||
derived part) and exactly those two warnings. The history below is kept
|
||||
because the rule it established (charge only landed rows) still holds. Every
|
||||
object row now lands.
|
||||
|
||||
improvise-yss5, found by ChatGPT Codex reviewing PR #150 at `af7fae0e` and
|
||||
correct. The first fix incremented `unparsed_dates` while walking a record's
|
||||
@@ -1566,8 +1602,11 @@ bounded to four advances, so the rival fails it rather than stalling the suite.
|
||||
|
||||
### keeping_duplicates_separate_is_refused_at_the_category_cap
|
||||
|
||||
Twelve categories plus a collision, which is the reported import. It asserts
|
||||
option (b) is disabled with a reason naming 12 and Skip, that choosing it leaves
|
||||
A full set of categories plus a collision, which is the shape of the reported
|
||||
import. The fixture is `category_records(MAX_CATEGORIES)`, so it tracks the cap
|
||||
(12 when the test was written, 24 since improvise-felo.1). It asserts option (b)
|
||||
is disabled with a reason naming the cap (`reason.contains(&cap)`, where `cap`
|
||||
is `MAX_CATEGORIES` spelled out) and Skip, that choosing it leaves
|
||||
the step and the policy (`Refuse`) untouched, and that the message is the
|
||||
reason. The rival **cap checked with `>`** fails it.
|
||||
|
||||
@@ -1614,3 +1653,396 @@ that name, which must be refused. It was green on arrival, so its evidence is
|
||||
the reviewer's rival, **date-derived names dropped from the taken names**,
|
||||
which fails it. That fault would have let `add_category` reuse the existing
|
||||
category, and every cell key would then name `when_Year` twice.
|
||||
|
||||
### a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group
|
||||
|
||||
The user's ruling on improvise-felo.6. Three spellings of one group given two
|
||||
folds, each refused as `Refusal::FoldConflict` naming the view and the group,
|
||||
plus a category given two depths. Rival: a second fold for a group silently
|
||||
wins. Only this test fails. Its control is
|
||||
`a_collapsed_group_inside_an_expanded_region_loads`: a DIFFERENT group
|
||||
collapsed inside an expanded region, and the same line twice, both load.
|
||||
|
||||
### a_cycle_among_group_parents_is_refused_naming_it
|
||||
|
||||
`> Q1-2025[|2025|]` and `> |2025|[Q1-2025]`. Rival: the cycle check disabled;
|
||||
only this test fails. The fixture's first version spelled `2024-01` bare, which
|
||||
the grammar refuses (a bare name starts with a letter), so every test on it
|
||||
passed or failed for the parse error, `a_malformed_depth_is_a_parse_error`
|
||||
vacuously. It now carries a control that parses.
|
||||
|
||||
### an_axis_line_for_a_category_named_expanded_is_still_an_axis_line
|
||||
|
||||
`axis_line` is tried before `expanded_line`, so the writer must never spell a
|
||||
category depth bare. Rival: the writer writing `expanded: Cat` for depth 1. This
|
||||
test fails: the category `row` at depth 1 reads back as an axis line for a
|
||||
category named `expanded`.
|
||||
|
||||
### an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines
|
||||
|
||||
"Everything opens closed, including old files." Every group of the file opens
|
||||
closed, the one its `collapsed:` line names and the three it does not, and the
|
||||
line is written back. Rival: the old open default; this test fails with the
|
||||
other eleven in that rival's list.
|
||||
|
||||
### a_date_column_imports_as_one_category_of_months_in_quarters_in_years
|
||||
|
||||
improvise-8jk6.3, the user's "dates should usually be one category". The
|
||||
fixture has two days of one month, so that month is one summed cell, and
|
||||
January of the next year, so a month must stay unique across years. Rivals, each
|
||||
run against the `date_hierarchy` module, and each caught here:
|
||||
- The split as the default shape.
|
||||
- Levels taken finest first, so the year hangs under the month.
|
||||
- Groups registered without their parents.
|
||||
- The raw dates still added beside the months.
|
||||
- A hierarchy counted as `1 + components` slots.
|
||||
|
||||
### a_level_left_unchosen_is_skipped_not_emptied
|
||||
|
||||
Year + Month nests months straight into years, and Quarter alone is a flat
|
||||
category of quarters. Caught the finest-first rival and the raw-dates rival.
|
||||
|
||||
### an_imported_date_hierarchy_opens_folded_to_its_years
|
||||
|
||||
The import sets nothing: the hierarchy opens folded, one row per year, because
|
||||
every group starts closed (improvise-felo.6). The 2024 row is the fold of its
|
||||
months, 111. Caught three of the rivals above.
|
||||
|
||||
### an_unparsed_date_keeps_its_value_and_is_warned_about
|
||||
|
||||
A format that reads none of the values keeps each raw value as an ungrouped
|
||||
item, and the build says so. Its first version passed on the base commit: the
|
||||
shape was ignored there and the SPLIT's warning happened to contain the same
|
||||
words. It now also asserts there is no `Date_Year`. Rival: the unparsed value
|
||||
not counted; this test fails.
|
||||
|
||||
### a_headless_date_import_splits_when_told_to
|
||||
|
||||
`--split-date` is the wizard's `s`, headless. It failed first as an unknown
|
||||
argument. Rival: the flag parsed and ignored; this test fails, and
|
||||
`a_headless_date_import_is_one_hierarchical_category` does not, which is the
|
||||
point of having both.
|
||||
|
||||
### every_fold_the_api_can_build_reads_back_from_the_file_it_writes
|
||||
|
||||
ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and
|
||||
`View::set_group_fold` are public, so `Expanded(0)` could be built, written as
|
||||
`expanded(0): …` and then refused by the grammar that wrote it. Confirmed by
|
||||
reading: the writer formatted any `usize`, and `depth_number` starts at `1`.
|
||||
The fix makes the state unrepresentable (`Expanded(NonZeroUsize)`), so the
|
||||
regression guard is the type, and the old failing input no longer compiles.
|
||||
This test covers every fold the API CAN build (`Collapsed` and depths 1 to 3,
|
||||
beside a category depth), each saved and read back.
|
||||
|
||||
### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate
|
||||
|
||||
improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342
|
||||
records missing category values: Settlement Date (6)". `Plan::place` returned
|
||||
`Placement::Missing` for a category with no value, so the build dropped the
|
||||
whole record, measures and all. The fixture has one complete record, one with
|
||||
`"Settlement Date": null` and one with `""`, for two different payees. At
|
||||
`e65d87fd`'s parent the build stored only the first cell. Now the stored cells
|
||||
are exactly the three records, the two blanks with no date coordinate, and
|
||||
there is no warning. `Settlement Date` holds only its one real item, so neither
|
||||
`"null"` nor `""` became one. Rival R1, a record with a present-but-blank
|
||||
category treated as unplaceable, fails this test and the two improvise-ych
|
||||
tests.
|
||||
|
||||
### files_with_different_headers_import_as_one_sparse_model
|
||||
|
||||
improvise-lpzu. code-maat's `age.csv` (`entity,age-months`) and `coupling.csv`
|
||||
(`entity,coupled,degree,average-revs`) merged through `merge_csvs_from`. Before
|
||||
the fix, the age records had no `coupled` key, so they took the same `Missing`
|
||||
path and **every age record was dropped**. The build stored the four coupling
|
||||
cells and nothing else. The test pins the proposals (each column's kind,
|
||||
judged over its present values), all six stored cells sorted, no empty-named
|
||||
item in any category, and no warning. Rival R2, a record whose file lacks a
|
||||
category column skipped, fails it along with the two CLI folder tests, the
|
||||
projection test and the per-file view test.
|
||||
|
||||
### a_label_column_a_record_lacks_is_no_coordinate_not_an_empty_item
|
||||
|
||||
improvise-lpzu, the label half. Labels were attached with
|
||||
`scalar_name(..).unwrap_or_default()`, so an age record, whose file has no
|
||||
`coupled`, stored `coupled=` as a coordinate, and `coupled` grew an item named
|
||||
`""`. Before the fix the first sorted cell was
|
||||
`File=age, _Measure=age-months, coupled=, entity=src/a.rs = 2`. Rival R3
|
||||
(`unwrap_or_default` restored) fails it.
|
||||
|
||||
### a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest
|
||||
|
||||
improvise-lpzu. The folder holds three CSVs written out of name order, one
|
||||
with an upper-case `.CSV`, plus a `.txt`, an `.improv` (the output a second run
|
||||
finds) and a CSV in *another* folder. Only the three are returned, sorted.
|
||||
`MemoryFs` stores in a `HashMap`, so the order comes from `Storage::list`'s
|
||||
contract and not from insertion. Rival R5 (the extension filter dropped) and
|
||||
rival R6 (the listing passed through a `HashSet`) each fail it.
|
||||
|
||||
### a_folder_with_no_csv_files_is_refused_by_name
|
||||
|
||||
improvise-lpzu. Both an empty folder and a folder holding only non-CSV files
|
||||
must refuse with `no CSV files in folder maat`. Importing nothing would write
|
||||
an empty document. Rival R4 (the `ensure!` removed) fails it, and so does R5,
|
||||
because the text-only folder then "has" files.
|
||||
|
||||
### files_and_folders_expand_in_argument_order
|
||||
|
||||
improvise-lpzu. `[z.csv, b/, a.csv]` must expand to
|
||||
`[z.csv, b/one.csv, b/two.csv, a.csv]`. Argument order is kept, and a folder
|
||||
is sorted within itself. A fixture listing only folders, or files in name
|
||||
order, could not tell "argument order" from "sort everything". R6 fails it.
|
||||
|
||||
### two_files_with_one_stem_union_their_columns
|
||||
|
||||
improvise-8jk6.12 (3). `a/x.csv` (`entity,one`) and `b/x.csv` (`entity,two`) are
|
||||
one `File` item, `x`, whose columns are `entity, one, two`. Rival F3 (the
|
||||
reviewer's fault: clear the stem's columns before each file) leaves
|
||||
`entity, two`, and the `x` view would then hide the measure `one` that half its
|
||||
records carry.
|
||||
|
||||
### files_with_different_headers_merge_without_inventing_the_missing_columns
|
||||
|
||||
improvise-lpzu, at the parser. Each merged record carries exactly its own
|
||||
file's keys plus `File`: no key, `null` or `0` for a column another file has.
|
||||
Each `SourceFile` lists its file's columns in header order. The per-file views
|
||||
are built from those lists.
|
||||
|
||||
### a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid
|
||||
|
||||
improvise-8jk6.2's downstream question, asked of the real layout rather than
|
||||
argued. Bolt has no `Date`:
|
||||
|
||||
- With `Date` on Column or on Page, the grid draws only Acme's `10`.
|
||||
- With `Date` on None, it draws both `10` and `20`.
|
||||
- In records mode, Bolt's row reads `["", "Bolt", "", "20", ""]`, a blank
|
||||
`Date` cell.
|
||||
- The Data line is `Payee=Bolt, _Measure=Amount = 20`, and it round-trips.
|
||||
- A drill on `Payee=Bolt` reaches the record.
|
||||
|
||||
This is a characterization of the model's existing sparse semantics. It found
|
||||
no bug. Its purpose is to make the Row/Column answer ("invisible") a stated
|
||||
fact the user can overrule, rather than an accident. R2 fails it.
|
||||
|
||||
### a_merge_gives_each_file_a_view_projected_to_its_columns
|
||||
|
||||
improvise-lpzu, the user's "one view filtered by the file column and
|
||||
projected to the columns in that file". The test asserts:
|
||||
|
||||
- the view order (`Default`, then the files in read order), with `Default`
|
||||
still active;
|
||||
- `File` on Filter at the file's item;
|
||||
- the file's own categories seated, and the other file's on None;
|
||||
- `_Measure` on Page at the file's first measure, with the other file's
|
||||
measures hidden;
|
||||
- the drawn values.
|
||||
|
||||
The age view draws `[2, 7]`. The coupling view draws `[5, 3]`, because its
|
||||
first measure in model order is `average-revs`: serde_json's map orders keys
|
||||
alphabetically. Failed at `bb505f8f`'s parent, where no view existed. Rivals
|
||||
R7 (File on None), R9 (every category seated) and R10 (nothing hidden) each
|
||||
fail it.
|
||||
|
||||
### a_files_view_holds_only_that_files_records
|
||||
|
||||
improvise-lpzu. The code-maat fixture cannot show that the *filter* works,
|
||||
because its files share no measure, so the measure page alone would isolate
|
||||
them. Two quarterly files with the same `Region,Revenue` schema can: without
|
||||
the filter, `q1` would draw `30` and `3`. Rival R7 fails it. R9 and R10 do
|
||||
not, and are not meant to.
|
||||
|
||||
### a_files_view_name_skips_names_already_held
|
||||
|
||||
improvise-lpzu. Two stems collide with names already held: `Default`, the
|
||||
merged view, and `_Drill`, a virtual view that is never written and is dropped
|
||||
on load. The first becomes `Default 2` and the second `_Drill 2`, and each
|
||||
still filters to its own item. Rival R8 (the virtual names not checked) fails
|
||||
it: the file's view would have been saved as `_Drill` and lost.
|
||||
|
||||
### per_file_views_survive_a_round_trip_and_repeat_byte_for_byte
|
||||
|
||||
improvise-lpzu. Two builds from two independently written `MemoryFs` give
|
||||
identical `.improv` text, and `format(parse(text)) == text`. The reloaded
|
||||
document still has the three views in order, `File: filter` at the file's item,
|
||||
and the same drawn values. This is the first persisted, non-virtual use of
|
||||
`Axis::Filter` (gotcha 27). R7 and R9 fail it.
|
||||
|
||||
### a_csv_column_read_as_a_category_keeps_its_text_and_as_a_measure_its_number
|
||||
|
||||
improvise-8jk6.4, at the pipeline. It checks three things in one fixture,
|
||||
one per reader of a CSV value:
|
||||
|
||||
- analysis still proposes the numeric-looking `commit` as a Measure (typed);
|
||||
- forced to Category, its items are `00565796` and `220944e2` (text);
|
||||
- `lines` is stored as the number 5 (typed).
|
||||
|
||||
The rivals, each run:
|
||||
|
||||
- **R1**, a category named from the typed value, fails it on the items.
|
||||
- **R2**, a CSV measure read with `as_f64` on its text, fails it on the cell.
|
||||
- **R3**, CSV analysed without typing, fails it on the proposal.
|
||||
|
||||
R2 and R3 also fail the five lpzu merge tests in this module and five
|
||||
headless imports. That is how much of the suite leans on CSV measures reading
|
||||
as numbers.
|
||||
|
||||
This test uses `from_csv` and so cannot compile on the base. The base failure
|
||||
for 8jk6.4 is the headless test
|
||||
`a_csv_column_forced_to_category_keeps_its_text_verbatim`.
|
||||
|
||||
### a_formula_that_does_not_parse_stops_the_build_and_is_named
|
||||
|
||||
improvise-8jk6.5. On the base the build returned `Ok`, keeping `Good` and
|
||||
silently dropping `Bad`. Two formulas fail to parse here, so the test proves
|
||||
every failure is named. **R5**, naming only the first, fails it on `Worse`,
|
||||
and since improvise-8jk6.17 the headless
|
||||
`an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`
|
||||
sees R5 too (it passes two bad formulas). It also asserts `Good` is not named, so a
|
||||
message that lists every formula cannot pass. **R4** (drop, as the base did)
|
||||
fails it.
|
||||
|
||||
### confirming_a_formula_that_does_not_parse_keeps_it_out_and_the_text_in
|
||||
|
||||
improvise-8jk6.5, the wizard half. On the base `confirm_formula` pushed
|
||||
`Bad = revenue +` into the list, which failed on `pipeline.formulas`. The test
|
||||
asserts the text is still in the buffer and the editor still open. A fix that
|
||||
refused but cleared the buffer would throw away what the user typed, the same
|
||||
hazard improvise-v4hg fixed in the TUI formula minibuffer. **R6**, confirming
|
||||
regardless, fails it. The headless suite cannot see R6.
|
||||
|
||||
### a_build_that_keeps_no_record_refuses_and_says_why
|
||||
|
||||
improvise-8jk6.6. On the base both halves got `At least one category must be
|
||||
accepted`, a refusal right only by accident. It asserts the exact message for
|
||||
the two cases `nothing_to_import` distinguishes: all skipped, and no records
|
||||
at all.
|
||||
|
||||
- **R7**, the check removed from the build, fails it.
|
||||
- **R9**, `any` swapped for `all`, also fails it, on the empty source: `all`
|
||||
is vacuously true over no records.
|
||||
|
||||
### the_review_step_says_when_no_record_can_be_imported
|
||||
|
||||
improvise-8jk6.6, the wizard half (gotcha 22: the check lives on the step
|
||||
where the user can still choose another array path). On the base the step
|
||||
refused with `No categories: … Press c to cycle a field's kind`, which offers
|
||||
a key that has no field to act on. **R8**, the step without the check, fails
|
||||
it. The build test does not see R8.
|
||||
|
||||
### a_header_only_file_keeps_its_file_item_its_columns_and_its_view
|
||||
|
||||
The ChatGPT Codex P2 on PR #314 at `4e68372c`, confirmed by the adversarial
|
||||
review. `age.csv` (one row), a header-only `coupling.csv` and `rev.csv` (one
|
||||
row). At `005b7a76` the test failed at the first assertion: `File` held only
|
||||
`age` and `rev`, so the `coupling` view filtered to an item that did not exist,
|
||||
and `coupled`, `degree` and `average-revs` were not in the model. Now `File`
|
||||
reads `age, coupling, rev` in file order, the three columns exist (as Labels),
|
||||
and the `coupling` view is a zero-row records view filtered to `coupling`, while
|
||||
the `age` view still draws `2`. Rival F5 (sources not seeded) and F6 (unseen
|
||||
columns not proposed) each fail it, and so do F7 and F8 below.
|
||||
|
||||
### a_file_with_no_measure_gets_a_records_view_not_another_files_measure
|
||||
|
||||
improvise-8jk6.13, from the review of PR #314. `a.csv` (`entity,x`) and
|
||||
`tags.csv` (`entity,tag`). At `005b7a76` the `tags` view had `_Measure` on Page
|
||||
with no selection, so the layout paged onto `x`, a measure the same view hides.
|
||||
The test failed there, with `row_count() == 1`: `a`'s record, drawn under
|
||||
`File=tags`'s view. Now the view has no `_Measure` page, is a records layout,
|
||||
and draws zero rows, while `a`'s view is still a pivot. Rival F7 (the old
|
||||
paging) fails it, and F8 (the File selection lost when `records_steps` moves
|
||||
`File` off Filter) fails it with the same `1`. F8 is the mistake the first
|
||||
version of the fix made.
|
||||
|
||||
### a_files_view_seats_the_date_parts_of_its_own_date_column
|
||||
|
||||
improvise-8jk6.12 (1). The reviewer replaced the date-parts clause of
|
||||
`source_view`'s `own` with `false` and every test passed, because no folder
|
||||
fixture extracted a date part. Here `abs-churn.csv` has a `date` column,
|
||||
extracted to `date_Month`, and `age.csv` does not. `date_Month` is seated in the
|
||||
`abs-churn` view and on None in the `age` view. Rival F1 (the reviewer's fault)
|
||||
puts it on None in both and fails the first assertion.
|
||||
|
||||
### two_files_whose_names_collide_in_one_batch_keep_a_view_each
|
||||
|
||||
improvise-8jk6.12 (2). `Default.csv` and `Default 2.csv`. The first takes
|
||||
`Default 2`, so the second must take `Default 2 2`. Without the same-batch
|
||||
check both are named `Default 2`, and `IndexMap::insert` silently replaces the
|
||||
first, leaving two views where three are owed. The test asserts all three names
|
||||
with their filters. Rival F2 (the reviewer's fault) fails it.
|
||||
|
||||
### a_whitespace_only_category_value_is_no_coordinate
|
||||
|
||||
improvise-8jk6.12 (4). A `region` of `" "` is kept as a record with no
|
||||
`region` coordinate, and `region` holds only `East`. The trim in `scalar_name`
|
||||
predates this PR (improvise-ych), but the sparse-record rule now rests on it:
|
||||
without it the value becomes an item named `" "`. Rival F4 (the reviewer's
|
||||
fault) fails it.
|
||||
|
||||
### a_raw_date_spelled_like_a_generated_item_is_refused
|
||||
|
||||
improvise-d0n6 (Codex P2 on PR #322). A `%Y-%m-%d` column imported Year > Month
|
||||
with `2024-01-15` and a raw `2024-01`. Before the refusal the build succeeded,
|
||||
put both records on the grouped item `2024-01` (summing them) and warned that
|
||||
the raw value was "kept ungrouped", which was false. Fault injected: removing
|
||||
the `spelling_clash` call makes this test fail with `unwrap_err()` on an `Ok`.
|
||||
|
||||
### a_raw_date_spelled_like_a_generated_group_is_refused
|
||||
|
||||
improvise-d0n6. The same with a raw `2024`, a generated group. Removing the
|
||||
`spelling_clash` call fails it the same way. The control,
|
||||
`a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped`, passes either
|
||||
way, so a blanket refusal of every unparsed value fails it.
|
||||
|
||||
### the_date_step_will_not_advance_over_a_spelling_clash
|
||||
|
||||
improvise-d0n6 (Copilot on PR #322, and the adversarial review's B1). The
|
||||
spelling-clash refusal sat only in the build, so a wizard with a raw `2024` in
|
||||
a `%Y-%m-%d` column passed ConfigureDates and failed on NameModel, three steps
|
||||
from the `s` that fixes it. Before the check joined `validation_error` the
|
||||
test failed with the step already `DefineFormulas`; removing that call again
|
||||
reproduces it. It also toggles to the split shape and requires the step to
|
||||
advance, so a check that blocks forever fails.
|
||||
|
||||
### split_date_naming_an_unknown_or_non_date_field_is_refused
|
||||
|
||||
improvise-tpm8 class. `--split-date Nope` and `--split-date Revenue` (a
|
||||
measure) exited 0 and did nothing. Both now exit 1 naming the flag and the
|
||||
field; removing the check in `run_headless_import` fails both rows.
|
||||
|
||||
### an_axis_naming_a_category_the_import_did_not_build_is_refused
|
||||
|
||||
improvise-8jk6.3 review. `--extract Date:Month --axis Date_Month:column` used
|
||||
to build one `Date` category and drop the `--axis` for the vanished
|
||||
`Date_Month`, exit 0. It now exits 1 naming the category and `--split-date`.
|
||||
This also refuses any `--axis` naming an absent category, which was silently
|
||||
ignored before this PR as well.
|
||||
|
||||
### a_folder_of_only_header_only_files_keeps_no_record_and_refuses
|
||||
|
||||
improvise-8jk6.6 meeting PR #314's rule that a header-only file is still a
|
||||
file. Written after merging `main` at `c91cc055`, where the two rules first
|
||||
met. The file seeds give the pipeline a `File` Category proposal even with no
|
||||
records. So without `nothing_to_import` the build succeeds and saves `File`
|
||||
items and empty views: the category check alone cannot refuse. The `setup`
|
||||
assertion proves that seeded proposal exists, so the test is not passing only
|
||||
because there was no category. **R7**, the check removed from the build, fails
|
||||
it. The user confirmed the refusal on PR #321: "Refuse (as #321 does)".
|
||||
|
||||
### an_absent_axis_category_that_is_no_date_part_names_what_was_built
|
||||
|
||||
improvise-8jk6.3 review (B2). The README's own recipe passed
|
||||
`--axis Measure:column`, a silent no-op because the category is `_Measure`; the
|
||||
first refusal blamed dates for it. The test uses a non-date absent name and
|
||||
asserts the message lists the built categories and carries no date hint.
|
||||
Fault injected: making the hint unconditional fails it.
|
||||
|
||||
### the_readmes_import_recipe_runs
|
||||
|
||||
improvise-8jk6.3 review (B2). Runs the README's recipe as printed. Fault
|
||||
injected: restoring `--axis Measure:column` in the README fails it with the
|
||||
refusal.
|
||||
|
||||
### split_date_on_a_time_field_with_no_date_format_is_refused
|
||||
|
||||
improvise-8jk6.3 review. `--time Revenue --split-date Revenue` made the field a
|
||||
time category with no date format and the split was a silent no-op. Without
|
||||
the `date_format.is_some()` condition the import exits 0 and this fails.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Layout and keypress cost (improvise-felo.13)
|
||||
|
||||
Measured on an **Apple M4 Max, 64 GB**, macOS, release build, with
|
||||
`benches/scale_bisect.rs` (merged from `bench/scale-bisect`, `13918bb9`), one
|
||||
configuration per process, 3 categories of K items on Row, N = 100,000 capped at
|
||||
K^3/2 records, prune off unless stated. Baseline is this branch with only the
|
||||
tests added; "after" is the branch head. `key` is `App::dispatch_key(Down)`;
|
||||
"cold" is the first key after open or after an edit, which pays the one
|
||||
whole-grid measurement; "warm" is every key after it.
|
||||
|
||||
## Before and after
|
||||
|
||||
| rows | before key | after key warm | after key cold | before browser window | after browser window | SetCell before / after |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 729 (K=9) | 5.5 | 0.00 | 1.4 | 5.0 | 0.13 | 0.9 / 0.9 |
|
||||
| 4,913 (K=17) | 108 | 0.00 | 6.0 | 105 | 0.12 | 5.8 / 5.9 |
|
||||
| 5,832 (K=18) | | 0.00 | 7.7 | | 0.12 | 6.8 |
|
||||
| 6,859 (K=19) | | 0.00 | 8.8 | | 0.12 | 8.6 |
|
||||
| 19,683 (K=27) | 1,271 | 0.00 | 24.5 | 1,259 | 0.14 | 23.0 / 22.7 |
|
||||
| 64,000 (K=40) | 10,771 | 0.00 | 84 | 10,746 | 0.20 | 79 / 80 |
|
||||
|
||||
Prune on: K=17 (2,456 rows) key 264 ms -> 0.00 warm, 3.6 cold; prune toggle
|
||||
172 -> 8.8 ms; K=27 prune toggle 2,189 -> 41.5 ms. Records mode (3 x 1000, 14,500
|
||||
records): key 1,004 ms -> 0.00 warm, 2.8 cold; **SetCell 1,511 ms -> 1,512 ms,
|
||||
unchanged** (not this ticket's cost).
|
||||
|
||||
Boundaries, warm key and browser window: inside 8 ms at every size measured (to
|
||||
216,000 rows, 303 ms cold). Cold key: inside 8 ms to K=18 (5,832 rows), over 8 ms at
|
||||
K=19. Before: inside 8 ms to 729 rows, over 100 ms at 4,913, over 1 s at 19,683.
|
||||
The remaining cold cost is linear (1.2-1.4 us per row) and is the whole-grid width
|
||||
and total pass; the warm path does not repeat it.
|
||||
|
||||
## What the ticket's reading got right and what it did not predict
|
||||
|
||||
The O(rows^2) in `cell_key` was real and is one of three causes. Two more sat
|
||||
under it. First, `compute_col_widths` ran on **every** keypress, through
|
||||
`cmd_context`, and `dispatch_key` and `apply_effects` each rebuilt the layout
|
||||
whether or not anything had moved. Second, once the index was O(1) the width pass
|
||||
was still O(K^2) per cell: with `_Index`/`_Dim` on `Axis::None` the aggregation is
|
||||
not `Exact`, so each cell went through `DataStore::matching_values`, which
|
||||
narrows by the smallest index set (about N/K candidates) and probes the rest. A
|
||||
partial as wide as the widest stored cell can match only one cell, so
|
||||
`exact_positions` probes it directly. That second cause is why K=40's cold key
|
||||
stayed at 8 s after the first two fixes.
|
||||
|
||||
### a_cursor_key_rebuilds_neither_the_layout_nor_the_widths
|
||||
|
||||
`ui::app::tests`. Two keys after a warm-up leave `layout_builds` and
|
||||
`width_passes` where they were. Failed before the fix on the layout count (a
|
||||
cursor key rebuilt it). Rival 1 (`refresh_layout` always rebuilding): fails on
|
||||
`layout rebuilt on a cursor key`. The test seats the cursor at row 2, so "the
|
||||
keys moved the cursor" is asserted and a key that does nothing cannot pass.
|
||||
|
||||
### an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value
|
||||
|
||||
The control for the test above, and the guard on invalidation. A `SetCell` of a
|
||||
long text must rebuild the layout and remeasure, and the cached widths must equal
|
||||
a fresh `compute_col_widths`. Rival 2 (`rebuild_layout` not dropping the cached
|
||||
geometry): fails on `an edit must remeasure`. It also asserts the widths changed,
|
||||
so a fixture where the edit does not widen a column cannot pass.
|
||||
|
||||
### switching_view_rebuilds_though_the_effect_is_view_only
|
||||
|
||||
`SwitchView` is `ViewOnly` (gotcha 3), so skipping the rebuild by `EffectKind`
|
||||
alone would leave the old view's layout. The layout records the view it drew and
|
||||
the drill snapshot it froze, and rebuilds when either differs.
|
||||
|
||||
### an_axis_lookup_does_work_independent_of_the_row_index
|
||||
|
||||
`view::layout::lookup_cost_tests`. Counts the items `data_items` yields during
|
||||
`cell_key`, `row_tuple`, `row_label` and `column_key`; row 399 of 400 must cost no
|
||||
more than row 0 plus two. Failed before the fix: `cell_key: row 399 cost 402 steps
|
||||
against 3 at row 0`. Rival (`cell_key` going back to `data_items(..).nth(row)`):
|
||||
`cost 400 steps against 1 at row 0`. Limit: the counter lives in `data_items`, so
|
||||
a different linear walk written without it is not seen; the next test is what
|
||||
holds the index to the walk's answers.
|
||||
|
||||
### the_position_index_answers_what_the_walk_answered
|
||||
|
||||
Compares `row_tuple`, `data_row_to_visual` and `row_folds_a_group` against the
|
||||
walk over a grouped, collapsed and pruned axis, including two indices past the
|
||||
end. Rival (`data_index_to_visual` reading `data_idx + 1`): fails at `visual 0`.
|
||||
|
||||
### a_full_width_partial_probes_one_cell_instead_of_narrowing
|
||||
|
||||
`model::cell::exact_path_tests`. A full-width partial must not reach set
|
||||
narrowing, and an absent cell and an unseen item must match nothing. Rival
|
||||
(`exact_positions` returning `None`): the narrowing count is 1, not 0. Rival
|
||||
(`widest <= partial.len() + 1`): fails `a_wider_cell_turns_the_shortcut_off_until_it_is_removed`,
|
||||
which stores a cell one coordinate wider and expects it matched.
|
||||
`a_narrower_partial_still_narrows_and_matches_every_superset` is the control: the
|
||||
shortcut is not a blanket exact lookup. `an_unsorted_full_width_partial_is_still_answered`
|
||||
holds the canonical-order condition: a partial not in a stored key's order falls
|
||||
back.
|
||||
|
||||
### a_window_over_a_held_summary_equals_the_one_walk_grid_body
|
||||
|
||||
`ui::viewport::tests`. The host keeps one `GridSummary` (widths, total formats,
|
||||
totals) per layout and reads a window over it with `grid_window`; it must equal
|
||||
what `grid_body` builds for the same window, over the whole grid, an interior
|
||||
window, the last row and the empty window. Rival (`grid_window` reading column
|
||||
`ci + 1`): fails at `window 0..2 x 0..10`. Widths stay the **whole-grid** maximum,
|
||||
because the summary is the same walk `grid_body` did; nothing the user sees moves.
|
||||
|
||||
### a_frozen_records_snapshot_replaces_the_live_row_index
|
||||
|
||||
`view::layout::lookup_cost_tests`. Three records are frozen, two are then cleared,
|
||||
and `with_frozen_records` over the snapshot must draw 3 rows (the control: with no
|
||||
snapshot it draws 1). A drill held open while another client removes records is the
|
||||
production analogue. Rival (deleting `layout.reindex()` after the snapshot swap):
|
||||
fails `left: 1, right: 3`; the whole workspace passed it before this test.
|
||||
|
||||
### a_drill_snapshot_replaced_alone_rebuilds_the_layout
|
||||
|
||||
`ui::app::tests`. `StartDrill` is `ViewOnly`, so a batch of it alone has no `Model`
|
||||
effect to mark the layout stale; the drill pointer in `LayoutBasis` is the only
|
||||
thing that rebuilds it. The test starts one snapshot, then a second: the first
|
||||
step alone passes even with a constant pointer (drill absent to present changes the
|
||||
basis), which is why the second step is the test. Rival (pointer replaced by a
|
||||
constant): fails `no rebuild for a second drill snapshot`.
|
||||
|
||||
### Observation: the TUI frame
|
||||
|
||||
`tui_frame_grid_of_whole` is still 47-53 ms at 19,683 rows after this change: the
|
||||
draw path projects the whole grid, linear and out of scope for this ticket.
|
||||
@@ -1259,3 +1259,51 @@ the Enter commit.
|
||||
- **Rival** (the commit naming `state.view.active_view`, the view at
|
||||
`Enter`), run across the whole workspace: fails **only** this test, with
|
||||
`["Later"]` against `["Other"]`.
|
||||
|
||||
### resolution_takes_each_attribute_from_the_nearest_face_that_states_it
|
||||
|
||||
ADR 004 clause 3: inheritance resolves once, in shared code. The fixture states
|
||||
a different attribute at each of three levels — `default` (foreground White,
|
||||
italic), `mode` (foreground Black, background Blue), and
|
||||
`mode.text-entry.save` (background `Colour::Default`, underline) — so the one
|
||||
resolved face has to take its foreground from the middle, its slant from the
|
||||
root and its background from itself. `Some(Colour::Default)` is the case that
|
||||
matters: it is a stated value and must not fall through to `mode`'s Blue, which
|
||||
the sibling `mode.text-entry.export` (stating nothing) does inherit.
|
||||
|
||||
**Injected fault**: fold the chain so the parent wins. This test fails, and so
|
||||
does the TUI's `the_shipped_theme_reproduces_every_migrated_literal`, because
|
||||
`mode.text-entry.edit` then draws its parent's DarkGray.
|
||||
|
||||
### every_axis_gets_its_own_accent_in_the_shipped_theme
|
||||
|
||||
Moved from `improvise-core`'s `every_axis_gets_its_own_color` when `AxisColor`
|
||||
left core (ADR 004 clause 6). Distinct axes must be distinguishable at a
|
||||
glance, and the axis faces exist for exactly that, so two axes resolving alike
|
||||
defeats them. It now asks the shipped theme rather than an enum's arms, which
|
||||
is the stronger question: two distinct `AxisColor` arms could always have been
|
||||
mapped to one terminal colour.
|
||||
|
||||
### a_theme_that_restyles_text_entry_reaches_every_minibuffer
|
||||
|
||||
ADR 004 says `minibuffer.*` inherits `mode.text-entry` directly. PR #319's first
|
||||
head gave the minibuffer faces a `minibuffer` base face instead, and ChatGPT
|
||||
Codex flagged it (P2): face parents are compatibility behaviour once a theme
|
||||
can be downloaded, so a theme that restyles `mode.text-entry` must reach every
|
||||
prompt. The test overrides only `mode.text-entry` with italic, an attribute no
|
||||
minibuffer face states in the shipped theme, and asserts every
|
||||
`minibuffer.<entry>` resolves italic. A test that sets a colour instead would
|
||||
be vacuous, because each minibuffer face states its own colours.
|
||||
|
||||
**Failed first**, against the `minibuffer` base parent: `Edit` resolved to
|
||||
`Normal`. It passes under the accepted chain. The no-op oracle
|
||||
`the_shipped_theme_reproduces_every_migrated_literal` passed both before and
|
||||
after the change, which shows the change moved no pixel.
|
||||
|
||||
### a_quarters_end_leaves_its_year_open_on_the_same_level
|
||||
|
||||
improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct`
|
||||
sits in `2024` after `Q1`'s `GroupEnd { level: 0, depth: 1 }`, and `Undated`
|
||||
in nothing after `2024` ends. Rival: `groups()` closing by level alone, as it did
|
||||
before depth existed. Only this test fails in `improvise-protocol`: every other
|
||||
frame fixture puts one group per level.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Scale bisect: how big a document does improvise handle today (improvise-felo.10)
|
||||
|
||||
Measured on `gh/main` @ 8220a065, **Apple M4 Max, 64 GB** (`sysctl`: `hw.memsize 68719476736`), macOS,
|
||||
release build (`lto`, `opt-level 3`), native only (no wasm, no real OPFS, no terminal).
|
||||
Harness: `benches/scale_bisect.rs` (one configuration per process) driven by `bench/scale_bisect.py`,
|
||||
`bench/sweep.py`, `bench/edge.py`, `bench/full.py`. Reproduce:
|
||||
`nix develop --command cargo build --release --bench scale_bisect`, copy the binary from
|
||||
`target/release/deps/scale_bisect-*` to `/tmp/sb`, then `python3 bench/edge.py`.
|
||||
|
||||
Budgets (user ruling 2026-10-03): **8 ms is the headline** (120 FPS, hot path or defer to background);
|
||||
16 ms, 100 ms (noticeable) and 1 s (broken) are reference crossings.
|
||||
|
||||
## Method, and what is real
|
||||
|
||||
Every number goes through the production path: `App::new` (open: formula recompute +
|
||||
`GridLayout::with_frozen_records`), `App::dispatch_key(Down)` (**keypress/scroll**: it calls
|
||||
`rebuild_layout`, then `cmd_context`, which runs `compute_col_widths` over the whole grid, then applies effects),
|
||||
`project::grid_of` (TUI frame: whole grid; browser: a 45x12 window), `App::apply_effects` with `effect::SetCell`,
|
||||
`SetAxis`/`HideItem`/`ToggleGroup`/`TogglePruneEmpty` (reshape), `Host::connect` and `Host::handle` with a
|
||||
`caught_up` `ModelCommand::SetCell` (browser path incl. projection), `format_md`/`parse_md`/`save_to(MemoryFs)`.
|
||||
Document: C0..C{cats-1} with K items, N distinct random tuples ("records", capped at K^cats/2), two measures
|
||||
each (2N cells), `_Measure` on Column. Timings are the **median** of up to 3-8 in-process repeats; boundary
|
||||
rows are the median of **3 separate processes** run sequentially (spread shown). Earlier exploratory sweeps
|
||||
ran 2-3 processes in parallel and agree with the sequential runs within ~5%.
|
||||
|
||||
**`View::prune_empty` defaults to false** (`view/types.rs`), so "prune off" is what a user gets until they
|
||||
toggle it; "prune on" is reported next to it. Rows below are the layout's `row_count`.
|
||||
|
||||
**Dominant cost (reasoned, not profiled):** the release profile strips symbols, so `sample` could not
|
||||
symbolicate. The reason is in the code and the timings agree with it: `GridLayout::cell_key`/`row_item` do
|
||||
`data_items(&self.row_items).nth(row)` (`view/layout.rs` ~253/292/807/870), O(row) per lookup, and
|
||||
`compute_col_widths` (every keypress, via `cmd_context`) and `grid_of` visit every cell, so both are
|
||||
O(rows^2). Evidence: with prune off, rows 729 -> 5.4 ms, 4096 -> 74.6 ms, 19683 -> 1248 ms (x6.7 rows ->
|
||||
x14 time -> x17 time). A 45x12 browser window costs **the same** as the whole-grid TUI frame (K=30:
|
||||
2274 vs 2298 ms) because the widths pass is whole-grid whatever the viewport says, so viewport narrowing buys
|
||||
nothing today. `GridLayout` build itself is cheap (~90 ns/row, ADR 008).
|
||||
|
||||
## Attribution: state+projection vs render/serialize (K=10, 1000 rows, prune off; 3 runs)
|
||||
|
||||
| step | ms |
|
||||
|---|---|
|
||||
| keypress (state: rebuild + widths + effect) | 9.5-9.7 |
|
||||
| `grid_of` whole grid (TUI projection) | 11.7-12.0 |
|
||||
| `GridWidget` render into a 200x50 ratatui `Buffer` | 0.12 |
|
||||
| `grid_of` 45x12 window (browser projection) | 9.1-9.3 |
|
||||
| JSON encode of one SetCell's projected updates | 0.001-0.005 (0.6-1.1 KB) |
|
||||
| JSON encode of the connect Snapshot | 0.02 (21 KB) |
|
||||
|
||||
Render and serialize are **<=0.5 ms in every row measured** (GridWidget 0.08-0.53 ms up to 14,500 records;
|
||||
snapshot encode <=0.07 ms; records-mode SetCell updates 21 KB in 0.02 ms). The 8 ms is entirely state and
|
||||
projection. Not measured: the rest of `draw()` (panels, status; `draw` is private to the binary), terminal I/O,
|
||||
browser JSON *decode* and DOM paint.
|
||||
|
||||
## Axis B: items per category K, 3 categories on Row, N = 100k (capped at K^3/2), prune OFF
|
||||
|
||||
| K | rows | open | keypress | browser window | SetCell | maxrss MB |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 5 | 125 | 0.1 | 0.5 | 0.5 | 0.2 | 8 |
|
||||
| 9 | 729 | 0.2 | **5.4** (5.3-5.5) | 5.0 | 0.9 | 13 |
|
||||
| 10 | 1,000 | 0.2 | **9.6** (9.5-9.7) | 9.2 | 1.2 | 14 |
|
||||
| 11 | 1,331 | 0.3 | 12.7 | 12.7 | 1.6 | 10 |
|
||||
| 12 | 1,728 | 0.3 | 20.0 | 19.3 | 2.1 | 11 |
|
||||
| 15 | 3,375 | 0.5 | 65 | 64 | 4.0 | 14 |
|
||||
| 16 | 4,096 | 0.6 | 74.6 (74.3-75.2) | 73.4 | - | - |
|
||||
| 17 | 4,913 | 0.7 | **105** (105.0-105.5) | 103 | - | - |
|
||||
| 20 | 8,000 | 1.1 | 302 | 301 | 9.4 | 21 |
|
||||
| 25 | 15,625 | 1.9 | 773 | 771 | 18.8 | 33 |
|
||||
| 26 | 17,576 | 2.2 | 967-977 | 962 | - | - |
|
||||
| 27 | 19,683 | 2.4 | **1,248** (1,244-1,251) | 1,244 | - | - |
|
||||
| 30 | 27,000 | 3.1 | 2,262 | 2,274 | 31 | 49 |
|
||||
| 40 | 64,000 | 6.7 | 10,858 | 10,833 | 73 | 235 |
|
||||
| 60 | 216,000 | 21.6 | timeout (>60 s) | - | - | - |
|
||||
| 1000 | 10^9 | **refused** (see below) | | | | |
|
||||
|
||||
Hot-path keypress/scroll and browser window: **largest inside 8 ms: K=9 (729 rows)**; inside 16 ms: K=11;
|
||||
first over 100 ms: **K=17 (4,913 rows)**; first over 1 s: **K=27 (19,683 rows)**. SetCell alone
|
||||
(no widths pass; layout rebuild only, ~linear in rows): inside 8 ms to K~19 (K=15 4.0, K=20 9.4), 16 ms ~K=24,
|
||||
100 ms ~K=45 (K=40 = 73 ms), 1 s extrapolated ~K=95 (**extrapolated, not measured**). Open stays <25 ms to K=60.
|
||||
|
||||
**3 x 1,000 on Row cannot be opened at any N**: the layout materializes the cross product (10^9 rows, ~150 B
|
||||
each = ~150 GB, ADR 008). The harness refuses above 30M rows (`rows_cap`) rather than swap the machine, so
|
||||
the 10^9 number is the ADR's estimate, not a measurement here.
|
||||
|
||||
### Prune ON (same documents)
|
||||
|
||||
| K | rows | open | keypress | browser window | SetCell |
|
||||
|---|---|---|---|---|---|
|
||||
| 7 | 171 | 2.0 | 4.5 (4.4-4.6) | 1.1 | 5.4 |
|
||||
| 8 | 256 | 3.0 | **7.0** (7.0-7.1) | 1.6 | 8.5 |
|
||||
| 9 | 364 | 5.3 | 12.8 | 2.7 | 15.3 |
|
||||
| 11 | 665 | 12.2 | 29.6 | 6.2 | ~28 |
|
||||
| 13 | 1,098 | 29.0 | 71 | 14.5 | ~70 |
|
||||
| 14 | 1,372 | 48 | **119** | 24 | - |
|
||||
| 20 | 4,000 | 300 | 747 | 145 | 903 |
|
||||
| 21 | 4,630 | 401 | 968-992 | 190 | - |
|
||||
| 25 | 7,812 | 767 | 1,899 | 366 | 2,328 |
|
||||
| 30 | 13,500 | 2,255 | 5,555 | 1,047 | 6,804 |
|
||||
|
||||
Prune on: keypress inside 8 ms to **K=8**, first over 100 ms **K=14**, ~1 s at **K~21-22** (K=22 measured
|
||||
0.93-0.95 s, a different N cap; first clearly over: K=25). Pruning shrinks the grid, so the browser window is
|
||||
cheaper than prune off at the same K (K=20: 145 ms against 302 ms), but **every keypress re-runs
|
||||
the prune** (`dispatch_key` calls `rebuild_layout`), so keypress and SetCell are *worse* with prune on
|
||||
(K=10: 23 vs 9.9 ms). Prune is also quadratic (`nth`). Conclusion: prune on/off moves the boundary by about
|
||||
one K at 8 ms and makes 100 ms/1 s *earlier* (K=14/21 vs 17/27); it never rescues the 3x1000 case.
|
||||
|
||||
## Axis A: records N at fixed K (K=30, 27,000 rows)
|
||||
|
||||
| N | prune off keypress | prune on rows | prune on keypress | prune on window |
|
||||
|---|---|---|---|---|
|
||||
| 100 | 451 | 100 | 861 | 0.3 |
|
||||
| 1,000 | 527 | 1,000 | 1,059 | 6.4 |
|
||||
| 5,000 | 1,013 | 5,000 | 2,178 | 140 |
|
||||
| 13,500 (cap) | 2,267 | 13,500 | 5,555 | 1,047 |
|
||||
|
||||
Prune off: ~450 ms of widths over 27k empty rows + ~1.3 us per stored cell; **N never gets the keypress
|
||||
under 100 ms at K=30**, and at K=1000 N is irrelevant because the layout cannot be built (rows = K^3).
|
||||
The N-axis has no bisect point in the 3x1000 regime; N only matters once K is small enough that rows fit, and
|
||||
there it is a second-order term. Open is flat (3.1-3.6 ms) in N with prune off.
|
||||
|
||||
## Axis C: categories on Row (prune off)
|
||||
|
||||
| K | cats | rows | open | keypress | note |
|
||||
|---|---|---|---|---|---|
|
||||
| 10 | 2 | 100 | 0.1 | 0.2 | |
|
||||
| 10 | 3 | 1,000 | 0.2 | 9.9 | over 8 ms |
|
||||
| 10 | 4 | 10,000 | 1.4 | **880** | ~1 s |
|
||||
| 10 | 5 | 100,000 | 14 | timeout >100 s | |
|
||||
| 3 | 6 | 729 | 0.3 | 27.9 | 5x worse than K=9 x 3 cats at the same rows |
|
||||
| 3 | 8 | 6,561 | 2.0 | 2,798 | |
|
||||
| 3 | 10-12 | 59k-531k | 18-210 | timeout | open alone passes 100 ms at 11 cats |
|
||||
| 2 | 10 | 1,024 | 0.6 | 172 | |
|
||||
| 2 | 12 | 4,096 | 2.2 | 3,190 | |
|
||||
| 2 | 14 | 16,384 | 9 | 63,100 | |
|
||||
|
||||
The cap of 24 is unreachable: rows = product of items, so a view with c Row categories of K items dies by
|
||||
K^c ~ 10^4 rows (1 s) regardless of N, and with two items per category by 12-14 categories. Per-row cost
|
||||
also grows with category count (each row_item is a Vec of c items). Not measured: Row categories with prune
|
||||
on, other axes absorbing categories (the realistic mitigation: only 1-2 on Row).
|
||||
|
||||
## Axis D: records mode over N records (`with_frozen_records`, the improvise-sk32 quadratic)
|
||||
|
||||
| N | open | keypress | browser window | SetCell (apply) | host SetCell+project | maxrss MB |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 300 | 1.5 | 0.7 | 0.1 | 1.6 | - | 10 |
|
||||
| 1,000 | 2.9 | 5.6 (5.5-5.7) | 0.3 | 10 | - | 12 |
|
||||
| 1,200 | 3.9 | **7.9** (7.7-8.0) | 0.3 | - | - | - |
|
||||
| 1,300 | 4.5 | 9.3 | 0.3 | 15.5 | 17 | 26 |
|
||||
| 1,800 | 8.4 | 16.5 | 0.4 | - | - | - |
|
||||
| 3,000 | 22.9 | 47 | 0.7 | 74 | - | 20 |
|
||||
| 4,400-4,600 | 47-51 | **95-102** | 0.9 | 149 | 158 | 55 |
|
||||
| 10,000 | 248 | 492 | 1.8 | 734 | - | 46 |
|
||||
| 14,000-14,500 | 472-500 | **940-1,002** | 2.6-2.8 | 1,435 | 1,459 | 135-148 |
|
||||
| 30,000 | 2,198 | 4,314 | 5.6 | 6,467 | - | 125 |
|
||||
| 100,000 | **25,420** | **51,234** | 19.3 | not reached (killed at 100 s) | - | not reached |
|
||||
|
||||
Records mode, keypress/scroll: inside 8 ms **to N~1,200**; 16 ms N~1,800; 100 ms N~4,500 (first over: 4,600 =
|
||||
102 ms); 1 s N~14,500. Open crosses 8 ms ~1,750, 16 ms ~2,300, 100 ms ~6,500, 1 s ~20,000. Time ~ N^1.95.
|
||||
SetCell is ~1.5x keypress (it rebuilds and re-groups). The *browser window* is cheap (<20 ms even at 100k)
|
||||
because records mode has two columns and the width walk is short, so the 51 s is `rebuild_layout`, not
|
||||
projection. Prune on/off does not change records mode (prune applies to pivoted axes; the sweep ran off).
|
||||
Records per KB: ~4 KB/record process-wide at 14k (maxrss includes the document generator, so read as an
|
||||
upper bound); the 100k memory was not obtained.
|
||||
|
||||
## Axis E: save / load and per-command persistence
|
||||
|
||||
Mode `none` (all categories off-axis, so layout is trivial and only the store scales), 3 x 1,000, N records
|
||||
= 2N cells:
|
||||
|
||||
| cells | format_md | text MB | parse_md | persist per accepted command (`save_to` MemoryFs) | maxrss MB |
|
||||
|---|---|---|---|---|---|
|
||||
| 2,000 | 13.8 | 0.1 | 4.5 | 12.4 | 18 |
|
||||
| 20,000 | 156 | 0.86 | 37 | 161 | 80 |
|
||||
| 60,000 | 527 | 2.5 | 119 | 539 | 236 |
|
||||
| **200,000 (the target)** | **1,974** | 8.4 | **401** | **1,984** | 663 |
|
||||
| 600,000 | 6,470 | 25 | 1,320 | 6,460 | 1,894 |
|
||||
| 2,000,000 | 23,120 | 84 | 4,668 | 23,088 | 5,611 |
|
||||
|
||||
Linear: ~10 us/cell to format, ~2 us/cell to parse. Crossings, format_md: 8 ms ~700 cells, 16 ms ~1.5k,
|
||||
100 ms ~11k, 1 s ~100k cells. parse_md: 8 ms ~4k cells, 100 ms ~50k, 1 s ~450k. **At the target (200k cells)
|
||||
every accepted command in the browser worker costs ~2 s of format alone** before OPFS write time (not
|
||||
measured: MemoryFs only). Persist per command also appears in smaller documents inside the grid axes
|
||||
(K=10 grid: 5.5 ms; records N=4,400: 63 ms).
|
||||
|
||||
## Other operations (3 runs each; "grouped" fixtures start with C0 groups collapsed, so rows are not K^3)
|
||||
|
||||
Reshape (prune off, K=10, 1000 rows): hide item 0.25 ms, set axis 0.19 ms, **toggle prune 12.7 ms**.
|
||||
K=26 grouped, 2,028 rows: hide 0.5, toggle group 1.8, set axis 1.8, **toggle prune 727-750 ms**.
|
||||
Prune on K=13 grouped, 321 rows: hide 9.4, toggle group 37, set axis 37, toggle prune 37-39 ms.
|
||||
`Host::connect` snapshot: K=10 6.5 ms, K=26 375 ms, records N=14,000 2.9 ms. Host SetCell+project: K=10 7.7 ms,
|
||||
K=26 ~400 ms, records N=4,400 158 ms (equal to apply + a whole-grid width walk).
|
||||
|
||||
## Classification and feasibility of yielding
|
||||
|
||||
| operation | class | today's first size over 8 ms | dominated by | chunked/yielding form |
|
||||
|---|---|---|---|---|
|
||||
| keypress / scroll | **hot (<=8 ms)** | 1,000 rows (K=10) / N=1,300 records | `rebuild_layout` + whole-grid `compute_col_widths` (O(rows^2) `nth`), recomputed though nothing changed | not a slicing problem: cache widths/layout across keys, O(1) row lookup (ADR 008/009). Slicing would only hide it |
|
||||
| browser window projection | **hot** | 1,000 rows | same width walk, whole grid regardless of window | needs the same fix; then window cost is O(window) |
|
||||
| SetCell apply + project | **hot** | K~19 (prune off), 1,300 records | layout rebuild O(rows) + widths + projection | incremental update of one row/total, not a rebuild |
|
||||
| column totals | hot | inside `grid_of` (not separable here) | whole-grid sum in the projection | maintained total (ADR 009) |
|
||||
| open / layout build | deferrable | 40k rows (prune off); N~1,750 (records) | build is ~90 ns/row (sliceable by outer index); the prune/records grouping is the quadratic part | build is feasible in slices once the quadratic is removed; the quadratic itself is not sliceable |
|
||||
| reshape (hide/collapse/axis) | deferrable | cheap prune-off (<2 ms to ~2k rows); 37 ms prune-on at 321 rows | layout rebuild + prune | one sort/remap at 100k occupied tuples was 2.3 ms in ADR 008's prototype, so a reshape can fit inline; prune toggle needs the quadratic fixed first |
|
||||
| save (format_md) | deferrable | ~700 cells | per-cell formatting, linear | the Data section is a flat per-cell line stream, so chunking by cell range with a yield every ~400 cells looks feasible, but needs a consistent snapshot (clone or journal); not profiled by function |
|
||||
| load (parse_md) | deferrable | ~4k cells | pest parse + tree walk, linear | tree walk is chunkable by line; the pest parse of one text is not, unless the Data section is parsed line by line (grammar is line oriented); not profiled by function |
|
||||
| persistence per command | deferrable (debounce) | ~700 cells | the whole-document `format_md` | coalesce/debounce or append-journal: formatting 2 s per command at target is unusable inline at any slicing |
|
||||
|
||||
## What I could not measure
|
||||
|
||||
* wasm: all numbers are native; the browser worker is typically slower. Real OPFS I/O, JSON *decode* and
|
||||
DOM paint, the full `draw()` frame, and Safari are not covered.
|
||||
* Records mode at 100k: SetCell, host and RSS (killed after open and keypress, ~76 s).
|
||||
* Per-function profile: symbols are stripped in `[profile.release]`; attribution is reasoned from the
|
||||
code and the timing exponents, not a flame graph.
|
||||
* SetCell 100 ms/1 s for prune off past K=40 are extrapolations.
|
||||
* Memory (`maxrss`) includes the generator's hash set and `recs` vector; it is an upper bound on the
|
||||
document. Layout cost ~150 B/row per ADR 008, consistent with K=40 (64k rows, 235 MB peak with prune off
|
||||
including the generator and a second layout during rebuild).
|
||||
* Noise: boundary rows are 3 sequential processes, spread <=3% (e.g. K=17: 105.0-105.5 ms). Non-boundary
|
||||
rows are single runs, some taken with 2-3 concurrent processes on a 16+ core machine; treat them as +-5-10%.
|
||||
@@ -1772,3 +1772,146 @@ The output includes the user's inline answers on
|
||||
**Scope sweep.** `scripts/pr-watch` passes its JSON through `printf '%s' | jq`,
|
||||
never as an argument. `grep -rn 'argjson\|--arg '` over `scripts/`,
|
||||
`.agents/` and `.claude/skills/` finds no other data argument.
|
||||
|
||||
### An agent authors its commits, and the committer is left alone — `scripts/agent-git-test.sh` (improvise-1xfb.3)
|
||||
|
||||
**What was missing.** Every agent commit was authored as the repository owner,
|
||||
so history could not say which agent wrote what. `scripts/agent-git` runs one
|
||||
git command with `GIT_AUTHOR_NAME="Claude ($BEADS_ACTOR)"` and refuses, exit 2,
|
||||
when `BEADS_ACTOR` is unset or empty.
|
||||
|
||||
**Why not `git -c user.name=…`,** the mechanism the ticket and the brief both
|
||||
offered first. `user.name` is the default for the committer as well as the
|
||||
author, so it renames both. The ticket asks for the committer to be left
|
||||
alone. Probed in a scratch repo: a `-c user.name` commit reads
|
||||
`Claude (u) <o@x> | Claude (u)` as author and committer. A `GIT_AUTHOR_NAME`
|
||||
commit reads `Claude (t) <o@x> | Owner`. The same probe showed that a merge
|
||||
takes `GIT_AUTHOR_NAME` too, and that a plain `--amend` keeps the author it
|
||||
amends.
|
||||
|
||||
**A cherry-pick keeps its source's author** (Codex P2 and the review on PR
|
||||
#310). The first version of the rule listed `cherry-pick` among the wrapper's
|
||||
subcommands, but git ignores `GIT_AUTHOR_NAME` there. That is the behaviour
|
||||
this repo wants, since rule 8 allows only `cherry-pick -x` of commits that keep
|
||||
their own author. So the contract names only `commit` and `merge`. The case "a
|
||||
cherry-pick keeps its source's author" picks a commit authored `Claude
|
||||
(agent-b2)` with the wrapper set to `agent-a1`, and asserts `agent-b2`. A pick
|
||||
that failed would leave the owner-authored base at HEAD and fail the case too.
|
||||
|
||||
**Rivals** (run against the first eleven cases).
|
||||
- **`exec git -c user.name="Claude ($BEADS_ACTOR)" "$@"`**: 10 passed,
|
||||
1 failed, "the committer is untouched" (got `Claude (agent-a1)`).
|
||||
- **The refusal removed** (`if false`): 7 passed, 4 failed. They are "an unset
|
||||
BEADS_ACTOR is refused" (exit 1 from `set -u`, not 2), "the refusal names
|
||||
BEADS_ACTOR", "an empty BEADS_ACTOR is refused" (exit 0: the commit landed
|
||||
under the name `Claude ()`), and "a refusal commits nothing". So `set -u`
|
||||
alone would not cover the empty case.
|
||||
|
||||
**Fixture.** The repo's identity comes from its own config, with
|
||||
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, and every `GIT_*`
|
||||
identity variable is unset first. So a caller's own identity cannot make the
|
||||
author or committer assertions agree by accident.
|
||||
|
||||
### The repo-stats pipeline computes what it claims to — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
|
||||
|
||||
**What it pins.** The fixture repository has one commit before the window, a
|
||||
side branch merged with `--no-ff`, a shrink in the next ISO week, and one file
|
||||
in every area. So each number the report prints is known by construction. The
|
||||
suite runs the real pipeline, code-maat and improvise included, and asserts
|
||||
improvise's exported tables: each area's added, deleted and deletion ratio;
|
||||
prose, code and the `ProseToCode` row; the week split; and the per-day totals.
|
||||
It also checks the doc sizes and the coupling flags, and that an improvise that
|
||||
refuses or warns makes the pipeline exit 3.
|
||||
|
||||
**Two fixture choices are load-bearing.**
|
||||
- The repo map's `4.` item sits after the `---` that closes the Gotchas
|
||||
section, with no `##` heading between them. In an earlier fixture a `##`
|
||||
heading followed the `---`, so the `---` rule could be deleted and the suite
|
||||
stayed green.
|
||||
- `churn.csv` must equal code-maat's `abs-churn` on every day. That compares
|
||||
two independent derivations of one number, one of them improvise's
|
||||
aggregation. The fixture has a binary-free, rename-free history, so the two
|
||||
agree exactly.
|
||||
|
||||
**Rivals**, each injected into the script or into `repo-stats.improvise` and run
|
||||
through `checkers.sh repo-stats`:
|
||||
- Rust src matched before Rust tests: the rust src and rust tests rows fail.
|
||||
- `DeletionRatio = deleted / (added + deleted)`: six ratio rows fail.
|
||||
- `ProseToCode = code / prose`: the `ProseToCode` row fails.
|
||||
- `--no-merges` dropped: the commit count fails (6, wanted 5).
|
||||
- The `---` end of the Gotchas section ignored: the gotcha count fails (4, wanted 3).
|
||||
- A crate crossing as "the crates differ", so a crate against no crate counts:
|
||||
the crate-crossing case fails.
|
||||
- `kb.pl` counted as prose: the prose row and the ratio fail.
|
||||
- The category padding removed: the import skipped every record, and both
|
||||
pipeline runs failed with exit 3. That rival is retired: folder import
|
||||
(improvise-lpzu) keeps sparse records, and the padding is gone. The suite
|
||||
passes 41 of 41 without it.
|
||||
- Months instead of ISO weeks: both week cases fail.
|
||||
- A `warning:` from improvise ignored: the warning-stub case fails.
|
||||
|
||||
### A window of empty commits, and four faults the first fixture missed — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
|
||||
|
||||
**What broke.** ChatGPT Codex found it on PR #313, and the adversarial review
|
||||
reproduced it. When every non-merge commit in the window is `--allow-empty`,
|
||||
the log has commit headers and no files. `build_dir` took `churn.csv`'s header
|
||||
from `rows[0]`, raised `IndexError` and exited 1. That exit is outside the
|
||||
pipeline's failure split: it is neither a number nor improvise's exit 3.
|
||||
|
||||
**Fix.** `churn.csv` has a fixed header. When no file changed, the script
|
||||
skips code-maat and improvise, writes the header alone, and exits 0.
|
||||
|
||||
**Red first.** The new case is a second fixture repository whose only
|
||||
in-window commit is empty. Restoring the `rows[0]` header fails its run case
|
||||
with the traceback.
|
||||
|
||||
**The review's four surviving faults, each now killed:**
|
||||
- **`--no-renames` dropped.** The fixture now renames `scripts/foo.sh` to
|
||||
`scripts/bar.sh`. Without the flag git reports one `{foo.sh => bar.sh}` entity
|
||||
with no lines changed, not a delete and an add. Four cases fail: the scripts/CI
|
||||
row, the code row, the ratio row and the coupling count.
|
||||
- **`crate_of` forgetting the root crate.** `src/main.rs` now co-changes with
|
||||
both crates. The crossing list loses three pairs.
|
||||
- **A failing `script` call ignored.** A third stub exits 0 for `import` and
|
||||
refuses `script`. The pipeline then reads exports that were never written and
|
||||
exits 1, not 3.
|
||||
- **`.beads/` and `site/` left out of generated.** Both are in the fixture now.
|
||||
The generated row and the other row both fail.
|
||||
|
||||
**Also counted now:** `docs/guide.md` as other docs and `.codex/config.toml` as
|
||||
agent docs. The coupling count is 16, not 15: `web/app.js` and
|
||||
`scripts/foo.sh` change together in two commits, the 06-04 one and the rename.
|
||||
|
||||
### The pipeline on folder import — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1)
|
||||
|
||||
Once #314 merged, `improvise import <dir>` replaced the file list, the `-`
|
||||
padding and the generated per-file views. The suite needed no change of
|
||||
expectations and still passed 41 of 41. That shows only that the numbers
|
||||
agree. It does **not** show that the model is right: the suite could not tell
|
||||
sparse from padded. The delta review re-added `pad_categories` and the suite
|
||||
still passed 41 of 41, while the model then had a `|-|` item in 13 places and
|
||||
the `churn` view seated `agent-rules` on rows and paged everything else. The
|
||||
view case counted 10 views either way.
|
||||
|
||||
**What separates them now:**
|
||||
- **"the sparse model has no padding item"**: no line of the model names `|-|`.
|
||||
- **"the churn view seats …"**: four cases, one per seat. `date` is on rows, and
|
||||
`agent-rules`, `coupled` and `crosses-crate`, which are coupling-only, are on
|
||||
`none`. The churn view puts `entity` on columns, not `area` as the review
|
||||
guessed. What discriminates is `date` on rows and the coupling-only
|
||||
categories on `none`, which a padded CSV cannot give, because it claims all
|
||||
nine categories.
|
||||
|
||||
**Rivals:**
|
||||
- **Padding re-added** (the review's own injection): 5 cases fail. They are
|
||||
the `|-|` case, with 161 lines naming it in the fixture model, and all four
|
||||
seat cases.
|
||||
- **A report view named like a file stem**: `By day` renamed to `churn` leaves
|
||||
9 views, and the view-count case fails.
|
||||
- **The foreign-CSV guard removed**: "a foreign CSV in --out is refused with
|
||||
exit 2" fails with exit 0. The guard is the answer to the review's
|
||||
non-blocking finding that `import .` picks up any CSV in `--out`. It refuses
|
||||
instead of clearing. Deleting files in a directory the caller named is the
|
||||
wrong default, and the script's own five CSVs are overwritten on every run,
|
||||
so a rerun into the same `--out` is still accepted. The same fixture run
|
||||
writes JSON and then text into one directory, which shows that.
|
||||
|
||||
@@ -1429,3 +1429,51 @@ to the list's header row, which no item can cover. This test fills three rows
|
||||
with a three-line item and a second item after it. It asserts the first item
|
||||
is drawn whole and the header reads `1–1 of 2`. The rival **the header claims
|
||||
every item shown** fails it.
|
||||
|
||||
### the_category_add_prompt_draws_as_the_item_add_prompt_does
|
||||
|
||||
ADR 004 clause 8's one declared exception, decided by the user (Q1 on PR #122:
|
||||
the green/yellow split between item-add and category-add is an accident). It
|
||||
draws both prompts through the real `draw_bottom_bar` into a `TestBackend` and
|
||||
compares the style of the prompt's first cell, after checking that cell really
|
||||
holds the prompt's `n`.
|
||||
|
||||
**Failed first**, at `gh/main` `b639266b` before any face existed: `left:
|
||||
...yellow().bg(Indexed(235)).bold()`, `right: ...green()...` — the category-add
|
||||
prompt was yellow. **Injected fault** after the migration: set the shipped
|
||||
theme's `minibuffer.category-add` back to `Named(Yellow)`; this test fails and
|
||||
nothing else in the TUI does, which is the point of naming the exception in a
|
||||
test of its own rather than leaving it out of the no-op table.
|
||||
|
||||
### the_shipped_theme_reproduces_every_migrated_literal
|
||||
|
||||
ADR 004 clause 8's property: migrating a call site onto a face is a no-op. The
|
||||
oracle is the literal tables as they stood before the migration — axis
|
||||
accents, wizard field kinds, `mode_style`'s exhaustive match (kept verbatim as
|
||||
`mode_bg_before_faces`) and the minibuffer's accent-on-`Indexed(235)`-bold —
|
||||
and every one is compared with `face_style` of its face. Category-add is left
|
||||
out on purpose (the entry above). It was first written against the old
|
||||
functions themselves and passed, then the functions were deleted and their
|
||||
tables moved into the test.
|
||||
|
||||
**Injected faults**, each failing here by the case name in the message:
|
||||
`axis.column` changed from Blue to Cyan (fails at `Column`); `face_style` made
|
||||
to add `BOLD` for `Weight::Normal` instead of `Bold`; `Theme::resolve` folded so
|
||||
that the parent wins over the child (fails at `Editing`, which draws DarkGray
|
||||
instead of its own Green); and `Face::of_mode(TileSelect)` returning
|
||||
`mode.resting` (also caught by `the_status_line_is_drawn_in_the_mode_face`).
|
||||
|
||||
### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows
|
||||
|
||||
improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer
|
||||
category, with a year open two levels, a quarter closed and a year folded. The
|
||||
precondition pins the header sequence, so the agreement cannot pass on a frame
|
||||
that drew no nested header. What makes the two ends agree is that neither
|
||||
derives membership: both walk the same `FrameEntry` list. Rival: the browser's
|
||||
`render.rs` skipping a `Group` entry whose `depth > 0`. This test fails on the
|
||||
row count (the TUI draws the quarter line, the browser does not); the column
|
||||
test does not, since the band shows only each column's innermost group, which
|
||||
the rival leaves alone. Group names are short because the TUI
|
||||
truncates a band label to its column's width, which the browser does not: the
|
||||
first version named its years `Y2024` and the column test failed on `▶ Y2…`
|
||||
against `▶ Y2025`, a truncation, not a disagreement.
|
||||
|
||||
@@ -204,10 +204,15 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.buffers = &bufs;
|
||||
let effects = CommitExport.execute(&ctx);
|
||||
assert_eq!(effects.len(), 2);
|
||||
// 3 effects: SetBuffer (clear), ChangeMode, ExportCsv (improvise-2mqz).
|
||||
assert_eq!(effects.len(), 3);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}");
|
||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||
assert!(
|
||||
dbg.contains("SetBuffer { name: \"export\", value: \"\" }"),
|
||||
"Expected buffer clear, got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.find("Normal").unwrap() < dbg.find("ExportCsv").unwrap(),
|
||||
"the mode change must precede the write an abort can reach: {dbg}"
|
||||
@@ -1270,9 +1275,13 @@ impl Cmd for CommitSave {
|
||||
effect::change_mode(AppMode::Normal),
|
||||
];
|
||||
}
|
||||
// The mode change comes **first**, or a failed `SaveAs` aborts with the
|
||||
// minibuffer still drawn over its error (Copilot, PR #27).
|
||||
// Clear before `SaveAs`, whose failure aborts; this command owns the clear
|
||||
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||
vec![
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "save".into(),
|
||||
value: String::new(),
|
||||
}),
|
||||
effect::change_mode(AppMode::Normal),
|
||||
Box::new(effect::SaveAs(std::path::PathBuf::from(path))),
|
||||
]
|
||||
@@ -1294,9 +1303,13 @@ impl Cmd for CommitExport {
|
||||
effect::change_mode(AppMode::Normal),
|
||||
];
|
||||
}
|
||||
// The mode change comes **first**, or a failed export aborts with the
|
||||
// minibuffer still drawn over its error (gotcha 21).
|
||||
// Clear before export, whose failure aborts; this command owns the clear
|
||||
// because its LocalThenRequest cannot live in a sequence (improvise-2mqz).
|
||||
vec![
|
||||
Box::new(effect::SetBuffer {
|
||||
name: "export".into(),
|
||||
value: String::new(),
|
||||
}),
|
||||
effect::change_mode(AppMode::Normal),
|
||||
Box::new(effect::ExportCsv(std::path::PathBuf::from(path))),
|
||||
]
|
||||
|
||||
@@ -250,12 +250,37 @@ pub trait AtCursor: Cmd {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState;
|
||||
}
|
||||
|
||||
/// How many arguments a command takes. The registry refuses any beyond it, for
|
||||
/// every command in one place — `context/code/cli.md` §14.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MaxArgs {
|
||||
AtMost(usize),
|
||||
Any,
|
||||
}
|
||||
|
||||
impl MaxArgs {
|
||||
fn admit(self, args: &[String]) -> Result<(), String> {
|
||||
match self {
|
||||
MaxArgs::AtMost(n) if args.len() > n => {
|
||||
let extra: Vec<String> = args[n..].iter().map(|a| format!("{a:?}")).collect();
|
||||
Err(format!(
|
||||
"takes at most {n} argument(s), got {}; unexpected {}",
|
||||
args.len(),
|
||||
extra.join(" ")
|
||||
))
|
||||
}
|
||||
MaxArgs::AtMost(_) | MaxArgs::Any => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
||||
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
||||
|
||||
/// A registered command entry with both text and interactive constructors.
|
||||
struct CmdEntry {
|
||||
name: &'static str,
|
||||
max: MaxArgs,
|
||||
parse: BoxParseFn,
|
||||
interactive: BoxInteractiveFn,
|
||||
}
|
||||
@@ -295,10 +320,11 @@ impl CmdRegistry {
|
||||
pub fn register<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
max: MaxArgs,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |_, _| {});
|
||||
self.push(prototype.name(), max, parse, fallback, |_, _| {});
|
||||
}
|
||||
|
||||
/// As [`register`](Self::register), then the interactive arm moves the
|
||||
@@ -306,10 +332,11 @@ impl CmdRegistry {
|
||||
pub fn register_at_cursor<C: AtCursor + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
max: MaxArgs,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
|
||||
self.push(prototype.name(), max, parse, fallback, |cmd, ctx| {
|
||||
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
|
||||
});
|
||||
}
|
||||
@@ -317,12 +344,14 @@ impl CmdRegistry {
|
||||
fn push<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
max: MaxArgs,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
at_cursor: fn(&mut C, &CmdContext),
|
||||
) {
|
||||
self.entries.push(CmdEntry {
|
||||
name,
|
||||
max,
|
||||
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
|
||||
interactive: Box::new(move |args, ctx| {
|
||||
let mut built = match (parse(args), fallback) {
|
||||
@@ -338,9 +367,10 @@ impl CmdRegistry {
|
||||
|
||||
/// Register a command that needs no interactive context: BOTH arms are
|
||||
/// `parse`, so neither can demote the other — `context/code/command.md`.
|
||||
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
||||
pub fn register_pure(&mut self, prototype: &dyn Cmd, max: MaxArgs, parse: ParseFn) {
|
||||
self.entries.push(CmdEntry {
|
||||
name: prototype.name(),
|
||||
max,
|
||||
parse: Box::new(parse),
|
||||
interactive: Box::new(move |args, _ctx| parse(args)),
|
||||
});
|
||||
@@ -352,6 +382,7 @@ impl CmdRegistry {
|
||||
let name = f().name();
|
||||
self.entries.push(CmdEntry {
|
||||
name,
|
||||
max: MaxArgs::AtMost(0),
|
||||
parse: Box::new(move |_| Ok(f())),
|
||||
interactive: Box::new(move |_, _| Ok(f())),
|
||||
});
|
||||
@@ -364,7 +395,9 @@ impl CmdRegistry {
|
||||
let canonical = self.resolve(name);
|
||||
for e in &self.entries {
|
||||
if e.name == canonical {
|
||||
return (e.parse)(args).map_err(|err| format!("{name}: {err}"));
|
||||
return (e.parse)(args)
|
||||
.and_then(|cmd| e.max.admit(args).map(|()| cmd))
|
||||
.map_err(|err| format!("{name}: {err}"));
|
||||
}
|
||||
}
|
||||
Err(format!("Unknown command: {name}"))
|
||||
@@ -381,7 +414,7 @@ impl CmdRegistry {
|
||||
let name = self.resolve(name);
|
||||
for e in &self.entries {
|
||||
if e.name == name {
|
||||
return (e.interactive)(args, ctx);
|
||||
return (e.interactive)(args, ctx).and_then(|cmd| e.max.admit(args).map(|()| cmd));
|
||||
}
|
||||
}
|
||||
Err(format!("Unknown command: {name}"))
|
||||
@@ -401,16 +434,31 @@ pub(super) fn require_args(args: &[String], n: usize) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Cat/Item coordinate args into a CellKey.
|
||||
pub(super) fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey {
|
||||
let coords: Vec<(String, String)> = args
|
||||
/// Parse Cat/Item coordinate args into a CellKey, refusing any token that is not
|
||||
/// one rather than dropping it — `context/code/cli.md` §14.
|
||||
pub(super) fn parse_cell_key_from_args(
|
||||
args: &[String],
|
||||
) -> Result<crate::model::cell::CellKey, String> {
|
||||
let coords = args
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let (cat, item) = s.split_once('/')?;
|
||||
Some((cat.to_string(), item.to_string()))
|
||||
.map(|s| match s.split_once('/') {
|
||||
Some((cat, item)) if !cat.is_empty() && !item.is_empty() => {
|
||||
Ok((cat.to_string(), item.to_string()))
|
||||
}
|
||||
_ => Err(format!("{s:?} is not a Category/Item coordinate")),
|
||||
})
|
||||
.collect();
|
||||
crate::model::cell::CellKey::new(coords)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(crate::model::cell::CellKey::new(coords))
|
||||
}
|
||||
|
||||
/// A cursor fallback answers only for coordinates that were OMITTED; given and
|
||||
/// wrong, they are refused, never the cursor's cell — `context/code/cli.md` §14.
|
||||
pub(super) fn only_when_omitted(args: &[String]) -> Result<(), String> {
|
||||
if args.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
parse_cell_key_from_args(args)?;
|
||||
Err("takes Category/Item coordinates, or none to use the cursor".to_string())
|
||||
}
|
||||
|
||||
/// Read the current value of a named buffer from context.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::ui::effect::{self, Effect, ViewTarget};
|
||||
use crate::view::Axis;
|
||||
|
||||
use super::core::{Cmd, CmdContext, require_args};
|
||||
use super::core::{Cmd, CmdContext, MaxArgs, parse_axis, parse_cell_key_from_args, require_args};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
use crate::view::Axis;
|
||||
|
||||
#[test]
|
||||
fn add_category_cmd_produces_add_category_effect() {
|
||||
@@ -59,7 +59,7 @@ mod tests {
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]);
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
assert_eq!(effects.len(), 2, "the name check, then the write");
|
||||
let mut app = crate::ui::app::App::new(two_cat_model(), None);
|
||||
app.apply_effects(effects);
|
||||
let view = app.model_state.workbook.active_view();
|
||||
@@ -95,6 +95,104 @@ mod tests {
|
||||
assert!(present.model_state.workbook.model.formulas().is_empty());
|
||||
}
|
||||
|
||||
/// Run `name args` built by the pure arm against a fresh `App` on `wb`.
|
||||
fn run_on(wb: &crate::workbook::Workbook, name: &str, args: &[&str]) -> crate::ui::app::App {
|
||||
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let mut app = crate::ui::app::App::new(wb.clone(), None);
|
||||
let cmd = crate::command::cmd::registry::default_registry()
|
||||
.parse(name, &args)
|
||||
.unwrap_or_else(|e| panic!("`{name} {args:?}` must build: {e}"));
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(
|
||||
crate::command::key::KeyCode::Null,
|
||||
crate::command::key::KeyModifiers::NONE,
|
||||
);
|
||||
cmd.execute(&ctx)
|
||||
};
|
||||
app.apply_effects(effects);
|
||||
app
|
||||
}
|
||||
|
||||
/// A document where every sibling below has something real to name.
|
||||
fn named_things() -> crate::workbook::Workbook {
|
||||
let mut wb = two_cat_model();
|
||||
wb.add_item_in_group("Type", "Tools", "Hardware").unwrap();
|
||||
wb.create_view("Second");
|
||||
wb
|
||||
}
|
||||
|
||||
/// improvise-31oe.2 and its siblings: each names something missing, is
|
||||
/// refused naming it, and changes nothing. Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_command_naming_something_the_document_lacks_is_a_refusal() {
|
||||
let wb = named_things();
|
||||
let cases: [(&str, &[&str], &str); 10] = [
|
||||
("set-axis", &["Regoin", "column"], "Regoin"),
|
||||
("set-page", &["Regoin", "Food"], "Regoin"),
|
||||
("set-page", &["Type", "Nowhere"], "Nowhere"),
|
||||
("hide-item", &["Regoin", "Food"], "Regoin"),
|
||||
("hide-item", &["Type", "Nowhere"], "Nowhere"),
|
||||
("show-item", &["Regoin", "Food"], "Regoin"),
|
||||
("show-item", &["Type", "Nowhere"], "Nowhere"),
|
||||
("toggle-group", &["Regoin", "Hardware"], "Regoin"),
|
||||
("toggle-group", &["Type", "Nowhere"], "Nowhere"),
|
||||
("switch-view", &["Nowhere"], "Nowhere"),
|
||||
];
|
||||
let untouched = crate::persistence::format_md(&wb);
|
||||
for (name, args, missing) in cases {
|
||||
let app = run_on(&wb, name, args);
|
||||
let said = &app.view_state.status_msg;
|
||||
assert!(
|
||||
app.last_batch_refused(),
|
||||
"`{name} {args:?}` was not refused: {said:?}"
|
||||
);
|
||||
assert!(
|
||||
said.contains(missing),
|
||||
"`{name} {args:?}` did not name {missing}: {said:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::persistence::format_md(&app.model_state.workbook),
|
||||
untouched,
|
||||
"`{name} {args:?}` changed the document"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view_state.active_view, wb.active_view,
|
||||
"`{name}` moved the view"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The controls: the same commands naming what IS there are not refused.
|
||||
#[test]
|
||||
fn a_command_naming_what_the_document_has_is_not_refused() {
|
||||
let wb = named_things();
|
||||
let cases: [(&str, &[&str]); 6] = [
|
||||
("set-axis", &["Type", "column"]),
|
||||
("set-page", &["Type", "Clothing"]),
|
||||
("hide-item", &["Type", "Clothing"]),
|
||||
("show-item", &["Type", "Clothing"]),
|
||||
("toggle-group", &["Type", "Hardware"]),
|
||||
("switch-view", &["Second"]),
|
||||
];
|
||||
for (name, args) in cases {
|
||||
let app = run_on(&wb, name, args);
|
||||
assert!(
|
||||
!app.last_batch_refused(),
|
||||
"`{name} {args:?}` was refused: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis keyword is an argument error, caught before anything runs.
|
||||
#[test]
|
||||
fn set_axis_with_an_unknown_axis_is_an_argument_error() {
|
||||
let err = crate::command::parse_line("set-axis Type sideways")
|
||||
.expect_err("sideways is not an axis");
|
||||
assert!(err.starts_with("set-axis:"), "names the command: {err}");
|
||||
assert!(err.contains("sideways"), "names the axis: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_cmd_without_args_saves() {
|
||||
let m = two_cat_model();
|
||||
@@ -129,7 +227,7 @@ mod tests {
|
||||
// Thin Cmd wrappers around effects, on the same path as keymap dispatch.
|
||||
|
||||
macro_rules! effect_cmd {
|
||||
($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => {
|
||||
($name:ident, $cmd_name:expr, $max:expr, $parse:expr, $exec:expr) => {
|
||||
#[derive(Debug)]
|
||||
pub struct $name(pub Vec<String>);
|
||||
impl Cmd for $name {
|
||||
@@ -143,6 +241,7 @@ macro_rules! effect_cmd {
|
||||
}
|
||||
}
|
||||
impl $name {
|
||||
pub const MAX_ARGS: MaxArgs = $max;
|
||||
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
||||
#[allow(clippy::redundant_closure_call)]
|
||||
($parse)(args)?;
|
||||
@@ -152,9 +251,16 @@ macro_rules! effect_cmd {
|
||||
};
|
||||
}
|
||||
|
||||
/// `effect`, preceded by the check that what it names exists, run at APPLY time:
|
||||
/// a `:` line is built before any of it runs — `context/code/cli.md` §15.
|
||||
fn unless_unknown(needs: effect::Needs, effect: impl Effect + 'static) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::RequireKnown(needs)), Box::new(effect)]
|
||||
}
|
||||
|
||||
effect_cmd!(
|
||||
AddCategoryCmd,
|
||||
"add-category",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::AddCategory(args[0].clone()))]
|
||||
@@ -164,6 +270,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
AddItemCmd,
|
||||
"add-item",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::AddItem {
|
||||
@@ -176,6 +283,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
AddItemsCmd,
|
||||
"add-items",
|
||||
MaxArgs::Any,
|
||||
|args: &[String]| {
|
||||
if args.len() < 2 {
|
||||
Err("add-items requires a category and at least one item".to_string())
|
||||
@@ -200,6 +308,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
AddItemInGroupCmd,
|
||||
"add-item-in-group",
|
||||
MaxArgs::AtMost(3),
|
||||
|args: &[String]| require_args(args, 3),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::AddItemInGroup {
|
||||
@@ -213,11 +322,12 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
SetCellCmd,
|
||||
"set-cell",
|
||||
MaxArgs::Any,
|
||||
|args: &[String]| {
|
||||
if args.len() < 2 {
|
||||
Err("set-cell requires a value and at least one Cat/Item coordinate".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
parse_cell_key_from_args(&args[1..]).map(|_| ())
|
||||
}
|
||||
},
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
@@ -226,21 +336,17 @@ effect_cmd!(
|
||||
} else {
|
||||
CellValue::Text(args[0].clone())
|
||||
};
|
||||
let coords: Vec<(String, String)> = args[1..]
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let (cat, item) = s.split_once('/')?;
|
||||
Some((cat.to_string(), item.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let key = crate::model::cell::CellKey::new(coords);
|
||||
vec![Box::new(effect::SetCell(key, value))]
|
||||
match parse_cell_key_from_args(&args[1..]) {
|
||||
Ok(key) => vec![Box::new(effect::SetCell(key, value))],
|
||||
Err(why) => vec![effect::refuse(why)],
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
AddFormulaCmd,
|
||||
"add-formula",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| {
|
||||
if args.is_empty() || args.len() > 2 {
|
||||
// The arity alone does not tell anyone to quote — improvise-z05h.
|
||||
@@ -269,6 +375,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
ClearBufferCmd,
|
||||
"clear-buffer",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![super::core::write_buffer(&args[0], String::new())]
|
||||
@@ -278,6 +385,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
RemoveFormulaCmd,
|
||||
"remove-formula",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
let (category, target) = (&args[0], &args[1]);
|
||||
@@ -298,6 +406,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
CreateViewCmd,
|
||||
"create-view",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::CreateView(args[0].clone()))]
|
||||
@@ -307,6 +416,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
DeleteViewCmd,
|
||||
"delete-view",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::DeleteView(args[0].clone()))]
|
||||
@@ -316,87 +426,111 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
SwitchViewCmd,
|
||||
"switch-view",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SwitchView(args[0].clone()))]
|
||||
unless_unknown(
|
||||
effect::Needs::View(args[0].clone()),
|
||||
effect::SwitchView(args[0].clone()),
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
SetAxisCmd,
|
||||
"set-axis",
|
||||
|args: &[String]| require_args(args, 2),
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| {
|
||||
require_args(args, 2)?;
|
||||
parse_axis(&args[1]).map(|_| ())
|
||||
},
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
let axis = match args[1].to_lowercase().as_str() {
|
||||
"row" => Axis::Row,
|
||||
"column" | "col" => Axis::Column,
|
||||
"page" => Axis::Page,
|
||||
"none" => Axis::None,
|
||||
_ => return vec![], // parse step already validated
|
||||
};
|
||||
vec![Box::new(effect::SetAxis {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
axis,
|
||||
})]
|
||||
match parse_axis(&args[1]) {
|
||||
Ok(axis) => unless_unknown(
|
||||
effect::Needs::Category(args[0].clone()),
|
||||
effect::SetAxis {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
axis,
|
||||
},
|
||||
),
|
||||
Err(why) => vec![effect::refuse(why)],
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
SetPageCmd,
|
||||
"set-page",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SetPageSelection {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
})]
|
||||
unless_unknown(
|
||||
effect::Needs::Item(args[0].clone(), args[1].clone()),
|
||||
effect::SetPageSelection {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
ToggleGroupCmd,
|
||||
"toggle-group",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::ToggleGroup {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
group: args[1].clone(),
|
||||
})]
|
||||
unless_unknown(
|
||||
effect::Needs::Group(args[0].clone(), args[1].clone()),
|
||||
effect::ToggleGroup {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
group: args[1].clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
HideItemCmd,
|
||||
"hide-item",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::HideItem {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
})]
|
||||
unless_unknown(
|
||||
effect::Needs::Item(args[0].clone(), args[1].clone()),
|
||||
effect::HideItem {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
ShowItemCmd,
|
||||
"show-item",
|
||||
MaxArgs::AtMost(2),
|
||||
|args: &[String]| require_args(args, 2),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::ShowItem {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
})]
|
||||
unless_unknown(
|
||||
effect::Needs::Item(args[0].clone(), args[1].clone()),
|
||||
effect::ShowItem {
|
||||
view: ViewTarget::Active,
|
||||
category: args[0].clone(),
|
||||
item: args[1].clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
effect_cmd!(
|
||||
SaveAsCmd,
|
||||
"save-as",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
||||
@@ -406,6 +540,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
SetFormatCmd,
|
||||
"set-format",
|
||||
MaxArgs::Any,
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SetNumberFormat(
|
||||
@@ -418,6 +553,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
ImportCmd,
|
||||
"import",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::StartImportWizard(args[0].clone()))]
|
||||
@@ -427,6 +563,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
ExportCmd,
|
||||
"export",
|
||||
MaxArgs::AtMost(1),
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv");
|
||||
@@ -437,6 +574,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
WriteCmd,
|
||||
"w",
|
||||
MaxArgs::AtMost(1),
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
if args.is_empty() {
|
||||
@@ -450,6 +588,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
HelpCmd,
|
||||
"help",
|
||||
MaxArgs::AtMost(0),
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![
|
||||
@@ -462,6 +601,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
HelpPageNextCmd,
|
||||
"help-page-next",
|
||||
MaxArgs::AtMost(0),
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![effect::help_page_next()]
|
||||
@@ -471,6 +611,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
HelpPagePrevCmd,
|
||||
"help-page-prev",
|
||||
MaxArgs::AtMost(0),
|
||||
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![effect::help_page_prev()]
|
||||
@@ -480,6 +621,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
LoadModelCmd,
|
||||
"load",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::LoadModel(std::path::PathBuf::from(
|
||||
@@ -491,6 +633,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
ExportCsvCmd,
|
||||
"export-csv",
|
||||
MaxArgs::AtMost(1),
|
||||
|args: &[String]| require_args(args, 1),
|
||||
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(
|
||||
@@ -502,6 +645,7 @@ effect_cmd!(
|
||||
effect_cmd!(
|
||||
ImportJsonCmd,
|
||||
"import-json",
|
||||
MaxArgs::AtMost(3),
|
||||
|args: &[String]| {
|
||||
if args.is_empty() {
|
||||
Err("import-json requires a path".to_string())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use improvise_gp::fit::one_line;
|
||||
use improvise_gp::{FitRequest, fit_measure, fitted_measure_name};
|
||||
|
||||
use crate::command::cmd::core::{Cmd, CmdContext};
|
||||
use crate::command::cmd::core::{Cmd, CmdContext, MaxArgs};
|
||||
use crate::ui::effect::{self, Effect};
|
||||
|
||||
/// A seed as the status line spells it (`0x…`, either case) or in decimal. The hex
|
||||
@@ -27,6 +27,8 @@ pub struct FitMeasure {
|
||||
}
|
||||
|
||||
impl FitMeasure {
|
||||
pub const MAX_ARGS: MaxArgs = MaxArgs::AtMost(3);
|
||||
|
||||
/// A nameless instance for `register_pure`, which reads a command's name
|
||||
/// off a prototype rather than off a string literal (gotcha 15).
|
||||
pub fn prototype() -> Self {
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.add_item_in_group("Month", "May", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb
|
||||
}
|
||||
@@ -213,7 +214,7 @@ mod tests {
|
||||
!app.model_state
|
||||
.workbook
|
||||
.active_view()
|
||||
.is_group_collapsed("Month", "Q2"),
|
||||
.is_group_collapsed("Month", &["Q2"]),
|
||||
"the fixture starts expanded, or the assertion below proves nothing"
|
||||
);
|
||||
|
||||
@@ -225,11 +226,11 @@ mod tests {
|
||||
);
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(
|
||||
view.is_group_collapsed("Month", "Q2"),
|
||||
view.is_group_collapsed("Month", &["Q2"]),
|
||||
"the effect was returned but the view did not move"
|
||||
);
|
||||
assert!(
|
||||
!view.is_group_collapsed("Month", "Q1"),
|
||||
!view.is_group_collapsed("Month", &["Q1"]),
|
||||
"the neighbour was left alone"
|
||||
);
|
||||
}
|
||||
@@ -262,9 +263,9 @@ mod tests {
|
||||
);
|
||||
app.apply_effects(effects);
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(view.is_group_collapsed("Month", "Q2"));
|
||||
assert!(view.is_group_collapsed("Month", &["Q2"]));
|
||||
assert!(
|
||||
!view.is_group_collapsed("Month", "Q1"),
|
||||
!view.is_group_collapsed("Month", &["Q1"]),
|
||||
"the neighbour stays"
|
||||
);
|
||||
}
|
||||
@@ -286,6 +287,7 @@ mod tests {
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.add_item("Type", "Tools").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Column);
|
||||
@@ -328,6 +330,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.6: `z` walks DOWN a closed hierarchy — year, then quarter —
|
||||
/// and back up, the cursor on the group it acted on each time.
|
||||
#[test]
|
||||
fn z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again() {
|
||||
use crate::ui::app::App;
|
||||
let mut wb = crate::workbook::Workbook::new("T");
|
||||
wb.add_category("Date").unwrap();
|
||||
wb.add_category("Type").unwrap();
|
||||
for (month, quarter) in [
|
||||
("Jan", "Q1"),
|
||||
("Feb", "Q1"),
|
||||
("Apr", "Q2"),
|
||||
("Jan25", "Q1-25"),
|
||||
] {
|
||||
wb.add_item_in_group("Date", month, quarter).unwrap();
|
||||
}
|
||||
let date = wb.model.category_mut("Date").unwrap();
|
||||
for (quarter, year) in [("Q1", "Y2024"), ("Q2", "Y2024"), ("Q1-25", "Y2025")] {
|
||||
date.add_group(crate::model::category::Group::new(quarter).with_parent(year));
|
||||
}
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(row_labels(&app), ["Y2024", "Y2025"], "closed by default");
|
||||
|
||||
app.cursor_mut().selected = (1, 0);
|
||||
toggle_group_at_cursor(&mut app);
|
||||
assert_eq!(
|
||||
row_labels(&app),
|
||||
["Y2024", "Q1-25"],
|
||||
"Y2025 opens to its quarter"
|
||||
);
|
||||
assert_eq!(app.cursor().selected, (1, 0));
|
||||
|
||||
toggle_group_at_cursor(&mut app);
|
||||
assert_eq!(
|
||||
row_labels(&app),
|
||||
["Y2024", "Jan25"],
|
||||
"then the quarter to its month"
|
||||
);
|
||||
|
||||
toggle_group_at_cursor(&mut app);
|
||||
assert_eq!(
|
||||
row_labels(&app),
|
||||
["Y2024", "Q1-25"],
|
||||
"z on the month closes its quarter"
|
||||
);
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
toggle_group_at_cursor(&mut app);
|
||||
assert_eq!(row_labels(&app), ["Q1", "Q2", "Q1-25"]);
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.active_view()
|
||||
.group_fold("Date", "Q1-25"),
|
||||
None,
|
||||
"closing back to the default leaves no fold behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
|
||||
@@ -393,6 +455,7 @@ mod tests {
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
@@ -704,7 +767,9 @@ impl ToggleGroupAtCursor {
|
||||
};
|
||||
let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?;
|
||||
let mut view = ctx.view.clone();
|
||||
view.toggle_group_collapse(cat, group);
|
||||
let chain = ctx.model.group_chain(cat, group);
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
view.toggle_group_collapse(cat, &chain);
|
||||
let after = GridLayout::with_frozen_records(ctx.model, &view, None);
|
||||
match self.is_row {
|
||||
true => after.row_run_start(cat, group, prefix),
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) mod test_helpers {
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
|
||||
@@ -168,7 +169,7 @@ pub(super) mod test_helpers {
|
||||
}
|
||||
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");
|
||||
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);
|
||||
|
||||
@@ -197,8 +197,9 @@ mod tests {
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
|
||||
let layout = make_layout(&wb);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&wb, &layout, ®);
|
||||
|
||||
@@ -81,52 +81,130 @@ pub fn default_registry() -> CmdRegistry {
|
||||
let mut r = CmdRegistry::new();
|
||||
|
||||
// ── Model mutations (effect_cmd! wrappers) ───────────────────────────
|
||||
r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse);
|
||||
r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse);
|
||||
r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse);
|
||||
r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse);
|
||||
r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse);
|
||||
r.register_pure(&ClearBufferCmd(vec![]), ClearBufferCmd::parse);
|
||||
r.register_pure(
|
||||
&AddCategoryCmd(vec![]),
|
||||
AddCategoryCmd::MAX_ARGS,
|
||||
AddCategoryCmd::parse,
|
||||
);
|
||||
r.register_pure(&AddItemCmd(vec![]), AddItemCmd::MAX_ARGS, AddItemCmd::parse);
|
||||
r.register_pure(
|
||||
&AddItemsCmd(vec![]),
|
||||
AddItemsCmd::MAX_ARGS,
|
||||
AddItemsCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&AddItemInGroupCmd(vec![]),
|
||||
AddItemInGroupCmd::MAX_ARGS,
|
||||
AddItemInGroupCmd::parse,
|
||||
);
|
||||
r.register_pure(&SetCellCmd(vec![]), SetCellCmd::MAX_ARGS, SetCellCmd::parse);
|
||||
r.register_pure(
|
||||
&ClearBufferCmd(vec![]),
|
||||
ClearBufferCmd::MAX_ARGS,
|
||||
ClearBufferCmd::parse,
|
||||
);
|
||||
r.register(
|
||||
&ClearCellCommand {
|
||||
key: CellKey::new(vec![]),
|
||||
},
|
||||
MaxArgs::Any,
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("clear-cell requires at least one Cat/Item coordinate".into());
|
||||
}
|
||||
Ok(ClearCellCommand {
|
||||
key: parse_cell_key_from_args(args),
|
||||
key: parse_cell_key_from_args(args)?,
|
||||
})
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Some(|args, ctx| {
|
||||
only_when_omitted(args)?;
|
||||
Ok(ClearCellCommand {
|
||||
key: cursor_value_key(ctx)?,
|
||||
})
|
||||
}),
|
||||
);
|
||||
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
|
||||
r.register_pure(&FitMeasure::prototype(), FitMeasure::parse);
|
||||
r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse);
|
||||
r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse);
|
||||
r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse);
|
||||
r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse);
|
||||
r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse);
|
||||
r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse);
|
||||
r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse);
|
||||
r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse);
|
||||
r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse);
|
||||
r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse);
|
||||
r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse);
|
||||
r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse);
|
||||
r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse);
|
||||
r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse);
|
||||
r.register_pure(&ImportCmd(vec![]), ImportCmd::parse);
|
||||
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
|
||||
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
|
||||
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
|
||||
r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse);
|
||||
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
|
||||
r.register_pure(
|
||||
&AddFormulaCmd(vec![]),
|
||||
AddFormulaCmd::MAX_ARGS,
|
||||
AddFormulaCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&FitMeasure::prototype(),
|
||||
FitMeasure::MAX_ARGS,
|
||||
FitMeasure::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&RemoveFormulaCmd(vec![]),
|
||||
RemoveFormulaCmd::MAX_ARGS,
|
||||
RemoveFormulaCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&CreateViewCmd(vec![]),
|
||||
CreateViewCmd::MAX_ARGS,
|
||||
CreateViewCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&DeleteViewCmd(vec![]),
|
||||
DeleteViewCmd::MAX_ARGS,
|
||||
DeleteViewCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&SwitchViewCmd(vec![]),
|
||||
SwitchViewCmd::MAX_ARGS,
|
||||
SwitchViewCmd::parse,
|
||||
);
|
||||
r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::MAX_ARGS, SetAxisCmd::parse);
|
||||
r.register_pure(&SetPageCmd(vec![]), SetPageCmd::MAX_ARGS, SetPageCmd::parse);
|
||||
r.register_pure(
|
||||
&ToggleGroupCmd(vec![]),
|
||||
ToggleGroupCmd::MAX_ARGS,
|
||||
ToggleGroupCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&HideItemCmd(vec![]),
|
||||
HideItemCmd::MAX_ARGS,
|
||||
HideItemCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&ShowItemCmd(vec![]),
|
||||
ShowItemCmd::MAX_ARGS,
|
||||
ShowItemCmd::parse,
|
||||
);
|
||||
r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::MAX_ARGS, SaveAsCmd::parse);
|
||||
r.register_pure(
|
||||
&LoadModelCmd(vec![]),
|
||||
LoadModelCmd::MAX_ARGS,
|
||||
LoadModelCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&ExportCsvCmd(vec![]),
|
||||
ExportCsvCmd::MAX_ARGS,
|
||||
ExportCsvCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&ImportJsonCmd(vec![]),
|
||||
ImportJsonCmd::MAX_ARGS,
|
||||
ImportJsonCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&SetFormatCmd(vec![]),
|
||||
SetFormatCmd::MAX_ARGS,
|
||||
SetFormatCmd::parse,
|
||||
);
|
||||
r.register_pure(&ImportCmd(vec![]), ImportCmd::MAX_ARGS, ImportCmd::parse);
|
||||
r.register_pure(&ExportCmd(vec![]), ExportCmd::MAX_ARGS, ExportCmd::parse);
|
||||
r.register_pure(&WriteCmd(vec![]), WriteCmd::MAX_ARGS, WriteCmd::parse);
|
||||
r.register_pure(&HelpCmd(vec![]), HelpCmd::MAX_ARGS, HelpCmd::parse);
|
||||
r.register_pure(
|
||||
&HelpPageNextCmd(vec![]),
|
||||
HelpPageNextCmd::MAX_ARGS,
|
||||
HelpPageNextCmd::parse,
|
||||
);
|
||||
r.register_pure(
|
||||
&HelpPagePrevCmd(vec![]),
|
||||
HelpPagePrevCmd::MAX_ARGS,
|
||||
HelpPagePrevCmd::parse,
|
||||
);
|
||||
|
||||
// ── Navigation (unified Move) ──────────────────────────────────────
|
||||
r.register_at_cursor(
|
||||
@@ -135,6 +213,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cursor: CursorState::default(),
|
||||
cmd_name: "move-selection",
|
||||
},
|
||||
MaxArgs::AtMost(2),
|
||||
|args| {
|
||||
require_args(args, 2)?;
|
||||
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
@@ -160,6 +239,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cursor: CursorState::default(),
|
||||
cmd_name: $name,
|
||||
},
|
||||
MaxArgs::AtMost(0),
|
||||
|_| {
|
||||
Ok(Move {
|
||||
kind: if $to_end {
|
||||
@@ -185,6 +265,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cursor: CursorState::default(),
|
||||
cmd_name: "scroll-rows",
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
@@ -202,6 +283,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cursor: CursorState::default(),
|
||||
cmd_name: "page-scroll",
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
@@ -217,6 +299,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&EnterAdvance {
|
||||
cursor: CursorState::default(),
|
||||
},
|
||||
MaxArgs::AtMost(0),
|
||||
|_| {
|
||||
Ok(EnterAdvance {
|
||||
cursor: CursorState {
|
||||
@@ -239,15 +322,17 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&YankCell {
|
||||
key: CellKey::new(vec![]),
|
||||
},
|
||||
MaxArgs::Any,
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("yank requires at least one Cat/Item coordinate".into());
|
||||
}
|
||||
Ok(YankCell {
|
||||
key: parse_cell_key_from_args(args),
|
||||
key: parse_cell_key_from_args(args)?,
|
||||
})
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Some(|args, ctx| {
|
||||
only_when_omitted(args)?;
|
||||
Ok(YankCell {
|
||||
key: cursor_key_to_read(ctx)?,
|
||||
})
|
||||
@@ -257,15 +342,17 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&PasteCell {
|
||||
at: PasteAt::Spelled(CellKey::new(vec![])),
|
||||
},
|
||||
MaxArgs::Any,
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("paste requires at least one Cat/Item coordinate".into());
|
||||
}
|
||||
Ok(PasteCell {
|
||||
at: PasteAt::Spelled(parse_cell_key_from_args(args)),
|
||||
at: PasteAt::Spelled(parse_cell_key_from_args(args)?),
|
||||
})
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Some(|args, ctx| {
|
||||
only_when_omitted(args)?;
|
||||
Ok(PasteCell {
|
||||
at: PasteAt::Resolved(cursor_value_key(ctx)?),
|
||||
})
|
||||
@@ -288,6 +375,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&EditOrDrill {
|
||||
edit_mode: AppMode::Normal,
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let edit_mode = parse_mode_name(&args[0])?;
|
||||
@@ -298,6 +386,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&EnterEditAtCursorCmd {
|
||||
target_mode: AppMode::Normal,
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let target_mode = parse_mode_name(&args[0])?;
|
||||
@@ -309,15 +398,17 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&DrillIntoCell {
|
||||
key: crate::model::cell::CellKey::new(vec![]),
|
||||
},
|
||||
MaxArgs::Any,
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("drill-into-cell requires Cat/Item coordinates".into());
|
||||
}
|
||||
Ok(DrillIntoCell {
|
||||
key: parse_cell_key_from_args(args),
|
||||
key: parse_cell_key_from_args(args)?,
|
||||
})
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Some(|args, ctx| {
|
||||
only_when_omitted(args)?;
|
||||
Ok(DrillIntoCell {
|
||||
key: cursor_key(ctx)?,
|
||||
})
|
||||
@@ -325,13 +416,13 @@ pub fn default_registry() -> CmdRegistry {
|
||||
);
|
||||
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
|
||||
r.register_nullary(|| Box::new(ViewNavigate { forward: true }));
|
||||
r.register_pure(&EnterMode(AppMode::Normal), |args| {
|
||||
r.register_pure(&EnterMode(AppMode::Normal), MaxArgs::AtMost(1), |args| {
|
||||
require_args(args, 1)?;
|
||||
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
|
||||
});
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────
|
||||
r.register_pure(&SearchNavigate(true), |args| {
|
||||
r.register_pure(&SearchNavigate(true), MaxArgs::AtMost(1), |args| {
|
||||
let forward = args.first().map(|s| s != "backward").unwrap_or(true);
|
||||
Ok(Box::new(SearchNavigate(forward)))
|
||||
});
|
||||
@@ -344,6 +435,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
panel: Panel::Formula,
|
||||
target: None,
|
||||
},
|
||||
MaxArgs::AtMost(3),
|
||||
|args| {
|
||||
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
||||
require_args(args, 1)?;
|
||||
@@ -360,6 +452,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&TogglePanelVisibility {
|
||||
panel: Panel::Formula,
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
@@ -373,6 +466,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
panel: Panel::Formula,
|
||||
delta: 0,
|
||||
},
|
||||
MaxArgs::AtMost(2),
|
||||
|args| {
|
||||
require_args(args, 2)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
@@ -396,7 +490,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
||||
|
||||
// ── Tile select ──────────────────────────────────────────────────────
|
||||
r.register_pure(&MoveTileCursor(0), |args| {
|
||||
r.register_pure(&MoveTileCursor(0), MaxArgs::AtMost(1), |args| {
|
||||
require_args(args, 1)?;
|
||||
let delta = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
Ok(Box::new(MoveTileCursor(delta)))
|
||||
@@ -406,6 +500,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&TileAxisOp {
|
||||
axis: Some(crate::view::Axis::Row),
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let axis = parse_axis(&args[0])?;
|
||||
@@ -423,6 +518,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&AppendChar {
|
||||
buffer: String::new(),
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
Ok(Box::new(AppendChar {
|
||||
@@ -434,6 +530,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&PopChar {
|
||||
buffer: String::new(),
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
Ok(Box::new(PopChar {
|
||||
@@ -454,13 +551,14 @@ pub fn default_registry() -> CmdRegistry {
|
||||
edit_mode: AppMode::editing(),
|
||||
opened: None,
|
||||
},
|
||||
MaxArgs::Any,
|
||||
|args| {
|
||||
if args.len() < 3 {
|
||||
return Err("commit-cell-edit requires a mode, value, and coords".into());
|
||||
}
|
||||
let edit_mode = parse_mode_name(&args[0])?;
|
||||
Ok(CommitAndAdvance {
|
||||
key: parse_cell_key_from_args(&args[2..]),
|
||||
key: parse_cell_key_from_args(&args[2..])?,
|
||||
value: args[1].clone(),
|
||||
advance: AdvanceDir::Down,
|
||||
cursor: CursorState::default(),
|
||||
@@ -479,6 +577,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
edit_mode: AppMode::editing(),
|
||||
opened: None,
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|_| Err("commit-and-advance-right requires context".into()),
|
||||
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
|
||||
);
|
||||
@@ -486,6 +585,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
&CommitBuffer {
|
||||
kind: CommitKind::Category,
|
||||
},
|
||||
MaxArgs::AtMost(1),
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
|
||||
@@ -534,6 +634,7 @@ pub(super) mod tests {
|
||||
vec!["a"],
|
||||
vec!["Region"],
|
||||
vec!["Region", "East"],
|
||||
vec!["Region", "row"],
|
||||
vec!["Region", "East", "1"],
|
||||
vec!["Profit = 1"],
|
||||
vec![",.2f"],
|
||||
@@ -544,6 +645,7 @@ pub(super) mod tests {
|
||||
vec!["editing", "5", "Region/East"],
|
||||
vec!["formula", "false", "false"],
|
||||
vec!["Region/East"],
|
||||
vec!["1", "Region/East"],
|
||||
]
|
||||
.into_iter()
|
||||
.map(|v| v.into_iter().map(String::from).collect())
|
||||
@@ -592,6 +694,87 @@ pub(super) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn no_command_silently_drops_an_argument_it_was_given() {
|
||||
let r = default_registry();
|
||||
let wb = two_cat_model();
|
||||
let layout = make_layout(&wb);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&wb, &layout, ®);
|
||||
let variadic = [
|
||||
"add-items",
|
||||
"set-cell",
|
||||
"set-format",
|
||||
"clear-cell",
|
||||
"yank",
|
||||
"paste",
|
||||
"drill-into-cell",
|
||||
"commit-cell-edit",
|
||||
];
|
||||
// Their own refusal states the shape, and it runs before the rule.
|
||||
let own_shape = ["add-formula", "fit-measure", "commit-and-advance-right"];
|
||||
let mut checked = 0;
|
||||
for name in r.names().collect::<Vec<_>>() {
|
||||
if variadic.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
for args in candidate_args() {
|
||||
// Four more than any list accepted, so an optional argument
|
||||
// cannot absorb them; the last one is surplus whatever the command.
|
||||
let mut more = args.clone();
|
||||
more.extend(["EXTRA1", "EXTRA2", "EXTRA3", "EXTRA4"].map(String::from));
|
||||
let arms = [
|
||||
r.parse(name, &args).map(|_| r.parse(name, &more)),
|
||||
r.interactive(name, &args, &ctx)
|
||||
.map(|_| r.interactive(name, &more, &ctx)),
|
||||
];
|
||||
for extended in arms.into_iter().flatten() {
|
||||
checked += 1;
|
||||
match extended {
|
||||
Ok(_) => panic!("`{name} {more:?}` dropped arguments without a word"),
|
||||
Err(e) if own_shape.contains(&name) || e.contains("\"EXTRA4\"") => {}
|
||||
Err(e) => {
|
||||
panic!("`{name}` refused an extra argument without naming it: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100, "only {checked} accepted lists were extended");
|
||||
}
|
||||
|
||||
/// Codex on PR #316: a variadic coordinate list dropped a token that was no
|
||||
/// coordinate. Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_malformed_coordinate_is_refused_not_dropped() {
|
||||
let lines = [
|
||||
"set-cell 1 Type/Food unexpected",
|
||||
"clear-cell Type/Food unexpected",
|
||||
"yank Type/Food unexpected",
|
||||
"paste Type/Food unexpected",
|
||||
"drill-into-cell Type/Food unexpected",
|
||||
"commit-cell-edit editing 5 Type/Food unexpected",
|
||||
];
|
||||
for line in lines {
|
||||
let err = crate::command::parse_line(line)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("`{line}` dropped \"unexpected\" without a word"));
|
||||
assert!(
|
||||
err.contains("\"unexpected\""),
|
||||
"`{line}` did not name it: {err}"
|
||||
);
|
||||
}
|
||||
for half_empty in ["set-cell 1 Type/", "clear-cell /Food"] {
|
||||
assert!(
|
||||
crate::command::parse_line(half_empty).is_err(),
|
||||
"`{half_empty}`"
|
||||
);
|
||||
}
|
||||
let control = "set-cell 1 Type/Food Month/Jan . clear-cell Type/Food";
|
||||
assert!(crate::command::parse_line(control).is_ok(), "the control");
|
||||
}
|
||||
|
||||
/// A variant mix-up shows up as a **collision**, not a mismatch: the wrong entry
|
||||
/// takes its sibling's name and the sibling vanishes. Uniqueness catches it.
|
||||
#[test]
|
||||
|
||||
@@ -216,6 +216,110 @@ mod tests {
|
||||
app.apply_effects(effects);
|
||||
}
|
||||
|
||||
fn cell(wb: &crate::workbook::Workbook, ty: &str, month: &str) -> Option<f64> {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let key = CellKey::new(vec![
|
||||
("Type".into(), ty.into()),
|
||||
("Month".into(), month.into()),
|
||||
]);
|
||||
match wb.model.get_cell(&key) {
|
||||
Some(CellValue::Number(n)) => Some(*n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An app whose cursor (0,0) is on Food/Jan = 1, with Clothing/Feb = 2 beside it.
|
||||
fn app_with_two_cells() -> crate::ui::app::App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut app = wizard_test_app();
|
||||
for (ty, month, n) in [("Food", "Jan", 1.0), ("Clothing", "Feb", 2.0)] {
|
||||
let key = CellKey::new(vec![
|
||||
("Type".into(), ty.into()),
|
||||
("Month".into(), month.into()),
|
||||
]);
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.set_cell(key, CellValue::Number(n));
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// PR #316 review: a malformed coordinate on `:` fell into the cursor fallback
|
||||
/// and wrote the CURSOR's cell. Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn colon_refuses_a_malformed_coordinate_and_leaves_the_cursor_cell_alone() {
|
||||
let lines = [
|
||||
"clear-cell Type/Clothing Month/Feb junk",
|
||||
"yank Type/Clothing Month/Feb junk",
|
||||
"paste Type/Clothing Month/Feb junk",
|
||||
"drill-into-cell Type/Clothing Month/Feb junk",
|
||||
];
|
||||
for line in lines {
|
||||
let mut app = app_with_two_cells();
|
||||
app.view_state.yanked = Some(crate::model::cell::CellValue::Number(9.0));
|
||||
let before = app.view_state.yanked.clone();
|
||||
run_colon_command(&mut app, line);
|
||||
assert!(app.last_batch_refused(), "`:{line}` was not refused");
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("\"junk\""),
|
||||
"`:{line}` did not name the token: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(
|
||||
cell(wb, "Food", "Jan"),
|
||||
Some(1.0),
|
||||
"`:{line}` wrote the cursor cell"
|
||||
);
|
||||
assert_eq!(
|
||||
cell(wb, "Clothing", "Feb"),
|
||||
Some(2.0),
|
||||
"`:{line}` wrote the named cell"
|
||||
);
|
||||
assert_eq!(app.view_state.yanked, before, "`:{line}` yanked");
|
||||
assert!(app.drill_state.is_none(), "`:{line}` opened a drill");
|
||||
}
|
||||
}
|
||||
|
||||
/// The fallback still answers for coordinates that were OMITTED.
|
||||
#[test]
|
||||
fn colon_clear_cell_with_no_coordinates_still_uses_the_cursor() {
|
||||
let mut app = app_with_two_cells();
|
||||
run_colon_command(&mut app, "clear-cell");
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(cell(wb, "Food", "Jan"), None);
|
||||
assert_eq!(cell(wb, "Clothing", "Feb"), Some(2.0));
|
||||
}
|
||||
|
||||
/// A later `:` segment sees what an earlier one made, as `improvise cmd` does:
|
||||
/// the line is built before either half runs. Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn colon_lets_a_later_segment_use_what_an_earlier_one_created() {
|
||||
let mut app = wizard_test_app();
|
||||
run_colon_command(&mut app, "create-view Second . switch-view Second");
|
||||
assert!(!app.last_batch_refused(), "{:?}", app.view_state.status_msg);
|
||||
assert_eq!(app.view_state.active_view, "Second");
|
||||
|
||||
let mut app = wizard_test_app();
|
||||
run_colon_command(&mut app, "add-item Type Tools . set-page Type Tools");
|
||||
assert!(!app.last_batch_refused(), "{:?}", app.view_state.status_msg);
|
||||
}
|
||||
|
||||
/// The control: a name NOTHING creates is still refused, on `:` too.
|
||||
#[test]
|
||||
fn colon_still_refuses_a_name_nothing_creates() {
|
||||
for line in [
|
||||
"switch-view Nowhere",
|
||||
"set-page Type Nowhere",
|
||||
"hide-item Regoin Food",
|
||||
] {
|
||||
let mut app = wizard_test_app();
|
||||
run_colon_command(&mut app, line);
|
||||
assert!(app.last_batch_refused(), "`:{line}` was not refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-toc: `StartImportWizard` swaps the mode only on success, so a
|
||||
/// per-type answer snapped `:import` straight back out of the wizard.
|
||||
#[test]
|
||||
@@ -271,6 +375,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-31oe.2 on the `:` line: the refusal is on the status line,
|
||||
/// naming what is missing. Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_colon_line_naming_something_missing_refuses_on_the_status_line() {
|
||||
for (line, missing) in [
|
||||
("set-axis Regoin row", "Regoin"),
|
||||
("set-axis Type sideways", "sideways"),
|
||||
("set-page Type Nowhere", "Nowhere"),
|
||||
("create-view By area", "\"area\""),
|
||||
] {
|
||||
let mut app = wizard_test_app();
|
||||
run_colon_command(&mut app, line);
|
||||
let said = &app.view_state.status_msg;
|
||||
assert!(
|
||||
app.last_batch_refused(),
|
||||
"`:{line}` was not refused: {said:?}"
|
||||
);
|
||||
assert!(
|
||||
said.contains(missing),
|
||||
"`:{line}` did not name {missing}: {said:?}"
|
||||
);
|
||||
assert!(matches!(app.view_state.mode, AppMode::Normal), "`:{line}`");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-31oe.3 on the `:` line.
|
||||
#[test]
|
||||
fn a_colon_line_reads_a_pipe_quoted_name() {
|
||||
let mut app = wizard_test_app();
|
||||
run_colon_command(&mut app, "create-view |By area|");
|
||||
let views: Vec<&String> = app.model_state.workbook.views.keys().collect();
|
||||
assert!(
|
||||
app.model_state.workbook.views.contains_key("By area"),
|
||||
"{views:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A command that does not touch the mode still returns to Normal.
|
||||
#[test]
|
||||
fn colon_plain_command_returns_to_normal() {
|
||||
@@ -372,9 +513,12 @@ impl Cmd for ExecuteCommand {
|
||||
|
||||
// Built against the live cursor, exactly as a script's is — improvise-6uva.
|
||||
let built: Result<Vec<Box<dyn Cmd>>, String> = crate::command::parse::segments(&raw)
|
||||
.into_iter()
|
||||
.map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.and_then(|segments| {
|
||||
segments
|
||||
.into_iter()
|
||||
.map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.map(|built| built.into_iter().flatten().collect());
|
||||
match built {
|
||||
Ok(cmds) => {
|
||||
|
||||
@@ -65,6 +65,7 @@ fn review_proposal_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
||||
fn configure_date_keys(key: KeyCode) -> Vec<Box<dyn Effect>> {
|
||||
match key {
|
||||
KeyCode::Char(' ') => op(WizardOp::ToggleDateComponent),
|
||||
KeyCode::Char('s') => op(WizardOp::ToggleDateShape),
|
||||
KeyCode::Enter => op(WizardOp::Advance),
|
||||
_ => cursor_keys(key),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands,
|
||||
//! `Category/Item` coordinate pairs, `"quoted strings"`.
|
||||
//! `Category/Item` coordinate pairs, `"quoted strings"` and `|pipe-quoted names|`.
|
||||
|
||||
use std::iter::Peekable;
|
||||
use std::str::CharIndices;
|
||||
|
||||
use improvise_formula::quote::unescape_pipe_body;
|
||||
|
||||
use super::cmd::{Cmd, CmdContext, CmdRegistry, default_registry};
|
||||
|
||||
@@ -11,39 +16,19 @@ pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
||||
|
||||
/// Parse a line into commands using a given registry.
|
||||
pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut commands = Vec::new();
|
||||
for segment in split_on_dot(line) {
|
||||
let segment = segment.trim();
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tokens = tokenize(segment);
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let word = &tokens[0];
|
||||
let args = &tokens[1..];
|
||||
commands.push(registry.parse(word, args)?);
|
||||
}
|
||||
Ok(commands)
|
||||
groups(line.trim())?
|
||||
.iter()
|
||||
.map(|words| registry.parse(&words[0].text, &texts(&words[1..])))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The command segments of a line, with comments and blank segments dropped.
|
||||
pub fn segments(line: &str) -> Vec<&str> {
|
||||
pub fn segments(line: &str) -> Result<Vec<&str>, String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
|
||||
return Vec::new();
|
||||
}
|
||||
split_on_dot(line)
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
Ok(groups(line)?
|
||||
.iter()
|
||||
.map(|words| &line[words[0].start..words[words.len() - 1].end])
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build one segment's command against a LIVE context — `context/code/cli.md`.
|
||||
@@ -51,7 +36,7 @@ pub fn parse_segment_interactive(
|
||||
segment: &str,
|
||||
ctx: &CmdContext,
|
||||
) -> Result<Option<Box<dyn Cmd>>, String> {
|
||||
let tokens = tokenize(segment.trim());
|
||||
let tokens = texts(&lex(segment.trim())?);
|
||||
let Some(word) = tokens.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -67,65 +52,99 @@ pub fn parse_segment_interactive(
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a line on ` . ` separators (dot must be a standalone word,
|
||||
/// surrounded by whitespace or at line boundaries). Respects quoted strings.
|
||||
fn split_on_dot(line: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut in_quote = false;
|
||||
let bytes = line.as_bytes();
|
||||
|
||||
for (i, c) in line.char_indices() {
|
||||
match c {
|
||||
'"' => in_quote = !in_quote,
|
||||
'.' if !in_quote => {
|
||||
let before_ws = i == 0 || bytes[i - 1].is_ascii_whitespace();
|
||||
let after_ws = i + 1 >= bytes.len() || bytes[i + 1].is_ascii_whitespace();
|
||||
if before_ws && after_ws {
|
||||
segments.push(&line[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
segments.push(&line[start..]);
|
||||
segments
|
||||
/// One word of a line and the byte span it was read from. A `separator` is a
|
||||
/// bare `.`; a quoted one is an argument like any other.
|
||||
struct Word {
|
||||
text: String,
|
||||
start: usize,
|
||||
end: usize,
|
||||
separator: bool,
|
||||
}
|
||||
|
||||
/// Tokenize a command segment into words, handling quoted strings.
|
||||
fn tokenize(input: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
fn texts(words: &[Word]) -> Vec<String> {
|
||||
words.iter().map(|w| w.text.clone()).collect()
|
||||
}
|
||||
|
||||
while let Some(&c) = chars.peek() {
|
||||
/// The words of a trimmed line, split at separators, blank commands and
|
||||
/// comment lines dropped.
|
||||
fn groups(line: &str) -> Result<Vec<Vec<Word>>, String> {
|
||||
if line.starts_with('#') || line.starts_with("//") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut groups: Vec<Vec<Word>> = vec![Vec::new()];
|
||||
for word in lex(line)? {
|
||||
match word.separator {
|
||||
true => groups.push(Vec::new()),
|
||||
false => groups.last_mut().expect("never empty").push(word),
|
||||
}
|
||||
}
|
||||
groups.retain(|g| !g.is_empty());
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Split `line` into words. The quoting rules: `context/code/cli.md` §14.
|
||||
fn lex(line: &str) -> Result<Vec<Word>, String> {
|
||||
let mut words = Vec::new();
|
||||
let mut chars = line.char_indices().peekable();
|
||||
while let Some(&(start, c)) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
chars.next(); // consume opening quote
|
||||
let mut s = String::new();
|
||||
for ch in chars.by_ref() {
|
||||
if ch == '"' {
|
||||
break;
|
||||
}
|
||||
s.push(ch);
|
||||
let (text, bare) = match c {
|
||||
'"' => (double_quoted(&mut chars), false),
|
||||
_ => bare_word(&mut chars, line)?,
|
||||
};
|
||||
let end = chars.peek().map_or(line.len(), |&(i, _)| i);
|
||||
words.push(Word {
|
||||
separator: bare && text == ".",
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
});
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
/// `"…"` read verbatim: no escapes, and a `|` inside stays a `|`.
|
||||
fn double_quoted(chars: &mut Peekable<CharIndices>) -> String {
|
||||
chars.next();
|
||||
chars
|
||||
.by_ref()
|
||||
.map(|(_, c)| c)
|
||||
.take_while(|&c| c != '"')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Text up to whitespace, each `|…|` piece unquoted in place; `bare` is false
|
||||
/// once a piece was quoted, so a quoted `|.|` is an argument and not a separator.
|
||||
fn bare_word(chars: &mut Peekable<CharIndices>, line: &str) -> Result<(String, bool), String> {
|
||||
let mut text = String::new();
|
||||
let mut bare = true;
|
||||
while let Some((at, c)) = chars.next_if(|&(_, c)| !c.is_whitespace()) {
|
||||
match c {
|
||||
'|' => {
|
||||
text.push_str(&pipe_body(chars, line, at)?);
|
||||
bare = false;
|
||||
}
|
||||
tokens.push(s);
|
||||
} else {
|
||||
let mut s = String::new();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
s.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
tokens.push(s);
|
||||
_ => text.push(c),
|
||||
}
|
||||
}
|
||||
tokens
|
||||
Ok((text, bare))
|
||||
}
|
||||
|
||||
/// The body of the `|…|` opened at `open`, escapes applied by the one shared
|
||||
/// reader (gotcha 34).
|
||||
fn pipe_body(chars: &mut Peekable<CharIndices>, line: &str, open: usize) -> Result<String, String> {
|
||||
let mut body = String::new();
|
||||
while let Some((_, c)) = chars.next() {
|
||||
match c {
|
||||
'|' => return Ok(unescape_pipe_body(&body)),
|
||||
'\\' => body.extend(std::iter::once(c).chain(chars.next().map(|(_, e)| e))),
|
||||
_ => body.push(c),
|
||||
}
|
||||
}
|
||||
Err(format!("unterminated |…| in `{}`", &line[open..]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -302,11 +321,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn segments_drops_comments_and_blanks() {
|
||||
assert_eq!(segments("# a comment"), Vec::<&str>::new());
|
||||
assert_eq!(segments("// also a comment"), Vec::<&str>::new());
|
||||
assert_eq!(segments(" "), Vec::<&str>::new());
|
||||
assert_eq!(segments("a . . b"), vec!["a", "b"]);
|
||||
assert_eq!(segments("move-selection 2 0"), vec!["move-selection 2 0"]);
|
||||
assert_eq!(segments("# a comment"), Ok(Vec::<&str>::new()));
|
||||
assert_eq!(segments("// also a comment"), Ok(Vec::<&str>::new()));
|
||||
assert_eq!(segments(" "), Ok(Vec::<&str>::new()));
|
||||
assert_eq!(segments("a . . b"), Ok(vec!["a", "b"]));
|
||||
assert_eq!(
|
||||
segments("move-selection 2 0"),
|
||||
Ok(vec!["move-selection 2 0"])
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
@@ -347,4 +369,105 @@ mod tests {
|
||||
.expect_err("an unknown word is refused");
|
||||
assert!(err.contains("no-such-command"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── pipe quoting and arity (improvise-31oe.3) ─────────────────────────
|
||||
|
||||
/// The one command `line` builds, as its `Debug` shows it.
|
||||
fn built(line: &str) -> String {
|
||||
let cmds = parse_line(line).unwrap_or_else(|e| panic!("`{line}` must parse: {e}"));
|
||||
assert_eq!(cmds.len(), 1, "`{line}` is one command");
|
||||
format!("{:?}", cmds[0])
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_pipe_quoted_argument_is_one_argument_without_its_pipes() {
|
||||
assert_eq!(
|
||||
built("create-view |By area|"),
|
||||
r#"CreateViewCmd(["By area"])"#
|
||||
);
|
||||
assert_eq!(
|
||||
built("set-page |Sales Region| |East Coast|"),
|
||||
r#"SetPageCmd(["Sales Region", "East Coast"])"#
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_pipe_quoted_argument_reads_the_escapes_improv_does() {
|
||||
assert_eq!(built(r"create-view |a\|b|"), r#"CreateViewCmd(["a|b"])"#);
|
||||
assert_eq!(
|
||||
built(r"create-view |back\\slash|"),
|
||||
r#"CreateViewCmd(["back\\slash"])"#
|
||||
);
|
||||
assert_eq!(
|
||||
built(r"create-view |two\nlines|"),
|
||||
r#"CreateViewCmd(["two\nlines"])"#
|
||||
);
|
||||
}
|
||||
|
||||
/// A coordinate quotes each half, as a `.improv` data line does.
|
||||
#[test]
|
||||
fn a_coordinate_may_pipe_quote_either_half() {
|
||||
assert_eq!(
|
||||
built("set-cell 5 |Sales Region|/|East Coast| Measure/Revenue"),
|
||||
r#"SetCellCmd(["5", "Sales Region/East Coast", "Measure/Revenue"])"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dot_inside_pipes_does_not_split_the_line() {
|
||||
assert_eq!(built("create-view |a . b|"), r#"CreateViewCmd(["a . b"])"#);
|
||||
assert_eq!(
|
||||
segments("create-view |a . b|"),
|
||||
Ok(vec!["create-view |a . b|"])
|
||||
);
|
||||
}
|
||||
|
||||
/// Double quotes stay verbatim: formula text carries its own pipes.
|
||||
#[test]
|
||||
fn a_double_quoted_argument_keeps_the_pipes_inside_it() {
|
||||
assert_eq!(
|
||||
built(r#"add-formula Measure "Net = |Gross Rev| - Cost""#),
|
||||
r#"AddFormulaCmd(["Measure", "Net = |Gross Rev| - Cost"])"#
|
||||
);
|
||||
}
|
||||
|
||||
/// `context/code/cli.md` §14: only a BARE `.` separates commands. A quoted one
|
||||
/// is an argument, so `|.|` and `"."` name a thing called `.`.
|
||||
#[test]
|
||||
fn a_quoted_dot_is_an_argument_not_a_separator() {
|
||||
for line in ["create-view |.|", "create-view \".\""] {
|
||||
let groups = groups(line).expect("lexes");
|
||||
assert_eq!(groups.len(), 1, "`{line}` split into commands");
|
||||
assert_eq!(texts(&groups[0]), ["create-view", "."], "`{line}`");
|
||||
}
|
||||
assert_eq!(groups("create-view A . create-view B").unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unterminated_pipe_is_refused() {
|
||||
let err = parse_line("create-view |By area").expect_err("an open pipe is refused");
|
||||
assert!(
|
||||
err.contains("|By area"),
|
||||
"the refusal names the text: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_command_given_more_arguments_than_it_takes_is_refused() {
|
||||
let err = parse_line("create-view By area").expect_err("two words for one argument");
|
||||
assert!(err.starts_with("create-view:"), "names the command: {err}");
|
||||
assert!(err.contains("\"area\""), "names the extra argument: {err}");
|
||||
let err = parse_line("set-axis Type row sideways").expect_err("a third argument");
|
||||
assert!(
|
||||
err.contains("\"sideways\""),
|
||||
"names the extra argument: {err}"
|
||||
);
|
||||
assert!(
|
||||
parse_line("create-view |By area|").is_ok(),
|
||||
"the control parses"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::persistence;
|
||||
use crate::ui::effect::{EffectKind, ModelChange, ViewTarget};
|
||||
use crate::ui::project::Dirty;
|
||||
use crate::ui::viewport::{
|
||||
compute_col_widths, compute_row_header_width, compute_visible_cols, grid_width,
|
||||
GridSummary, compute_row_header_width, compute_visible_cols, grid_summary, grid_width,
|
||||
};
|
||||
use crate::view::{GridLayout, View};
|
||||
use crate::workbook::Workbook;
|
||||
@@ -50,17 +50,17 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 {
|
||||
.filter(|item| !hidden.is_some_and(|h| h.contains(item)))
|
||||
.collect();
|
||||
drawn.hash(&mut h);
|
||||
// Off the drawn items, not `Category::groups`: `add_item_in_group` does not
|
||||
// populate that, and a group with nothing drawn under it is not shape.
|
||||
// Off the drawn items' chains, not `Category::groups`: a group with nothing
|
||||
// drawn under it is not shape, and a fold at any depth is.
|
||||
let mut collapsed: Vec<&str> = model
|
||||
.category(cat)
|
||||
.into_iter()
|
||||
.flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item)))
|
||||
.filter_map(|item| item.group.as_deref())
|
||||
.filter(|group| {
|
||||
view.collapsed_groups
|
||||
.get(cat)
|
||||
.is_some_and(|c| c.contains(*group))
|
||||
.flat_map(|c| drawn.iter().map(move |item| c.item_chain(item)))
|
||||
.flat_map(|chain| {
|
||||
(0..chain.len())
|
||||
.filter(|&d| view.is_group_collapsed(cat, &chain[..=d]))
|
||||
.map(|d| chain[d])
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
collapsed.sort_unstable();
|
||||
@@ -77,15 +77,14 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 {
|
||||
fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 {
|
||||
// `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a
|
||||
// pruned row is exactly the kind of reframing this observation exists for.
|
||||
frame_of(
|
||||
&crate::view::layout::GridLayout::with_frozen_records(model, view, None),
|
||||
view,
|
||||
)
|
||||
frame_of(&crate::view::layout::GridLayout::with_frozen_records(
|
||||
model, view, None,
|
||||
))
|
||||
}
|
||||
|
||||
/// The same hash off a layout already built. The batch's opening basis is free
|
||||
/// this way — `context/code/command.md` (improvise-mcq4).
|
||||
fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
|
||||
fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
layout.row_cats.hash(&mut h);
|
||||
@@ -95,14 +94,17 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
|
||||
for entry in entries {
|
||||
match entry {
|
||||
crate::view::layout::AxisEntry::GroupHeader {
|
||||
depth,
|
||||
cat_name,
|
||||
group_name,
|
||||
collapsed,
|
||||
..
|
||||
} => {
|
||||
0u8.hash(&mut h);
|
||||
depth.hash(&mut h);
|
||||
cat_name.hash(&mut h);
|
||||
group_name.hash(&mut h);
|
||||
view.is_group_collapsed(cat_name, group_name).hash(&mut h);
|
||||
collapsed.hash(&mut h);
|
||||
}
|
||||
crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => {
|
||||
1u8.hash(&mut h);
|
||||
@@ -111,9 +113,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
|
||||
}
|
||||
// Drawn as nothing, but it ends a group's run (improvise-gd83),
|
||||
// so where it sits is what a row's group attribution depends on.
|
||||
crate::view::layout::AxisEntry::GroupEnd { level } => {
|
||||
crate::view::layout::AxisEntry::GroupEnd { level, depth } => {
|
||||
2u8.hash(&mut h);
|
||||
level.hash(&mut h);
|
||||
depth.hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +207,16 @@ pub(crate) fn document_config(workbook: &Workbook) -> u64 {
|
||||
axis.hash(&mut h);
|
||||
}
|
||||
sorted_pairs(&view.hidden_items).hash(&mut h);
|
||||
sorted_pairs(&view.collapsed_groups).hash(&mut h);
|
||||
let mut depths: Vec<_> = view.expand_depth.iter().collect();
|
||||
depths.sort_unstable();
|
||||
depths.hash(&mut h);
|
||||
let mut folds: Vec<_> = view
|
||||
.group_folds
|
||||
.iter()
|
||||
.flat_map(|(cat, groups)| groups.iter().map(move |(g, fold)| (cat, g, fold)))
|
||||
.collect();
|
||||
folds.sort_unstable();
|
||||
folds.hash(&mut h);
|
||||
let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect();
|
||||
pages.sort_unstable();
|
||||
pages.hash(&mut h);
|
||||
@@ -422,6 +434,20 @@ impl Default for ModelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a layout was built from other than the model.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct LayoutBasis {
|
||||
view: String,
|
||||
/// The address of the frozen records, never dereferenced.
|
||||
drill: Option<usize>,
|
||||
}
|
||||
|
||||
/// What the keypress path measures of a built layout.
|
||||
pub(crate) struct GridGeometry {
|
||||
pub summary: GridSummary,
|
||||
pub row_header_width: u16,
|
||||
}
|
||||
|
||||
/// Top-level application state: `model_state`, `view_state`, and a residue of
|
||||
/// fields tagged below. The five residue tags are in `context/code/command.md`.
|
||||
pub struct App {
|
||||
@@ -441,8 +467,21 @@ pub struct App {
|
||||
pub term_width: u16,
|
||||
pub term_height: u16,
|
||||
/// **Derived cache**: a pure function of the slices, rebuilt by
|
||||
/// `rebuild_layout()`. Owned by `App` because the rebuild is the host's job.
|
||||
/// `rebuild_layout()`; a writer that bypasses effects must call it (felo.13).
|
||||
pub layout: GridLayout,
|
||||
/// How many times `rebuild_layout` has run: what a keypress that changes
|
||||
/// neither the model nor the view must leave alone (improvise-felo.13).
|
||||
layout_builds: usize,
|
||||
/// How many times the grid's column widths were measured, for the same reason.
|
||||
width_passes: std::cell::Cell<usize>,
|
||||
/// The widths of the layout above, measured on first use and dropped with it.
|
||||
geometry: std::cell::OnceCell<GridGeometry>,
|
||||
/// What the layout above was built from besides the model: the view it drew
|
||||
/// and the drill snapshot it froze. `layout_current` compares it.
|
||||
layout_basis: LayoutBasis,
|
||||
/// The model changed since the layout was built. Set by the one writer that
|
||||
/// does not rebuild at once.
|
||||
layout_stale: bool,
|
||||
/// **Transient**: set during `apply_effects` to skip the rest of the batch,
|
||||
/// reset at the start of every call. Reach it through `AbortChain`.
|
||||
pub abort_effects: bool,
|
||||
@@ -541,6 +580,11 @@ impl App {
|
||||
term_width: 80,
|
||||
term_height: 24,
|
||||
layout,
|
||||
layout_builds: 0,
|
||||
width_passes: std::cell::Cell::new(0),
|
||||
geometry: std::cell::OnceCell::new(),
|
||||
layout_basis: LayoutBasis::default(),
|
||||
layout_stale: false,
|
||||
abort_effects: false,
|
||||
batch_aborted: false,
|
||||
batch_refused: false,
|
||||
@@ -554,6 +598,7 @@ impl App {
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
storage,
|
||||
};
|
||||
app.layout_basis = app.current_layout_basis();
|
||||
app.settle_normal_mode();
|
||||
app
|
||||
}
|
||||
@@ -598,6 +643,7 @@ impl App {
|
||||
self.view_state.view_back_stack.clear();
|
||||
self.view_state.view_forward_stack.clear();
|
||||
self.model_state.workbook = workbook;
|
||||
self.layout_stale = true;
|
||||
// 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.
|
||||
self.document_generation += 1;
|
||||
@@ -862,7 +908,7 @@ impl App {
|
||||
fn opening_view_frame(&self) -> u64 {
|
||||
let (_, view) = self.resolve_view(self.view_state.active_view.as_str());
|
||||
match self.drill_state.is_none() {
|
||||
true => frame_of(&self.layout, view),
|
||||
true => frame_of(&self.layout),
|
||||
// A drill froze the record list, so the held layout is not what
|
||||
// `view_frame` would build and the two must not be compared.
|
||||
false => view_frame(&self.model_state.workbook.model, view),
|
||||
@@ -888,6 +934,42 @@ impl App {
|
||||
let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records));
|
||||
self.layout =
|
||||
GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen);
|
||||
self.layout_builds += 1;
|
||||
self.layout_stale = false;
|
||||
self.layout_basis = self.current_layout_basis();
|
||||
self.geometry = std::cell::OnceCell::new();
|
||||
}
|
||||
|
||||
/// Rebuild only if the model, the view drawn or the drill snapshot moved; a
|
||||
/// cursor key moves none of them (layout-cost.md).
|
||||
fn refresh_layout(&mut self) {
|
||||
if self.layout_stale || self.layout_basis != self.current_layout_basis() {
|
||||
self.rebuild_layout();
|
||||
}
|
||||
}
|
||||
|
||||
fn current_layout_basis(&self) -> LayoutBasis {
|
||||
LayoutBasis {
|
||||
view: self.view_name().to_string(),
|
||||
drill: self
|
||||
.drill_state
|
||||
.as_ref()
|
||||
.map(|d| Rc::as_ptr(&d.records) as usize),
|
||||
}
|
||||
}
|
||||
|
||||
/// The measurements a keypress needs of the grid: column widths and the row
|
||||
/// header. Counted, so a test can see whether a keypress paid for them.
|
||||
pub(crate) fn grid_geometry(&self) -> &GridGeometry {
|
||||
self.geometry.get_or_init(|| {
|
||||
self.width_passes.set(self.width_passes.get() + 1);
|
||||
let model = &self.model_state.workbook.model;
|
||||
let plan = FormatPlan::of(model, self.view());
|
||||
GridGeometry {
|
||||
summary: grid_summary(model, &self.layout, &plan),
|
||||
row_header_width: compute_row_header_width(&self.layout),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Editable text for the cell under the cursor: staged drill edit, else the
|
||||
@@ -968,12 +1050,10 @@ impl App {
|
||||
display_value: self.display_value_at_cursor(),
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
visible_cols: {
|
||||
let model = &self.model_state.workbook.model;
|
||||
let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view));
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
let geometry = self.grid_geometry();
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
row_header_width,
|
||||
&geometry.summary.col_widths,
|
||||
geometry.row_header_width,
|
||||
self.grid_width(),
|
||||
cursor.col_offset,
|
||||
)
|
||||
@@ -1104,7 +1184,8 @@ impl App {
|
||||
// is read only behind the epoch gate — `context/code/command.md`.
|
||||
let reshaped = self.last_reshaped_views.clone();
|
||||
self.last_reframed_views.extend(reshaped);
|
||||
self.rebuild_layout();
|
||||
self.layout_stale |= touches_model;
|
||||
self.refresh_layout();
|
||||
// After the rebuild, or a batch that crosses into or out of records mode
|
||||
// settles against the grid it started on — `context/code/command.md`.
|
||||
self.settle_normal_mode();
|
||||
@@ -1184,7 +1265,7 @@ impl App {
|
||||
/// Resolve one keypress through the keymap and apply what it produces. The
|
||||
/// projection half is factored out; `ui::reduce` is the named door (improvise-gxi).
|
||||
pub fn dispatch_key(&mut self, key: KeyEvent) -> Dirty {
|
||||
self.rebuild_layout();
|
||||
self.refresh_layout();
|
||||
|
||||
// An armed prefix key takes priority. Resolve it back to its
|
||||
// sub-keymap through the keymap set (improvise-jc8).
|
||||
@@ -1224,7 +1305,12 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn start_import_wizard(&mut self, json: serde_json::Value) {
|
||||
self.wizard = Some(ImportWizard::new(json));
|
||||
self.start_import(crate::import::wizard::ImportPipeline::new(json));
|
||||
}
|
||||
|
||||
/// Open the wizard over a pipeline the host built, sources and all.
|
||||
pub fn start_import(&mut self, pipeline: crate::import::wizard::ImportPipeline) {
|
||||
self.wizard = Some(ImportWizard::from_pipeline(pipeline));
|
||||
self.view_state.mode = AppMode::ImportWizard;
|
||||
}
|
||||
|
||||
@@ -1577,6 +1663,32 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-felo.13: `StartDrill` is `ViewOnly`, so a batch of it alone moves
|
||||
/// the layout with no `Model` effect to mark it stale. Evidence: layout-cost.md.
|
||||
#[test]
|
||||
fn a_drill_snapshot_replaced_alone_rebuilds_the_layout() {
|
||||
let mut app = records_app_for_drill();
|
||||
assert_eq!(app.layout.row_count(), 3);
|
||||
let rows: Vec<_> = app.layout.records.as_ref().unwrap().to_vec();
|
||||
let start = |app: &mut App, n: usize| {
|
||||
let snapshot = rows.iter().take(n).cloned().collect();
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::StartDrill(snapshot))]);
|
||||
};
|
||||
start(&mut app, 1);
|
||||
assert_eq!(
|
||||
app.layout.row_count(),
|
||||
1,
|
||||
"the layout draws the frozen snapshot"
|
||||
);
|
||||
let builds = app.layout_builds;
|
||||
start(&mut app, 2);
|
||||
assert!(
|
||||
app.layout_builds > builds,
|
||||
"no rebuild for a second drill snapshot"
|
||||
);
|
||||
assert_eq!(app.layout.row_count(), 2);
|
||||
}
|
||||
|
||||
/// The opening basis may be read off `App::layout` only when no drill froze
|
||||
/// it. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
@@ -1881,6 +1993,71 @@ mod tests {
|
||||
App::new(wb, None)
|
||||
}
|
||||
|
||||
/// improvise-felo.13: context/test-evidence/layout-cost.md.
|
||||
#[test]
|
||||
fn a_cursor_key_rebuilds_neither_the_layout_nor_the_widths() {
|
||||
let mut app = two_col_model();
|
||||
let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||||
app.dispatch_key(down());
|
||||
let (builds, widths) = (app.layout_builds, app.width_passes.get());
|
||||
app.dispatch_key(down());
|
||||
app.dispatch_key(down());
|
||||
assert_eq!(app.cursor().selected.0, 2, "the keys moved the cursor");
|
||||
assert_eq!(app.layout_builds, builds, "layout rebuilt on a cursor key");
|
||||
assert_eq!(app.width_passes.get(), widths, "widths remeasured");
|
||||
}
|
||||
|
||||
/// improvise-felo.13: the control for the test above, and the guard on
|
||||
/// invalidation: an edit must rebuild, and the widths must follow the value.
|
||||
#[test]
|
||||
fn an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value() {
|
||||
use crate::ui::effect::SetCell;
|
||||
let mut app = two_col_model();
|
||||
let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||||
app.dispatch_key(down());
|
||||
let narrow = app.grid_geometry().summary.col_widths.clone();
|
||||
let (builds, widths) = (app.layout_builds, app.width_passes.get());
|
||||
let key = CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]);
|
||||
let long = CellValue::Text("a value far wider than a column".into());
|
||||
app.apply_effects(vec![Box::new(SetCell(key, long))]);
|
||||
assert!(
|
||||
app.layout_builds > builds,
|
||||
"an edit must rebuild the layout"
|
||||
);
|
||||
app.dispatch_key(down());
|
||||
assert!(app.width_passes.get() > widths, "an edit must remeasure");
|
||||
let model = &app.model_state.workbook.model;
|
||||
let fresh = crate::ui::viewport::compute_col_widths(
|
||||
model,
|
||||
&app.layout,
|
||||
&FormatPlan::of(model, app.view()),
|
||||
);
|
||||
assert_eq!(
|
||||
app.grid_geometry().summary.col_widths,
|
||||
fresh,
|
||||
"stale widths"
|
||||
);
|
||||
assert_ne!(
|
||||
fresh, narrow,
|
||||
"the edit widened a column, or this proves nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.13: a `ViewOnly` effect that changes which view is drawn
|
||||
/// must still rebuild, so skipping the rebuild is not keyed on `EffectKind` alone.
|
||||
#[test]
|
||||
fn switching_view_rebuilds_though_the_effect_is_view_only() {
|
||||
let mut app = two_col_model();
|
||||
app.model_state.workbook.create_view("Second");
|
||||
app.rebuild_layout();
|
||||
let builds = app.layout_builds;
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView(
|
||||
"Second".into(),
|
||||
))]);
|
||||
assert_eq!(app.view_state.active_view.as_str(), "Second");
|
||||
assert!(app.layout_builds > builds, "the layout still draws Default");
|
||||
}
|
||||
|
||||
/// improvise-htl6. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn the_view_history_walks_both_ways_from_the_keyboard() {
|
||||
@@ -2117,6 +2294,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 28; // ~20 visible rows → delta = 15
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
|
||||
@@ -2141,6 +2319,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 28;
|
||||
app.cursor_mut().selected = (20, 0);
|
||||
app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE))
|
||||
@@ -2160,6 +2339,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
// G jumps to last row (row 12)
|
||||
@@ -2187,6 +2367,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.add_item(format!("R{i}"));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
app.term_height = 13; // ~5 visible rows
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
// Ctrl+d scrolls by 5 rows
|
||||
@@ -3281,7 +3462,9 @@ mod tests {
|
||||
/// 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 segments = crate::command::parse::segments(line)
|
||||
.unwrap_or_else(|e| panic!("`{line}` must lex: {e}"));
|
||||
for segment in segments {
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
crate::command::parse::parse_segment_interactive(segment, &ctx)
|
||||
@@ -3314,7 +3497,7 @@ mod tests {
|
||||
/// 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 {
|
||||
for n in 0..crate::model::MAX_CATEGORIES - 1 {
|
||||
wb.add_category(format!("C{n}")).unwrap();
|
||||
}
|
||||
App::new(wb, None)
|
||||
@@ -3425,8 +3608,9 @@ mod tests {
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
@@ -3464,12 +3648,13 @@ mod tests {
|
||||
fn every_refusal_site_refuses_its_failure_and_only_its_failure() {
|
||||
type Setup = fn() -> App;
|
||||
type Drive = fn(&mut App) -> bool;
|
||||
let limit = format!("Maximum of {} categories", crate::model::MAX_CATEGORIES);
|
||||
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",
|
||||
&limit,
|
||||
one_category_short_of_the_limit,
|
||||
|app| run_line(app, "add-category A"),
|
||||
),
|
||||
|
||||
@@ -137,6 +137,7 @@ mod tests {
|
||||
fn cat_name_works_for_both_variants() {
|
||||
let header = CatTreeEntry::Category {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
item_count: 2,
|
||||
expanded: false,
|
||||
axis: crate::view::Axis::None,
|
||||
|
||||
@@ -550,8 +550,14 @@ pub struct ToggleGroup {
|
||||
}
|
||||
impl Effect for ToggleGroup {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let chain = app
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.group_chain(&self.category, &self.group);
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
on_view(app, &self.view, |v| {
|
||||
v.toggle_group_collapse(&self.category, &self.group);
|
||||
v.toggle_group_collapse(&self.category, &chain);
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
@@ -1047,6 +1053,7 @@ pub enum WizardOp {
|
||||
ToggleProposal,
|
||||
CycleProposalKind,
|
||||
ToggleDateComponent,
|
||||
ToggleDateShape,
|
||||
StartFormulaEdit,
|
||||
DeleteFormula,
|
||||
ConfirmFormula,
|
||||
@@ -1072,6 +1079,7 @@ impl WizardOp {
|
||||
Self::ToggleProposal => wizard.toggle_proposal(),
|
||||
Self::CycleProposalKind => wizard.cycle_proposal_kind(),
|
||||
Self::ToggleDateComponent => wizard.toggle_date_component(),
|
||||
Self::ToggleDateShape => wizard.toggle_date_shape(),
|
||||
Self::StartFormulaEdit => wizard.start_formula_edit(),
|
||||
Self::DeleteFormula => wizard.delete_formula(),
|
||||
Self::ConfirmFormula => wizard.confirm_formula(),
|
||||
@@ -1239,7 +1247,7 @@ pub struct ImportJsonHeadless {
|
||||
}
|
||||
impl Effect for ImportJsonHeadless {
|
||||
fn apply(&self, app: &mut App) {
|
||||
use crate::import::analyzer::{FieldKind, analyze_records};
|
||||
use crate::import::analyzer::FieldKind;
|
||||
use crate::import::wizard::{DuplicatePolicy, ImportPipeline};
|
||||
|
||||
let is_csv = self
|
||||
@@ -1247,10 +1255,10 @@ impl Effect for ImportJsonHeadless {
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
|
||||
|
||||
// Parse the file exactly once: `records` feeds analysis, `raw` is reused
|
||||
// as the pipeline's value (improvise-oaq). CSV warnings go to the status.
|
||||
// Parse the file exactly once, into the pipeline that reads it
|
||||
// (improvise-oaq, improvise-8jk6.4). CSV warnings go to the status.
|
||||
let mut warnings: Vec<String> = vec![];
|
||||
let (records, raw) = if is_csv {
|
||||
let mut pipeline = if is_csv {
|
||||
let bytes = match app.storage.read(&self.path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -1261,8 +1269,7 @@ impl Effect for ImportJsonHeadless {
|
||||
match crate::import::csv_parser::parse_csv_bytes_with_warnings(&bytes) {
|
||||
Ok((recs, w)) => {
|
||||
warnings = w;
|
||||
let raw = serde_json::Value::Array(recs.clone());
|
||||
(recs, raw)
|
||||
ImportPipeline::from_csv(recs)
|
||||
}
|
||||
Err(e) => {
|
||||
refuse_and_stop(app, format!("CSV error: {e}"));
|
||||
@@ -1285,37 +1292,28 @@ impl Effect for ImportJsonHeadless {
|
||||
}
|
||||
};
|
||||
match json_import_records(&value, self.array_path.as_deref()) {
|
||||
Ok(recs) => (recs, value),
|
||||
Ok(recs) => {
|
||||
let mut pipeline = ImportPipeline::new(value);
|
||||
pipeline.selected_path = self.array_path.clone().unwrap_or_default();
|
||||
pipeline.records = recs;
|
||||
pipeline.analyze();
|
||||
pipeline
|
||||
}
|
||||
Err(msg) => {
|
||||
refuse_and_stop(app, msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let proposals = analyze_records(&records);
|
||||
|
||||
let pipeline = ImportPipeline {
|
||||
raw,
|
||||
array_paths: vec![],
|
||||
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
|
||||
records,
|
||||
proposals: proposals
|
||||
.into_iter()
|
||||
.map(|mut p| {
|
||||
p.accepted = p.kind != FieldKind::Label;
|
||||
p
|
||||
})
|
||||
.collect(),
|
||||
model_name: self
|
||||
.model_name
|
||||
.as_deref()
|
||||
.unwrap_or("Imported Model")
|
||||
.to_string(),
|
||||
formulas: vec![],
|
||||
// Headless: nobody to ask, so a collision refuses (improvise-cgqt).
|
||||
on_duplicate: DuplicatePolicy::Refuse,
|
||||
};
|
||||
pipeline.model_name = self
|
||||
.model_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Imported Model".to_string());
|
||||
// Headless: nobody to ask, so a collision refuses (improvise-cgqt).
|
||||
pipeline.on_duplicate = DuplicatePolicy::Refuse;
|
||||
for p in &mut pipeline.proposals {
|
||||
p.accepted = p.kind != FieldKind::Label;
|
||||
}
|
||||
|
||||
match pipeline.build_model_with_warnings() {
|
||||
Ok((new_workbook, build_warnings)) => {
|
||||
@@ -1372,6 +1370,63 @@ impl Effect for SetPanelCursor {
|
||||
|
||||
// ── Chain control ────────────────────────────────────────────────────────────
|
||||
|
||||
/// What a command's effects name that the document must have.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Needs {
|
||||
Category(String),
|
||||
Item(String, String),
|
||||
Group(String, String),
|
||||
View(String),
|
||||
}
|
||||
|
||||
/// Why the document cannot satisfy `needs` in the active view, if it cannot.
|
||||
fn unknown_name(app: &App, needs: &Needs) -> Option<String> {
|
||||
let workbook = &app.model_state.workbook;
|
||||
let (category, rest) = match needs {
|
||||
Needs::View(name) => {
|
||||
return (!workbook.views.contains_key(name))
|
||||
.then(|| format!("No view named \"{name}\""));
|
||||
}
|
||||
Needs::Category(c) => (c, None),
|
||||
Needs::Item(c, i) => (c, Some((i, false))),
|
||||
Needs::Group(c, g) => (c, Some((g, true))),
|
||||
};
|
||||
let view = workbook.views.get(app.view_name())?;
|
||||
if view.try_axis_of(category).is_none() {
|
||||
return Some(format!("Unknown category '{category}'"));
|
||||
}
|
||||
let (name, is_group) = rest?;
|
||||
if !is_group {
|
||||
// `effective_item_names`, so a formula target is a known `_Measure` item.
|
||||
let known = workbook
|
||||
.model
|
||||
.effective_item_names(category)
|
||||
.iter()
|
||||
.any(|i| i == name);
|
||||
return (!known).then(|| format!("Unknown item '{name}' in '{category}'"));
|
||||
}
|
||||
// An item's own group registers no `Group`; a parent group has no items.
|
||||
let known = workbook.model.category(category).is_some_and(|c| {
|
||||
!c.items_in_group(name).is_empty() || c.groups.iter().any(|g| g.name == *name)
|
||||
});
|
||||
(!known).then(|| format!("Unknown group '{name}' in '{category}'"))
|
||||
}
|
||||
|
||||
/// Refuse and stop unless the document has what the next effects name, asked at
|
||||
/// apply time so a `:` line sees its earlier segments (`cli.md` §15).
|
||||
#[derive(Debug)]
|
||||
pub struct RequireKnown(pub Needs);
|
||||
impl Effect for RequireKnown {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(why) = unknown_name(app, &self.0) {
|
||||
refuse_and_stop(app, why);
|
||||
}
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::ViewOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip the remaining effects in this `apply_effects` batch, for when a
|
||||
/// sequence's premise no longer holds. The flag resets per dispatch.
|
||||
#[derive(Debug)]
|
||||
@@ -1926,8 +1981,8 @@ mod tests {
|
||||
#[test]
|
||||
fn add_category_past_the_limit_reports_the_failure() {
|
||||
let mut app = test_app();
|
||||
// test_app() holds 2 regular categories; fill up to the limit of 12.
|
||||
for i in 0..10 {
|
||||
// test_app() holds 2 regular categories; fill up to the limit.
|
||||
for i in 0..crate::model::MAX_CATEGORIES - 2 {
|
||||
AddCategory(format!("Cat{i}")).apply(&mut app);
|
||||
}
|
||||
app.view_state.status_msg = "Added category \"Overflow\"".to_string();
|
||||
@@ -1938,7 +1993,7 @@ mod tests {
|
||||
.model
|
||||
.category("Overflow")
|
||||
.is_none(),
|
||||
"the 13th category must not be added"
|
||||
"the category past the limit must not be added"
|
||||
);
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("Maximum"),
|
||||
@@ -3875,10 +3930,11 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(
|
||||
app.model_state
|
||||
!app.model_state
|
||||
.workbook
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
.is_group_collapsed("Type", &["MyGroup"]),
|
||||
"a group starts collapsed, so the first toggle opens it"
|
||||
);
|
||||
ToggleGroup {
|
||||
view: ViewTarget::Active,
|
||||
@@ -3887,10 +3943,10 @@ mod tests {
|
||||
}
|
||||
.apply(&mut app);
|
||||
assert!(
|
||||
!app.model_state
|
||||
app.model_state
|
||||
.workbook
|
||||
.active_view()
|
||||
.is_group_collapsed("Type", "MyGroup")
|
||||
.is_group_collapsed("Type", &["MyGroup"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::ui::app::App;
|
||||
use crate::ui::effect;
|
||||
use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions};
|
||||
use crate::ui::reduce::{self, Checked, CheckedBatch};
|
||||
use improvise_protocol::{Command, Rejection};
|
||||
use improvise_protocol::{AppMode, Command, Rejection};
|
||||
|
||||
use improvise_protocol::{ClientFrame, ServerFrame};
|
||||
|
||||
@@ -87,6 +87,8 @@ impl Host {
|
||||
// resolution diverges (improvise-bpq2, gotcha 47).
|
||||
ClientFrame::Key { .. } => Vec::new(),
|
||||
ClientFrame::Save => self.save(id),
|
||||
ClientFrame::DownloadDocument { filename } => self.download_document(id, filename),
|
||||
ClientFrame::DownloadCsv { filename } => self.download_csv(id, filename),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +115,67 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the document to `.improv` format and return the bytes for download
|
||||
/// after closing the prompt in the session mirror (improvise-2mqz).
|
||||
fn download_document(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||
let Some(mut answer) = self.close_download_prompt(id, "save") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let bytes =
|
||||
improvise_io::persistence::format_md(&self.app.model_state.workbook).into_bytes();
|
||||
answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::DocumentBytes { filename, bytes },
|
||||
});
|
||||
answer
|
||||
}
|
||||
|
||||
/// Render the requesting session's view as downloadable CSV bytes.
|
||||
/// Close its prompt mirror first; reject only if rendering fails (improvise-2mqz).
|
||||
fn download_csv(&mut self, id: SessionId, filename: String) -> Vec<Addressed> {
|
||||
let Some(view_name) = self
|
||||
.sessions
|
||||
.get(id)
|
||||
.map(|session| session.view().active_view.clone())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(mut answer) = self.close_download_prompt(id, "export") else {
|
||||
return Vec::new();
|
||||
};
|
||||
match improvise_io::persistence::render_csv(&self.app.model_state.workbook, &view_name) {
|
||||
Ok(bytes) => answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::CsvBytes { filename, bytes },
|
||||
}),
|
||||
Err(_) => answer.push(Addressed {
|
||||
to: id,
|
||||
msg: ServerFrame::Rejected(Rejection::ExportFailed),
|
||||
}),
|
||||
}
|
||||
answer
|
||||
}
|
||||
|
||||
/// The browser folded these before sending the download request; mirror
|
||||
/// that fold so a later projection cannot restore the prompt.
|
||||
fn close_download_prompt(
|
||||
&mut self,
|
||||
id: SessionId,
|
||||
buffer: &'static str,
|
||||
) -> Option<Vec<Addressed>> {
|
||||
self.sessions.get(id)?;
|
||||
Some(self.commands(
|
||||
id,
|
||||
&[
|
||||
Command::SetBuffer {
|
||||
name: buffer.into(),
|
||||
value: String::new(),
|
||||
},
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
],
|
||||
))
|
||||
}
|
||||
|
||||
/// Fan out whatever moved, for a host that changed the document outside
|
||||
/// `handle` — a worker's own persist is the caller (improvise-z754).
|
||||
pub fn project_all(&mut self) -> Vec<Addressed> {
|
||||
@@ -435,9 +498,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Neither answer carries state: one applied nothing, and what
|
||||
// the other applied arrived as projections before it.
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// These carry no foldable state: downloads are consumed by the
|
||||
// browser, while other results arrived as projections or did nothing.
|
||||
ServerFrame::Rejected(_)
|
||||
| ServerFrame::Accepted
|
||||
| ServerFrame::DocumentBytes { .. }
|
||||
| ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
grid
|
||||
@@ -1177,7 +1243,7 @@ mod tests {
|
||||
let mut host = host();
|
||||
// Fill to MAX_CATEGORIES, so the frame's second command is refused by
|
||||
// `Model::add_category` — a DUPLICATE add is idempotent and vacuous.
|
||||
for n in 0..11 {
|
||||
for n in 0..crate::model::MAX_CATEGORIES - 1 {
|
||||
host.app_mut()
|
||||
.model_state
|
||||
.workbook
|
||||
@@ -1547,11 +1613,12 @@ mod tests {
|
||||
wb.model.set_cell(month_cell(month, measure), value);
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
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");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert!(
|
||||
@@ -1733,13 +1800,14 @@ mod tests {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("_Measure", 1);
|
||||
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");
|
||||
.toggle_group_collapse("_Measure", &["Money"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let target = month_cell("Feb", "Units");
|
||||
@@ -1779,12 +1847,13 @@ mod tests {
|
||||
wb.model.set_cell(key, CellValue::Number(10.0));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("_Measure", 1);
|
||||
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");
|
||||
.toggle_group_collapse("_Measure", &["Money"]);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
@@ -1808,6 +1877,7 @@ mod tests {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("_Measure", 1);
|
||||
let measures = wb.model.category_mut("_Measure").unwrap();
|
||||
measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals"));
|
||||
wb.model
|
||||
@@ -2709,6 +2779,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-2mqz: view selection belongs to a session, so an export must
|
||||
/// not silently use the document's default view or another client's view.
|
||||
#[test]
|
||||
fn a_csv_download_exports_the_requesting_sessions_active_view() {
|
||||
let mut host = host();
|
||||
let second = host.app.model_state.workbook.create_view("Second");
|
||||
second.set_axis("Region", Axis::Row);
|
||||
second.set_axis("_Measure", Axis::Column);
|
||||
second.hide_item("Region", "South");
|
||||
let (requester, _) = host.connect(10, 10);
|
||||
let (bystander, _) = host.connect(10, 10);
|
||||
|
||||
host.handle(
|
||||
requester,
|
||||
ClientFrame::command(Command::SwitchView("Second".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
host.sessions().get(requester).unwrap().view().active_view,
|
||||
"Second",
|
||||
"setup: the requester must be on its session-local view"
|
||||
);
|
||||
assert_eq!(
|
||||
host.sessions().get(bystander).unwrap().view().active_view,
|
||||
"Default",
|
||||
"setup: the second session must remain on the default view"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.active_view,
|
||||
"Default",
|
||||
"setup: the document's active view must differ from the requester"
|
||||
);
|
||||
|
||||
let answer = host.handle(
|
||||
requester,
|
||||
ClientFrame::DownloadCsv {
|
||||
filename: "second.csv".into(),
|
||||
},
|
||||
);
|
||||
let expected =
|
||||
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Second")
|
||||
.unwrap();
|
||||
let default =
|
||||
improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Default")
|
||||
.unwrap();
|
||||
assert_ne!(expected, default, "setup: the two exports must differ");
|
||||
assert_eq!(
|
||||
to(&answer, requester),
|
||||
vec![ServerFrame::CsvBytes {
|
||||
filename: "second.csv".into(),
|
||||
bytes: expected,
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
to(&answer, bystander).is_empty(),
|
||||
"the download is owed only to its requester: {answer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── disconnect ──────────────────────────────────────────────────────
|
||||
|
||||
/// AC 4: a dropped client stops being projected to, and a late frame
|
||||
@@ -2979,7 +3107,8 @@ mod tests {
|
||||
let fresh = || {
|
||||
let mut wb = workbook();
|
||||
wb.create_view("Scratch");
|
||||
(0..12).for_each(|n| drop(wb.add_category(format!("Filler{n}"))));
|
||||
(0..crate::model::MAX_CATEGORIES)
|
||||
.for_each(|n| drop(wb.add_category(format!("Filler{n}"))));
|
||||
let mut app = App::new(wb, None);
|
||||
app.storage = Box::new(super::import_fixture_storage());
|
||||
app.rebuild_layout();
|
||||
|
||||
@@ -11,7 +11,7 @@ use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||
use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol};
|
||||
use improvise_core::view::{Axis, View};
|
||||
use improvise_core::workbook::Workbook;
|
||||
use improvise_io::import::analyzer::{DateComponent, FieldKind};
|
||||
use improvise_io::import::analyzer::{DateComponent, DateShape, FieldKind};
|
||||
use improvise_io::import::wizard::{DuplicatePolicy, ImportWizard, WizardStep};
|
||||
use improvise_protocol::{
|
||||
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
|
||||
@@ -25,7 +25,7 @@ use improvise_protocol::{
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::effect::{EffectKind, ModelChange};
|
||||
use crate::ui::reduce::{Checked, apply_command};
|
||||
use crate::ui::viewport::{GridBody, grid_body};
|
||||
use crate::ui::viewport::{GridBody, grid_body, grid_window};
|
||||
|
||||
/// Which widgets a batch of effects could have changed. A gate, not a description:
|
||||
/// over-reporting costs work, under-reporting is a correctness bug (gotcha 35).
|
||||
@@ -142,13 +142,23 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
|
||||
let plan = FormatPlan::of(model, view);
|
||||
let rows = viewport.rows_in(layout.row_count());
|
||||
let cols = viewport.cols_in(layout.col_count());
|
||||
// ONE walk: the widths, the totals and this window's values all come off the
|
||||
// same evaluation — `context/code/command.md` (improvise-jmjz).
|
||||
let body = grid_body(model, layout, &plan, rows.clone(), cols.clone());
|
||||
// One walk gives widths, totals and window (improvise-jmjz); the host's layout
|
||||
// keeps the first two until rebuilt, so its window costs its own cells.
|
||||
let hosted = elsewhere.is_none() && derived.is_none();
|
||||
let body = match hosted {
|
||||
true => grid_window(
|
||||
model,
|
||||
layout,
|
||||
&app.grid_geometry().summary,
|
||||
rows.clone(),
|
||||
cols.clone(),
|
||||
),
|
||||
false => grid_body(model, layout, &plan, rows.clone(), cols.clone()),
|
||||
};
|
||||
|
||||
GridCache {
|
||||
row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()),
|
||||
col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()),
|
||||
row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()),
|
||||
col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()),
|
||||
col_widths: cols
|
||||
.clone()
|
||||
.map(|c| body.col_widths.get(c).copied().unwrap_or(0))
|
||||
@@ -212,12 +222,7 @@ pub(crate) fn rederived_for(
|
||||
|
||||
/// One axis's frame, windowed to the data indices `window` names. Three edge rules,
|
||||
/// all about group headers — see `context/code/command.md`.
|
||||
fn axis_frame(
|
||||
cats: &[String],
|
||||
items: &[AxisEntry],
|
||||
view: &View,
|
||||
window: std::ops::Range<usize>,
|
||||
) -> AxisFrame {
|
||||
fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> AxisFrame {
|
||||
let total = items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::DataItem { .. }))
|
||||
@@ -225,40 +230,48 @@ fn axis_frame(
|
||||
let mut data_idx = 0usize;
|
||||
let mut entries: Vec<FrameEntry> = Vec::new();
|
||||
let mut pending: Vec<FrameEntry> = Vec::new();
|
||||
// Before the window opens, only the runs still open matter, one per level:
|
||||
// O(levels), however many groups a deep scroll passed (improvise-e265).
|
||||
let mut carried: Vec<Option<FrameEntry>> = Vec::new();
|
||||
// Before the window opens, only the runs still open matter, keyed `(level,
|
||||
// depth)`: O(depth), however many groups a deep scroll passed (e265, felo.6).
|
||||
let mut carried: Vec<((usize, usize), FrameEntry)> = Vec::new();
|
||||
let close = |carried: &mut Vec<((usize, usize), FrameEntry)>, key: (usize, usize)| {
|
||||
carried.truncate(carried.partition_point(|(k, _)| *k < key))
|
||||
};
|
||||
for entry in items {
|
||||
match entry {
|
||||
AxisEntry::GroupHeader {
|
||||
level,
|
||||
cat_name,
|
||||
depth,
|
||||
group_name,
|
||||
collapsed,
|
||||
..
|
||||
} => {
|
||||
let group = FrameEntry::Group(FrameGroup {
|
||||
level: *level,
|
||||
depth: *depth,
|
||||
name: group_name.clone(),
|
||||
collapsed: view.is_group_collapsed(cat_name, group_name),
|
||||
collapsed: *collapsed,
|
||||
});
|
||||
match entries.is_empty() {
|
||||
true => {
|
||||
carried.truncate(*level);
|
||||
carried.resize(*level, None);
|
||||
carried.push(Some(group));
|
||||
close(&mut carried, (*level, *depth));
|
||||
carried.push(((*level, *depth), group));
|
||||
}
|
||||
false => pending.push(group),
|
||||
}
|
||||
}
|
||||
AxisEntry::GroupEnd { level } => match entries.is_empty() {
|
||||
true => carried.truncate(*level),
|
||||
false => pending.push(FrameEntry::GroupEnd { level: *level }),
|
||||
AxisEntry::GroupEnd { level, depth } => match entries.is_empty() {
|
||||
true => close(&mut carried, (*level, *depth)),
|
||||
false => pending.push(FrameEntry::GroupEnd {
|
||||
level: *level,
|
||||
depth: *depth,
|
||||
}),
|
||||
},
|
||||
AxisEntry::DataItem { tuple, collapsed } => {
|
||||
let idx = data_idx;
|
||||
data_idx += 1;
|
||||
if window.contains(&idx) {
|
||||
if entries.is_empty() {
|
||||
entries.extend(carried.drain(..).flatten());
|
||||
entries.extend(carried.drain(..).map(|(_, group)| group));
|
||||
}
|
||||
entries.append(&mut pending);
|
||||
entries.push(FrameEntry::Item {
|
||||
@@ -271,7 +284,7 @@ fn axis_frame(
|
||||
}
|
||||
if window.end >= total {
|
||||
if entries.is_empty() {
|
||||
entries.extend(carried.drain(..).flatten());
|
||||
entries.extend(carried.drain(..).map(|(_, group)| group));
|
||||
}
|
||||
entries.append(&mut pending);
|
||||
}
|
||||
@@ -423,6 +436,11 @@ fn non_item(category: &str, item: &str) -> CellId {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a person sees for a category key; the key itself when no category holds it.
|
||||
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
|
||||
model.category(name).map_or(name, |c| c.display())
|
||||
}
|
||||
|
||||
/// The categories the category panel draws: **every** category, virtual ones
|
||||
/// included, with their items and their axis (improvise-vjms, gotcha 7).
|
||||
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
@@ -431,6 +449,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
.into_iter()
|
||||
.map(|name| CategoryRow {
|
||||
name: name.to_string(),
|
||||
display: category_display(model, name).to_string(),
|
||||
items: model.effective_item_names(name),
|
||||
// `try_axis_of`, not `axis_of`: a view that has never seen a category
|
||||
// has no entry and `axis_of` panics. Unseen shows as `None`.
|
||||
@@ -460,6 +479,7 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
|
||||
.iter()
|
||||
.map(|(name, view)| ViewSummary {
|
||||
name: name.clone(),
|
||||
display: view.display().to_string(),
|
||||
axes: view
|
||||
.category_axes
|
||||
.iter()
|
||||
@@ -481,6 +501,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
|
||||
.into_iter()
|
||||
.map(|cat| TileEntry {
|
||||
category: cat.to_string(),
|
||||
display: category_display(model, cat).to_string(),
|
||||
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
|
||||
kind: model
|
||||
.category(cat)
|
||||
@@ -641,6 +662,7 @@ pub fn wizard_cache(w: &ImportWizard) -> WizardCache {
|
||||
((*label).to_string(), p.date_components.contains(component))
|
||||
})
|
||||
.collect(),
|
||||
shape: date_shape_sentence(p),
|
||||
})
|
||||
.collect(),
|
||||
collisions: w
|
||||
@@ -679,6 +701,27 @@ pub fn wizard_cache(w: &ImportWizard) -> WizardCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a date field's chosen components become, worded for every front end.
|
||||
fn date_shape_sentence(p: &improvise_io::import::analyzer::FieldProposal) -> String {
|
||||
let chosen = |order: &[DateComponent]| -> Vec<&str> {
|
||||
order
|
||||
.iter()
|
||||
.filter(|c| p.date_components.contains(c))
|
||||
.filter_map(|c| DATE_COMPONENTS.iter().find(|(d, _)| d == c))
|
||||
.map(|(_, label)| *label)
|
||||
.collect()
|
||||
};
|
||||
match (p.date_shape, chosen(&DateComponent::COARSE_TO_FINE)) {
|
||||
(_, none) if none.is_empty() => "one category of the raw dates".to_string(),
|
||||
(DateShape::Hierarchy, levels) => format!("one category: {}", levels.join(" > ")),
|
||||
(DateShape::Split, _) => {
|
||||
let parts = chosen(&DATE_COMPONENTS.map(|(c, _)| c));
|
||||
let names: Vec<String> = parts.iter().map(|l| format!("{}_{l}", p.field)).collect();
|
||||
format!("split: {}, {}", p.field, names.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The date components the wizard offers, **in the order it offers them**:
|
||||
/// `ImportWizard::date_config_at_cursor` counts these same rows.
|
||||
const DATE_COMPONENTS: [(DateComponent, &str); 3] = [
|
||||
@@ -1458,6 +1501,7 @@ mod tests {
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item_in_group("Region", "East", "Late").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
@@ -1469,7 +1513,8 @@ mod tests {
|
||||
wb.model.set_cell(key, CellValue::Number(1.0));
|
||||
}
|
||||
if collapsed {
|
||||
wb.active_view_mut().toggle_group_collapse("Region", "Late");
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Region", &["Late"]);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
@@ -1573,6 +1618,7 @@ mod tests {
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item_in_group("Region", "Inner", "Hidden").unwrap();
|
||||
wb.add_item_in_group("Region", "East", "Late").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
@@ -1584,7 +1630,7 @@ mod tests {
|
||||
wb.model.set_cell(key, CellValue::Number(1.0));
|
||||
}
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Region", "Hidden");
|
||||
.toggle_group_collapse("Region", &["Hidden"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
@@ -1626,9 +1672,11 @@ mod tests {
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item_in_group("Region", "North", "Upper").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Month", "Mar").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
@@ -1674,6 +1722,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.6: the same agreement for runs nested INSIDE one category,
|
||||
/// at every window — the carried stack is keyed by depth as well as level.
|
||||
#[test]
|
||||
fn the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_category("Date").unwrap();
|
||||
wb.add_item_in_group("Region", "North", "Upper").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
for (month, group) in [("Jan", "Q1"), ("Feb", "Q1"), ("Jun", "H1"), ("Jul", "Q3")] {
|
||||
wb.add_item_in_group("Date", month, group).unwrap();
|
||||
}
|
||||
wb.add_item("Date", "Undated").unwrap();
|
||||
let date = wb.model.category_mut("Date").unwrap();
|
||||
date.add_group(crate::model::category::Group::new("Q1").with_parent("H1"));
|
||||
date.add_group(crate::model::category::Group::new("Q3").with_parent("H2"));
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
let view = wb.active_view_mut();
|
||||
view.set_axis("Region", Axis::Row);
|
||||
view.set_axis("Date", Axis::Row);
|
||||
view.set_axis("_Measure", Axis::Column);
|
||||
view.set_expand_depth("Region", 1);
|
||||
view.set_expand_depth("Date", 2);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let rows = app.layout.row_count();
|
||||
assert_eq!(rows, 10, "two regions by five dates");
|
||||
for offset in 0..rows {
|
||||
let frame = grid_of(&app, rows_from(offset)).row_frame;
|
||||
let from_frame: Vec<Option<(String, String)>> = frame
|
||||
.groups()
|
||||
.into_iter()
|
||||
.map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone())))
|
||||
.collect();
|
||||
let from_layout: Vec<Option<(String, String)>> = (offset..rows)
|
||||
.map(|r| app.layout.row_group_for(r))
|
||||
.collect();
|
||||
assert_eq!(from_frame, from_layout, "window from row {offset}");
|
||||
}
|
||||
assert_eq!(
|
||||
app.layout.row_group_for(2),
|
||||
Some(("Date".into(), "H1".into())),
|
||||
"precondition: North/Jun sits in H1 once Q1 ends, not in Q1 nor Upper"
|
||||
);
|
||||
}
|
||||
|
||||
/// A window opening inside `North/Q1` carries the open run at EVERY level, or
|
||||
/// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md.
|
||||
#[test]
|
||||
@@ -1693,7 +1787,7 @@ mod tests {
|
||||
fn z_on_a_row_past_an_inner_run_folds_the_outer_group() {
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
|
||||
let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, group);
|
||||
let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, &[group]);
|
||||
|
||||
let mut app = app_with_nested_groups();
|
||||
app.cursor_mut().selected = (0, 0);
|
||||
@@ -1739,6 +1833,46 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// One category and the active view each shown under a display that differs from their key.
|
||||
fn app_with_displays_unlike_their_keys() -> App {
|
||||
let mut app = app_with_data();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.model
|
||||
.category_mut("Region")
|
||||
.unwrap()
|
||||
.set_display("Sales Area");
|
||||
wb.active_view_mut().set_display("Overview");
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let rows = category_rows(&app.model_state.workbook.model, app.view());
|
||||
let region = rows.iter().find(|r| r.name == "Region").unwrap();
|
||||
assert_eq!(region.display, "Sales Area");
|
||||
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
|
||||
assert_eq!(measure.display, "_Measure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let tiles = tiles_of(&app.model_state.workbook.model, app.view());
|
||||
let region = tiles.iter().find(|t| t.category == "Region").unwrap();
|
||||
assert_eq!(region.display, "Sales Area");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() {
|
||||
let app = app_with_displays_unlike_their_keys();
|
||||
let active = app.model_state.workbook.active_view.clone();
|
||||
let summaries = view_summaries(&app);
|
||||
let summary = summaries.iter().find(|s| s.name == active).unwrap();
|
||||
assert_eq!(summary.display, "Overview");
|
||||
assert_ne!(summary.name, "Overview");
|
||||
}
|
||||
|
||||
/// A pivot-shaped default view plus a records-shaped second one, so a
|
||||
/// switch between them crosses the boundary `settled_mode` watches.
|
||||
fn app_with_a_records_shaped_second_view() -> App {
|
||||
@@ -2895,6 +3029,7 @@ mod tests {
|
||||
.unwrap();
|
||||
wb.add_item_in_group("Product", "Gadget", "Hardware")
|
||||
.unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Product", 1);
|
||||
wb.add_item("Product", "Service").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
@@ -3698,7 +3833,7 @@ mod tests {
|
||||
item: "Gizmo".into(),
|
||||
group: "Hardware".into(),
|
||||
},
|
||||
// The 12-category limit; the fixture already holds two.
|
||||
// The category limit; the fixture already holds two.
|
||||
ModelCommand::AddCategory("Overflow".into()),
|
||||
];
|
||||
|
||||
@@ -4658,6 +4793,7 @@ mod cell_identity {
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
|
||||
wb.add_item_in_group("Region", "West", "Coastal").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
@@ -4671,7 +4807,7 @@ mod cell_identity {
|
||||
);
|
||||
}
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Region", "Coastal");
|
||||
.toggle_group_collapse("Region", &["Coastal"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(app.layout.row_label(1), "Coastal");
|
||||
@@ -5642,9 +5778,11 @@ mod cell_identity {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
for month in ["Jan", "Feb", "Mar"] {
|
||||
wb.add_item_in_group("Month", month, "Q1").unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
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)] {
|
||||
@@ -5663,7 +5801,7 @@ mod cell_identity {
|
||||
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");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let app = App::new(wb, None);
|
||||
|
||||
let grid = grid_of(&app, WHOLE);
|
||||
@@ -6334,13 +6472,14 @@ mod column_totals {
|
||||
wb.model.set_cell(at, CellValue::Number(v));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
let max = crate::formula::AggFunc::Max;
|
||||
wb.model.measure_agg.insert("Revenue".into(), max);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().set_axis("Region", Axis::None);
|
||||
if collapsed {
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
}
|
||||
wb
|
||||
};
|
||||
@@ -6405,3 +6544,42 @@ mod column_totals {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-8jk6.3: the wizard's date step says what each date field becomes,
|
||||
/// and `s` switches it between one category and the split.
|
||||
#[cfg(test)]
|
||||
mod date_shape {
|
||||
use super::wizard_cache;
|
||||
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_date_step_says_what_a_date_field_becomes_and_s_switches_it() {
|
||||
let mut w = ImportWizard::new(json!([
|
||||
{"Region": "East", "When": "2024-01-15", "Revenue": 1.0},
|
||||
{"Region": "West", "When": "2024-04-02", "Revenue": 2.0},
|
||||
]));
|
||||
w.advance();
|
||||
assert_eq!(w.step, WizardStep::ConfigureDates, "setup");
|
||||
let shape = |w: &ImportWizard| wizard_cache(w).date_fields[0].shape.clone();
|
||||
assert_eq!(shape(&w), "one category of the raw dates");
|
||||
w.toggle_date_component();
|
||||
w.move_cursor(1);
|
||||
w.toggle_date_component();
|
||||
w.move_cursor(1);
|
||||
w.toggle_date_component();
|
||||
assert_eq!(shape(&w), "one category: Year > Quarter > Month");
|
||||
w.toggle_date_shape();
|
||||
assert_eq!(
|
||||
shape(&w),
|
||||
"split: When, When_Year, When_Month, When_Quarter"
|
||||
);
|
||||
let built = w.pipeline.build_model().unwrap();
|
||||
assert!(built.model.category("When_Quarter").is_some());
|
||||
w.toggle_date_shape();
|
||||
let built = w.pipeline.build_model().unwrap();
|
||||
assert!(built.model.category("When_Quarter").is_none());
|
||||
let when = built.model.category("When").unwrap();
|
||||
assert_eq!(when.item_chain("2024-04"), vec!["2024", "2024-Q2"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,9 +1052,9 @@ pub(crate) mod tests {
|
||||
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");
|
||||
view.toggle_group_collapse("Region", &["Coastal"]);
|
||||
view.toggle_group_collapse("Region", &["Inland"]);
|
||||
view.toggle_group_collapse("Month", &["Q1"]);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ enum Verdict {
|
||||
Frame(&'static str),
|
||||
/// Deliberately off the wire, and the argument.
|
||||
Off(&'static str),
|
||||
/// Missing, and the ticket that owns closing it.
|
||||
Gap(&'static str),
|
||||
}
|
||||
|
||||
/// Checked in all directions: an entry must name a real effect, must not be on
|
||||
@@ -29,14 +27,8 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[
|
||||
// Its text; the refusal flag is one host batch's outcome (context/code/cli.md §10).
|
||||
("Refuse", Verdict::Command("SetStatus")),
|
||||
("Save", Verdict::Frame("Save")),
|
||||
(
|
||||
"SaveAs",
|
||||
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
|
||||
),
|
||||
(
|
||||
"ExportCsv",
|
||||
Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"),
|
||||
),
|
||||
("SaveAs", Verdict::Frame("DownloadDocument")),
|
||||
("ExportCsv", Verdict::Frame("DownloadCsv")),
|
||||
(
|
||||
"ApplyAndClearDrill",
|
||||
Verdict::Off("operand is one session's staged drill edits (gotcha 38, improvise-3x3u)"),
|
||||
@@ -71,6 +63,10 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[
|
||||
"refreshes the host's layout cache mid-batch; a client holds none (gotcha 23)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"RequireKnown",
|
||||
Verdict::Off("a command-side name check that writes no state (gotcha 21, cli.md §15)"),
|
||||
),
|
||||
(
|
||||
"AbortChain",
|
||||
Verdict::Off("control flow inside one apply_effects batch; it writes no state (gotcha 21)"),
|
||||
@@ -134,7 +130,7 @@ fn the_verdict_list_names_only_real_effects_and_real_variants() {
|
||||
frames.iter().any(|f| f == variant),
|
||||
"{name}'s verdict names ClientFrame::{variant}, which does not exist"
|
||||
),
|
||||
Verdict::Off(why) | Verdict::Gap(why) => {
|
||||
Verdict::Off(why) => {
|
||||
assert!(!why.is_empty(), "{name}'s verdict gives no argument")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,50 @@ pub fn grid_body(
|
||||
}
|
||||
}
|
||||
|
||||
/// What of a grid build no window changes: the widths, each column's total
|
||||
/// format and the totals. Measured over every cell, so a host keeps one per layout.
|
||||
pub struct GridSummary {
|
||||
pub col_widths: Vec<u16>,
|
||||
pub col_formats: Vec<NumberFormat>,
|
||||
pub totals: Vec<Option<CellValue>>,
|
||||
}
|
||||
|
||||
/// The whole-grid walk, answering only what a window cannot.
|
||||
pub fn grid_summary(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> GridSummary {
|
||||
let body = grid_body(model, layout, plan, 0..0, 0..0);
|
||||
GridSummary {
|
||||
col_widths: body.col_widths,
|
||||
col_formats: body.col_formats,
|
||||
totals: body.totals,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`grid_body`] for a window, given the `summary` of the same layout: evaluates
|
||||
/// only the window's cells. Equal to `grid_body` where `summary` is current.
|
||||
pub fn grid_window(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
summary: &GridSummary,
|
||||
rows: std::ops::Range<usize>,
|
||||
cols: std::ops::Range<usize>,
|
||||
) -> GridBody {
|
||||
let eval = layout.evaluator(model);
|
||||
let records = layout.is_records_mode();
|
||||
let window = rows
|
||||
.map(|ri| {
|
||||
cols.clone()
|
||||
.map(|ci| cell_value(layout, &eval, records, ri, ci).1)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
GridBody {
|
||||
col_widths: summary.col_widths.clone(),
|
||||
col_formats: summary.col_formats.clone(),
|
||||
totals: summary.totals.clone(),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
/// The one evaluation. Records mode reads its wide row; pivot mode aggregates.
|
||||
fn cell_value(
|
||||
layout: &GridLayout,
|
||||
@@ -146,7 +190,7 @@ fn widen(widths: &mut [u16], col: usize, width: usize) {
|
||||
/// Adaptive column widths alone, for the keypress path, which needs no cells.
|
||||
/// It is [`grid_body`] with an empty window so there is one width rule.
|
||||
pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> Vec<u16> {
|
||||
grid_body(model, layout, plan, 0..0, 0..0).col_widths
|
||||
grid_summary(model, layout, plan).col_widths
|
||||
}
|
||||
|
||||
/// The total row-header width for a layout. The rule is the protocol's; this is the
|
||||
@@ -214,6 +258,7 @@ mod tests {
|
||||
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.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] {
|
||||
let key = CellKey::new(vec![
|
||||
@@ -231,7 +276,7 @@ mod tests {
|
||||
}
|
||||
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", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
wb.model.recompute_formulas(&wb.active_view().none_cats());
|
||||
wb
|
||||
}
|
||||
@@ -271,4 +316,33 @@ mod tests {
|
||||
"F0..F8 once for the Q1 row, once for the totals"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-felo.13: a window read off a held summary is the window `grid_body`
|
||||
/// builds. Evidence: `context/test-evidence/layout-cost.md`.
|
||||
#[test]
|
||||
fn a_window_over_a_held_summary_equals_the_one_walk_grid_body() {
|
||||
let wb = folded_chain();
|
||||
let view = wb.active_view();
|
||||
let layout = GridLayout::new(&wb.model, view);
|
||||
let plan = FormatPlan::of(&wb.model, view);
|
||||
let summary = grid_summary(&wb.model, &layout, &plan);
|
||||
let (rows, cols) = (layout.row_count(), layout.col_count());
|
||||
assert!(
|
||||
rows >= 2 && cols >= 3,
|
||||
"a window strictly inside is possible"
|
||||
);
|
||||
for (r, c) in [
|
||||
(0..rows, 0..cols),
|
||||
(1..2, 1..3),
|
||||
(rows - 1..rows, 0..1),
|
||||
(0..0, 0..0),
|
||||
] {
|
||||
let walked = grid_body(&wb.model, &layout, &plan, r.clone(), c.clone());
|
||||
let held = grid_window(&wb.model, &layout, &summary, r.clone(), c.clone());
|
||||
assert_eq!(held.window, walked.window, "window {r:?} x {c:?}");
|
||||
assert_eq!(held.col_widths, walked.col_widths);
|
||||
assert_eq!(held.totals, walked.totals);
|
||||
assert_eq!(held.col_formats, walked.col_formats);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
# (ADR 002). Already in the tree as a transitive dependency; no_std and
|
||||
# wasm-clean.
|
||||
siphasher = "1"
|
||||
# Unicode full case folding for slugify (ADR 007 answer 26). no_std, no dependencies.
|
||||
unicase = "2.9"
|
||||
|
||||
[features]
|
||||
# Exposes the fold-evaluation counter to other crates' tests (improvise-0jw5).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! What a wider document costs: the same records at 12 and at 24 regular
|
||||
//! categories. improvise-felo.1; the argument is `context/code/core.md` §9.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use improvise_core::model::cell::{CellKey, CellValue};
|
||||
use improvise_core::view::{Axis, GridLayout, ViewStep, records_steps};
|
||||
use improvise_core::workbook::Workbook;
|
||||
|
||||
const MEASURE: &str = "_Measure";
|
||||
const ITEMS: u64 = 4;
|
||||
|
||||
/// `records` records over `width` categories of `ITEMS` items, two measures
|
||||
/// each; coordinates from a fixed LCG so both widths see comparable data.
|
||||
fn document(width: usize, records: usize) -> Workbook {
|
||||
let mut wb = Workbook::new("bench");
|
||||
for c in 0..width {
|
||||
wb.add_category(format!("C{c}")).expect("under the limit");
|
||||
}
|
||||
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
let mut next = move || {
|
||||
state = state
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
(state >> 33) % ITEMS
|
||||
};
|
||||
for _ in 0..records {
|
||||
let coords: Vec<(String, String)> = (0..width)
|
||||
.map(|c| (format!("C{c}"), format!("i{}", next())))
|
||||
.collect();
|
||||
for (name, value) in [("Cost", 3.0), ("Rev", 5.0)] {
|
||||
let mut key = coords.clone();
|
||||
key.push((MEASURE.to_string(), name.to_string()));
|
||||
wb.model
|
||||
.set_cell(CellKey::new(key), CellValue::Number(value));
|
||||
}
|
||||
}
|
||||
for c in 0..width {
|
||||
let cat = wb.model.category_mut(&format!("C{c}")).expect("added");
|
||||
for i in 0..ITEMS {
|
||||
cat.add_item(format!("i{i}"));
|
||||
}
|
||||
}
|
||||
for name in ["Cost", "Rev"] {
|
||||
wb.model
|
||||
.category_mut(MEASURE)
|
||||
.expect("virtual")
|
||||
.add_item(name);
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
/// Best of `runs`, in milliseconds.
|
||||
fn best(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn pivot(wb: &mut Workbook, width: usize) -> f64 {
|
||||
let view = wb.active_view_mut();
|
||||
for c in 0..width {
|
||||
view.set_axis(
|
||||
&format!("C{c}"),
|
||||
if c == 0 { Axis::Row } else { Axis::None },
|
||||
);
|
||||
}
|
||||
view.set_axis(MEASURE, Axis::Column);
|
||||
let (model, view) = (&wb.model, wb.active_view());
|
||||
best(5, || {
|
||||
let layout = GridLayout::with_frozen_records(model, view, None);
|
||||
let eval = layout.evaluator(model);
|
||||
for r in 0..layout.row_count() {
|
||||
for c in 0..layout.col_count() {
|
||||
let key = layout.cell_key(r, c).expect("in bounds");
|
||||
std::hint::black_box(eval.cell(&key));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// (build, draw): the records layout, then every cell's display text.
|
||||
fn records(wb: &mut Workbook) -> (f64, f64) {
|
||||
let steps = records_steps(&wb.model);
|
||||
let view = wb.active_view_mut();
|
||||
for step in steps {
|
||||
if let ViewStep::Axis { category, axis } = step {
|
||||
view.set_axis(&category, axis);
|
||||
}
|
||||
}
|
||||
let (model, view) = (&wb.model, wb.active_view());
|
||||
let build = best(5, || {
|
||||
std::hint::black_box(GridLayout::with_frozen_records(model, view, None));
|
||||
});
|
||||
let layout = GridLayout::with_frozen_records(model, view, None);
|
||||
let draw = best(5, || {
|
||||
for r in 0..layout.row_count() {
|
||||
for c in 0..layout.col_count() {
|
||||
std::hint::black_box(layout.records_display(r, c));
|
||||
}
|
||||
}
|
||||
});
|
||||
(build, draw)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"{:>6} {:>8} {:>7} {:>9} {:>9} {:>14} {:>13}",
|
||||
"width", "records", "cells", "store ms", "pivot ms", "rec build ms", "rec draw ms"
|
||||
);
|
||||
for records_n in [2_500, 5_000] {
|
||||
for width in [12, 24] {
|
||||
let mut built = None;
|
||||
let store = best(3, || built = Some(document(width, records_n)));
|
||||
let mut wb = built.expect("ran");
|
||||
let cells = wb.model.data.iter_cells().count();
|
||||
let p = pivot(&mut wb, width);
|
||||
let (rb, rd) = records(&mut wb);
|
||||
println!(
|
||||
"{width:>6} {records_n:>8} {cells:>7} {store:>9.1} {p:>9.1} {rb:>14.1} {rd:>13.1}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,27 @@ pub type ItemId = usize;
|
||||
pub struct Item {
|
||||
pub id: ItemId,
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Parent group name, if any
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
pub fn with_group(mut self, group: impl Into<String>) -> Self {
|
||||
self.group = Some(group.into());
|
||||
self
|
||||
@@ -129,6 +137,8 @@ impl CategoryKind {
|
||||
pub struct Category {
|
||||
pub id: CategoryId,
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Items in insertion order
|
||||
pub items: IndexMap<String, Item>,
|
||||
/// Named groups (hierarchy nodes)
|
||||
@@ -141,9 +151,11 @@ pub struct Category {
|
||||
|
||||
impl Category {
|
||||
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
items: IndexMap::new(),
|
||||
groups: Vec::new(),
|
||||
next_item_id: 0,
|
||||
@@ -151,6 +163,23 @@ impl Category {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Changes what a person sees and nothing else: the key does not move.
|
||||
pub fn set_display(&mut self, display: impl Into<String>) {
|
||||
self.display = display.into();
|
||||
}
|
||||
|
||||
/// Changes an item's display; false when the category has no such item.
|
||||
pub fn set_item_display(&mut self, item: &str, display: impl Into<String>) -> bool {
|
||||
self.items
|
||||
.get_mut(item)
|
||||
.map(|i| i.display = display.into())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
@@ -199,15 +228,61 @@ impl Category {
|
||||
self.items.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
/// The items whose own `group` names `group`, in insertion order. Read off the
|
||||
/// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14).
|
||||
/// The items `group` covers — its own and every child group's, through any
|
||||
/// depth — in insertion order. Read off the items, not `groups` (core.md §14).
|
||||
pub fn items_in_group(&self, group: &str) -> Vec<&str> {
|
||||
self.items
|
||||
.values()
|
||||
.filter(|i| i.group.as_deref() == Some(group))
|
||||
.filter(|i| self.item_chain(&i.name).contains(&group))
|
||||
.map(|i| i.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parent a `>` line gave `group`; `None` for a root.
|
||||
pub fn group_parent(&self, group: &str) -> Option<&str> {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|g| g.name == group)
|
||||
.and_then(|g| g.parent.as_deref())
|
||||
}
|
||||
|
||||
/// `group` and its ancestors, ROOT FIRST. A cycle built in code stops the walk
|
||||
/// where it would repeat; a file cannot carry one (`hierarchy_cycle`).
|
||||
pub fn group_chain<'a>(&'a self, group: &'a str) -> Vec<&'a str> {
|
||||
let mut chain = vec![group];
|
||||
while let Some(parent) = chain.last().and_then(|g| self.group_parent(g)) {
|
||||
if chain.contains(&parent) {
|
||||
break;
|
||||
}
|
||||
chain.push(parent);
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// The groups an item sits in, root first; empty for an ungrouped item.
|
||||
pub fn item_chain(&self, item: &str) -> Vec<&str> {
|
||||
self.items
|
||||
.get(item)
|
||||
.and_then(|i| i.group.as_deref())
|
||||
.map(|g| self.group_chain(g))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A cycle among the declared parents, its members in walk order: the one
|
||||
/// shape a hierarchy can take that is not a forest (core.md §19).
|
||||
pub fn hierarchy_cycle(&self) -> Option<Vec<&str>> {
|
||||
self.groups.iter().find_map(|start| {
|
||||
let mut path: Vec<&str> = vec![start.name.as_str()];
|
||||
while let Some(parent) = path.last().and_then(|g| self.group_parent(g)) {
|
||||
if let Some(at) = path.iter().position(|g| *g == parent) {
|
||||
return Some(path[at..].to_vec());
|
||||
}
|
||||
path.push(parent);
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -218,6 +293,20 @@ mod tests {
|
||||
Category::new(0, "Region")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_display_starts_as_the_key_and_changing_it_moves_no_key() {
|
||||
let mut c = cat();
|
||||
c.add_item("East");
|
||||
assert_eq!(c.display(), "Region");
|
||||
assert_eq!(c.items["East"].display(), "East");
|
||||
c.set_display("Sales Area");
|
||||
assert!(c.set_item_display("East", "Eastern"));
|
||||
assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area"));
|
||||
assert_eq!(c.items["East"].display(), "Eastern");
|
||||
assert_eq!(c.items["East"].name, "East");
|
||||
assert!(!c.set_item_display("West", "Western"), "no such item");
|
||||
}
|
||||
|
||||
/// improvise-2lh: call sites ask `of_name` rather than compare literals,
|
||||
/// so both directions of the mapping must agree.
|
||||
#[test]
|
||||
@@ -297,6 +386,66 @@ mod tests {
|
||||
assert_eq!(c.groups.len(), 1);
|
||||
}
|
||||
|
||||
/// Year > Quarter > Month, with `2024-03` added LAST so Q1-2024 and 2024 are
|
||||
/// non-contiguous in insertion order.
|
||||
fn dates() -> Category {
|
||||
let mut c = Category::new(0, "Date");
|
||||
c.add_item_in_group("2024-01", "Q1-2024");
|
||||
c.add_item_in_group("2024-04", "Q2-2024");
|
||||
c.add_item_in_group("2025-01", "Q1-2025");
|
||||
c.add_item("Undated");
|
||||
c.add_item_in_group("2024-03", "Q1-2024");
|
||||
c.add_group(Group::new("Q1-2024").with_parent("2024"));
|
||||
c.add_group(Group::new("Q2-2024").with_parent("2024"));
|
||||
c.add_group(Group::new("Q1-2025").with_parent("2025"));
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_group_covers_every_leaf_beneath_its_child_groups() {
|
||||
let c = dates();
|
||||
assert_eq!(
|
||||
c.items_in_group("2024"),
|
||||
vec!["2024-01", "2024-04", "2024-03"]
|
||||
);
|
||||
assert_eq!(c.items_in_group("Q1-2024"), vec!["2024-01", "2024-03"]);
|
||||
assert_eq!(c.items_in_group("2025"), vec!["2025-01"]);
|
||||
assert!(c.items_in_group("Undated").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_chain_runs_root_first() {
|
||||
let c = dates();
|
||||
assert_eq!(c.item_chain("2024-03"), vec!["2024", "Q1-2024"]);
|
||||
assert!(c.item_chain("Undated").is_empty());
|
||||
assert_eq!(c.group_chain("2024"), vec!["2024"]);
|
||||
assert_eq!(c.group_chain("Q2-2024"), vec!["2024", "Q2-2024"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_among_parents_is_found_and_named() {
|
||||
let mut c = dates();
|
||||
assert_eq!(c.hierarchy_cycle(), None);
|
||||
c.add_group(Group::new("2024").with_parent("Q2-2024"));
|
||||
let cycle = c.hierarchy_cycle().expect("2024 -> Q2-2024 -> 2024");
|
||||
assert!(
|
||||
cycle.contains(&"2024") && cycle.contains(&"Q2-2024"),
|
||||
"{cycle:?}"
|
||||
);
|
||||
assert!(!cycle.contains(&"Q1-2025"), "{cycle:?}");
|
||||
assert!(
|
||||
c.group_chain("Q1-2024").len() <= 3,
|
||||
"a walk through a cycle stops"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_parent_is_a_cycle_of_one() {
|
||||
let mut c = cat();
|
||||
c.add_group(Group::new("G").with_parent("G"));
|
||||
assert_eq!(c.hierarchy_cycle(), Some(vec!["G"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_index_reflects_insertion_order() {
|
||||
let mut c = cat();
|
||||
|
||||
@@ -194,6 +194,12 @@ impl RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many lookups fell through to set narrowing, for the exact-path test.
|
||||
static NARROWED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DataStore {
|
||||
/// Primary storage: interned keys, insertion-ordered so records mode can
|
||||
@@ -206,6 +212,9 @@ pub struct DataStore {
|
||||
/// Reverse index for the client-facing surrogate (ADR 002), plus each
|
||||
/// record's write counter. Why it lives here: core.md §8.
|
||||
records: HashMap<RecordHash, RecordEntry>,
|
||||
/// How many stored cells have each number of coordinates; its largest key
|
||||
/// says when a partial can match only one exact cell.
|
||||
arities: std::collections::BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
/// Resolved through the symbol table and sorted wherever the storage is a
|
||||
@@ -217,6 +226,7 @@ impl std::fmt::Debug for DataStore {
|
||||
symbols,
|
||||
index,
|
||||
records,
|
||||
arities: _,
|
||||
} = self;
|
||||
let cells: Vec<_> = cells
|
||||
.iter()
|
||||
@@ -291,7 +301,11 @@ impl DataStore {
|
||||
for pair in &ikey.0 {
|
||||
self.index.entry(*pair).or_default().insert(ikey.clone());
|
||||
}
|
||||
let width = ikey.0.len();
|
||||
let is_new_cell = self.cells.insert(ikey, value).is_none();
|
||||
if is_new_cell {
|
||||
*self.arities.entry(width).or_default() += 1;
|
||||
}
|
||||
let entry = self.records.entry(hash).or_insert(RecordEntry {
|
||||
record,
|
||||
cells: 0,
|
||||
@@ -327,6 +341,12 @@ impl DataStore {
|
||||
return;
|
||||
};
|
||||
if self.cells.shift_remove(&ikey).is_some() {
|
||||
if let Some(n) = self.arities.get_mut(&ikey.0.len()) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.arities.remove(&ikey.0.len());
|
||||
}
|
||||
}
|
||||
for pair in &ikey.0 {
|
||||
if let Some(set) = self.index.get_mut(pair) {
|
||||
set.remove(&ikey);
|
||||
@@ -378,11 +398,28 @@ impl DataStore {
|
||||
/// Positions in `cells` of every cell matching `partial`, **in insertion
|
||||
/// order**. The index narrows; the primary map orders (§8).
|
||||
fn matching_positions(&self, partial: &[(String, String)]) -> Vec<usize> {
|
||||
if let Some(exact) = self.exact_positions(partial) {
|
||||
return exact;
|
||||
}
|
||||
self.intern_partial(partial)
|
||||
.map(|pairs| self.positions_of(&pairs))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The answer without narrowing, when `partial` is as wide as the widest cell
|
||||
/// stored: only an equal cell can match (layout-cost.md). `None` otherwise.
|
||||
fn exact_positions(&self, partial: &[(String, String)]) -> Option<Vec<usize>> {
|
||||
let widest = *self.arities.keys().next_back()?;
|
||||
// Strictly sorted by category, the order a stored key is in, so one probe is the lookup.
|
||||
let canonical = partial.windows(2).all(|w| w[0].0 < w[1].0);
|
||||
(!partial.is_empty() && widest <= partial.len() && canonical).then(|| {
|
||||
let found = self
|
||||
.intern_partial(partial)
|
||||
.and_then(|pairs| self.cells.get_index_of(&InternedKey(pairs)));
|
||||
found.into_iter().collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `partial` as symbols, or `None` when a name was never interned, which
|
||||
/// no stored cell can then match.
|
||||
fn intern_partial(&self, partial: &[(String, String)]) -> Option<Vec<(Symbol, Symbol)>> {
|
||||
@@ -412,6 +449,8 @@ impl DataStore {
|
||||
}
|
||||
|
||||
sets.sort_by_key(|s| s.len());
|
||||
#[cfg(test)]
|
||||
NARROWED.with(|n| n.set(n.get() + 1));
|
||||
|
||||
let mut positions: Vec<usize> = match sets.split_first() {
|
||||
Some((first, rest)) => first
|
||||
@@ -1356,3 +1395,105 @@ mod record_index {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exact_path_tests {
|
||||
use super::{CellKey, CellValue, DataStore, NARROWED};
|
||||
|
||||
fn key(a: &str, b: &str, c: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("A".into(), a.into()),
|
||||
("B".into(), b.into()),
|
||||
("C".into(), c.into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn store() -> DataStore {
|
||||
let mut d = DataStore::new();
|
||||
for i in 0..6 {
|
||||
for j in 0..6 {
|
||||
d.set(
|
||||
key(&format!("a{i}"), &format!("b{j}"), "c"),
|
||||
CellValue::Number((i * 6 + j) as f64),
|
||||
);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn narrowings(f: impl FnOnce()) -> usize {
|
||||
NARROWED.with(|n| n.set(0));
|
||||
f();
|
||||
NARROWED.with(|n| n.get())
|
||||
}
|
||||
|
||||
/// improvise-felo.13: context/test-evidence/layout-cost.md.
|
||||
#[test]
|
||||
fn a_full_width_partial_probes_one_cell_instead_of_narrowing() {
|
||||
let d = store();
|
||||
let full = key("a2", "b3", "c").0;
|
||||
let mut got = Vec::new();
|
||||
assert_eq!(
|
||||
narrowings(|| got = d.matching_values(&full).into_iter().cloned().collect()),
|
||||
0
|
||||
);
|
||||
assert_eq!(got, vec![CellValue::Number(15.0)]);
|
||||
let missing = key("a2", "b9", "c").0;
|
||||
assert!(
|
||||
d.matching_values(&missing).is_empty(),
|
||||
"an absent cell matches nothing"
|
||||
);
|
||||
let unknown = key("a2", "b3", "zzz").0;
|
||||
assert!(
|
||||
d.matching_values(&unknown).is_empty(),
|
||||
"an unseen item matches nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The control: a narrower partial still sums its supersets, so the shortcut
|
||||
/// is not a blanket exact lookup.
|
||||
#[test]
|
||||
fn a_narrower_partial_still_narrows_and_matches_every_superset() {
|
||||
let d = store();
|
||||
let narrow = vec![
|
||||
("A".to_string(), "a2".to_string()),
|
||||
("C".to_string(), "c".to_string()),
|
||||
];
|
||||
let mut n = 0;
|
||||
assert_eq!(narrowings(|| n = d.matching_values(&narrow).len()), 1);
|
||||
assert_eq!(n, 6);
|
||||
}
|
||||
|
||||
/// A wider cell appearing, or leaving, moves the line the shortcut is allowed at.
|
||||
#[test]
|
||||
fn a_wider_cell_turns_the_shortcut_off_until_it_is_removed() {
|
||||
let mut d = store();
|
||||
let wide = CellKey::new(vec![
|
||||
("A".into(), "a2".into()),
|
||||
("B".into(), "b3".into()),
|
||||
("C".into(), "c".into()),
|
||||
("D".into(), "d".into()),
|
||||
]);
|
||||
d.set(wide.clone(), CellValue::Number(100.0));
|
||||
let full = key("a2", "b3", "c").0;
|
||||
assert_eq!(
|
||||
d.matching_values(&full).len(),
|
||||
2,
|
||||
"the exact cell and the wider cell under it"
|
||||
);
|
||||
d.remove(&wide);
|
||||
assert_eq!(
|
||||
narrowings(|| assert_eq!(d.matching_values(&full).len(), 1)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// An unsorted partial is not a stored key's order, so it must not be probed.
|
||||
#[test]
|
||||
fn an_unsorted_full_width_partial_is_still_answered() {
|
||||
let d = store();
|
||||
let mut full = key("a2", "b3", "c").0;
|
||||
full.reverse();
|
||||
assert_eq!(d.matching_values(&full).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4).
|
||||
//! Reasoning: `context/code/core.md` §20.
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// What a generated id names; spells the `<kind>` of an empty slug's `<kind>-<n>`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IdKind {
|
||||
Category,
|
||||
Item,
|
||||
Formula,
|
||||
View,
|
||||
}
|
||||
|
||||
impl IdKind {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
IdKind::Category => "category",
|
||||
IdKind::Item => "item",
|
||||
IdKind::Formula => "formula",
|
||||
IdKind::View => "view",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The only ids that may begin with `_` (answer 23).
|
||||
pub const VIRTUAL_IDS: [&str; 3] = ["_index", "_dim", "_measure"];
|
||||
|
||||
/// A display name's slug; empty when nothing survives (answers 16, 18, 21, 23, 26).
|
||||
pub fn slugify(display: &str) -> String {
|
||||
let folded = unicase::UniCase::new(display).to_folded_case();
|
||||
let kept: String = underscore_space_runs(&folded)
|
||||
.chars()
|
||||
.filter(|&c| in_id_alphabet(c))
|
||||
.collect();
|
||||
kept.trim_start_matches('_').to_string()
|
||||
}
|
||||
|
||||
fn underscore_space_runs(s: &str) -> String {
|
||||
let (out, _) = s
|
||||
.chars()
|
||||
.fold((String::new(), false), |(mut out, prev), c| {
|
||||
let space = c == ' ';
|
||||
if !(space && prev) {
|
||||
out.push(if space { '_' } else { c });
|
||||
}
|
||||
(out, space)
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn in_id_alphabet(c: char) -> bool {
|
||||
matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')
|
||||
}
|
||||
|
||||
/// Why a hand-written id cannot enter the document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClaimError {
|
||||
Duplicate(String),
|
||||
Malformed(String),
|
||||
Reserved(String),
|
||||
}
|
||||
|
||||
/// One document's ids: every id it has held this session, and the `gensym` counter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdAllocator {
|
||||
next: u64,
|
||||
seen: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for IdAllocator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next: 1,
|
||||
seen: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdAllocator {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Admit a hand-written id verbatim: it is canonical and never renamed (answer 29).
|
||||
pub fn claim(&mut self, id: &str) -> Result<(), ClaimError> {
|
||||
let refusal = if id.is_empty() || !id.chars().all(in_id_alphabet) {
|
||||
Some(ClaimError::Malformed(id.to_string()))
|
||||
} else if id.starts_with('_') && !VIRTUAL_IDS.contains(&id) {
|
||||
Some(ClaimError::Reserved(id.to_string()))
|
||||
} else if self.seen.contains(id) {
|
||||
Some(ClaimError::Duplicate(id.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match refusal {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
self.seen.insert(id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh id for `display`, suffixed from the counter only when the slug is taken.
|
||||
pub fn generate(&mut self, kind: IdKind, display: &str) -> String {
|
||||
let slug = slugify(display);
|
||||
let id = if slug.is_empty() {
|
||||
self.suffixed(kind.name())
|
||||
} else if self.seen.contains(&slug) {
|
||||
self.suffixed(&slug)
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
self.seen.insert(id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
fn suffixed(&mut self, base: &str) -> String {
|
||||
loop {
|
||||
let candidate = format!("{base}-{}", self.next);
|
||||
self.next += 1;
|
||||
if !self.seen.contains(&candidate) {
|
||||
break candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn slugify_follows_the_adrs_examples() {
|
||||
let cases = [
|
||||
("East", "east"),
|
||||
("Time Period", "time_period"),
|
||||
("Coastal_East", "coastal_east"),
|
||||
("Q1-2024", "q1-2024"),
|
||||
("Region(code)", "regioncode"),
|
||||
("Café", "caf"),
|
||||
("_Draft", "draft"),
|
||||
("Ü", ""),
|
||||
];
|
||||
for (display, slug) in cases {
|
||||
assert_eq!(slugify(display), slug, "slugify({display:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_case_folds_rather_than_lowercases() {
|
||||
assert_eq!(slugify("Straße"), "strasse");
|
||||
assert_eq!(slugify("ſtate"), "state");
|
||||
assert_eq!(slugify("file"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_collapses_a_run_of_spaces_to_one_underscore() {
|
||||
assert_eq!(slugify("Time Period"), "time_period");
|
||||
assert_eq!(slugify("East "), "east_");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_keeps_an_underscore_the_display_already_had() {
|
||||
assert_eq!(slugify("a_ b"), "a__b");
|
||||
assert_eq!(slugify("a__b"), "a__b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_slug_takes_its_kind_and_the_shared_counter() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Category, "Ü"), "category-1");
|
||||
assert_eq!(ids.generate(IdKind::Item, "Ö"), "item-2");
|
||||
assert_eq!(ids.generate(IdKind::View, ""), "view-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slug_taken_by_another_kind_takes_a_suffix() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Category, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-1");
|
||||
assert_eq!(ids.generate(IdKind::View, "Time Period"), "time_period");
|
||||
assert_eq!(
|
||||
ids.generate(IdKind::Formula, "Time_Period"),
|
||||
"time_period-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_suffix_is_the_next_counter_value_not_the_lowest_free_number() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "West"), "west");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-1");
|
||||
assert_eq!(ids.generate(IdKind::Item, "West"), "west-2");
|
||||
assert_eq!(ids.generate(IdKind::Item, "EAST"), "east-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimed_id_keeps_its_spelling_and_generation_steps_past_it() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.claim("east-1"), Ok(()));
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east");
|
||||
assert_eq!(ids.generate(IdKind::Item, "East"), "east-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_refuses_a_duplicate_a_malformed_and_a_reserved_id() {
|
||||
let mut ids = IdAllocator::new();
|
||||
assert_eq!(ids.claim("east"), Ok(()));
|
||||
assert_eq!(ids.claim("east"), Err(ClaimError::Duplicate("east".into())));
|
||||
assert_eq!(ids.claim("East"), Err(ClaimError::Malformed("East".into())));
|
||||
assert_eq!(ids.claim(""), Err(ClaimError::Malformed(String::new())));
|
||||
assert_eq!(
|
||||
ids.claim("_draft"),
|
||||
Err(ClaimError::Reserved("_draft".into()))
|
||||
);
|
||||
assert_eq!(ids.claim("_measure"), Ok(()));
|
||||
let generated = ids.generate(IdKind::Category, "West");
|
||||
assert_eq!(
|
||||
ids.claim(&generated),
|
||||
Err(ClaimError::Duplicate("west".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_refuses_an_id_that_only_begins_like_a_virtual_one() {
|
||||
let mut ids = IdAllocator::new();
|
||||
for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures"] {
|
||||
assert_eq!(
|
||||
ids.claim(lookalike),
|
||||
Err(ClaimError::Reserved(lookalike.into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_display_with_leading_spaces_never_slugs_into_the_reserved_space() {
|
||||
assert_eq!(slugify(" Draft"), "draft");
|
||||
assert_eq!(slugify(" _ Draft"), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_claimed_run_of_suffixes_is_walked_without_recursion() {
|
||||
let mut ids = IdAllocator::new();
|
||||
for n in 1..=20_000 {
|
||||
assert_eq!(ids.claim(&format!("item-{n}")), Ok(()));
|
||||
}
|
||||
assert_eq!(ids.generate(IdKind::Item, "Ü"), "item-20001");
|
||||
assert_eq!(ids.generate(IdKind::Category, ""), "category-20002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_allocators_fed_alike_generate_alike() {
|
||||
let displays = [
|
||||
"East",
|
||||
"east",
|
||||
"Ü",
|
||||
"Time Period",
|
||||
"Time_Period",
|
||||
"",
|
||||
"EAST",
|
||||
];
|
||||
let run = || {
|
||||
let mut ids = IdAllocator::new();
|
||||
displays
|
||||
.iter()
|
||||
.map(|d| ids.generate(IdKind::Item, d))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(), run());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn generated_ids_are_distinct_well_formed_and_never_reserved(
|
||||
displays in proptest::collection::vec(".{0,6}", 0..24)
|
||||
) {
|
||||
let mut ids = IdAllocator::new();
|
||||
let mut out = HashSet::new();
|
||||
for d in &displays {
|
||||
let id = ids.generate(IdKind::Item, d);
|
||||
prop_assert!(!id.is_empty() && !id.starts_with('_'), "{id:?}");
|
||||
prop_assert!(id.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')));
|
||||
prop_assert!(out.insert(id.clone()), "reissued {id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod id;
|
||||
pub mod record;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
pub use types::{Aggregation, Model};
|
||||
pub use types::{Aggregation, MAX_CATEGORIES, Model};
|
||||
|
||||
@@ -7,7 +7,9 @@ use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId};
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
/// Regular categories a model may hold; labels and virtuals do not count.
|
||||
/// Why 24 and what it costs: context/code/core.md §9 (improvise-felo.1).
|
||||
pub const MAX_CATEGORIES: usize = 24;
|
||||
|
||||
/// Shared by `=`/`!=` **and** the division-by-zero guard, in both evaluators.
|
||||
/// They must agree; why, and why 1e-10: context/code/core.md §4.
|
||||
@@ -523,6 +525,15 @@ impl Model {
|
||||
self.categories.get(name)
|
||||
}
|
||||
|
||||
/// `group` and its ancestors in `cat`, root first and owned: the chain a
|
||||
/// `View` folds by (core.md §19). Just `[group]` where `cat` is unknown.
|
||||
pub fn group_chain(&self, cat: &str, group: &str) -> Vec<String> {
|
||||
match self.category(cat) {
|
||||
Some(c) => c.group_chain(group).into_iter().map(String::from).collect(),
|
||||
None => vec![group.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_cell(&mut self, key: CellKey, value: CellValue) {
|
||||
self.data.set(key, value);
|
||||
}
|
||||
@@ -1386,7 +1397,8 @@ mod model_tests {
|
||||
assert_eq!(id, m.categories["C0"].id);
|
||||
assert_eq!(m.categories.len(), before, "nothing was created");
|
||||
// The limit still refuses a category that WOULD be created.
|
||||
assert!(m.add_category("C12").is_err(), "the limit still holds");
|
||||
let fresh = format!("C{}", super::MAX_CATEGORIES);
|
||||
assert!(m.add_category(fresh).is_err(), "the limit still holds");
|
||||
}
|
||||
|
||||
/// improvise-s08t — see context/test-evidence/core.md.
|
||||
@@ -1550,13 +1562,20 @@ mod model_tests {
|
||||
assert_eq!(m.category_names().len(), 4);
|
||||
}
|
||||
|
||||
/// The user's number (improvise-felo.1), spelled once here; every other
|
||||
/// fill-to-the-cap test derives it from `MAX_CATEGORIES`.
|
||||
#[test]
|
||||
fn add_category_max_limit() {
|
||||
let mut m = Model::new("Test");
|
||||
for i in 0..12 {
|
||||
m.add_category(format!("Cat{i}")).unwrap();
|
||||
for i in 0..24 {
|
||||
m.add_category(format!("Cat{i}"))
|
||||
.unwrap_or_else(|e| panic!("category {} of 24 refused: {e}", i + 1));
|
||||
}
|
||||
assert!(m.add_category("TooMany").is_err());
|
||||
let err = m.add_category("TooMany").expect_err("the 25th is refused");
|
||||
assert!(
|
||||
err.to_string().contains("24"),
|
||||
"the refusal names the real limit: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3152,11 +3171,11 @@ mod five_category {
|
||||
// 4 regular + 3 virtual.
|
||||
assert_eq!(m.category_names().len(), 7);
|
||||
let mut m2 = build_model();
|
||||
for i in 0..8 {
|
||||
for i in 0..super::MAX_CATEGORIES - 4 {
|
||||
m2.add_category(format!("Extra{i}")).unwrap();
|
||||
}
|
||||
// 12 regular + 3 virtuals = 15.
|
||||
assert_eq!(m2.category_names().len(), 15);
|
||||
// The limit's worth of regular categories, plus 3 virtuals.
|
||||
assert_eq!(m2.category_names().len(), super::MAX_CATEGORIES + 3);
|
||||
assert!(m2.add_category("OneMore").is_err());
|
||||
}
|
||||
}
|
||||
@@ -3225,7 +3244,7 @@ mod prop_tests {
|
||||
if let Some(c) = m.category_mut("_Measure") { c.add_item("Revenue"); }
|
||||
let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]);
|
||||
m.set_cell(key.clone(), CellValue::Number(val));
|
||||
let _ = m.add_category(&new_cat); // may hit the 12-cat limit
|
||||
let _ = m.add_category(&new_cat); // may be refused; either way the cell survives
|
||||
prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val)));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Axis {
|
||||
Row,
|
||||
@@ -12,17 +12,6 @@ pub enum Axis {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Accent color for an axis. Semantic palette only; `draw.rs::axis_fg` maps
|
||||
/// each variant to a terminal color, so `Axis` names no ratatui type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AxisColor {
|
||||
Row,
|
||||
Column,
|
||||
Page,
|
||||
Filter,
|
||||
Inactive,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// The next axis in the Row → Column → Page → None → Row rotation.
|
||||
/// `Filter` is outside it and cycling one is a no-op (improvise-dz8).
|
||||
@@ -48,18 +37,6 @@ impl Axis {
|
||||
!matches!(self, Axis::Filter)
|
||||
}
|
||||
|
||||
/// Accent color. The single source for axis coloring: the tile bar and the
|
||||
/// category panel each carried a copy before (improvise-rml).
|
||||
pub fn color(self) -> AxisColor {
|
||||
match self {
|
||||
Axis::Row => AxisColor::Row,
|
||||
Axis::Column => AxisColor::Column,
|
||||
Axis::Page => AxisColor::Page,
|
||||
Axis::Filter => AxisColor::Filter,
|
||||
Axis::None => AxisColor::Inactive,
|
||||
}
|
||||
}
|
||||
|
||||
/// Name with no decoration: "Row", "Col", "Page", "None".
|
||||
pub fn plain_label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -176,15 +153,4 @@ mod tests {
|
||||
assert!(!Axis::Column.filters_data());
|
||||
assert!(!Axis::None.filters_data());
|
||||
}
|
||||
|
||||
/// A collision defeats the only reason `AxisColor` exists.
|
||||
#[test]
|
||||
fn every_axis_gets_its_own_color() {
|
||||
let colors = ALL.map(Axis::color);
|
||||
for (i, a) in colors.iter().enumerate() {
|
||||
for b in &colors[i + 1..] {
|
||||
assert_ne!(a, b, "two axes share an accent color");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,12 @@ pub mod axis;
|
||||
pub mod layout;
|
||||
pub mod types;
|
||||
|
||||
pub use axis::{Axis, AxisColor};
|
||||
pub use axis::Axis;
|
||||
pub use layout::{
|
||||
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
|
||||
ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine,
|
||||
records_steps, synthetic_record_info,
|
||||
};
|
||||
pub use types::{
|
||||
DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
|
||||
DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use super::axis::Axis;
|
||||
use crate::model::category::CategoryKind;
|
||||
@@ -27,17 +28,32 @@ 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")
|
||||
}
|
||||
|
||||
/// One group's own fold, overriding what it inherits (core.md §19).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum GroupFold {
|
||||
/// `collapsed: Cat/Group` — closed, whatever reaches it from above.
|
||||
Collapsed,
|
||||
/// `expanded(N): Cat/Group` — open, with N-1 levels beneath it. Non-zero by
|
||||
/// type, since `expanded(0)` is no spelling the grammar reads back.
|
||||
Expanded(NonZeroUsize),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
/// What a person sees; the key until a rename (ADR 007 clause 2).
|
||||
display: String,
|
||||
/// Axis assignment for each category
|
||||
pub category_axes: IndexMap<String, Axis>,
|
||||
/// For page axis: selected item per category
|
||||
pub page_selections: HashMap<String, String>,
|
||||
/// Hidden items per category
|
||||
pub hidden_items: HashMap<String, HashSet<String>>,
|
||||
/// Collapsed groups per category
|
||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||
/// How deep each category's hierarchy opens: groups shallower than this start
|
||||
/// open, the rest closed. Absent is 0 — everything closed (core.md §19).
|
||||
pub expand_depth: HashMap<String, usize>,
|
||||
/// Per-group answers that override the depth default, in either direction.
|
||||
pub group_folds: HashMap<String, HashMap<String, GroupFold>>,
|
||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||
pub number_format: String,
|
||||
/// When true, empty rows/columns are pruned from the display.
|
||||
@@ -51,10 +67,12 @@ impl std::fmt::Debug for View {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let View {
|
||||
name,
|
||||
display,
|
||||
category_axes,
|
||||
page_selections,
|
||||
hidden_items,
|
||||
collapsed_groups,
|
||||
expand_depth,
|
||||
group_folds,
|
||||
number_format,
|
||||
prune_empty,
|
||||
} = self;
|
||||
@@ -64,12 +82,19 @@ impl std::fmt::Debug for View {
|
||||
.collect()
|
||||
};
|
||||
let pages: BTreeMap<_, _> = page_selections.iter().collect();
|
||||
let depths: BTreeMap<_, _> = expand_depth.iter().collect();
|
||||
let folds: BTreeMap<_, BTreeMap<_, _>> = group_folds
|
||||
.iter()
|
||||
.map(|(k, v)| (k, v.iter().collect()))
|
||||
.collect();
|
||||
f.debug_struct("View")
|
||||
.field("name", name)
|
||||
.field("display", display)
|
||||
.field("category_axes", category_axes)
|
||||
.field("page_selections", &pages)
|
||||
.field("hidden_items", &sorted(hidden_items))
|
||||
.field("collapsed_groups", &sorted(collapsed_groups))
|
||||
.field("expand_depth", &depths)
|
||||
.field("group_folds", &folds)
|
||||
.field("number_format", number_format)
|
||||
.field("prune_empty", prune_empty)
|
||||
.finish()
|
||||
@@ -82,13 +107,25 @@ impl View {
|
||||
VIRTUAL_VIEW_NAMES.contains(&self.name.as_str())
|
||||
}
|
||||
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Changes what a person sees and nothing else: the key does not move.
|
||||
pub fn set_display(&mut self, display: impl Into<String>) {
|
||||
self.display = display.into();
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
Self {
|
||||
name: name.into(),
|
||||
display: name.clone(),
|
||||
name,
|
||||
category_axes: IndexMap::new(),
|
||||
page_selections: HashMap::new(),
|
||||
hidden_items: HashMap::new(),
|
||||
collapsed_groups: HashMap::new(),
|
||||
expand_depth: HashMap::new(),
|
||||
group_folds: HashMap::new(),
|
||||
// Decimals by default: a ratio is the case a format cannot be
|
||||
// guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex).
|
||||
number_format: ",.2".to_string(),
|
||||
@@ -135,7 +172,8 @@ impl View {
|
||||
self.category_axes.shift_remove(cat_name);
|
||||
self.page_selections.remove(cat_name);
|
||||
self.hidden_items.remove(cat_name);
|
||||
self.collapsed_groups.remove(cat_name);
|
||||
self.expand_depth.remove(cat_name);
|
||||
self.group_folds.remove(cat_name);
|
||||
}
|
||||
|
||||
/// Forget an item this view names. A hidden or paged-to item that outlives
|
||||
@@ -222,23 +260,78 @@ impl View {
|
||||
self.page_selections.get(cat_name).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
|
||||
let set = self
|
||||
.collapsed_groups
|
||||
.entry(cat_name.to_string())
|
||||
.or_default();
|
||||
if set.contains(group_name) {
|
||||
set.remove(group_name);
|
||||
} else {
|
||||
set.insert(group_name.to_string());
|
||||
/// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all
|
||||
/// closed — if unset.
|
||||
pub fn expand_depth(&self, cat_name: &str) -> usize {
|
||||
self.expand_depth.get(cat_name).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Open `cat_name`'s top `depth` levels; 0 forgets the setting.
|
||||
pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) {
|
||||
match depth {
|
||||
0 => self.expand_depth.remove(cat_name),
|
||||
_ => self.expand_depth.insert(cat_name.to_string(), depth),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the group ending `chain` (root first, from `Category::group_chain`)
|
||||
/// is collapsed: its own fold wins, else what it inherits (core.md §19).
|
||||
pub fn is_group_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
|
||||
match chain.last().and_then(|g| self.group_fold(cat_name, g)) {
|
||||
Some(GroupFold::Collapsed) => true,
|
||||
Some(GroupFold::Expanded(_)) => false,
|
||||
None => self.inherits_collapsed(cat_name, chain),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
|
||||
self.collapsed_groups
|
||||
.get(cat_name)
|
||||
.map(|s| s.contains(group_name))
|
||||
.unwrap_or(false)
|
||||
/// The answer without the group's own fold: open when the category's depth or
|
||||
/// an ancestor's `expanded(N)` reaches it, closed otherwise.
|
||||
fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool {
|
||||
let depth = chain.len().saturating_sub(1);
|
||||
let by_ancestor = chain[..depth].iter().enumerate().any(|(at, ancestor)| {
|
||||
matches!(self.group_fold(cat_name, ancestor),
|
||||
Some(GroupFold::Expanded(n)) if depth - at < n.get())
|
||||
});
|
||||
!by_ancestor && depth >= self.expand_depth(cat_name)
|
||||
}
|
||||
|
||||
/// The fold this view holds for one group, if any.
|
||||
pub fn group_fold(&self, cat_name: &str, group_name: &str) -> Option<GroupFold> {
|
||||
self.group_folds.get(cat_name)?.get(group_name).copied()
|
||||
}
|
||||
|
||||
/// Record a fold as given — what a file's `collapsed:`/`expanded:` says.
|
||||
pub fn set_group_fold(&mut self, cat_name: &str, group_name: &str, fold: GroupFold) {
|
||||
self.group_folds
|
||||
.entry(cat_name.to_string())
|
||||
.or_default()
|
||||
.insert(group_name.to_string(), fold);
|
||||
}
|
||||
|
||||
/// Flip the group ending `chain`: `z`. It writes `Collapsed` or `Expanded(1)`
|
||||
/// — or nothing, where the flip lands on what the group inherits (§19).
|
||||
pub fn toggle_group_collapse(&mut self, cat_name: &str, chain: &[&str]) {
|
||||
let Some(group) = chain.last() else {
|
||||
return;
|
||||
};
|
||||
let collapse = !self.is_group_collapsed(cat_name, chain);
|
||||
match collapse == self.inherits_collapsed(cat_name, chain) {
|
||||
true => {
|
||||
if let Some(folds) = self.group_folds.get_mut(cat_name) {
|
||||
folds.remove(*group);
|
||||
if folds.is_empty() {
|
||||
self.group_folds.remove(cat_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
false => {
|
||||
let fold = match collapse {
|
||||
true => GroupFold::Collapsed,
|
||||
false => GroupFold::Expanded(NonZeroUsize::MIN),
|
||||
};
|
||||
self.set_group_fold(cat_name, group, fold);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
|
||||
@@ -292,9 +385,18 @@ impl View {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{View, unused_view_name};
|
||||
use super::{GroupFold, View, unused_view_name};
|
||||
use crate::model::category::CategoryKind;
|
||||
use crate::view::Axis;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
#[test]
|
||||
fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() {
|
||||
let mut v = View::new("Default");
|
||||
assert_eq!(v.display(), "Default");
|
||||
v.set_display("Overview");
|
||||
assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview"));
|
||||
}
|
||||
|
||||
/// improvise-vw47: the count is the starting guess, never the answer.
|
||||
/// What a taken name costs: `context/test-evidence/core.md`.
|
||||
@@ -563,28 +665,87 @@ mod tests {
|
||||
assert_eq!(v.page_selection("Region"), None);
|
||||
}
|
||||
|
||||
/// The user's ruling on improvise-felo.6: every group starts closed.
|
||||
#[test]
|
||||
fn toggle_group_collapse_toggles_twice() {
|
||||
fn a_group_starts_collapsed_at_every_depth() {
|
||||
let v = View::new("Test");
|
||||
assert!(v.is_group_collapsed("Time", &["2024"]));
|
||||
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
|
||||
}
|
||||
|
||||
/// `expanded(1): Time` opens the roots and nothing below them.
|
||||
#[test]
|
||||
fn a_category_depth_opens_its_top_levels() {
|
||||
let mut v = View::new("Test");
|
||||
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||
v.toggle_group_collapse("Time", "Q1");
|
||||
assert!(v.is_group_collapsed("Time", "Q1"));
|
||||
v.toggle_group_collapse("Time", "Q1");
|
||||
assert!(!v.is_group_collapsed("Time", "Q1"));
|
||||
v.set_expand_depth("Time", 1);
|
||||
assert!(!v.is_group_collapsed("Time", &["2024"]));
|
||||
assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"]));
|
||||
assert!(v.is_group_collapsed("Other", &["G"]), "per category");
|
||||
v.set_expand_depth("Time", 0);
|
||||
assert!(v.expand_depth.is_empty(), "0 is the absence, not a setting");
|
||||
}
|
||||
|
||||
/// `expanded(2): Time/2024` opens 2024 and one level beneath it, no deeper,
|
||||
/// and nothing of the sibling year.
|
||||
#[test]
|
||||
fn a_groups_depth_opens_it_and_n_minus_one_levels_beneath() {
|
||||
let mut v = View::new("Test");
|
||||
v.set_group_fold(
|
||||
"Time",
|
||||
"2024",
|
||||
GroupFold::Expanded(NonZeroUsize::new(2).unwrap()),
|
||||
);
|
||||
assert!(!v.is_group_collapsed("Time", &["2024"]));
|
||||
assert!(!v.is_group_collapsed("Time", &["2024", "Q1"]));
|
||||
assert!(v.is_group_collapsed("Time", &["2024", "Q1", "Jan"]));
|
||||
assert!(v.is_group_collapsed("Time", &["2025", "Q1"]));
|
||||
}
|
||||
|
||||
/// A group's own `collapsed:` wins over an `expanded(N)` that reaches it.
|
||||
#[test]
|
||||
fn a_collapsed_group_inside_an_expanded_region_stays_collapsed() {
|
||||
let mut v = View::new("Test");
|
||||
v.set_expand_depth("Time", 3);
|
||||
v.set_group_fold("Time", "Q1", GroupFold::Collapsed);
|
||||
assert!(v.is_group_collapsed("Time", &["2024", "Q1"]));
|
||||
assert!(!v.is_group_collapsed("Time", &["2024", "Q2"]));
|
||||
}
|
||||
|
||||
/// `z` writes `Expanded(1)` or `Collapsed`, in BOTH directions, and
|
||||
/// toggling back leaves no fold behind.
|
||||
#[test]
|
||||
fn a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it() {
|
||||
let mut v = View::new("Test");
|
||||
v.set_expand_depth("Time", 1);
|
||||
v.toggle_group_collapse("Time", &["2024", "Q1-2024"]);
|
||||
assert_eq!(
|
||||
v.group_fold("Time", "Q1-2024"),
|
||||
Some(GroupFold::Expanded(NonZeroUsize::MIN))
|
||||
);
|
||||
v.toggle_group_collapse("Time", &["2024"]);
|
||||
assert_eq!(v.group_fold("Time", "2024"), Some(GroupFold::Collapsed));
|
||||
assert!(
|
||||
!v.is_group_collapsed("Time", &["2025"]),
|
||||
"a sibling is untouched"
|
||||
);
|
||||
v.toggle_group_collapse("Time", &["2024", "Q1-2024"]);
|
||||
v.toggle_group_collapse("Time", &["2024"]);
|
||||
assert!(v.group_folds.is_empty(), "{:?}", v.group_folds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_group_collapsed_isolated_across_categories() {
|
||||
let mut v = View::new("Test");
|
||||
v.toggle_group_collapse("Cat1", "G1");
|
||||
assert!(!v.is_group_collapsed("Cat2", "G1"));
|
||||
v.toggle_group_collapse("Cat1", &["G1"]);
|
||||
assert!(!v.is_group_collapsed("Cat1", &["G1"]));
|
||||
assert!(v.is_group_collapsed("Cat2", &["G1"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_group_collapsed_isolated_across_groups() {
|
||||
let mut v = View::new("Test");
|
||||
v.toggle_group_collapse("Cat1", "G1");
|
||||
assert!(!v.is_group_collapsed("Cat1", "G2"));
|
||||
v.toggle_group_collapse("Cat1", &["G1"]);
|
||||
assert!(v.is_group_collapsed("Cat1", &["G2"]));
|
||||
}
|
||||
|
||||
/// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed`
|
||||
@@ -593,12 +754,13 @@ mod tests {
|
||||
fn on_category_removed_forgets_a_collapsed_group() {
|
||||
let mut v = View::new("V");
|
||||
v.on_category_added("Month", CategoryKind::of_name("Month"));
|
||||
v.toggle_group_collapse("Month", "Q1");
|
||||
v.set_expand_depth("Month", 1);
|
||||
v.toggle_group_collapse("Month", &["Q1"]);
|
||||
v.on_category_removed("Month");
|
||||
v.on_category_added("Month", CategoryKind::of_name("Month"));
|
||||
assert!(
|
||||
!v.is_group_collapsed("Month", "Q1"),
|
||||
"a re-added category came back with its group collapsed"
|
||||
v.group_folds.is_empty() && v.expand_depth.is_empty(),
|
||||
"a re-added category came back with its old fold state"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -808,10 +970,10 @@ mod prop_tests {
|
||||
group in "[A-Za-z][a-z]{1,7}",
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
let initial = v.is_group_collapsed(&cat, &group);
|
||||
v.toggle_group_collapse(&cat, &group);
|
||||
v.toggle_group_collapse(&cat, &group);
|
||||
prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
|
||||
let initial = v.is_group_collapsed(&cat, &[group.as_str()]);
|
||||
v.toggle_group_collapse(&cat, &[group.as_str()]);
|
||||
v.toggle_group_collapse(&cat, &[group.as_str()]);
|
||||
prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,26 @@ pub enum DateComponent {
|
||||
Quarter,
|
||||
}
|
||||
|
||||
impl DateComponent {
|
||||
/// The components as a hierarchy nests them, coarsest first.
|
||||
pub const COARSE_TO_FINE: [DateComponent; 3] = [
|
||||
DateComponent::Year,
|
||||
DateComponent::Quarter,
|
||||
DateComponent::Month,
|
||||
];
|
||||
}
|
||||
|
||||
/// How a date column's components become categories (improvise-8jk6.3).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DateShape {
|
||||
/// ONE category named after the column: its items are the finest chosen
|
||||
/// component, grouped by the coarser ones. The user's default.
|
||||
#[default]
|
||||
Hierarchy,
|
||||
/// The column's raw values, plus one flat category per chosen component.
|
||||
Split,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FieldProposal {
|
||||
pub field: String,
|
||||
@@ -32,6 +52,8 @@ pub struct FieldProposal {
|
||||
pub date_format: Option<String>,
|
||||
/// Which date components to extract as new categories.
|
||||
pub date_components: Vec<DateComponent>,
|
||||
/// Whether those components nest in one category or split into several.
|
||||
pub date_shape: DateShape,
|
||||
}
|
||||
|
||||
impl FieldProposal {
|
||||
@@ -132,6 +154,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
date_shape: DateShape::default(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +182,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
accepted: true,
|
||||
date_format,
|
||||
date_components: vec![],
|
||||
date_shape: DateShape::default(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +194,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
date_shape: DateShape::default(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +205,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
date_shape: DateShape::default(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,6 +217,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
||||
accepted: true,
|
||||
date_format: None,
|
||||
date_components: vec![],
|
||||
date_shape: DateShape::default(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, ensure};
|
||||
use csv::ReaderBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::storage::{PhysicalFs, Storage};
|
||||
|
||||
/// Parsed CSV records. Every value is its source text, so they reach an import
|
||||
/// only through `ImportPipeline::from_csv` (improvise-8jk6.4).
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct CsvRecords(Vec<Value>);
|
||||
|
||||
impl std::ops::Deref for CsvRecords {
|
||||
type Target = [Value];
|
||||
fn deref(&self) -> &[Value] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl CsvRecords {
|
||||
pub(crate) fn into_values(self) -> Vec<Value> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn csv_path_p(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"))
|
||||
}
|
||||
|
||||
/// Parse a CSV file into a `serde_json::Value` array. Short-row warnings go to
|
||||
/// stderr; [`parse_csv_with_warnings`] captures them instead.
|
||||
pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
/// Parse a CSV file into records. Short-row warnings go to stderr;
|
||||
/// [`parse_csv_with_warnings`] captures them instead.
|
||||
pub fn parse_csv(path: &Path) -> Result<CsvRecords> {
|
||||
let (records, warnings) = parse_csv_with_warnings(path)?;
|
||||
for warning in &warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
@@ -22,7 +42,7 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
|
||||
|
||||
/// Parse a CSV file, returning records plus warnings. A short row keeps every
|
||||
/// header column, missing ones `Null`, each reported with a row count.
|
||||
pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec<Value>, Vec<String>)> {
|
||||
pub fn parse_csv_with_warnings(path: &Path) -> Result<(CsvRecords, Vec<String>)> {
|
||||
let bytes = std::fs::read(path)
|
||||
.with_context(|| format!("Failed to open CSV file: {}", path.display()))?;
|
||||
parse_csv_bytes_with_warnings(&bytes)
|
||||
@@ -30,7 +50,13 @@ pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec<Value>, Vec<String>)>
|
||||
|
||||
/// As [`parse_csv_with_warnings`] but over bytes, so import can come from any
|
||||
/// [`Storage`](crate::storage::Storage) backend, not only a file (improvise-fuj).
|
||||
pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec<Value>, Vec<String>)> {
|
||||
pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(CsvRecords, Vec<String>)> {
|
||||
let (_, records, warnings) = parse_csv_bytes(bytes)?;
|
||||
Ok((CsvRecords(records), warnings))
|
||||
}
|
||||
|
||||
/// The header (after renaming), the records and the warnings.
|
||||
fn parse_csv_bytes(bytes: &[u8]) -> Result<(Vec<String>, Vec<Value>, Vec<String>)> {
|
||||
let mut reader = ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.flexible(true)
|
||||
@@ -110,7 +136,7 @@ pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec<Value>, Vec<St
|
||||
}),
|
||||
);
|
||||
|
||||
Ok((records, warnings))
|
||||
Ok((headers, records, warnings))
|
||||
}
|
||||
|
||||
/// A header name keys the record object, so two equal names were one column and
|
||||
@@ -144,8 +170,42 @@ fn disambiguate_headers(raw: Vec<String>) -> (Vec<String>, Vec<String>) {
|
||||
|
||||
/// Parse multiple CSV files and merge into a single JSON array.
|
||||
/// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv").
|
||||
pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<Vec<Value>> {
|
||||
let mut all_records = Vec::new();
|
||||
pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<CsvRecords> {
|
||||
let merged = merge_csvs_from(&PhysicalFs, paths)?;
|
||||
for warning in &merged.warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
Ok(merged.records)
|
||||
}
|
||||
|
||||
/// The category every merged record names its source file by.
|
||||
pub const FILE_CATEGORY: &str = "File";
|
||||
|
||||
/// One merged file: its `File` item and the columns its header named.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourceFile {
|
||||
pub item: String,
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
|
||||
/// Several CSV files as one record set, with each file's own columns kept.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Merged {
|
||||
pub records: CsvRecords,
|
||||
/// One per distinct `File` item, in the order the files were read.
|
||||
pub sources: Vec<SourceFile>,
|
||||
/// Each file's parse warnings, prefixed with its path.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// As [`merge_csvs`] over any [`Storage`]. Files may have different headers,
|
||||
/// and two files with one stem share its item and its columns (improvise-lpzu).
|
||||
pub fn merge_csvs_from(fs: &dyn Storage, paths: &[impl AsRef<Path>]) -> Result<Merged> {
|
||||
let mut merged = Merged {
|
||||
records: CsvRecords::default(),
|
||||
sources: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
for path in paths {
|
||||
let path = path.as_ref();
|
||||
let stem = path
|
||||
@@ -153,37 +213,104 @@ pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<Vec<Value>> {
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let records = parse_csv(path)?;
|
||||
let bytes = fs.read(path)?;
|
||||
let (headers, records, warnings) = parse_csv_bytes(&bytes)
|
||||
.with_context(|| format!("Failed to parse CSV file: {}", path.display()))?;
|
||||
let at = merged.source_for(&stem);
|
||||
let columns = &mut merged.sources[at].columns;
|
||||
for header in headers {
|
||||
if header != FILE_CATEGORY && !columns.contains(&header) {
|
||||
columns.push(header);
|
||||
}
|
||||
}
|
||||
merged
|
||||
.warnings
|
||||
.extend(warnings.iter().map(|w| format!("{}: {w}", path.display())));
|
||||
for mut record in records {
|
||||
if let Value::Object(ref mut map) = record {
|
||||
map.insert("File".to_string(), Value::String(stem.clone()));
|
||||
map.insert(FILE_CATEGORY.to_string(), Value::String(stem.clone()));
|
||||
}
|
||||
all_records.push(record);
|
||||
merged.records.0.push(record);
|
||||
}
|
||||
}
|
||||
Ok(all_records)
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
impl Merged {
|
||||
fn source_for(&mut self, item: &str) -> usize {
|
||||
match self.sources.iter().position(|s| s.item == item) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
self.sources.push(SourceFile {
|
||||
item: item.to_string(),
|
||||
columns: vec![],
|
||||
});
|
||||
self.sources.len() - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `improvise import` argument. Whether a path names a folder is the
|
||||
/// host's question, so the caller classifies and [`expand_import_paths`] lists.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ImportPath {
|
||||
File(PathBuf),
|
||||
Folder(PathBuf),
|
||||
}
|
||||
|
||||
/// The files an import reads: each argument in turn, a folder standing for its
|
||||
/// `*.csv` documents in name order. See `context/code/formula-and-io.md`.
|
||||
pub fn expand_import_paths(fs: &dyn Storage, args: &[ImportPath]) -> Result<Vec<PathBuf>> {
|
||||
let mut paths = Vec::new();
|
||||
for arg in args {
|
||||
match arg {
|
||||
ImportPath::File(path) => paths.push(path.clone()),
|
||||
ImportPath::Folder(dir) => paths.extend(csv_documents_in(fs, dir)?),
|
||||
}
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
/// A folder's `*.csv` documents, sorted by `Storage::list`'s contract. A folder
|
||||
/// with none is refused by name rather than imported as nothing.
|
||||
fn csv_documents_in(fs: &dyn Storage, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let csvs: Vec<PathBuf> = fs
|
||||
.list(dir)?
|
||||
.into_iter()
|
||||
.filter(|p| csv_path_p(p))
|
||||
.collect();
|
||||
ensure!(!csvs.is_empty(), "no CSV files in folder {}", dir.display());
|
||||
Ok(csvs)
|
||||
}
|
||||
|
||||
/// A field is its source text: which ones are numbers is decided by the role
|
||||
/// the import gives the column (improvise-8jk6.4, `context/code/formula-and-io.md`).
|
||||
fn parse_csv_field(field: &str) -> Value {
|
||||
if field.is_empty() {
|
||||
return Value::Null;
|
||||
match field.is_empty() {
|
||||
true => Value::Null,
|
||||
false => Value::String(field.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as number (integer or float)
|
||||
if let Ok(num) = field.parse::<i64>() {
|
||||
return Value::Number(serde_json::Number::from(num));
|
||||
/// The number a CSV field's text reads as, where a number is wanted.
|
||||
pub fn number_in(text: &str) -> Option<serde_json::Number> {
|
||||
if let Ok(num) = text.parse::<i64>() {
|
||||
return Some(serde_json::Number::from(num));
|
||||
}
|
||||
|
||||
// NaN and the infinities have no JSON number. A fabricated 0 is an invented
|
||||
// measurement (improvise-jc95), so the token falls through and stays text.
|
||||
if let Ok(num) = field.parse::<f64>()
|
||||
&& let Some(number) = serde_json::Number::from_f64(num)
|
||||
{
|
||||
return Value::Number(number);
|
||||
}
|
||||
// measurement (improvise-jc95), so the token stays text.
|
||||
text.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(serde_json::Number::from_f64)
|
||||
}
|
||||
|
||||
// Otherwise treat as string
|
||||
Value::String(field.to_string())
|
||||
/// A CSV value as a typed JSON value: numeric text becomes its number.
|
||||
pub fn typed(value: &Value) -> Value {
|
||||
match value.as_str().and_then(number_in) {
|
||||
Some(number) => Value::Number(number),
|
||||
None => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -208,8 +335,9 @@ mod tests {
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
|
||||
assert_eq!(records[0]["Product"], Value::String("Shirts".to_string()));
|
||||
assert_eq!(records[0]["Revenue"], Value::String("1000".to_string()));
|
||||
assert_eq!(
|
||||
records[0]["Revenue"],
|
||||
typed(&records[0]["Revenue"]),
|
||||
Value::Number(serde_json::Number::from(1000))
|
||||
);
|
||||
}
|
||||
@@ -221,9 +349,10 @@ mod tests {
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 2);
|
||||
assert!(records[0]["Revenue"].is_f64());
|
||||
assert_eq!(records[0]["Revenue"], Value::String("1000.50".to_string()));
|
||||
assert!(typed(&records[0]["Revenue"]).is_f64());
|
||||
assert_eq!(
|
||||
records[0]["Revenue"],
|
||||
typed(&records[0]["Revenue"]),
|
||||
Value::Number(serde_json::Number::from_f64(1000.50).unwrap())
|
||||
);
|
||||
}
|
||||
@@ -261,10 +390,10 @@ mod tests {
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Name"], Value::String("Widget".to_string()));
|
||||
assert_eq!(
|
||||
records[0]["Count"],
|
||||
typed(&records[0]["Count"]),
|
||||
Value::Number(serde_json::Number::from(5))
|
||||
);
|
||||
assert!(records[0]["Price"].is_f64());
|
||||
assert!(typed(&records[0]["Price"]).is_f64());
|
||||
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
|
||||
}
|
||||
|
||||
@@ -284,10 +413,7 @@ mod tests {
|
||||
assert_eq!(records[3]["File"], Value::String("expenses".to_string()));
|
||||
// Original fields preserved
|
||||
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
|
||||
assert_eq!(
|
||||
records[2]["Revenue"],
|
||||
Value::Number(serde_json::Number::from(50))
|
||||
);
|
||||
assert_eq!(records[2]["Revenue"], Value::String("50".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,6 +428,118 @@ mod tests {
|
||||
assert_eq!(records[0]["Name"], Value::String("A".to_string()));
|
||||
}
|
||||
|
||||
// ── Folder arguments (improvise-lpzu) ─────────────────────────────
|
||||
|
||||
use crate::storage::MemoryFs;
|
||||
|
||||
fn folder(files: &[(&str, &str)]) -> MemoryFs {
|
||||
let fs = MemoryFs::new();
|
||||
for (path, body) in files {
|
||||
fs.write(Path::new(path), body.as_bytes()).unwrap();
|
||||
}
|
||||
fs
|
||||
}
|
||||
|
||||
fn names(paths: &[PathBuf]) -> Vec<String> {
|
||||
paths.iter().map(|p| p.display().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest() {
|
||||
let fs = folder(&[
|
||||
("maat/coupling.csv", "entity\n"),
|
||||
("maat/notes.txt", "not data"),
|
||||
("maat/age.CSV", "entity\n"),
|
||||
("maat/out.improv", "v2025-04-09\n"),
|
||||
("maat/abs-churn.csv", "date\n"),
|
||||
("elsewhere/other.csv", "x\n"),
|
||||
]);
|
||||
let paths = expand_import_paths(&fs, &[ImportPath::Folder("maat".into())]).unwrap();
|
||||
assert_eq!(
|
||||
names(&paths),
|
||||
["maat/abs-churn.csv", "maat/age.CSV", "maat/coupling.csv"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn a_folder_with_no_csv_files_is_refused_by_name() {
|
||||
let only_text = folder(&[("maat/notes.txt", "x"), ("maat/out.improv", "y")]);
|
||||
let empty = MemoryFs::new();
|
||||
for fs in [&only_text, &empty] {
|
||||
let err = expand_import_paths(fs, &[ImportPath::Folder("maat".into())])
|
||||
.expect_err("a folder with nothing to import is refused");
|
||||
assert_eq!(err.to_string(), "no CSV files in folder maat");
|
||||
}
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn files_and_folders_expand_in_argument_order() {
|
||||
let fs = folder(&[
|
||||
("b/two.csv", "x\n"),
|
||||
("b/one.csv", "x\n"),
|
||||
("a.csv", "x\n"),
|
||||
("z.csv", "x\n"),
|
||||
]);
|
||||
let args = [
|
||||
ImportPath::File("z.csv".into()),
|
||||
ImportPath::Folder("b".into()),
|
||||
ImportPath::File("a.csv".into()),
|
||||
];
|
||||
let paths = expand_import_paths(&fs, &args).unwrap();
|
||||
assert_eq!(names(&paths), ["z.csv", "b/one.csv", "b/two.csv", "a.csv"]);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn files_with_different_headers_merge_without_inventing_the_missing_columns() {
|
||||
let fs = folder(&[
|
||||
(
|
||||
"m/coupling.csv",
|
||||
"entity,coupled,degree\nsrc/a.rs,src/b.rs,80\n",
|
||||
),
|
||||
("m/age.csv", "entity,age-months\nsrc/a.rs,2\n"),
|
||||
]);
|
||||
let merged = merge_csvs_from(&fs, &["m/coupling.csv", "m/age.csv"]).unwrap();
|
||||
assert!(merged.warnings.is_empty(), "{:?}", merged.warnings);
|
||||
let source = |item: &str, columns: &[&str]| SourceFile {
|
||||
item: item.to_string(),
|
||||
columns: columns.iter().map(|c| c.to_string()).collect(),
|
||||
};
|
||||
assert_eq!(
|
||||
merged.sources,
|
||||
[
|
||||
source("coupling", &["entity", "coupled", "degree"]),
|
||||
source("age", &["entity", "age-months"]),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
*merged.records,
|
||||
[
|
||||
serde_json::json!({"entity": "src/a.rs", "coupled": "src/b.rs", "degree": "80", "File": "coupling"}),
|
||||
serde_json::json!({"entity": "src/a.rs", "age-months": "2", "File": "age"}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-8jk6.12 (3): two files with one stem are one item, and its
|
||||
/// columns are both files'. Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn two_files_with_one_stem_union_their_columns() {
|
||||
let fs = folder(&[
|
||||
("a/x.csv", "entity,one\ne,1\n"),
|
||||
("b/x.csv", "entity,two\ne,2\n"),
|
||||
]);
|
||||
let merged = merge_csvs_from(&fs, &["a/x.csv", "b/x.csv"]).unwrap();
|
||||
let expected = SourceFile {
|
||||
item: "x".to_string(),
|
||||
columns: ["entity", "one", "two"].map(String::from).to_vec(),
|
||||
};
|
||||
assert_eq!(merged.sources, [expected]);
|
||||
}
|
||||
|
||||
// ── RFC 4180 edge cases ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -442,10 +680,7 @@ mod tests {
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
|
||||
assert_eq!(
|
||||
records[0]["Revenue"],
|
||||
Value::Number(serde_json::Number::from(1))
|
||||
);
|
||||
assert_eq!(records[0]["Revenue"], Value::String("1".to_string()));
|
||||
assert_eq!(records[1]["Region"], Value::String("West".to_string()));
|
||||
|
||||
let (control, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEa\rst,1\n").unwrap();
|
||||
@@ -500,7 +735,8 @@ mod tests {
|
||||
token.parse::<f64>().is_ok_and(|n| !n.is_finite()),
|
||||
"precondition: {token} parses as a non-finite f64"
|
||||
);
|
||||
assert_eq!(parse_csv_field(token), Value::String(token.to_string()));
|
||||
let text = Value::String(token.to_string());
|
||||
assert_eq!(typed(&text), text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,10 +745,10 @@ mod tests {
|
||||
#[test]
|
||||
fn an_ordinary_float_is_still_a_number() {
|
||||
assert_eq!(
|
||||
parse_csv_field("1000.50"),
|
||||
typed(&Value::String("1000.50".into())),
|
||||
Value::Number(serde_json::Number::from_f64(1000.50).unwrap())
|
||||
);
|
||||
assert_eq!(parse_csv_field("0"), Value::Number(0.into()));
|
||||
assert_eq!(typed(&Value::String("0".into())), Value::Number(0.into()));
|
||||
}
|
||||
|
||||
/// improvise-5hfh: two equal header names are one JSON key, so the later
|
||||
@@ -521,9 +757,9 @@ mod tests {
|
||||
fn a_repeated_header_is_renamed_rather_than_overwriting_its_twin() {
|
||||
let (records, warnings) = parse_csv_bytes_with_warnings(b"A,B,A\n1,2,3\n").unwrap();
|
||||
let map = records[0].as_object().unwrap();
|
||||
assert_eq!(map["A"], Value::Number(1.into()));
|
||||
assert_eq!(map["B"], Value::Number(2.into()));
|
||||
assert_eq!(map["A_2"], Value::Number(3.into()));
|
||||
assert_eq!(map["A"], Value::String("1".into()));
|
||||
assert_eq!(map["B"], Value::String("2".into()));
|
||||
assert_eq!(map["A_2"], Value::String("3".into()));
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("'A'") && warnings[0].contains("'A_2'"));
|
||||
assert!(warnings[0].contains("column 3"), "{}", warnings[0]);
|
||||
@@ -536,8 +772,8 @@ mod tests {
|
||||
let (records, _) = parse_csv_bytes_with_warnings(b"A,A_2,A\n1,2,3\n").unwrap();
|
||||
let map = records[0].as_object().unwrap();
|
||||
assert_eq!(map.len(), 3, "{map:?}");
|
||||
assert_eq!(map["A_2"], Value::Number(2.into()));
|
||||
assert_eq!(map["A_3"], Value::Number(3.into()));
|
||||
assert_eq!(map["A_2"], Value::String("2".into()));
|
||||
assert_eq!(map["A_3"], Value::String("3".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -572,8 +808,9 @@ mod tests {
|
||||
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Date"], Value::String("03/31/2026".to_string()));
|
||||
assert_eq!(records[0]["Amount"], Value::String("-50.00".to_string()));
|
||||
assert_eq!(
|
||||
records[0]["Amount"],
|
||||
typed(&records[0]["Amount"]),
|
||||
Value::Number(serde_json::Number::from_f64(-50.00).unwrap())
|
||||
);
|
||||
assert_eq!(records[0]["Flag"], Value::String("*".to_string()));
|
||||
@@ -583,7 +820,7 @@ mod tests {
|
||||
Value::String("VENMO PAYMENT 260331".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
records[1]["Amount"],
|
||||
typed(&records[1]["Amount"]),
|
||||
Value::Number(serde_json::Number::from_f64(-240.00).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,9 @@ pub fn export_csv_to(
|
||||
storage.write(path, &render_csv(workbook, view_name)?)
|
||||
}
|
||||
|
||||
/// Render a view as CSV bytes. Separated from writing so the bytes can go to
|
||||
/// any [`Storage`] backend, not just a file (improvise-fuj).
|
||||
fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
/// Render a view as CSV bytes for any [`Storage`] backend or a download frame
|
||||
/// (improvise-fuj, improvise-2mqz).
|
||||
pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
let view = workbook
|
||||
.views
|
||||
.get(view_name)
|
||||
@@ -214,6 +214,7 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] {
|
||||
wb.model.set_cell(
|
||||
@@ -226,7 +227,7 @@ mod tests {
|
||||
v.set_axis("Month", Axis::Row);
|
||||
v.set_axis("Type", Axis::Column);
|
||||
v.prune_empty = true;
|
||||
v.toggle_group_collapse("Month", "Q1");
|
||||
v.toggle_group_collapse("Month", &["Q1"]);
|
||||
}
|
||||
let csv = String::from_utf8(super::render_csv(&wb, "Default").unwrap()).unwrap();
|
||||
let rows: Vec<&str> = csv.lines().skip(1).collect();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::model::category::CategoryKind;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
use crate::view::{Axis, GroupFold};
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
// The one home of the pipe-quoting rule (improvise-i2m5); a name written here
|
||||
@@ -119,19 +119,37 @@ pub fn format_md(workbook: &Workbook) -> String {
|
||||
if view.prune_empty {
|
||||
w!(out, "prune: true");
|
||||
}
|
||||
for (prefix, map) in [
|
||||
("hidden", &view.hidden_items),
|
||||
("collapsed", &view.collapsed_groups),
|
||||
] {
|
||||
let mut pairs: Vec<_> = map
|
||||
.iter()
|
||||
.flat_map(|(cat, items)| {
|
||||
items.iter().map(move |item| (cat.as_str(), item.as_str()))
|
||||
})
|
||||
.collect();
|
||||
pairs.sort();
|
||||
for (cat, item) in pairs {
|
||||
w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item));
|
||||
let mut hidden: Vec<_> = view
|
||||
.hidden_items
|
||||
.iter()
|
||||
.flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str())))
|
||||
.collect();
|
||||
hidden.sort();
|
||||
for (cat, item) in hidden {
|
||||
w!(out, "hidden: {}/{}", quote_name(cat), quote_name(item));
|
||||
}
|
||||
let mut depths: Vec<_> = view.expand_depth.iter().collect();
|
||||
depths.sort();
|
||||
// Never bare: `expanded: row` would read back as an axis line.
|
||||
for (cat, depth) in depths {
|
||||
w!(out, "expanded({depth}): {}", quote_name(cat));
|
||||
}
|
||||
let mut folds: Vec<_> = view
|
||||
.group_folds
|
||||
.iter()
|
||||
.flat_map(|(cat, groups)| {
|
||||
groups
|
||||
.iter()
|
||||
.map(move |(g, fold)| (cat.as_str(), g.as_str(), *fold))
|
||||
})
|
||||
.collect();
|
||||
folds.sort();
|
||||
for (cat, group, fold) in folds {
|
||||
let target = format!("{}/{}", quote_name(cat), quote_name(group));
|
||||
match fold {
|
||||
GroupFold::Collapsed => w!(out, "collapsed: {target}"),
|
||||
GroupFold::Expanded(n) if n.get() == 1 => w!(out, "expanded: {target}"),
|
||||
GroupFold::Expanded(n) => w!(out, "expanded({n}): {target}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,7 +388,8 @@ mod tests {
|
||||
#[test]
|
||||
fn format_md_collapsed_group() {
|
||||
let mut m = two_cat_model();
|
||||
m.active_view_mut().toggle_group_collapse("Type", "MyGroup");
|
||||
m.active_view_mut()
|
||||
.set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed);
|
||||
let text = format_md(&m);
|
||||
assert!(text.contains("collapsed: Type/MyGroup"));
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ view_section = {
|
||||
view_entry*
|
||||
}
|
||||
|
||||
view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line }
|
||||
// `axis_line` is tried before `expanded_line`, so a category named `expanded`
|
||||
// keeps its `expanded: row`; the writer never spells a category depth bare.
|
||||
view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line | expanded_line }
|
||||
|
||||
axis_line = {
|
||||
name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines
|
||||
@@ -135,6 +137,12 @@ prune_line = { "prune: " ~ bool_kw ~ NEWLINE ~ blank_lines }
|
||||
bool_kw = @{ "true" | "false" }
|
||||
hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
|
||||
collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
|
||||
// Groups start collapsed. `expanded(N): Cat` opens the category's top N levels;
|
||||
// `expanded(N): Cat/Group` opens the group and N-1 levels beneath it. A bare
|
||||
// `expanded:` is N = 1, and N is at least 1. context/code/formula-and-io.md.
|
||||
expanded_line = { "expanded" ~ fold_depth? ~ ": " ~ name ~ ("/" ~ name)? ~ NEWLINE ~ blank_lines }
|
||||
fold_depth = { "(" ~ depth_number ~ ")" }
|
||||
depth_number = @{ '1'..'9' ~ ASCII_DIGIT* }
|
||||
|
||||
// ── Names ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -36,7 +36,7 @@ mod test_helpers {
|
||||
}
|
||||
}
|
||||
|
||||
pub use export::{export_csv, export_csv_to};
|
||||
pub use export::{export_csv, export_csv_to, render_csv};
|
||||
pub use format::format_md;
|
||||
pub use parse::parse_md;
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ use pest_derive::Parser;
|
||||
use crate::formula::{AggFunc, parse_formula};
|
||||
use crate::model::category::{CategoryKind, Group};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
use crate::view::{Axis, GroupFold};
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
use crate::formula::quote::unescape_pipe_body;
|
||||
use crate::formula::quote::{quote_name, unescape_pipe_body};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[grammar = "persistence/improv.pest"]
|
||||
@@ -40,9 +40,74 @@ struct PView {
|
||||
format: String,
|
||||
prune_empty: bool,
|
||||
hidden: Vec<(String, String)>,
|
||||
collapsed: Vec<(String, String)>,
|
||||
folds: Vec<PFold>,
|
||||
}
|
||||
|
||||
/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category
|
||||
/// form, which only `expanded` has.
|
||||
struct PFold {
|
||||
category: String,
|
||||
group: Option<String>,
|
||||
fold: GroupFold,
|
||||
}
|
||||
|
||||
/// A file the grammar accepts and a model cannot hold (improvise-felo.6).
|
||||
/// Typed, so the generator's property can tell it from a parse failure.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// `> A[B]` and `> B[A]`: a hierarchy must be a forest.
|
||||
HierarchyCycle {
|
||||
category: String,
|
||||
groups: Vec<String>,
|
||||
},
|
||||
/// One view gives one group (or one category's depth) two different folds.
|
||||
FoldConflict {
|
||||
view: String,
|
||||
category: String,
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Refusal {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Refusal::HierarchyCycle { category, groups } => write!(
|
||||
f,
|
||||
"category {}: the group parents form a cycle through {}",
|
||||
quote_name(category),
|
||||
groups
|
||||
.iter()
|
||||
.map(|g| quote_name(g))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" > ")
|
||||
),
|
||||
Refusal::FoldConflict {
|
||||
view,
|
||||
category,
|
||||
group: Some(group),
|
||||
} => write!(
|
||||
f,
|
||||
"view {}: group {}/{} is both expanded and collapsed, or expanded to two depths",
|
||||
quote_name(view),
|
||||
quote_name(category),
|
||||
quote_name(group)
|
||||
),
|
||||
Refusal::FoldConflict {
|
||||
view,
|
||||
category,
|
||||
group: None,
|
||||
} => write!(
|
||||
f,
|
||||
"view {}: category {} is expanded to two depths",
|
||||
quote_name(view),
|
||||
quote_name(category)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refusal {}
|
||||
|
||||
/// Everything pass 1 collects, before any of it is resolved against the model.
|
||||
#[derive(Default)]
|
||||
struct ParsedFile {
|
||||
@@ -332,7 +397,31 @@ fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `## View: Name` plus its axis, format, hidden and collapsed lines.
|
||||
/// `expanded(N): Cat` or `expanded(N): Cat/Group`; a bare `expanded:` is N = 1.
|
||||
fn walk_expanded_line(entry: Pair<'_, Rule>) -> Result<PFold> {
|
||||
let mut depth = std::num::NonZeroUsize::MIN;
|
||||
let mut names = Vec::new();
|
||||
for part in entry.into_inner() {
|
||||
match part.as_rule() {
|
||||
Rule::fold_depth => {
|
||||
let digits = first_str(part)?;
|
||||
depth = digits
|
||||
.parse()
|
||||
.with_context(|| format!("expanded({digits}): depth out of range"))?;
|
||||
}
|
||||
_ => names.push(extract_name(part)?),
|
||||
}
|
||||
}
|
||||
let mut names = names.into_iter();
|
||||
let category = names.next().context("expanded_line: no category")?;
|
||||
Ok(PFold {
|
||||
category,
|
||||
group: names.next(),
|
||||
fold: GroupFold::Expanded(depth),
|
||||
})
|
||||
}
|
||||
|
||||
/// `## View: Name` plus its axis, format, hidden, collapsed and expanded lines.
|
||||
fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
|
||||
let mut inner = pair.into_inner();
|
||||
let mut pv = PView {
|
||||
@@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
|
||||
format: String::new(),
|
||||
prune_empty: false,
|
||||
hidden: Vec::new(),
|
||||
collapsed: Vec::new(),
|
||||
folds: Vec::new(),
|
||||
};
|
||||
for entry in inner {
|
||||
match entry.as_rule() {
|
||||
@@ -350,7 +439,15 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
|
||||
Rule::format_line => pv.format = first_str(entry)?,
|
||||
Rule::prune_line => pv.prune_empty = first_str(entry)? == "true",
|
||||
Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?),
|
||||
Rule::collapsed_line => pv.collapsed.push(extract_name_pair(entry)?),
|
||||
Rule::collapsed_line => {
|
||||
let (category, group) = extract_name_pair(entry)?;
|
||||
pv.folds.push(PFold {
|
||||
category,
|
||||
group: Some(group),
|
||||
fold: GroupFold::Collapsed,
|
||||
});
|
||||
}
|
||||
Rule::expanded_line => pv.folds.push(walk_expanded_line(entry)?),
|
||||
other => bail!("unhandled rule in view_section: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -411,6 +508,13 @@ fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> {
|
||||
None => cat.add_group(Group::new(group_name).with_parent(parent)),
|
||||
}
|
||||
}
|
||||
if let Some(cycle) = cat.hierarchy_cycle() {
|
||||
return Err(Refusal::HierarchyCycle {
|
||||
category: pc.name.clone(),
|
||||
groups: cycle.into_iter().map(String::from).collect(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -436,8 +540,29 @@ fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> {
|
||||
for (cat, item) in &pv.hidden {
|
||||
view.hide_item(cat, item);
|
||||
}
|
||||
for (cat, grp) in &pv.collapsed {
|
||||
view.toggle_group_collapse(cat, grp);
|
||||
for PFold {
|
||||
category,
|
||||
group,
|
||||
fold,
|
||||
} in &pv.folds
|
||||
{
|
||||
let conflict = || Refusal::FoldConflict {
|
||||
view: pv.name.clone(),
|
||||
category: category.clone(),
|
||||
group: group.clone(),
|
||||
};
|
||||
match (group, fold) {
|
||||
(Some(g), _) => match view.group_fold(category, g) {
|
||||
Some(held) if held != *fold => return Err(conflict().into()),
|
||||
_ => view.set_group_fold(category, g, *fold),
|
||||
},
|
||||
(None, GroupFold::Expanded(n)) => match view.expand_depth(category) {
|
||||
0 => view.set_expand_depth(category, n.get()),
|
||||
held if held != n.get() => return Err(conflict().into()),
|
||||
_ => {}
|
||||
},
|
||||
(None, GroupFold::Collapsed) => bail!("a category-wide collapse has no spelling"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -534,6 +659,7 @@ mod grammar_coverage {
|
||||
Rule::axis_line | Rule::axis_kind => "walk_axis_line",
|
||||
Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section",
|
||||
Rule::hidden_line | Rule::collapsed_line => "walk_view_section",
|
||||
Rule::expanded_line | Rule::fold_depth | Rule::depth_number => "walk_expanded_line",
|
||||
Rule::view_entry => "silent: hoisted into view_section",
|
||||
Rule::name => "silent: hoisted into its parent",
|
||||
Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name",
|
||||
@@ -1059,12 +1185,14 @@ mod parser_edge_cases {
|
||||
fn collapsed_group_with_slash_in_name() {
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Type").unwrap();
|
||||
m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2");
|
||||
m.active_view_mut()
|
||||
.set_group_fold("Type", "Q1/Q2", crate::view::GroupFold::Collapsed);
|
||||
|
||||
let text = format_md(&m);
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
assert!(
|
||||
loaded.active_view().is_group_collapsed("Type", "Q1/Q2"),
|
||||
loaded.active_view().group_fold("Type", "Q1/Q2")
|
||||
== Some(crate::view::GroupFold::Collapsed),
|
||||
"Collapsed group with '/' in name was corrupted.\n{text}"
|
||||
);
|
||||
}
|
||||
@@ -1604,6 +1732,24 @@ mod generator {
|
||||
})
|
||||
}
|
||||
|
||||
/// improvise-felo.6: the round-trip properties see `expanded(N):` lines only
|
||||
/// if the walk emits them, depths included.
|
||||
#[test]
|
||||
fn the_generator_writes_expanded_lines_with_and_without_a_depth() {
|
||||
use proptest::strategy::ValueTree;
|
||||
use proptest::test_runner::TestRunner;
|
||||
|
||||
let mut runner = TestRunner::deterministic();
|
||||
let strategy = improv_file();
|
||||
let files: Vec<String> = (0..512)
|
||||
.map(|_| strategy.new_tree(&mut runner).unwrap().current())
|
||||
.collect();
|
||||
let lines = || files.iter().flat_map(|f| f.lines());
|
||||
assert!(lines().any(|l| l.starts_with("expanded(")), "a depth");
|
||||
assert!(lines().any(|l| l.starts_with("expanded: ")), "a bare line");
|
||||
assert!(lines().any(|l| l.starts_with("collapsed: ")), "a collapse");
|
||||
}
|
||||
|
||||
/// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`.
|
||||
#[test]
|
||||
fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() {
|
||||
@@ -1625,7 +1771,7 @@ mod generator {
|
||||
|
||||
#[cfg(test)]
|
||||
mod grammar_prop_tests {
|
||||
use super::{generator, parse_md};
|
||||
use super::{Refusal, generator, parse_md};
|
||||
|
||||
use crate::persistence::format_md;
|
||||
use proptest::prelude::*;
|
||||
@@ -1633,11 +1779,13 @@ mod grammar_prop_tests {
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// parse(generate()) — every generated file parses without error.
|
||||
/// parse(generate()) — every generated file parses, or is refused for a
|
||||
/// named reason the grammar cannot express (a `Refusal`, improvise-felo.6).
|
||||
#[test]
|
||||
fn generated_file_parses(file in generator::improv_file()) {
|
||||
let result = parse_md(&file);
|
||||
prop_assert!(result.is_ok(),
|
||||
let refused = result.as_ref().err().is_some_and(|e| e.downcast_ref::<Refusal>().is_some());
|
||||
prop_assert!(result.is_ok() || refused,
|
||||
"Generated file failed to parse:\n{}\nError: {}",
|
||||
file, result.unwrap_err());
|
||||
}
|
||||
|
||||
@@ -267,10 +267,198 @@ fn parse_md_round_trips_text_cell_values() {
|
||||
#[test]
|
||||
fn parse_md_round_trips_collapsed_group() {
|
||||
let mut m = two_cat_model();
|
||||
m.active_view_mut().toggle_group_collapse("Type", "MyGroup");
|
||||
m.active_view_mut()
|
||||
.set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed);
|
||||
let text = format_md(&m);
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup"));
|
||||
assert!(
|
||||
loaded.active_view().group_fold("Type", "MyGroup")
|
||||
== Some(crate::view::GroupFold::Collapsed)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Group hierarchies and folds (improvise-felo.6) ─────────────────────
|
||||
|
||||
/// Year > Quarter > Month as a file spells it: `> Quarter[Year]` lines.
|
||||
const DATES: &str = "v2025-04-09\n# T\nInitial View: Default\n\n## View: Default\nDate: row\n\
|
||||
_Measure: column\n@FOLDS@\n## Category: Date\n\
|
||||
- |2024-01|[Q1-2024]\n- |2024-04|[Q2-2024]\n- |2025-01|[Q1-2025]\n\
|
||||
> Q1-2024[|2024|]\n> Q2-2024[|2024|]\n> Q1-2025[|2025|]\n\n\
|
||||
## Category: _Measure\n- Revenue\n";
|
||||
|
||||
fn dates_with(folds: &str) -> String {
|
||||
DATES.replace("@FOLDS@", folds)
|
||||
}
|
||||
|
||||
/// Every spelling of a fold, together in one view, survives save and load —
|
||||
/// and the writer's own text is stable.
|
||||
#[test]
|
||||
fn expanded_and_collapsed_lines_round_trip_together() {
|
||||
use crate::view::GroupFold;
|
||||
let text = dates_with(
|
||||
"expanded(2): Date\ncollapsed: Date/Q1-2024\nexpanded: Date/Q2-2024\n\
|
||||
expanded(3): Date/|2025|",
|
||||
);
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
let v = loaded.active_view();
|
||||
assert_eq!(v.expand_depth("Date"), 2);
|
||||
assert_eq!(v.group_fold("Date", "Q1-2024"), Some(GroupFold::Collapsed));
|
||||
assert_eq!(
|
||||
v.group_fold("Date", "Q2-2024"),
|
||||
Some(GroupFold::Expanded(std::num::NonZeroUsize::MIN))
|
||||
);
|
||||
assert_eq!(
|
||||
v.group_fold("Date", "2025"),
|
||||
Some(GroupFold::Expanded(std::num::NonZeroUsize::new(3).unwrap()))
|
||||
);
|
||||
let written = format_md(&loaded);
|
||||
for line in [
|
||||
"expanded(2): Date",
|
||||
"collapsed: Date/Q1-2024",
|
||||
"expanded: Date/Q2-2024",
|
||||
"expanded(3): Date/|2025|",
|
||||
] {
|
||||
assert!(written.lines().any(|l| l == line), "{line} in:\n{written}");
|
||||
}
|
||||
assert_eq!(format_md(&parse_md(&written).unwrap()), written);
|
||||
}
|
||||
|
||||
/// A file from before improvise-felo.6 carries only `collapsed:` lines. Every
|
||||
/// group opens closed, the ones it names AND the ones it does not.
|
||||
#[test]
|
||||
fn an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines() {
|
||||
let text = dates_with("collapsed: Date/Q1-2024");
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
let v = loaded.active_view();
|
||||
let cat = loaded.model.category("Date").unwrap();
|
||||
for group in ["2024", "Q1-2024", "Q2-2024", "2025"] {
|
||||
assert!(
|
||||
v.is_group_collapsed("Date", &cat.group_chain(group)),
|
||||
"{group} opens closed"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
format_md(&loaded)
|
||||
.lines()
|
||||
.any(|l| l == "collapsed: Date/Q1-2024"),
|
||||
"the writer keeps the line the file had, though it agrees with the default"
|
||||
);
|
||||
}
|
||||
|
||||
/// The user's ruling: expanded and collapsed on ONE group is an error, named.
|
||||
#[test]
|
||||
fn a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group() {
|
||||
use super::parse::Refusal;
|
||||
for folds in [
|
||||
"collapsed: Date/Q1-2024\nexpanded: Date/Q1-2024",
|
||||
"expanded(2): Date/Q1-2024\ncollapsed: Date/Q1-2024",
|
||||
"expanded: Date/Q1-2024\nexpanded(2): Date/Q1-2024",
|
||||
] {
|
||||
let err = parse_md(&dates_with(folds)).unwrap_err();
|
||||
assert_eq!(
|
||||
err.downcast_ref::<Refusal>(),
|
||||
Some(&Refusal::FoldConflict {
|
||||
view: "Default".into(),
|
||||
category: "Date".into(),
|
||||
group: Some("Q1-2024".into()),
|
||||
}),
|
||||
"{folds}: {err}"
|
||||
);
|
||||
assert!(err.to_string().contains("Default"), "{err}");
|
||||
assert!(err.to_string().contains("Date/Q1-2024"), "{err}");
|
||||
}
|
||||
let err = parse_md(&dates_with("expanded(2): Date\nexpanded(3): Date")).unwrap_err();
|
||||
assert!(err.downcast_ref::<Refusal>().is_some(), "{err}");
|
||||
}
|
||||
|
||||
/// Not the error: a DIFFERENT group collapsed inside an expanded region, and
|
||||
/// the same line twice.
|
||||
#[test]
|
||||
fn a_collapsed_group_inside_an_expanded_region_loads() {
|
||||
let text = dates_with(
|
||||
"expanded(2): Date\nexpanded(2): Date/|2024|\ncollapsed: Date/Q1-2024\n\
|
||||
collapsed: Date/Q1-2024",
|
||||
);
|
||||
let loaded = parse_md(&text).unwrap();
|
||||
let v = loaded.active_view();
|
||||
assert!(v.is_group_collapsed("Date", &["2024", "Q1-2024"]));
|
||||
assert!(!v.is_group_collapsed("Date", &["2024", "Q2-2024"]));
|
||||
}
|
||||
|
||||
/// A cycle among `>` lines is refused on load, naming its members.
|
||||
#[test]
|
||||
fn a_cycle_among_group_parents_is_refused_naming_it() {
|
||||
use super::parse::Refusal;
|
||||
let text = DATES
|
||||
.replace("@FOLDS@", "")
|
||||
.replace("> Q1-2025[|2025|]", "> Q1-2025[|2025|]\n> |2025|[Q1-2025]");
|
||||
let err = parse_md(&text).unwrap_err();
|
||||
match err.downcast_ref::<Refusal>() {
|
||||
Some(Refusal::HierarchyCycle { category, groups }) => {
|
||||
assert_eq!(category, "Date");
|
||||
let mut groups = groups.clone();
|
||||
groups.sort();
|
||||
assert_eq!(groups, vec!["2025", "Q1-2025"]);
|
||||
}
|
||||
other => panic!("not a cycle refusal: {other:?} ({err})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `expanded(0)` opens nothing and is not a spelling; `(x)` is not a depth.
|
||||
#[test]
|
||||
fn a_malformed_depth_is_a_parse_error() {
|
||||
assert!(
|
||||
parse_md(&dates_with("expanded(10): Date")).is_ok(),
|
||||
"the control"
|
||||
);
|
||||
for bad in ["expanded(0): Date", "expanded(x): Date", "expanded(): Date"] {
|
||||
assert!(parse_md(&dates_with(bad)).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A category named `expanded` keeps its axis line, and a category whose NAME is
|
||||
/// an axis keyword keeps its depth: the writer never spells a depth bare.
|
||||
#[test]
|
||||
fn an_axis_line_for_a_category_named_expanded_is_still_an_axis_line() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("expanded").unwrap();
|
||||
wb.add_category("row").unwrap();
|
||||
wb.active_view_mut().set_axis("expanded", Axis::Column);
|
||||
wb.active_view_mut().set_expand_depth("row", 1);
|
||||
let loaded = parse_md(&format_md(&wb)).unwrap();
|
||||
assert_eq!(loaded.active_view().axis_of("expanded"), Axis::Column);
|
||||
assert_eq!(loaded.active_view().expand_depth("row"), 1);
|
||||
}
|
||||
|
||||
/// `z` writes through ONE map, so no sequence of toggles can save a file that
|
||||
/// names a group both ways. Every toggle order over three levels, twice over.
|
||||
#[test]
|
||||
fn no_sequence_of_toggles_writes_a_conflicting_file() {
|
||||
let base = parse_md(&dates_with("expanded(1): Date")).unwrap();
|
||||
let chains: [&[&str]; 4] = [
|
||||
&["2024"],
|
||||
&["2024", "Q1-2024"],
|
||||
&["2024", "Q2-2024"],
|
||||
&["2025", "Q1-2025"],
|
||||
];
|
||||
for order in 0..(4usize.pow(4)) {
|
||||
let mut wb = base.clone();
|
||||
let mut code = order;
|
||||
for _ in 0..4 {
|
||||
let chain = chains[code % 4];
|
||||
code /= 4;
|
||||
wb.active_view_mut().toggle_group_collapse("Date", chain);
|
||||
let text = format_md(&wb);
|
||||
let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
for chain in chains {
|
||||
assert_eq!(
|
||||
back.active_view().is_group_collapsed("Date", chain),
|
||||
wb.active_view().is_group_collapsed("Date", chain),
|
||||
"{chain:?} after order {order}:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── parse_md: number format roundtrip ───────────────────────────────
|
||||
@@ -336,7 +524,8 @@ fn full_roundtrip_preserves_all_features() {
|
||||
m.active_view_mut().set_axis("Month", Axis::Page);
|
||||
m.active_view_mut().set_page_selection("Month", "Jan");
|
||||
m.active_view_mut().hide_item("Type", "Gas");
|
||||
m.active_view_mut().toggle_group_collapse("Type", "G1");
|
||||
m.active_view_mut()
|
||||
.set_group_fold("Type", "G1", crate::view::GroupFold::Collapsed);
|
||||
m.active_view_mut().number_format = ",.0".to_string();
|
||||
|
||||
let text = format_md(&m);
|
||||
@@ -361,7 +550,7 @@ fn full_roundtrip_preserves_all_features() {
|
||||
assert_eq!(v.axis_of("Month"), Axis::Page);
|
||||
assert_eq!(v.page_selection("Month"), Some("Jan"));
|
||||
assert!(v.is_hidden("Type", "Gas"));
|
||||
assert!(v.is_group_collapsed("Type", "G1"));
|
||||
assert!(v.group_fold("Type", "G1") == Some(crate::view::GroupFold::Collapsed));
|
||||
assert_eq!(v.number_format, ",.0");
|
||||
}
|
||||
|
||||
@@ -842,7 +1031,7 @@ fn a_csv_header_with_an_embedded_newline_survives_a_save() {
|
||||
let csv = "\"Region\nName\",Amount\nEast,1\nWest,2\n";
|
||||
let (records, _) =
|
||||
crate::import::csv_parser::parse_csv_bytes_with_warnings(csv.as_bytes()).unwrap();
|
||||
let wb = crate::import::wizard::ImportPipeline::new(serde_json::Value::Array(records))
|
||||
let wb = crate::import::wizard::ImportPipeline::from_csv(records)
|
||||
.build_model()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -1187,7 +1376,7 @@ fn a_label_category_survives_the_round_trip_as_a_label() {
|
||||
#[test]
|
||||
fn a_document_at_the_category_limit_plus_a_label_reloads() {
|
||||
let mut wb = Workbook::new("T");
|
||||
for i in 0..12 {
|
||||
for i in 0..crate::model::MAX_CATEGORIES {
|
||||
wb.add_category(format!("C{i}")).unwrap();
|
||||
}
|
||||
wb.add_label_category("Note").unwrap();
|
||||
@@ -1197,7 +1386,10 @@ fn a_document_at_the_category_limit_plus_a_label_reloads() {
|
||||
loaded.model.category("Note").unwrap().kind,
|
||||
CategoryKind::Label
|
||||
);
|
||||
assert_eq!(loaded.model.regular_category_names().len(), 12);
|
||||
assert_eq!(
|
||||
loaded.model.regular_category_names().len(),
|
||||
crate::model::MAX_CATEGORIES
|
||||
);
|
||||
}
|
||||
|
||||
/// A file written before the `kind:` line existed reads its categories as
|
||||
@@ -1234,3 +1426,28 @@ fn a_loaded_label_category_is_not_on_a_pivot_axis() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatGPT Codex on #317: a fold built through the public API must survive its
|
||||
/// own save. `Expanded(0)` is now unconstructible; every depth that is survives.
|
||||
#[test]
|
||||
fn every_fold_the_api_can_build_reads_back_from_the_file_it_writes() {
|
||||
use crate::view::GroupFold;
|
||||
use std::num::NonZeroUsize;
|
||||
let base = parse_md(&dates_with("")).unwrap();
|
||||
for n in 1..=3 {
|
||||
let depth = NonZeroUsize::new(n).unwrap();
|
||||
for fold in [GroupFold::Collapsed, GroupFold::Expanded(depth)] {
|
||||
let mut wb = base.clone();
|
||||
wb.active_view_mut().set_group_fold("Date", "2024", fold);
|
||||
wb.active_view_mut().set_expand_depth("Date", n);
|
||||
let text = format_md(&wb);
|
||||
let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
assert_eq!(
|
||||
back.active_view().group_fold("Date", "2024"),
|
||||
Some(fold),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(back.active_view().expand_depth("Date"), n, "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ pub enum Rejection {
|
||||
/// A [`ClientFrame::Save`](crate::ClientFrame::Save) whose write failed.
|
||||
/// No message: [`Rejection`] is `Copy` and an I/O error's text is not.
|
||||
SaveFailed,
|
||||
/// A [`ClientFrame::DownloadCsv`](crate::ClientFrame::DownloadCsv) failed
|
||||
/// to render the view (improvise-2mqz). Typically the view was not found.
|
||||
ExportFailed,
|
||||
}
|
||||
|
||||
/// The basis a client holds, folded from the stream like any other widget
|
||||
@@ -131,7 +134,7 @@ impl Rejection {
|
||||
| Self::ChainBroken(_)
|
||||
| Self::MutationRefused
|
||||
| Self::ReadOnly => true,
|
||||
Self::NoDocumentPath | Self::SaveFailed => false,
|
||||
Self::NoDocumentPath | Self::SaveFailed | Self::ExportFailed => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +163,8 @@ impl Rejection {
|
||||
Self::MutationRefused => Self::ReadOnly,
|
||||
Self::ReadOnly => Self::NoDocumentPath,
|
||||
Self::NoDocumentPath => Self::SaveFailed,
|
||||
Self::SaveFailed => Self::DocumentReplaced,
|
||||
Self::SaveFailed => Self::ExportFailed,
|
||||
Self::ExportFailed => Self::DocumentReplaced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +514,7 @@ mod tests {
|
||||
live.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
@@ -523,11 +524,13 @@ mod tests {
|
||||
])));
|
||||
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
kind: crate::CategoryKind::Regular,
|
||||
axis: Axis::Row,
|
||||
}])));
|
||||
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}])));
|
||||
live.fold(&Command::SetSelected { row: 1, col: 1 });
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
//! Faces: appearance named by role, and a theme that binds roles to values
|
||||
//! (ADR 004). The reasoning is `context/code/protocol.md` §10.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Deserializer, Visitor};
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
|
||||
use crate::render::WizardFieldKind;
|
||||
use crate::{AppMode, Panel};
|
||||
use improvise_core::view::Axis;
|
||||
|
||||
/// One of the sixteen palette slots: "whatever THIS display calls that slot".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum AnsiSlot {
|
||||
Black,
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Magenta,
|
||||
Cyan,
|
||||
White,
|
||||
BrightBlack,
|
||||
BrightRed,
|
||||
BrightGreen,
|
||||
BrightYellow,
|
||||
BrightBlue,
|
||||
BrightMagenta,
|
||||
BrightCyan,
|
||||
BrightWhite,
|
||||
}
|
||||
|
||||
impl AnsiSlot {
|
||||
pub const ALL: [AnsiSlot; 16] = [
|
||||
AnsiSlot::Black,
|
||||
AnsiSlot::Red,
|
||||
AnsiSlot::Green,
|
||||
AnsiSlot::Yellow,
|
||||
AnsiSlot::Blue,
|
||||
AnsiSlot::Magenta,
|
||||
AnsiSlot::Cyan,
|
||||
AnsiSlot::White,
|
||||
AnsiSlot::BrightBlack,
|
||||
AnsiSlot::BrightRed,
|
||||
AnsiSlot::BrightGreen,
|
||||
AnsiSlot::BrightYellow,
|
||||
AnsiSlot::BrightBlue,
|
||||
AnsiSlot::BrightMagenta,
|
||||
AnsiSlot::BrightCyan,
|
||||
AnsiSlot::BrightWhite,
|
||||
];
|
||||
|
||||
/// The slot's number in the 256-colour palette, 0–15.
|
||||
pub fn index(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
/// The spelling a theme file uses.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
AnsiSlot::Black => "black",
|
||||
AnsiSlot::Red => "red",
|
||||
AnsiSlot::Green => "green",
|
||||
AnsiSlot::Yellow => "yellow",
|
||||
AnsiSlot::Blue => "blue",
|
||||
AnsiSlot::Magenta => "magenta",
|
||||
AnsiSlot::Cyan => "cyan",
|
||||
AnsiSlot::White => "white",
|
||||
AnsiSlot::BrightBlack => "bright-black",
|
||||
AnsiSlot::BrightRed => "bright-red",
|
||||
AnsiSlot::BrightGreen => "bright-green",
|
||||
AnsiSlot::BrightYellow => "bright-yellow",
|
||||
AnsiSlot::BrightBlue => "bright-blue",
|
||||
AnsiSlot::BrightMagenta => "bright-magenta",
|
||||
AnsiSlot::BrightCyan => "bright-cyan",
|
||||
AnsiSlot::BrightWhite => "bright-white",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|slot| slot.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour attribute. `Default`, `Named` and `Indexed` are palette-relative;
|
||||
/// `Rgb` is absolute, and the shipped theme never uses it (ADR 004 clause 4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Colour {
|
||||
Default,
|
||||
Named(AnsiSlot),
|
||||
Indexed(u8),
|
||||
Rgb(u8, u8, u8),
|
||||
}
|
||||
|
||||
impl Colour {
|
||||
/// True for the one arm that opts a viewer out of their own palette.
|
||||
pub fn is_absolute(self) -> bool {
|
||||
match self {
|
||||
Colour::Rgb(..) => true,
|
||||
Colour::Default | Colour::Named(_) | Colour::Indexed(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Colour {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match *self {
|
||||
Colour::Default => s.serialize_str("default"),
|
||||
Colour::Named(slot) => s.serialize_str(slot.name()),
|
||||
Colour::Indexed(n) => s.serialize_u8(n),
|
||||
Colour::Rgb(r, g, b) => s.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColourVisitor;
|
||||
|
||||
impl Visitor<'_> for ColourVisitor {
|
||||
type Value = Colour;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("\"default\", a slot name such as \"green\", 0-255, or \"#rrggbb\"")
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, n: u64) -> Result<Colour, E> {
|
||||
u8::try_from(n)
|
||||
.map(Colour::Indexed)
|
||||
.map_err(|_| E::custom(format!("colour index {n} is outside 0-255")))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Colour, E> {
|
||||
parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_colour(s: &str) -> Option<Colour> {
|
||||
match s.strip_prefix('#') {
|
||||
Some(hex) if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) => {
|
||||
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||
Some(Colour::Rgb(byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
Some(_) => None,
|
||||
None if s == "default" => Some(Colour::Default),
|
||||
None => AnsiSlot::from_name(s).map(Colour::Named),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Colour {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(ColourVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Weight {
|
||||
Normal,
|
||||
Bold,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Slant {
|
||||
Normal,
|
||||
Italic,
|
||||
}
|
||||
|
||||
/// The text-entry modes, one per `MinibufferConfig`-carrying `AppMode` arm
|
||||
/// except that cell editing is one role over two layouts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum TextEntry {
|
||||
Edit,
|
||||
Formula,
|
||||
Command,
|
||||
CategoryAdd,
|
||||
ItemAdd,
|
||||
Export,
|
||||
Save,
|
||||
}
|
||||
|
||||
impl TextEntry {
|
||||
pub const ALL: [TextEntry; 7] = [
|
||||
TextEntry::Edit,
|
||||
TextEntry::Formula,
|
||||
TextEntry::Command,
|
||||
TextEntry::CategoryAdd,
|
||||
TextEntry::ItemAdd,
|
||||
TextEntry::Export,
|
||||
TextEntry::Save,
|
||||
];
|
||||
|
||||
/// The text-entry role of `mode`, or `None` for a mode that takes no text.
|
||||
pub fn of(mode: &AppMode) -> Option<Self> {
|
||||
match mode {
|
||||
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Some(TextEntry::Edit),
|
||||
AppMode::FormulaEdit { .. } => Some(TextEntry::Formula),
|
||||
AppMode::CommandMode { .. } => Some(TextEntry::Command),
|
||||
AppMode::CategoryAdd { .. } => Some(TextEntry::CategoryAdd),
|
||||
AppMode::ItemAdd { .. } => Some(TextEntry::ItemAdd),
|
||||
AppMode::ExportPrompt { .. } => Some(TextEntry::Export),
|
||||
AppMode::SavePrompt { .. } => Some(TextEntry::Save),
|
||||
AppMode::Normal
|
||||
| AppMode::RecordsNormal
|
||||
| AppMode::FormulaPanel
|
||||
| AppMode::CategoryPanel
|
||||
| AppMode::ViewPanel
|
||||
| AppMode::TileSelect
|
||||
| AppMode::ImportWizard
|
||||
| AppMode::Help
|
||||
| AppMode::Quit => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
TextEntry::Edit => "edit",
|
||||
TextEntry::Formula => "formula",
|
||||
TextEntry::Command => "command",
|
||||
TextEntry::CategoryAdd => "category-add",
|
||||
TextEntry::ItemAdd => "item-add",
|
||||
TextEntry::Export => "export",
|
||||
TextEntry::Save => "save",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AXES: [Axis; 5] = [
|
||||
Axis::Row,
|
||||
Axis::Column,
|
||||
Axis::Page,
|
||||
Axis::Filter,
|
||||
Axis::None,
|
||||
];
|
||||
|
||||
const FIELD_KINDS: [WizardFieldKind; 4] = [
|
||||
WizardFieldKind::Category,
|
||||
WizardFieldKind::Measure,
|
||||
WizardFieldKind::TimeCategory,
|
||||
WizardFieldKind::Label,
|
||||
];
|
||||
|
||||
fn axis_name(axis: Axis) -> &'static str {
|
||||
match axis {
|
||||
Axis::Row => "row",
|
||||
Axis::Column => "column",
|
||||
Axis::Page => "page",
|
||||
Axis::Filter => "filter",
|
||||
Axis::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn field_kind_name(kind: WizardFieldKind) -> &'static str {
|
||||
match kind {
|
||||
WizardFieldKind::Category => "category",
|
||||
WizardFieldKind::Measure => "measure",
|
||||
WizardFieldKind::TimeCategory => "time",
|
||||
WizardFieldKind::Label => "label",
|
||||
}
|
||||
}
|
||||
|
||||
/// A role something is drawn in. Its `name` is a compatibility surface once a
|
||||
/// theme file carries it (ADR 004 clause 9), so never rename one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Face {
|
||||
Default,
|
||||
Dim,
|
||||
Axis(Axis),
|
||||
WizardField(WizardFieldKind),
|
||||
Mode,
|
||||
ModeResting,
|
||||
ModeTextEntry,
|
||||
ModeTextEntryOf(TextEntry),
|
||||
ModePanel(Panel),
|
||||
ModeOverlay,
|
||||
ModeTransient,
|
||||
ModeTileSelect,
|
||||
MinibufferOf(TextEntry),
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// Every face, parents before children.
|
||||
pub fn all() -> Vec<Face> {
|
||||
let mut out = vec![Face::Default, Face::Dim];
|
||||
out.extend(AXES.map(Face::Axis));
|
||||
out.extend(FIELD_KINDS.map(Face::WizardField));
|
||||
out.extend([Face::Mode, Face::ModeResting, Face::ModeTextEntry]);
|
||||
out.extend(TextEntry::ALL.map(Face::ModeTextEntryOf));
|
||||
out.extend(Panel::ALL.map(Face::ModePanel));
|
||||
out.extend([Face::ModeOverlay, Face::ModeTransient, Face::ModeTileSelect]);
|
||||
out.extend(TextEntry::ALL.map(Face::MinibufferOf));
|
||||
out
|
||||
}
|
||||
|
||||
/// The face this one inherits every attribute it does not state from.
|
||||
pub fn parent(self) -> Option<Face> {
|
||||
match self {
|
||||
Face::Default => None,
|
||||
Face::Dim | Face::Mode => Some(Face::Default),
|
||||
Face::Axis(Axis::None) | Face::WizardField(WizardFieldKind::Label) => Some(Face::Dim),
|
||||
Face::Axis(_) | Face::WizardField(_) => Some(Face::Default),
|
||||
Face::ModeResting
|
||||
| Face::ModeTextEntry
|
||||
| Face::ModePanel(_)
|
||||
| Face::ModeOverlay
|
||||
| Face::ModeTransient
|
||||
| Face::ModeTileSelect => Some(Face::Mode),
|
||||
Face::ModeTextEntryOf(_) | Face::MinibufferOf(_) => Some(Face::ModeTextEntry),
|
||||
}
|
||||
}
|
||||
|
||||
/// The spelling a theme file keys this face by.
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Face::Default => "default".into(),
|
||||
Face::Dim => "dim".into(),
|
||||
Face::Axis(axis) => format!("axis.{}", axis_name(axis)),
|
||||
Face::WizardField(kind) => format!("wizard.field.{}", field_kind_name(kind)),
|
||||
Face::Mode => "mode".into(),
|
||||
Face::ModeResting => "mode.resting".into(),
|
||||
Face::ModeTextEntry => "mode.text-entry".into(),
|
||||
Face::ModeTextEntryOf(entry) => format!("mode.text-entry.{}", entry.name()),
|
||||
Face::ModePanel(panel) => format!("mode.panel.{}", panel.name()),
|
||||
Face::ModeOverlay => "mode.overlay".into(),
|
||||
Face::ModeTransient => "mode.transient".into(),
|
||||
Face::ModeTileSelect => "mode.tile-select".into(),
|
||||
Face::MinibufferOf(entry) => format!("minibuffer.{}", entry.name()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Face> {
|
||||
Face::all().into_iter().find(|face| face.name() == name)
|
||||
}
|
||||
|
||||
/// The face the mode indicator is drawn in.
|
||||
pub fn of_mode(mode: &AppMode) -> Face {
|
||||
match mode {
|
||||
AppMode::Normal | AppMode::RecordsNormal => Face::ModeResting,
|
||||
AppMode::FormulaPanel => Face::ModePanel(Panel::Formula),
|
||||
AppMode::CategoryPanel => Face::ModePanel(Panel::Category),
|
||||
AppMode::ViewPanel => Face::ModePanel(Panel::View),
|
||||
AppMode::ImportWizard | AppMode::Help => Face::ModeOverlay,
|
||||
AppMode::Quit => Face::ModeTransient,
|
||||
AppMode::TileSelect => Face::ModeTileSelect,
|
||||
AppMode::Editing { .. }
|
||||
| AppMode::RecordsEditing { .. }
|
||||
| AppMode::FormulaEdit { .. }
|
||||
| AppMode::CommandMode { .. }
|
||||
| AppMode::CategoryAdd { .. }
|
||||
| AppMode::ItemAdd { .. }
|
||||
| AppMode::ExportPrompt { .. }
|
||||
| AppMode::SavePrompt { .. } => TextEntry::of(mode)
|
||||
.map(Face::ModeTextEntryOf)
|
||||
.unwrap_or(Face::ModeTextEntry),
|
||||
}
|
||||
}
|
||||
|
||||
/// The face a text-entry mode's minibuffer is drawn in.
|
||||
pub fn of_minibuffer(mode: &AppMode) -> Option<Face> {
|
||||
TextEntry::of(mode).map(Face::MinibufferOf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Face {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&self.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Face {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let name = String::deserialize(d)?;
|
||||
Face::from_name(&name).ok_or_else(|| de::Error::custom(format!("unknown face {name:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a theme states about one face. `None` inherits from the parent;
|
||||
/// `Some(Colour::Default)` states "the surface" and does not inherit.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceSpec {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreground: Option<Colour>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Colour>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub weight: Option<Weight>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slant: Option<Slant>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub underline: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reverse: Option<bool>,
|
||||
}
|
||||
|
||||
impl FaceSpec {
|
||||
fn or(self, parent: FaceSpec) -> FaceSpec {
|
||||
FaceSpec {
|
||||
foreground: self.foreground.or(parent.foreground),
|
||||
background: self.background.or(parent.background),
|
||||
weight: self.weight.or(parent.weight),
|
||||
slant: self.slant.or(parent.slant),
|
||||
underline: self.underline.or(parent.underline),
|
||||
reverse: self.reverse.or(parent.reverse),
|
||||
}
|
||||
}
|
||||
|
||||
fn fg(colour: Colour) -> FaceSpec {
|
||||
FaceSpec {
|
||||
foreground: Some(colour),
|
||||
..FaceSpec::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn bg(colour: Colour) -> FaceSpec {
|
||||
FaceSpec {
|
||||
background: Some(colour),
|
||||
..FaceSpec::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face with every attribute concrete: what a front end translates.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedFace {
|
||||
pub foreground: Colour,
|
||||
pub background: Colour,
|
||||
pub weight: Weight,
|
||||
pub slant: Slant,
|
||||
pub underline: bool,
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
/// Faces bound to values. Client-local: never a `Command`, never in
|
||||
/// `ViewState`, never projected, never in `.improv` (ADR 004 clause 7).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Theme {
|
||||
pub faces: BTreeMap<Face, FaceSpec>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The theme improvise ships: today's terminal rendering, palette-relative
|
||||
/// throughout, so the user's terminal theme still applies.
|
||||
pub fn shipped() -> Theme {
|
||||
use AnsiSlot::*;
|
||||
let named = Colour::Named;
|
||||
let minibuffer = |accent| FaceSpec {
|
||||
foreground: Some(named(accent)),
|
||||
background: Some(Colour::Indexed(235)),
|
||||
weight: Some(Weight::Bold),
|
||||
..FaceSpec::default()
|
||||
};
|
||||
let faces = [
|
||||
(Face::Dim, FaceSpec::fg(named(BrightBlack))),
|
||||
(Face::Axis(Axis::Row), FaceSpec::fg(named(Green))),
|
||||
(Face::Axis(Axis::Column), FaceSpec::fg(named(Blue))),
|
||||
(Face::Axis(Axis::Page), FaceSpec::fg(named(Magenta))),
|
||||
(Face::Axis(Axis::Filter), FaceSpec::fg(named(Yellow))),
|
||||
(
|
||||
Face::WizardField(WizardFieldKind::Category),
|
||||
FaceSpec::fg(named(Green)),
|
||||
),
|
||||
(
|
||||
Face::WizardField(WizardFieldKind::Measure),
|
||||
FaceSpec::fg(named(Cyan)),
|
||||
),
|
||||
(
|
||||
Face::WizardField(WizardFieldKind::TimeCategory),
|
||||
FaceSpec::fg(named(Magenta)),
|
||||
),
|
||||
(
|
||||
Face::Mode,
|
||||
FaceSpec {
|
||||
foreground: Some(named(Black)),
|
||||
background: Some(named(BrightBlack)),
|
||||
..FaceSpec::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Face::ModeTextEntryOf(TextEntry::Edit),
|
||||
FaceSpec::bg(named(Green)),
|
||||
),
|
||||
(
|
||||
Face::ModeTextEntryOf(TextEntry::Command),
|
||||
FaceSpec::bg(named(Yellow)),
|
||||
),
|
||||
(Face::ModeTileSelect, FaceSpec::bg(named(Magenta))),
|
||||
(Face::MinibufferOf(TextEntry::Edit), minibuffer(Green)),
|
||||
(Face::MinibufferOf(TextEntry::ItemAdd), minibuffer(Green)),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::CategoryAdd),
|
||||
minibuffer(Green),
|
||||
),
|
||||
(Face::MinibufferOf(TextEntry::Formula), minibuffer(Cyan)),
|
||||
(Face::MinibufferOf(TextEntry::Command), minibuffer(Yellow)),
|
||||
(Face::MinibufferOf(TextEntry::Export), minibuffer(Yellow)),
|
||||
(Face::MinibufferOf(TextEntry::Save), minibuffer(Yellow)),
|
||||
];
|
||||
Theme {
|
||||
faces: faces.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `face`'s inheritance chain once, so no front end ever does.
|
||||
pub fn resolve(&self, face: Face) -> ResolvedFace {
|
||||
let stated = std::iter::successors(Some(face), |f| f.parent())
|
||||
.filter_map(|f| self.faces.get(&f).copied())
|
||||
.fold(FaceSpec::default(), FaceSpec::or);
|
||||
ResolvedFace {
|
||||
foreground: stated.foreground.unwrap_or(Colour::Default),
|
||||
background: stated.background.unwrap_or(Colour::Default),
|
||||
weight: stated.weight.unwrap_or(Weight::Normal),
|
||||
slant: stated.slant.unwrap_or(Slant::Normal),
|
||||
underline: stated.underline.unwrap_or(false),
|
||||
reverse: stated.reverse.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_face_name_is_distinct_and_reads_back_as_that_face() {
|
||||
let all = Face::all();
|
||||
for face in &all {
|
||||
assert_eq!(Face::from_name(&face.name()), Some(*face), "{face:?}");
|
||||
}
|
||||
let mut names: Vec<String> = all.iter().map(|f| f.name()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), all.len(), "two faces share a name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parent_is_a_face_listed_before_its_child() {
|
||||
let all = Face::all();
|
||||
for (i, face) in all.iter().enumerate() {
|
||||
if let Some(parent) = face.parent() {
|
||||
let at = all.iter().position(|f| *f == parent);
|
||||
assert!(at.is_some_and(|p| p < i), "{face:?}'s parent {parent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR 004 clause 4: the shipped theme keeps the user's terminal palette.
|
||||
#[test]
|
||||
fn the_shipped_theme_states_no_absolute_colour() {
|
||||
let theme = Theme::shipped();
|
||||
for face in Face::all() {
|
||||
let resolved = theme.resolve(face);
|
||||
assert!(!resolved.foreground.is_absolute(), "{}", face.name());
|
||||
assert!(!resolved.background.is_absolute(), "{}", face.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR 004: `minibuffer.*` inherits `mode.text-entry` directly, so a theme
|
||||
/// that restyles text entry reaches every prompt. `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn a_theme_that_restyles_text_entry_reaches_every_minibuffer() {
|
||||
let mut theme = Theme::shipped();
|
||||
let italic = FaceSpec {
|
||||
slant: Some(Slant::Italic),
|
||||
..FaceSpec::default()
|
||||
};
|
||||
theme.faces.insert(Face::ModeTextEntry, italic);
|
||||
for entry in TextEntry::ALL {
|
||||
let got = theme.resolve(Face::MinibufferOf(entry));
|
||||
assert_eq!(got.slant, Slant::Italic, "{entry:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A collision defeats the reason the axis faces exist (improvise-rml).
|
||||
#[test]
|
||||
fn every_axis_gets_its_own_accent_in_the_shipped_theme() {
|
||||
let theme = Theme::shipped();
|
||||
let accents = AXES.map(|axis| theme.resolve(Face::Axis(axis)));
|
||||
for (i, a) in accents.iter().enumerate() {
|
||||
for b in &accents[i + 1..] {
|
||||
assert_ne!(a, b, "two axes share an accent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A child overrides what it states and inherits what it does not, from
|
||||
/// the nearest ancestor that states it — `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
fn resolution_takes_each_attribute_from_the_nearest_face_that_states_it() {
|
||||
let named = Colour::Named;
|
||||
let theme = Theme {
|
||||
faces: [
|
||||
(
|
||||
Face::Default,
|
||||
FaceSpec {
|
||||
foreground: Some(named(AnsiSlot::White)),
|
||||
slant: Some(Slant::Italic),
|
||||
..FaceSpec::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Face::Mode,
|
||||
FaceSpec {
|
||||
foreground: Some(named(AnsiSlot::Black)),
|
||||
background: Some(named(AnsiSlot::Blue)),
|
||||
..FaceSpec::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Face::ModeTextEntryOf(TextEntry::Save),
|
||||
FaceSpec {
|
||||
background: Some(Colour::Default),
|
||||
underline: Some(true),
|
||||
..FaceSpec::default()
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
};
|
||||
let got = theme.resolve(Face::ModeTextEntryOf(TextEntry::Save));
|
||||
assert_eq!(
|
||||
got,
|
||||
ResolvedFace {
|
||||
foreground: named(AnsiSlot::Black),
|
||||
background: Colour::Default,
|
||||
weight: Weight::Normal,
|
||||
slant: Slant::Italic,
|
||||
underline: true,
|
||||
reverse: false,
|
||||
}
|
||||
);
|
||||
let sibling = theme.resolve(Face::ModeTextEntryOf(TextEntry::Export));
|
||||
assert_eq!(sibling.background, named(AnsiSlot::Blue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_theme_survives_its_own_json() {
|
||||
let theme = Theme::shipped();
|
||||
let text = serde_json::to_string_pretty(&theme).unwrap();
|
||||
assert!(text.contains("\"minibuffer.category-add\""), "{text}");
|
||||
assert_eq!(serde_json::from_str::<Theme>(&text).unwrap(), theme);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_is_spelled_for_a_person_editing_the_file() {
|
||||
let cases = [
|
||||
(Colour::Default, "\"default\""),
|
||||
(Colour::Named(AnsiSlot::BrightBlack), "\"bright-black\""),
|
||||
(Colour::Indexed(235), "235"),
|
||||
(Colour::Rgb(0x3f, 0x7a, 0x4d), "\"#3f7a4d\""),
|
||||
];
|
||||
for (colour, json) in cases {
|
||||
assert_eq!(serde_json::to_string(&colour).unwrap(), json);
|
||||
assert_eq!(serde_json::from_str::<Colour>(json).unwrap(), colour);
|
||||
}
|
||||
for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] {
|
||||
assert!(serde_json::from_str::<Colour>(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelt_face_or_attribute_is_refused_rather_than_ignored() {
|
||||
let face = r#"{"faces":{"axis.rwo":{"foreground":"green"}}}"#;
|
||||
assert!(serde_json::from_str::<Theme>(face).is_err());
|
||||
let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#;
|
||||
assert!(serde_json::from_str::<Theme>(attr).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_entry_mode_has_a_text_entry_role_and_no_other_mode_does() {
|
||||
for mode in AppMode::all() {
|
||||
assert_eq!(
|
||||
TextEntry::of(&mode).is_some(),
|
||||
mode.minibuffer().is_some(),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ pub enum ClientFrame {
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
},
|
||||
/// Ask for document bytes named by SavePrompt; the browser downloads them
|
||||
/// via Blob + `<a download>` (improvise-2mqz).
|
||||
DownloadDocument { filename: String },
|
||||
/// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads
|
||||
/// them via Blob + `<a download>` (improvise-2mqz).
|
||||
DownloadCsv { filename: String },
|
||||
}
|
||||
|
||||
impl ClientFrame {
|
||||
@@ -55,6 +61,12 @@ pub enum ServerFrame {
|
||||
/// The asker's mutating frame landed whole. Carries no state: everything
|
||||
/// it moved arrived as projections first — `context/code/protocol.md` §4.
|
||||
Accepted,
|
||||
/// Downloadable `.improv` bytes addressed to the asker alone; the filename
|
||||
/// is echoed from its request (improvise-2mqz).
|
||||
DocumentBytes { filename: String, bytes: Vec<u8> },
|
||||
/// Downloadable active-view CSV bytes addressed to the asker alone; the
|
||||
/// filename is echoed from its request (improvise-2mqz).
|
||||
CsvBytes { filename: String, bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
impl ServerFrame {
|
||||
@@ -83,6 +95,12 @@ mod tests {
|
||||
)),
|
||||
ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
ClientFrame::Save,
|
||||
ClientFrame::DownloadDocument {
|
||||
filename: "doc.improv".into(),
|
||||
},
|
||||
ClientFrame::DownloadCsv {
|
||||
filename: "export.csv".into(),
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&frame).expect("serializes");
|
||||
assert_eq!(
|
||||
@@ -105,6 +123,14 @@ mod tests {
|
||||
))]),
|
||||
ServerFrame::Rejected(Rejection::LayoutMoved),
|
||||
ServerFrame::Accepted,
|
||||
ServerFrame::DocumentBytes {
|
||||
filename: "test.improv".into(),
|
||||
bytes: b"v2025-04-09\n# Test\n".to_vec(),
|
||||
},
|
||||
ServerFrame::CsvBytes {
|
||||
filename: "export.csv".into(),
|
||||
bytes: b"col1,col2\na,b\n".to_vec(),
|
||||
},
|
||||
] {
|
||||
let json = serde_json::to_string(&frame).expect("serializes");
|
||||
assert_eq!(
|
||||
|
||||
@@ -927,11 +927,13 @@ impl Keymaps {
|
||||
),
|
||||
);
|
||||
|
||||
// LocalThenRequest cannot live in a sequence, so these commands own
|
||||
// their buffer clears and are rebound directly (improvise-2mqz).
|
||||
set.insert(
|
||||
ModeKey::ExportPrompt,
|
||||
Arc::new(
|
||||
TextEntry::new(&AppMode::export_prompt(), "normal", global.clone())
|
||||
.commit(KeyCode::Enter, "commit-export", vec![])
|
||||
.rebind(KeyCode::Enter, "commit-export")
|
||||
.finish(),
|
||||
),
|
||||
);
|
||||
@@ -940,7 +942,7 @@ impl Keymaps {
|
||||
ModeKey::SavePrompt,
|
||||
Arc::new(
|
||||
TextEntry::new(&AppMode::save_prompt(), "normal", global.clone())
|
||||
.commit(KeyCode::Enter, "commit-save", vec![])
|
||||
.rebind(KeyCode::Enter, "commit-save")
|
||||
.finish(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||
|
||||
pub mod basis;
|
||||
pub mod client;
|
||||
pub mod face;
|
||||
pub mod frame;
|
||||
pub mod help;
|
||||
pub mod key;
|
||||
@@ -23,6 +24,7 @@ mod vocabulary_is_composed;
|
||||
|
||||
pub use basis::{Basis, BasisCache, Rejection};
|
||||
pub use client::{ClientState, Sent};
|
||||
pub use face::{Face, Theme};
|
||||
pub use frame::{ClientFrame, ServerFrame};
|
||||
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
||||
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -103,7 +105,7 @@ pub struct MinibufferConfig {
|
||||
|
||||
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
||||
/// the panel a mode is focused in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Panel {
|
||||
Formula,
|
||||
Category,
|
||||
@@ -394,7 +396,7 @@ impl AppMode {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "category".into(),
|
||||
prompt: "new category: ".into(),
|
||||
color: MinibufColor::Yellow,
|
||||
color: MinibufColor::Green,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,10 +556,12 @@ mod tests {
|
||||
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
|
||||
Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}])),
|
||||
Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
kind: crate::CategoryKind::Regular,
|
||||
axis: Axis::Page,
|
||||
}])),
|
||||
|
||||
@@ -214,6 +214,9 @@ pub struct FrameGroup {
|
||||
/// Which header level opened it — an index into [`AxisFrame::cats`]. Runs
|
||||
/// nest, so membership is per level (improvise-e265).
|
||||
pub level: usize,
|
||||
/// The group's depth in its category's hierarchy, 0 for a root. Runs nest
|
||||
/// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6).
|
||||
pub depth: usize,
|
||||
pub name: String,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
@@ -223,10 +226,11 @@ pub struct FrameGroup {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FrameEntry {
|
||||
Group(FrameGroup),
|
||||
/// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd`
|
||||
/// on the wire (improvise-gd83, e265). Nothing is drawn for it.
|
||||
/// The run at `(level, depth)`, and every run inside it, ends here —
|
||||
/// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn.
|
||||
GroupEnd {
|
||||
level: usize,
|
||||
depth: usize,
|
||||
},
|
||||
/// One label per header level, outermost first. Data indices — the
|
||||
/// `row`/`col` of every other field on [`GridCache`] — count only these.
|
||||
@@ -279,7 +283,7 @@ impl TryFrom<UncheckedAxisFrame> for AxisFrame {
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
FrameEntry::Group(g) => Some(g.level),
|
||||
FrameEntry::GroupEnd { level } => Some(*level),
|
||||
FrameEntry::GroupEnd { level, .. } => Some(*level),
|
||||
FrameEntry::Item { .. } => None,
|
||||
})
|
||||
.find(|level| *level >= raw.cats.len());
|
||||
@@ -418,20 +422,22 @@ impl AxisFrame {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The innermost group each data item sits in, in order — one pass. A
|
||||
/// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265).
|
||||
/// The innermost group each data item sits in, in order — one pass. A group
|
||||
/// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a).
|
||||
pub fn groups(&self) -> Vec<Option<&FrameGroup>> {
|
||||
let mut open: Vec<Option<&FrameGroup>> = Vec::new();
|
||||
let mut open: Vec<&FrameGroup> = Vec::new();
|
||||
let close = |open: &mut Vec<&FrameGroup>, key: (usize, usize)| {
|
||||
open.truncate(open.partition_point(|g| (g.level, g.depth) < key))
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in &self.entries {
|
||||
match entry {
|
||||
FrameEntry::Group(g) => {
|
||||
open.truncate(g.level);
|
||||
open.resize(g.level, None);
|
||||
open.push(Some(g));
|
||||
close(&mut open, (g.level, g.depth));
|
||||
open.push(g);
|
||||
}
|
||||
FrameEntry::GroupEnd { level } => open.truncate(*level),
|
||||
FrameEntry::Item { .. } => out.push(open.iter().rev().find_map(|g| *g)),
|
||||
FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)),
|
||||
FrameEntry::Item { .. } => out.push(open.last().copied()),
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -753,6 +759,8 @@ impl WidgetCache for GridCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CategoryRow {
|
||||
pub name: String,
|
||||
/// What the panel shows; `name` stays what a command names (ADR 007).
|
||||
pub display: String,
|
||||
pub items: Vec<String>,
|
||||
pub axis: Axis,
|
||||
}
|
||||
@@ -851,6 +859,8 @@ impl WidgetCache for FormulaCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewSummary {
|
||||
pub name: String,
|
||||
/// What the view panel shows; `name` stays what a command names.
|
||||
pub display: String,
|
||||
/// `(category, axis)` for the categories this view displays.
|
||||
pub axes: Vec<(String, Axis)>,
|
||||
}
|
||||
@@ -875,6 +885,8 @@ impl WidgetCache for ViewListCache {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TileEntry {
|
||||
pub category: String,
|
||||
/// What the tile shows; `category` stays what a command names.
|
||||
pub display: String,
|
||||
pub axis: Axis,
|
||||
pub kind: CategoryKind,
|
||||
}
|
||||
@@ -927,7 +939,7 @@ pub enum WizardStepKind {
|
||||
|
||||
/// What kind of thing a proposed field is. The semantic half of a colour
|
||||
/// decision; the terminal mapping lives in `draw.rs` (gotcha 26).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum WizardFieldKind {
|
||||
Category,
|
||||
Measure,
|
||||
@@ -955,6 +967,9 @@ pub struct WizardDateField {
|
||||
/// what is drawn, so the substitution is made once, here.
|
||||
pub format: String,
|
||||
pub components: Vec<(String, bool)>,
|
||||
/// What the chosen components become, as a sentence: one hierarchical
|
||||
/// category, or a split (improvise-8jk6.3). Worded once, in the projection.
|
||||
pub shape: String,
|
||||
}
|
||||
|
||||
/// Several source records sharing every coordinate (improvise-cgqt). Data,
|
||||
@@ -1066,6 +1081,7 @@ mod tests {
|
||||
fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry {
|
||||
TileEntry {
|
||||
category: category.to_string(),
|
||||
display: category.to_string(),
|
||||
axis,
|
||||
kind,
|
||||
}
|
||||
@@ -1150,6 +1166,7 @@ mod tests {
|
||||
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
|
||||
CategoryRow {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
items: items.iter().map(|s| (*s).to_string()).collect(),
|
||||
axis,
|
||||
}
|
||||
@@ -1349,6 +1366,7 @@ mod tests {
|
||||
FrameEntry::item(vec!["A".into(), "y".into()]),
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Late".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
@@ -1362,6 +1380,7 @@ mod tests {
|
||||
fn repeated_group_frame() -> AxisFrame {
|
||||
let q1 = || FrameGroup {
|
||||
level: 1,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
collapsed: false,
|
||||
};
|
||||
@@ -1388,12 +1407,13 @@ mod tests {
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
collapsed: false,
|
||||
}),
|
||||
FrameEntry::item(vec!["Jan".into()]),
|
||||
FrameEntry::item(vec!["Feb".into()]),
|
||||
FrameEntry::GroupEnd { level: 0 },
|
||||
FrameEntry::GroupEnd { level: 0, depth: 0 },
|
||||
FrameEntry::item(vec!["Sep".into()]),
|
||||
],
|
||||
}
|
||||
@@ -1406,6 +1426,7 @@ mod tests {
|
||||
let group = |level: usize, name: &str| {
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level,
|
||||
depth: 0,
|
||||
name: name.into(),
|
||||
collapsed: true,
|
||||
})
|
||||
@@ -1416,7 +1437,7 @@ mod tests {
|
||||
entries: vec![
|
||||
group(0, "Upper"),
|
||||
group(1, "Q1"),
|
||||
FrameEntry::GroupEnd { level: 1 },
|
||||
FrameEntry::GroupEnd { level: 1, depth: 0 },
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&frame).unwrap();
|
||||
@@ -1424,10 +1445,13 @@ mod tests {
|
||||
assert_eq!(back, frame);
|
||||
for (from, to) in [
|
||||
(
|
||||
r#""level":1,"name":"Q1""#,
|
||||
r#""level":4294967295,"name":"Q1""#,
|
||||
r#""level":1,"depth":0,"name":"Q1""#,
|
||||
r#""level":4294967295,"depth":0,"name":"Q1""#,
|
||||
),
|
||||
(
|
||||
r#"{"GroupEnd":{"level":1,"depth":0}}"#,
|
||||
r#"{"GroupEnd":{"level":2,"depth":0}}"#,
|
||||
),
|
||||
(r#"{"GroupEnd":{"level":1}}"#, r#"{"GroupEnd":{"level":2}}"#),
|
||||
] {
|
||||
let bad = json.replacen(from, to, 1);
|
||||
assert_ne!(bad, json, "the fault {to} was injected");
|
||||
@@ -1440,6 +1464,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-felo.6: runs nest by `(level, depth)` inside one category, so
|
||||
/// the end of a quarter leaves its year open. See the evidence file.
|
||||
#[test]
|
||||
fn a_quarters_end_leaves_its_year_open_on_the_same_level() {
|
||||
let group = |depth: usize, name: &str| {
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth,
|
||||
name: name.into(),
|
||||
collapsed: false,
|
||||
})
|
||||
};
|
||||
let frame = AxisFrame {
|
||||
cats: vec!["Date".into()],
|
||||
first: 0,
|
||||
entries: vec![
|
||||
group(0, "2024"),
|
||||
group(1, "Q1"),
|
||||
FrameEntry::item(vec!["Jan".into()]),
|
||||
FrameEntry::GroupEnd { level: 0, depth: 1 },
|
||||
FrameEntry::item(vec!["Direct".into()]),
|
||||
group(1, "Q2"),
|
||||
FrameEntry::item(vec!["Apr".into()]),
|
||||
FrameEntry::GroupEnd { level: 0, depth: 0 },
|
||||
FrameEntry::item(vec!["Undated".into()]),
|
||||
],
|
||||
};
|
||||
let names: Vec<Option<&str>> = frame
|
||||
.groups()
|
||||
.into_iter()
|
||||
.map(|g| g.map(|g| g.name.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![Some("Q1"), Some("2024"), Some("Q2"), None],
|
||||
"Direct sits in 2024 after Q1 ends; Undated in nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-gd83's property, asserted in the crate that owns the type.
|
||||
/// See `context/test-evidence/protocol.md`.
|
||||
#[test]
|
||||
@@ -1467,10 +1530,11 @@ mod tests {
|
||||
entries: vec![
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
FrameEntry::GroupEnd { level: 0 },
|
||||
FrameEntry::GroupEnd { level: 0, depth: 0 },
|
||||
FrameEntry::item(vec!["Sep".into()]),
|
||||
],
|
||||
};
|
||||
@@ -1748,12 +1812,14 @@ mod tests {
|
||||
|
||||
let views = ViewListCache::folded(&[vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row)],
|
||||
}]]);
|
||||
assert_eq!(views.views[0].name, "Default");
|
||||
|
||||
let tiles = TileBarCache::folded(&[vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]]);
|
||||
@@ -2148,10 +2214,12 @@ mod tests {
|
||||
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
|
||||
Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::None)],
|
||||
}]),
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]),
|
||||
@@ -2189,6 +2257,7 @@ mod tests {
|
||||
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
|
||||
Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Page,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}]),
|
||||
|
||||
@@ -27,6 +27,12 @@ pub enum KeyOutcome {
|
||||
/// Resolved here as a **request**: the caller sends the frame and folds
|
||||
/// nothing. Why it is not a `Command`: `context/code/clients.md`.
|
||||
Request(Request),
|
||||
/// Fold commands locally, then send a request: mode changes here and
|
||||
/// download bytes come from the server (improvise-2mqz).
|
||||
LocalThenRequest {
|
||||
cmds: Vec<Command>,
|
||||
request: Request,
|
||||
},
|
||||
/// Bound to a command this client cannot compose. Forwarded to the host,
|
||||
/// which today answers nothing — `context/code/clients.md`.
|
||||
Upstream(KeyEvent),
|
||||
@@ -37,10 +43,16 @@ pub enum KeyOutcome {
|
||||
|
||||
/// What a key may ask its **host** to do, as opposed to what it may compose.
|
||||
/// Narrow on purpose — the argument is in `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
/// Write the document back where the host keeps it (improvise-368h).
|
||||
Save,
|
||||
/// Get the document as bytes for download (improvise-2mqz). The filename
|
||||
/// is what the user typed in the SavePrompt minibuffer.
|
||||
DownloadDocument { filename: String },
|
||||
/// Get the active view as CSV bytes for download (improvise-2mqz). The filename
|
||||
/// is what the user typed in the ExportPrompt minibuffer.
|
||||
DownloadCsv { filename: String },
|
||||
}
|
||||
|
||||
impl Request {
|
||||
@@ -49,6 +61,8 @@ impl Request {
|
||||
pub fn frame(self) -> ClientFrame {
|
||||
match self {
|
||||
Request::Save => ClientFrame::Save,
|
||||
Request::DownloadDocument { filename } => ClientFrame::DownloadDocument { filename },
|
||||
Request::DownloadCsv { filename } => ClientFrame::DownloadCsv { filename },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +106,9 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome {
|
||||
Binding::Cmd { name, args } => match step(state, event, name, args) {
|
||||
Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands),
|
||||
Step::Request(request) => KeyOutcome::Request(request),
|
||||
Step::LocalThenRequest { cmds, request } => {
|
||||
KeyOutcome::LocalThenRequest { cmds, request }
|
||||
}
|
||||
Step::Upstream => KeyOutcome::Upstream(event),
|
||||
},
|
||||
// All or nothing, each step resolved against the *unchanged* state.
|
||||
@@ -131,7 +148,7 @@ fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> Key
|
||||
Step::Local(commands) | Step::Declined(commands) => Some(commands),
|
||||
// DISCARDED, which is why the guard refuses both classes rather
|
||||
// than counting them answered — `context/code/clients.md`.
|
||||
Step::Request(_) | Step::Upstream => None,
|
||||
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => None,
|
||||
},
|
||||
Some(Binding::Sequence(steps)) => sequence(state, event, steps),
|
||||
};
|
||||
@@ -294,7 +311,7 @@ fn sequence(
|
||||
Step::Declined(said) => return Some(said),
|
||||
// A request is a frame of its own and a sequence is one frame, so
|
||||
// there is nowhere to put it. No binding does — clients.md.
|
||||
Step::Request(_) | Step::Upstream => return None,
|
||||
Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => return None,
|
||||
}
|
||||
}
|
||||
Some(commands)
|
||||
@@ -306,22 +323,31 @@ enum Step {
|
||||
Local(Vec<Command>),
|
||||
Declined(Vec<Command>),
|
||||
Request(Request),
|
||||
/// Fold commands locally, THEN send a request. For commit-save/commit-export:
|
||||
/// the mode change folds here, the download request goes to the server.
|
||||
LocalThenRequest {
|
||||
cmds: Vec<Command>,
|
||||
request: Request,
|
||||
},
|
||||
Upstream,
|
||||
}
|
||||
|
||||
/// One name, answered. Each list is checked before the next, so no name is in
|
||||
/// two of them and [`composed`] has no arm for any earlier one.
|
||||
fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step {
|
||||
match requested(name) {
|
||||
Some(request) => Step::Request(request),
|
||||
None => match declined(name, args).or_else(|| refused_here(state, name)) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match committed(state, name, args) {
|
||||
Some(answer) => answer,
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
None => Step::Upstream,
|
||||
},
|
||||
if let Some(request) = requested(name) {
|
||||
return Step::Request(request);
|
||||
}
|
||||
if let Some(step) = download_request(state, name) {
|
||||
return step;
|
||||
}
|
||||
match declined(name, args).or_else(|| refused_here(state, name)) {
|
||||
Some(said) => Step::Declined(vec![Command::SetStatus(said)]),
|
||||
None => match committed(state, name, args) {
|
||||
Some(answer) => answer,
|
||||
None => match composed(state, event, name, args) {
|
||||
Some(commands) => Step::Local(commands),
|
||||
None => Step::Upstream,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -365,6 +391,36 @@ fn requested(name: &str) -> Option<Request> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download requests that need to read a buffer and fold a mode change locally
|
||||
/// before sending the request (improvise-2mqz).
|
||||
fn download_request(state: &ClientState, name: &str) -> Option<Step> {
|
||||
let (buffer_name, make_request): (&str, fn(String) -> Request) = match name {
|
||||
"commit-save" => ("save", |f| Request::DownloadDocument { filename: f }),
|
||||
"commit-export" => ("export", |f| Request::DownloadCsv { filename: f }),
|
||||
_ => return None,
|
||||
};
|
||||
let filename = read_buffer(&state.view, buffer_name).trim().to_string();
|
||||
if filename.is_empty() {
|
||||
return Some(Step::Declined(vec![Command::SetStatus(format!(
|
||||
"No filename given — not {}",
|
||||
if name == "commit-save" {
|
||||
"saved"
|
||||
} else {
|
||||
"exported"
|
||||
}
|
||||
))]));
|
||||
}
|
||||
// This owns the clear and mode change because LocalThenRequest cannot be
|
||||
// part of a Binding::Sequence (improvise-2mqz).
|
||||
Some(Step::LocalThenRequest {
|
||||
cmds: vec![
|
||||
write_buffer(buffer_name, String::new()),
|
||||
Command::ChangeMode(AppMode::Normal),
|
||||
],
|
||||
request: make_request(filename),
|
||||
})
|
||||
}
|
||||
|
||||
/// The commands this client cannot compose and answers **out loud**; the one
|
||||
/// home for the wording. Why silence was worse: clients.md (improvise-rmvx).
|
||||
fn declined(name: &str, args: &[String]) -> Option<String> {
|
||||
@@ -681,8 +737,8 @@ fn write_buffer(name: &str, value: String) -> Command {
|
||||
/// to decline by name. A dead room is worse than a locked door: clients.md.
|
||||
fn stranding(mode: &AppMode) -> Option<&'static str> {
|
||||
match mode {
|
||||
AppMode::SavePrompt { .. } => Some("saving to a path"),
|
||||
AppMode::ExportPrompt { .. } => Some("exporting"),
|
||||
// SavePrompt and ExportPrompt were stranded before improvise-2mqz added
|
||||
// DownloadDocument and DownloadCsv frames. Now they have wire forms.
|
||||
AppMode::CommandMode { .. } => Some("the command line"),
|
||||
_ => None,
|
||||
}
|
||||
@@ -1892,11 +1948,13 @@ mod tests {
|
||||
crate::CategoryTreeUpdate::Categories(vec![
|
||||
crate::CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
},
|
||||
crate::CategoryRow {
|
||||
name: "Product".into(),
|
||||
display: "Product".into(),
|
||||
items: vec!["Shirts".into()],
|
||||
axis: Axis::Column,
|
||||
},
|
||||
@@ -1975,11 +2033,13 @@ mod tests {
|
||||
state.tiles.tiles = vec![
|
||||
crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
},
|
||||
crate::TileEntry {
|
||||
category: "Product".into(),
|
||||
display: "Product".into(),
|
||||
axis: Axis::Column,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
},
|
||||
@@ -2334,10 +2394,12 @@ mod tests {
|
||||
state.fold(&Command::Project(Projection::ViewList(vec![
|
||||
crate::ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
crate::ViewSummary {
|
||||
name: "Plan".into(),
|
||||
display: "Plan".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
])));
|
||||
@@ -2617,6 +2679,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|name| crate::CategoryRow {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
})
|
||||
@@ -2635,6 +2698,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|name| crate::ViewSummary {
|
||||
name: name.into(),
|
||||
display: name.into(),
|
||||
axes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
@@ -2642,6 +2706,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.map(|category| crate::TileEntry {
|
||||
category: category.into(),
|
||||
display: category.into(),
|
||||
axis: Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
})
|
||||
@@ -2661,6 +2726,15 @@ mod tests {
|
||||
.insert("category".into(), "Product".into());
|
||||
state.view.buffers.insert("item".into(), "Shirts".into());
|
||||
state.view.buffers.insert("edit".into(), "42".into());
|
||||
// commit-save and commit-export need their buffers filled (improvise-2mqz).
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("save".into(), "data.improv".into());
|
||||
state
|
||||
.view
|
||||
.buffers
|
||||
.insert("export".into(), "data.csv".into());
|
||||
state
|
||||
}
|
||||
|
||||
@@ -2678,7 +2752,7 @@ mod tests {
|
||||
fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard {
|
||||
let commands = match outcome {
|
||||
KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent,
|
||||
KeyOutcome::Request(_) => return Heard::Acts,
|
||||
KeyOutcome::Request(_) | KeyOutcome::LocalThenRequest { .. } => return Heard::Acts,
|
||||
KeyOutcome::Local(commands) => commands,
|
||||
};
|
||||
let quiet = |view: &ViewState| ViewState {
|
||||
@@ -2826,8 +2900,6 @@ mod tests {
|
||||
// Reached only from a mode `declined` refuses at the door, so a
|
||||
// browser is never standing in front of these.
|
||||
"COMMAND: Enter execute-command Silent",
|
||||
"EXPORT: Enter commit-export Silent",
|
||||
"SAVE AS: Enter commit-save Silent",
|
||||
// Honestly unavailable, and saying so in `declined`'s words.
|
||||
"NORMAL: > drill-into-cell Declines",
|
||||
"NORMAL: : enter-mode Declines",
|
||||
@@ -3482,37 +3554,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The refusal above is only half the property: the bug guarded is
|
||||
/// *entering a room with no exit*. See `context/test-evidence/clients.md`.
|
||||
/// A client must not enter a room with no exit. SavePrompt and ExportPrompt
|
||||
/// now have download frames; see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_prompt_this_client_cannot_commit_is_never_entered() {
|
||||
let state = in_mode(AppMode::Normal);
|
||||
for (key, mode, subject) in [
|
||||
let mode = "command-mode";
|
||||
let KeyOutcome::Local(said) = resolve(&state, ev(KeyCode::Char(':'))) else {
|
||||
panic!("{mode} must be refused *out loud*, not in silence");
|
||||
};
|
||||
let [Command::SetStatus(msg)] = &said[..] else {
|
||||
panic!("{mode}: expected one status, got {said:?}");
|
||||
};
|
||||
assert!(
|
||||
msg.contains("command line"),
|
||||
"{mode}: the refusal must name its own subject, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode} must not be entered by a client that cannot commit it"
|
||||
);
|
||||
|
||||
// SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz).
|
||||
for (key, mode_name) in [
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
|
||||
"save-prompt",
|
||||
"saving",
|
||||
),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
|
||||
"export-prompt",
|
||||
"exporting",
|
||||
),
|
||||
(ev(KeyCode::Char(':')), "command-mode", "command line"),
|
||||
] {
|
||||
let KeyOutcome::Local(said) = resolve(&state, key) else {
|
||||
panic!("{mode} must be refused *out loud*, not in silence");
|
||||
};
|
||||
let [Command::SetStatus(msg)] = &said[..] else {
|
||||
panic!("{mode}: expected one status, got {said:?}");
|
||||
let outcome = resolve(&state, key);
|
||||
let KeyOutcome::Local(cmds) = outcome else {
|
||||
panic!("{mode_name} should resolve locally now that it has a wire form");
|
||||
};
|
||||
assert!(
|
||||
msg.contains(subject),
|
||||
"{mode}: the refusal must name its own subject, got {msg}"
|
||||
);
|
||||
assert!(
|
||||
!said.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode} must not be entered by a client that cannot commit it"
|
||||
cmds.iter().any(|c| matches!(c, Command::ChangeMode(_))),
|
||||
"{mode_name} should be entered now that commit-save/commit-export have wire forms"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3747,6 +3827,7 @@ mod tests {
|
||||
let mut tiles = in_mode(AppMode::TileSelect);
|
||||
tiles.tiles.tiles = vec![crate::TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: improvise_core::view::Axis::Row,
|
||||
kind: crate::CategoryKind::Regular,
|
||||
}];
|
||||
|
||||
@@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary};
|
||||
/// so the entry list *is* what the panel draws.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CatTreeEntry {
|
||||
/// Category header row: name, item count, expanded?
|
||||
/// Category header row: name (the key), what it shows, item count, expanded?
|
||||
Category {
|
||||
name: String,
|
||||
display: String,
|
||||
item_count: usize,
|
||||
expanded: bool,
|
||||
axis: Axis,
|
||||
@@ -49,6 +50,7 @@ pub fn flatten_cat_tree(
|
||||
let is_expanded = expanded.contains(&row.name);
|
||||
entries.push(CatTreeEntry::Category {
|
||||
name: row.name.clone(),
|
||||
display: row.display.clone(),
|
||||
item_count: row.items.len(),
|
||||
expanded: is_expanded,
|
||||
axis: row.axis,
|
||||
@@ -94,6 +96,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|(name, items)| CategoryRow {
|
||||
name: (*name).to_string(),
|
||||
display: (*name).to_string(),
|
||||
items: items.iter().map(|i| i.to_string()).collect(),
|
||||
axis: Axis::None,
|
||||
})
|
||||
@@ -112,6 +115,7 @@ mod tests {
|
||||
tree,
|
||||
vec![CatTreeEntry::Category {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
item_count: 2,
|
||||
expanded: false,
|
||||
axis: Axis::None,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user