Merge commit '8220a065819652bb224d0b25f5724d6e3a5fbbe8' into fix/headless-import-refusals
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:
|
||||
|
||||
+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
|
||||
@@ -68,3 +68,4 @@ __pycache__/
|
||||
# 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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+217
-9
@@ -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
|
||||
|
||||
@@ -1556,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+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`.
|
||||
|
||||
+38
-35
@@ -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
|
||||
|
||||
|
||||
@@ -269,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1654,6 +1654,48 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1811,3 +1811,107 @@ that failed would leave the owner-authored base at HEAD and fail the case too.
|
||||
`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))),
|
||||
]
|
||||
|
||||
@@ -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, ®);
|
||||
|
||||
@@ -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);
|
||||
@@ -862,7 +874,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),
|
||||
@@ -3430,8 +3442,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
|
||||
|
||||
@@ -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 {
|
||||
@@ -3865,10 +3871,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,
|
||||
@@ -3877,10 +3884,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
|
||||
@@ -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
|
||||
|
||||
@@ -147,8 +147,8 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
|
||||
let body = 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 +212,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 +220,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 +274,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 +426,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 +439,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 +469,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 +491,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)
|
||||
@@ -1458,6 +1469,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 +1481,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 +1586,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 +1598,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 +1640,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 +1690,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 +1755,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 +1801,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 +2997,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();
|
||||
@@ -4658,6 +4761,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 +4775,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 +5746,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 +5769,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 +6440,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
|
||||
};
|
||||
|
||||
@@ -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)"),
|
||||
@@ -134,7 +126,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,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 +232,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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod category;
|
||||
pub mod cell;
|
||||
pub mod id;
|
||||
pub mod record;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
@@ -525,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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,16 +205,19 @@ pub enum RecordCol {
|
||||
/// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AxisEntry {
|
||||
/// Opens a run at `level` (the category's place on the axis) and closes
|
||||
/// every run inside it. Membership is per level: core.md §14, improvise-e265.
|
||||
/// Opens a run at `(level, depth)` — the category's place on the axis, then the
|
||||
/// group's depth in its hierarchy — and closes every run inside it (§6, §19).
|
||||
GroupHeader {
|
||||
level: usize,
|
||||
depth: usize,
|
||||
cat_name: String,
|
||||
group_name: String,
|
||||
/// The view's answer for this group, so no reader re-derives it.
|
||||
collapsed: bool,
|
||||
},
|
||||
/// Ends the run at `level` and every run inside it (improvise-gd83, e265).
|
||||
/// Nothing is drawn for it; it exists so membership is not inherited.
|
||||
GroupEnd { level: usize },
|
||||
/// Ends the run at `(level, depth)` and every run inside it (improvise-gd83,
|
||||
/// e265). Nothing is drawn for it; it exists so membership is not inherited.
|
||||
GroupEnd { level: usize, depth: usize },
|
||||
/// `collapsed` is not derivable from `tuple`: a group and an item may share a
|
||||
/// name. Set for a folded group and for everything expanded beneath one.
|
||||
DataItem { tuple: Vec<String>, collapsed: bool },
|
||||
@@ -301,28 +304,38 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
|
||||
.nth(data_idx)
|
||||
}
|
||||
|
||||
/// Each open run, by level, as `(position of its header, the header)`. A header
|
||||
/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper.
|
||||
type OpenRuns = Vec<Option<(usize, AxisEntry)>>;
|
||||
/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than".
|
||||
/// Across categories the level decides; within one, the depth (§19).
|
||||
pub type RunKey = (usize, usize);
|
||||
|
||||
fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) {
|
||||
runs.truncate(level);
|
||||
runs.resize(level, None);
|
||||
runs.extend(run.map(Some));
|
||||
/// One open run: its key and the position of the header that opened it.
|
||||
pub type OpenRun = (RunKey, usize);
|
||||
|
||||
/// Each open run, innermost last, with its header. A header at K replaces K
|
||||
/// and closes every deeper run; a `GroupEnd` at K closes K and deeper.
|
||||
type OpenRuns = Vec<(RunKey, usize, AxisEntry)>;
|
||||
|
||||
/// Close every run at or inside `key`, then open `run` there if one is given.
|
||||
fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) {
|
||||
let outside = runs.partition_point(|(k, _, _)| *k < key);
|
||||
runs.truncate(outside);
|
||||
runs.extend(run.map(|(at, header)| (key, at, header)));
|
||||
}
|
||||
|
||||
/// Drop the data items `keep` rejects, then re-mark the survivors so each keeps
|
||||
/// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265.
|
||||
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
|
||||
let mut kept: Vec<AxisEntry> = Vec::new();
|
||||
// What the input has open at each level, and what `kept` has open so far.
|
||||
// What the input has open, and what `kept` has open so far.
|
||||
let mut input: OpenRuns = Vec::new();
|
||||
let mut output: Vec<Option<usize>> = Vec::new();
|
||||
let mut output: Vec<OpenRun> = Vec::new();
|
||||
let mut data_idx = 0usize;
|
||||
for (at, entry) in items.drain(..).enumerate() {
|
||||
match entry {
|
||||
AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))),
|
||||
AxisEntry::GroupEnd { level } => open_at(&mut input, level, None),
|
||||
AxisEntry::GroupHeader { level, depth, .. } => {
|
||||
open_at(&mut input, (level, depth), Some((at, entry)))
|
||||
}
|
||||
AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None),
|
||||
AxisEntry::DataItem { .. } => {
|
||||
if keep(data_idx) {
|
||||
remark(&input, &mut output, &mut kept);
|
||||
@@ -335,48 +348,58 @@ fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
|
||||
*items = kept;
|
||||
}
|
||||
|
||||
/// Emit the markers that make `kept`'s open runs equal the input's, outermost
|
||||
/// first: a header where a different run is open, a `GroupEnd` where none is.
|
||||
fn remark(input: &OpenRuns, output: &mut Vec<Option<usize>>, kept: &mut Vec<AxisEntry>) {
|
||||
for level in 0..input.len().max(output.len()) {
|
||||
let want = input.get(level).cloned().flatten();
|
||||
let have = output.get(level).copied().flatten();
|
||||
if want.as_ref().map(|(at, _)| *at) == have {
|
||||
continue;
|
||||
}
|
||||
// Deeper runs end first, innermost out, as `expand_category` ends them.
|
||||
for deeper in (level + 1..output.len()).rev() {
|
||||
if output[deeper].is_some() {
|
||||
kept.push(AxisEntry::GroupEnd { level: deeper });
|
||||
}
|
||||
}
|
||||
output.truncate(level);
|
||||
output.resize(level, None);
|
||||
match want {
|
||||
Some((at, header)) => {
|
||||
kept.push(header);
|
||||
output.push(Some(at));
|
||||
}
|
||||
None => kept.push(AxisEntry::GroupEnd { level }),
|
||||
}
|
||||
fn group_end((level, depth): RunKey) -> AxisEntry {
|
||||
AxisEntry::GroupEnd { level, depth }
|
||||
}
|
||||
|
||||
/// Emit the markers that make `kept`'s open runs equal the input's: past the
|
||||
/// runs they share, end the deeper ones innermost out, then open what differs.
|
||||
fn remark(input: &OpenRuns, output: &mut Vec<OpenRun>, kept: &mut Vec<AxisEntry>) {
|
||||
let shared = input
|
||||
.iter()
|
||||
.zip(output.iter())
|
||||
.take_while(|((_, want, _), (_, have))| want == have)
|
||||
.count();
|
||||
let want = input.get(shared);
|
||||
let have = output.get(shared).copied();
|
||||
let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Deeper runs end first, innermost out, as `expand_category` ends them.
|
||||
for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) {
|
||||
kept.push(group_end(*key));
|
||||
}
|
||||
output.truncate(shared);
|
||||
let replaces = want.is_some_and(|w| w.0 == slot);
|
||||
if !replaces && have.is_some_and(|h| h.0 == slot) {
|
||||
kept.push(group_end(slot));
|
||||
}
|
||||
for (key, at, header) in &input[shared..] {
|
||||
kept.push(header.clone());
|
||||
output.push((*key, *at));
|
||||
}
|
||||
}
|
||||
|
||||
/// The one reading of the markers: each data item's open runs, by level, as
|
||||
/// header positions, lent to `visit` — one stack, never copied (0r01).
|
||||
/// The one reading of the markers: each data item's open runs, innermost last,
|
||||
/// lent to `visit` — one stack, never copied (0r01).
|
||||
fn walk_open_runs<T>(
|
||||
items: &[AxisEntry],
|
||||
mut visit: impl FnMut(&[Option<usize>]) -> ControlFlow<T>,
|
||||
mut visit: impl FnMut(&[OpenRun]) -> ControlFlow<T>,
|
||||
) -> Option<T> {
|
||||
let mut open: Vec<Option<usize>> = Vec::new();
|
||||
let mut open: Vec<OpenRun> = Vec::new();
|
||||
for (at, entry) in items.iter().enumerate() {
|
||||
match entry {
|
||||
AxisEntry::GroupHeader { level, .. } => {
|
||||
open.truncate(*level);
|
||||
open.resize(*level, None);
|
||||
open.push(Some(at));
|
||||
AxisEntry::GroupHeader { level, depth, .. } => {
|
||||
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)));
|
||||
open.push(((*level, *depth), at));
|
||||
}
|
||||
AxisEntry::GroupEnd { level, depth } => {
|
||||
open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth)))
|
||||
}
|
||||
AxisEntry::GroupEnd { level } => open.truncate(*level),
|
||||
AxisEntry::DataItem { .. } => {
|
||||
if let ControlFlow::Break(found) = visit(&open) {
|
||||
return Some(found);
|
||||
@@ -392,7 +415,7 @@ fn walk_open_runs<T>(
|
||||
fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> {
|
||||
let mut data_idx = 0usize;
|
||||
let at = walk_open_runs(items, |open| match data_idx == idx {
|
||||
true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)),
|
||||
true => ControlFlow::Break(open.last().map(|(_, at)| *at)),
|
||||
false => {
|
||||
data_idx += 1;
|
||||
ControlFlow::Continue(())
|
||||
@@ -872,9 +895,13 @@ fn expand_category(
|
||||
return vec![];
|
||||
};
|
||||
// The prefix holds one item per outer category, so its length is this level.
|
||||
let level = prefix.len();
|
||||
let mut result = Vec::new();
|
||||
let mut last_group: Option<&str> = None;
|
||||
let mut runs = CategoryRuns {
|
||||
level: prefix.len(),
|
||||
cat_name,
|
||||
view,
|
||||
open: Vec::new(),
|
||||
out: Vec::new(),
|
||||
};
|
||||
// A group's items need not be contiguous, so a collapsed one folds ONCE.
|
||||
let mut folded: Vec<&str> = Vec::new();
|
||||
|
||||
@@ -884,50 +911,79 @@ fn expand_category(
|
||||
if view.is_hidden(cat_name, item_name) {
|
||||
continue;
|
||||
}
|
||||
let item_group = cat
|
||||
.items
|
||||
.get(item_name.as_str())
|
||||
.and_then(|i| i.group.as_deref());
|
||||
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g));
|
||||
if collapsed.is_some_and(|g| folded.contains(&g)) {
|
||||
let chain = cat.item_chain(item_name);
|
||||
// The OUTERMOST collapsed group folds every leaf beneath it (§19).
|
||||
let fold_depth =
|
||||
(0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth]));
|
||||
let fold = fold_depth.map(|depth| chain[depth]);
|
||||
if fold.is_some_and(|g| folded.contains(&g)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A header on entering a group; a `GroupEnd` only on leaving one for
|
||||
// NO group — another group's header already ends the run.
|
||||
if item_group != last_group {
|
||||
if last_group.is_some() && item_group.is_none() {
|
||||
result.push(AxisEntry::GroupEnd { level });
|
||||
}
|
||||
if let Some(g) = item_group {
|
||||
result.push(AxisEntry::GroupHeader {
|
||||
level,
|
||||
cat_name: cat_name.to_string(),
|
||||
group_name: g.to_string(),
|
||||
});
|
||||
}
|
||||
last_group = item_group;
|
||||
}
|
||||
runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]);
|
||||
|
||||
let mut tuple = prefix.clone();
|
||||
match collapsed {
|
||||
match fold {
|
||||
Some(g) => {
|
||||
folded.push(g);
|
||||
tuple.push(g.to_string());
|
||||
}
|
||||
None => tuple.push(item_name.to_string()),
|
||||
}
|
||||
result.push(AxisEntry::DataItem {
|
||||
runs.out.push(AxisEntry::DataItem {
|
||||
tuple,
|
||||
collapsed: prefix_collapsed || collapsed.is_some(),
|
||||
collapsed: prefix_collapsed || fold.is_some(),
|
||||
});
|
||||
}
|
||||
// The expansion is the end of the run too: the next prefix's items follow
|
||||
// immediately and must not inherit it (improvise-gd83).
|
||||
if last_group.is_some() {
|
||||
result.push(AxisEntry::GroupEnd { level });
|
||||
runs.enter(&[]);
|
||||
runs.out
|
||||
}
|
||||
|
||||
/// One category's group runs as `expand_category` emits them: the chain of groups
|
||||
/// open, root first, and the markers that move it from item to item.
|
||||
struct CategoryRuns<'a> {
|
||||
level: usize,
|
||||
cat_name: &'a str,
|
||||
view: &'a View,
|
||||
open: Vec<&'a str>,
|
||||
out: Vec<AxisEntry>,
|
||||
}
|
||||
|
||||
impl<'a> CategoryRuns<'a> {
|
||||
/// Move the open chain to `chain` in `remark`'s own convention, so an unpruned
|
||||
/// axis survives pruning marker for marker (core.md §6).
|
||||
fn enter(&mut self, chain: &[&'a str]) {
|
||||
let shared = self
|
||||
.open
|
||||
.iter()
|
||||
.zip(chain)
|
||||
.take_while(|(open, want)| open == want)
|
||||
.count();
|
||||
let level = self.level;
|
||||
for depth in (shared + 1..self.open.len()).rev() {
|
||||
self.out.push(AxisEntry::GroupEnd { level, depth });
|
||||
}
|
||||
if chain.len() == shared && self.open.len() > shared {
|
||||
self.out.push(AxisEntry::GroupEnd {
|
||||
level,
|
||||
depth: shared,
|
||||
});
|
||||
}
|
||||
self.open.truncate(shared);
|
||||
for (depth, group) in chain.iter().enumerate().skip(shared) {
|
||||
self.out.push(AxisEntry::GroupHeader {
|
||||
level,
|
||||
depth,
|
||||
cat_name: self.cat_name.to_string(),
|
||||
group_name: group.to_string(),
|
||||
collapsed: self
|
||||
.view
|
||||
.is_group_collapsed(self.cat_name, &chain[..=depth]),
|
||||
});
|
||||
self.open.push(group);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Cartesian product of visible items across `cats`, headers always emitted.
|
||||
@@ -1026,8 +1082,10 @@ mod tests {
|
||||
AxisEntry::item(vec!["A".into()]),
|
||||
AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
cat_name: "C".into(),
|
||||
group_name: "G".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["B".into()]),
|
||||
AxisEntry::item(vec!["C".into()]),
|
||||
@@ -1051,8 +1109,10 @@ mod tests {
|
||||
let mut items = vec![
|
||||
AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
cat_name: "C".into(),
|
||||
group_name: "G".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["A".into()]),
|
||||
AxisEntry::item(vec!["B".into()]),
|
||||
@@ -1418,6 +1478,7 @@ mod tests {
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count
|
||||
@@ -1430,6 +1491,7 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let headers: Vec<_> = layout
|
||||
@@ -1457,8 +1519,9 @@ 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.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let labels: Vec<String> = (0..layout.row_count())
|
||||
.map(|r| layout.row_label(r))
|
||||
@@ -1489,8 +1552,9 @@ mod tests {
|
||||
wb.add_item_in_group("Month", "A", "G1").unwrap();
|
||||
wb.add_item_in_group("Month", "B", "G2").unwrap();
|
||||
wb.add_item_in_group("Month", "C", "G1").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "G1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["G1"]);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let labels: Vec<String> = (0..layout.row_count())
|
||||
.map(|r| layout.row_label(r))
|
||||
@@ -1518,10 +1582,11 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(
|
||||
layout.row_tuple(0),
|
||||
@@ -1543,6 +1608,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("Month", "Sep").unwrap();
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
@@ -1572,6 +1638,7 @@ mod tests {
|
||||
wb.add_item_in_group("Recipient", "Alice", "Family")
|
||||
.unwrap();
|
||||
wb.add_item_in_group("Recipient", "Bob", "Family").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Recipient", 1);
|
||||
wb.active_view_mut().set_axis("Recipient", Axis::Row);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
|
||||
@@ -1612,6 +1679,7 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.model.set_cell(
|
||||
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
||||
@@ -1630,6 +1698,7 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
// visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||
@@ -1646,6 +1715,7 @@ mod tests {
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
|
||||
assert_eq!(layout.data_col_to_visual(0), Some(1));
|
||||
@@ -1660,6 +1730,7 @@ mod tests {
|
||||
wb.add_category("Type").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(
|
||||
@@ -1691,6 +1762,7 @@ mod tests {
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
assert_eq!(
|
||||
layout.col_group_for(0),
|
||||
@@ -1725,6 +1797,7 @@ mod tests {
|
||||
None => wb.add_item("Month", *name).unwrap(),
|
||||
};
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for m in funded {
|
||||
wb.model.set_cell(
|
||||
@@ -1755,7 +1828,7 @@ mod tests {
|
||||
&[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)],
|
||||
&["Jan", "Sep"],
|
||||
);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None);
|
||||
// The whole sequence, not `any(is Q1)`: that cannot see a header
|
||||
// pushed in the wrong place, nor a stray `GroupEnd`.
|
||||
@@ -1764,14 +1837,16 @@ mod tests {
|
||||
vec![
|
||||
AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
cat_name: "Month".into(),
|
||||
group_name: "Q1".into()
|
||||
group_name: "Q1".into(),
|
||||
collapsed: true,
|
||||
},
|
||||
AxisEntry::DataItem {
|
||||
tuple: vec!["Q1".into()],
|
||||
collapsed: true
|
||||
},
|
||||
AxisEntry::GroupEnd { level: 0 },
|
||||
AxisEntry::GroupEnd { level: 0, depth: 0 },
|
||||
AxisEntry::item(vec!["Sep".into()]),
|
||||
],
|
||||
"Q1 folds to one funded row under its header, and Mar pruned"
|
||||
@@ -1814,6 +1889,8 @@ mod tests {
|
||||
wb.add_item_in_group("Region", "South", "Lower").unwrap();
|
||||
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.model.set_cell(
|
||||
@@ -1895,6 +1972,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();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] {
|
||||
@@ -1937,7 +2015,7 @@ mod tests {
|
||||
.collect();
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
let mut wb = margin_book(&chain);
|
||||
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());
|
||||
let unpruned = GridLayout::new(&wb.model, wb.active_view());
|
||||
let kept_by_fresh_evaluation: Vec<String> = (0..unpruned.row_count())
|
||||
@@ -1976,7 +2054,7 @@ mod tests {
|
||||
.collect();
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
let mut wb = margin_book(&chain);
|
||||
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());
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let q1 = (0..layout.row_count())
|
||||
@@ -2055,7 +2133,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_grid_evaluates_under_its_own_covers_not_another_grids() {
|
||||
let mut wb = margin_book(&["R = Revenue / Cost"]);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
let all = GridLayout::new(&wb.model, wb.active_view());
|
||||
let mut no_feb_view = wb.active_view().clone();
|
||||
no_feb_view.hide_item("Month", "Feb");
|
||||
@@ -2093,7 +2171,7 @@ mod tests {
|
||||
let mut folded = margin_book(formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
.toggle_group_collapse("Month", &["Q1"]);
|
||||
let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold");
|
||||
|
||||
let mut none = margin_book(formulas);
|
||||
@@ -2120,7 +2198,7 @@ mod tests {
|
||||
let mut folded = margin_book(&formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
.toggle_group_collapse("Month", &["Q1"]);
|
||||
let fold = grid_value(&mut folded, "Q1", measure);
|
||||
let mut none = margin_book(&formulas);
|
||||
none.active_view_mut().set_axis("Month", Axis::None);
|
||||
@@ -2133,7 +2211,7 @@ mod tests {
|
||||
let mut folded = margin_book(&formulas);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
.toggle_group_collapse("Month", &["Q1"]);
|
||||
assert_eq!(
|
||||
grid_value(&mut folded, "Q1", "Flag"),
|
||||
Some(0.0),
|
||||
@@ -2146,7 +2224,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_folds_aggregate_formula_pools_its_members_records() {
|
||||
let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0));
|
||||
assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0));
|
||||
}
|
||||
@@ -2159,7 +2237,7 @@ mod tests {
|
||||
let mut folded = margin_book(&formula);
|
||||
folded
|
||||
.active_view_mut()
|
||||
.toggle_group_collapse("Month", "Q1");
|
||||
.toggle_group_collapse("Month", &["Q1"]);
|
||||
let fold = grid_value(&mut folded, "Q1", "Jans");
|
||||
|
||||
let mut none = margin_book(&formula);
|
||||
@@ -2198,12 +2276,13 @@ mod tests {
|
||||
CellValue::Number(n),
|
||||
);
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("Type", Axis::Column);
|
||||
wb.active_view_mut().hide_item("Month", "Feb");
|
||||
assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn");
|
||||
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]);
|
||||
assert_eq!(column_total(&wb), 103.0, "z moved the total");
|
||||
assert_eq!(
|
||||
grid_value(&mut wb, "Q1", "Food"),
|
||||
@@ -2290,7 +2369,7 @@ mod group_membership {
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
/// Every data item's open runs, collected through the one reader.
|
||||
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<Option<usize>>> {
|
||||
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<super::OpenRun>> {
|
||||
let mut all = Vec::new();
|
||||
walk_open_runs(items, |open| {
|
||||
all.push(open.to_vec());
|
||||
@@ -2300,7 +2379,9 @@ mod group_membership {
|
||||
}
|
||||
|
||||
type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]);
|
||||
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]);
|
||||
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)], &'a [Parent<'a>]);
|
||||
/// `(category, group, parent)`: a `>` line.
|
||||
type Parent<'a> = (&'a str, &'a str, &'a str);
|
||||
|
||||
const TWO: &[Level] = &[
|
||||
("Region", &[("North", Some("Upper")), ("South", None)]),
|
||||
@@ -2354,19 +2435,80 @@ mod group_membership {
|
||||
(
|
||||
TWO,
|
||||
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
|
||||
&[],
|
||||
),
|
||||
(
|
||||
THREE,
|
||||
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
|
||||
&[],
|
||||
),
|
||||
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]),
|
||||
(HOLE, &[("Region", "Upper"), ("Month", "Q1")]),
|
||||
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]),
|
||||
(HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]),
|
||||
(
|
||||
ADJACENT,
|
||||
&[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")],
|
||||
&[],
|
||||
),
|
||||
(
|
||||
NESTED,
|
||||
&[
|
||||
("Region", "Upper"),
|
||||
("Month", "H1"),
|
||||
("Month", "Q1"),
|
||||
("Month", "H2"),
|
||||
],
|
||||
NESTED_PARENTS,
|
||||
),
|
||||
];
|
||||
|
||||
/// improvise-felo.6: a hierarchy INSIDE one category, under an outer group.
|
||||
/// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2.
|
||||
const NESTED: &[Level] = &[
|
||||
("Region", &[("North", Some("Upper")), ("South", None)]),
|
||||
(
|
||||
"Month",
|
||||
&[
|
||||
("Jan", Some("Q1")),
|
||||
("Apr", Some("Q2")),
|
||||
("Feb", Some("Q1")),
|
||||
("Jun", Some("H1")),
|
||||
("Jul", Some("Q3")),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
const NESTED_PARENTS: &[Parent] = &[
|
||||
("Month", "Q1", "H1"),
|
||||
("Month", "Q2", "H1"),
|
||||
("Month", "Q3", "H2"),
|
||||
];
|
||||
|
||||
/// [`book`] with `parents` declared and every group OPEN before `collapsed`
|
||||
/// folds its own, so a subset of groups is exactly what is collapsed.
|
||||
fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook {
|
||||
let mut wb = book(levels, &[]);
|
||||
for (cat, group, parent) in parents {
|
||||
let c = wb.model.category_mut(cat).unwrap();
|
||||
c.add_group(crate::model::category::Group::new(*group).with_parent(*parent));
|
||||
}
|
||||
for (cat, _) in levels {
|
||||
wb.active_view_mut().set_expand_depth(cat, 8);
|
||||
}
|
||||
for (cat, group) in collapsed {
|
||||
let chain: Vec<String> = wb
|
||||
.model
|
||||
.category(cat)
|
||||
.unwrap()
|
||||
.group_chain(group)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
|
||||
wb.active_view_mut().toggle_group_collapse(cat, &chain);
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
/// `levels` on Row, one column, the groups `collapsed` names folded. No cell
|
||||
/// is set: the unpruned layout has every row, and pruning is driven directly.
|
||||
fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook {
|
||||
@@ -2380,12 +2522,13 @@ mod group_membership {
|
||||
};
|
||||
}
|
||||
wb.active_view_mut().set_axis(cat, Axis::Row);
|
||||
wb.active_view_mut().set_expand_depth(cat, 1);
|
||||
}
|
||||
wb.add_category("Col").unwrap();
|
||||
wb.add_item("Col", "X").unwrap();
|
||||
wb.active_view_mut().set_axis("Col", Axis::Column);
|
||||
for (cat, group) in collapsed {
|
||||
wb.active_view_mut().toggle_group_collapse(cat, group);
|
||||
wb.active_view_mut().toggle_group_collapse(cat, &[group]);
|
||||
}
|
||||
wb
|
||||
}
|
||||
@@ -2406,13 +2549,13 @@ mod group_membership {
|
||||
/// non-empty set of kept rows. The layout is the cost, so it is built once.
|
||||
fn cases() -> Vec<Case> {
|
||||
let mut out = Vec::new();
|
||||
for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() {
|
||||
for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() {
|
||||
for folds in 0..(1u32 << groups.len()) {
|
||||
let collapsed: Vec<(&str, &str)> = (0..groups.len())
|
||||
.filter(|g| folds & (1 << g) != 0)
|
||||
.map(|g| groups[g])
|
||||
.collect();
|
||||
let full = layout(&book(levels, &collapsed)).row_items;
|
||||
let full = layout(&nested_book(levels, parents, &collapsed)).row_items;
|
||||
let rows = data_items(&full).count();
|
||||
for mask in 1..(1u32 << rows) {
|
||||
let mut pruned = full.clone();
|
||||
@@ -2441,8 +2584,8 @@ mod group_membership {
|
||||
}
|
||||
}
|
||||
|
||||
fn at(runs: &[Option<usize>], level: usize) -> Option<usize> {
|
||||
runs.get(level).copied().flatten()
|
||||
fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option<usize> {
|
||||
runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2463,24 +2606,25 @@ mod group_membership {
|
||||
if kept.len() == full_runs.len() {
|
||||
assert_eq!(pruned, full, "{name}: nothing pruned");
|
||||
}
|
||||
let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let levels: std::collections::BTreeSet<super::RunKey> =
|
||||
full_runs.iter().flatten().map(|(k, _)| *k).collect();
|
||||
for (r, &u) in kept.iter().enumerate() {
|
||||
for level in 0..levels {
|
||||
for &level in &levels {
|
||||
assert_eq!(
|
||||
named(pruned, at(&pruned_runs[r], level)),
|
||||
named(full, at(&full_runs[u], level)),
|
||||
"{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}"
|
||||
"{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}"
|
||||
);
|
||||
if r == 0 {
|
||||
continue;
|
||||
}
|
||||
let same = |runs: &[Vec<Option<usize>>], a: usize, b: usize| {
|
||||
let same = |runs: &[Vec<super::OpenRun>], a: usize, b: usize| {
|
||||
at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level)
|
||||
};
|
||||
assert_eq!(
|
||||
same(&pruned_runs, r - 1, r),
|
||||
same(&full_runs, kept[r - 1], u),
|
||||
"{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}"
|
||||
"{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2538,14 +2682,18 @@ mod group_membership {
|
||||
let items = vec![
|
||||
AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
cat_name: "Region".into(),
|
||||
group_name: "Upper".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["North".into()]),
|
||||
AxisEntry::GroupHeader {
|
||||
level: usize::MAX,
|
||||
depth: 0,
|
||||
cat_name: "Nowhere".into(),
|
||||
group_name: "Unreachable".into(),
|
||||
collapsed: false,
|
||||
},
|
||||
AxisEntry::item(vec!["South".into()]),
|
||||
];
|
||||
@@ -2561,14 +2709,16 @@ mod group_membership {
|
||||
fn a_late_lookup_answers_for_its_own_row() {
|
||||
let header = |group: &str| AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
cat_name: "Month".into(),
|
||||
group_name: group.into(),
|
||||
collapsed: false,
|
||||
};
|
||||
let items = vec![
|
||||
header("Q1"),
|
||||
AxisEntry::item(vec!["Jan".into()]),
|
||||
AxisEntry::item(vec!["Feb".into()]),
|
||||
AxisEntry::GroupEnd { level: 0 },
|
||||
AxisEntry::GroupEnd { level: 0, depth: 0 },
|
||||
AxisEntry::item(vec!["Mar".into()]),
|
||||
header("Q2"),
|
||||
AxisEntry::item(vec!["Apr".into()]),
|
||||
@@ -2601,4 +2751,222 @@ mod group_membership {
|
||||
);
|
||||
assert_eq!(l.row_group_for(at(["South", "Mar"])), None);
|
||||
}
|
||||
|
||||
/// Precondition for the enumeration above: NESTED must really produce runs
|
||||
/// below depth 0, or it enumerates nothing new.
|
||||
#[test]
|
||||
fn the_nested_fixture_draws_runs_inside_one_category() {
|
||||
let wb = nested_book(NESTED, NESTED_PARENTS, &[]);
|
||||
let deep = layout(&wb)
|
||||
.row_items
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. }))
|
||||
.count();
|
||||
assert!(
|
||||
deep >= 4,
|
||||
"Q1, Q2, Q1 again and Q3 under each region: {deep}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month.
|
||||
#[cfg(test)]
|
||||
mod hierarchy {
|
||||
use super::{AxisEntry, GridLayout};
|
||||
use crate::model::category::Group;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
/// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are
|
||||
/// non-contiguous. Values are powers of ten, so every fold is its own sum.
|
||||
fn dates() -> Workbook {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Date").unwrap();
|
||||
for (month, quarter) in [
|
||||
("2024-01", Some("Q1-2024")),
|
||||
("2024-04", Some("Q2-2024")),
|
||||
("2025-01", Some("Q1-2025")),
|
||||
("Undated", None),
|
||||
("2024-02", Some("Q1-2024")),
|
||||
] {
|
||||
match quarter {
|
||||
Some(q) => wb.add_item_in_group("Date", month, q).unwrap(),
|
||||
None => wb.add_item("Date", month).unwrap(),
|
||||
};
|
||||
}
|
||||
let cat = wb.model.category_mut("Date").unwrap();
|
||||
for (quarter, year) in [
|
||||
("Q1-2024", "2024"),
|
||||
("Q2-2024", "2024"),
|
||||
("Q1-2025", "2025"),
|
||||
] {
|
||||
cat.add_group(Group::new(quarter).with_parent(year));
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Date", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
for (month, v) in [
|
||||
("2024-01", 1.0),
|
||||
("2024-02", 10.0),
|
||||
("2024-04", 100.0),
|
||||
("2025-01", 1000.0),
|
||||
("Undated", 5.0),
|
||||
] {
|
||||
wb.model.set_cell(key(month), CellValue::Number(v));
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
fn key(month: &str) -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("Date".into(), month.into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn layout(wb: &Workbook) -> GridLayout {
|
||||
GridLayout::with_frozen_records(&wb.model, wb.active_view(), None)
|
||||
}
|
||||
|
||||
fn rows(l: &GridLayout) -> Vec<String> {
|
||||
(0..l.row_count()).map(|r| l.row_label(r)).collect()
|
||||
}
|
||||
|
||||
fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option<f64> {
|
||||
let r = rows(l).iter().position(|x| x == label)?;
|
||||
l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64()
|
||||
}
|
||||
|
||||
fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry {
|
||||
AxisEntry::GroupHeader {
|
||||
level: 0,
|
||||
depth,
|
||||
cat_name: "Date".into(),
|
||||
group_name: group.into(),
|
||||
collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
fn end(depth: usize) -> AxisEntry {
|
||||
AxisEntry::GroupEnd { level: 0, depth }
|
||||
}
|
||||
|
||||
fn item(name: &str) -> AxisEntry {
|
||||
AxisEntry::item(vec![name.into()])
|
||||
}
|
||||
|
||||
/// Opened to depth 2, the whole sequence: runs nest by depth, a new year's
|
||||
/// header replaces the old, and a non-contiguous quarter reopens both.
|
||||
#[test]
|
||||
fn a_year_quarter_month_hierarchy_nests_its_runs() {
|
||||
let mut wb = dates();
|
||||
wb.active_view_mut().set_expand_depth("Date", 2);
|
||||
assert_eq!(
|
||||
layout(&wb).row_items,
|
||||
vec![
|
||||
header(0, "2024", false),
|
||||
header(1, "Q1-2024", false),
|
||||
item("2024-01"),
|
||||
header(1, "Q2-2024", false),
|
||||
item("2024-04"),
|
||||
end(1),
|
||||
header(0, "2025", false),
|
||||
header(1, "Q1-2025", false),
|
||||
item("2025-01"),
|
||||
end(1),
|
||||
end(0),
|
||||
item("Undated"),
|
||||
header(0, "2024", false),
|
||||
header(1, "Q1-2024", false),
|
||||
item("2024-02"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The user's ruling: a hierarchy starts closed, one row per root.
|
||||
#[test]
|
||||
fn a_hierarchy_opens_closed_as_one_fold_per_root() {
|
||||
let wb = dates();
|
||||
let l = layout(&wb);
|
||||
assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]);
|
||||
assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024");
|
||||
assert_eq!(value(&wb, &l, "2025"), Some(1000.0));
|
||||
assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2));
|
||||
}
|
||||
|
||||
/// Open the years only: each quarter folds its months, ONCE, even when its
|
||||
/// months are not contiguous.
|
||||
#[test]
|
||||
fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() {
|
||||
let mut wb = dates();
|
||||
wb.active_view_mut().set_expand_depth("Date", 1);
|
||||
let l = layout(&wb);
|
||||
assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]);
|
||||
assert_eq!(
|
||||
value(&wb, &l, "Q1-2024"),
|
||||
Some(11.0),
|
||||
"Jan and the late Feb"
|
||||
);
|
||||
assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0));
|
||||
}
|
||||
|
||||
/// Collapsing an outer group folds everything under it, whatever its
|
||||
/// children's own state.
|
||||
#[test]
|
||||
fn collapsing_a_parent_folds_its_children_whatever_their_state() {
|
||||
let mut wb = dates();
|
||||
wb.active_view_mut().set_expand_depth("Date", 2);
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Date", &["2024"]);
|
||||
let l = layout(&wb);
|
||||
assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]);
|
||||
assert_eq!(value(&wb, &l, "2024"), Some(111.0));
|
||||
}
|
||||
|
||||
/// `z` names the innermost open group: a month's quarter, a quarter fold's
|
||||
/// own quarter, a year fold's year — at a nonzero row each time.
|
||||
#[test]
|
||||
fn the_group_under_the_cursor_is_the_innermost_at_every_depth() {
|
||||
let mut wb = dates();
|
||||
wb.active_view_mut().set_expand_depth("Date", 1);
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Date", &["2024", "Q2-2024"]);
|
||||
let l = layout(&wb);
|
||||
let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap();
|
||||
let g = |name: &str| Some(("Date".to_string(), name.to_string()));
|
||||
assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024"));
|
||||
assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025"));
|
||||
assert_eq!(l.row_group_for(at("Undated")), None);
|
||||
wb.active_view_mut().set_expand_depth("Date", 0);
|
||||
let l = layout(&wb);
|
||||
assert_eq!(l.row_group_for(1), g("2025"));
|
||||
}
|
||||
|
||||
/// A hidden month leaves every fold above it; a quarter whose every month
|
||||
/// is hidden has no row and no header.
|
||||
#[test]
|
||||
fn hidden_items_leave_every_fold_above_them() {
|
||||
let mut wb = dates();
|
||||
wb.active_view_mut().hide_item("Date", "2024-04");
|
||||
let l = layout(&wb);
|
||||
assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden");
|
||||
wb.active_view_mut().set_expand_depth("Date", 2);
|
||||
let l = layout(&wb);
|
||||
let q2 = |e: &AxisEntry| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024");
|
||||
assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items);
|
||||
}
|
||||
|
||||
/// `prune_empty` at depth: an empty quarter goes, header and all, and the
|
||||
/// year above it stays for the quarter that has data.
|
||||
#[test]
|
||||
fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() {
|
||||
let mut wb = dates();
|
||||
wb.model.clear_cell(&key("2024-04"));
|
||||
wb.active_view_mut().set_expand_depth("Date", 1);
|
||||
wb.active_view_mut().prune_empty = true;
|
||||
let l = layout(&wb);
|
||||
assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]);
|
||||
assert_eq!(l.row_items[0], header(0, "2024", false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
|
||||
@@ -1237,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,
|
||||
@@ -1066,6 +1078,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 +1163,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 +1363,7 @@ mod tests {
|
||||
FrameEntry::item(vec!["A".into(), "y".into()]),
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: "Late".into(),
|
||||
collapsed: true,
|
||||
}),
|
||||
@@ -1362,6 +1377,7 @@ mod tests {
|
||||
fn repeated_group_frame() -> AxisFrame {
|
||||
let q1 = || FrameGroup {
|
||||
level: 1,
|
||||
depth: 0,
|
||||
name: "Q1".into(),
|
||||
collapsed: false,
|
||||
};
|
||||
@@ -1388,12 +1404,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 +1423,7 @@ mod tests {
|
||||
let group = |level: usize, name: &str| {
|
||||
FrameEntry::Group(FrameGroup {
|
||||
level,
|
||||
depth: 0,
|
||||
name: name.into(),
|
||||
collapsed: true,
|
||||
})
|
||||
@@ -1416,7 +1434,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 +1442,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 +1461,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 +1527,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 +1809,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 +2211,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 +2254,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,
|
||||
|
||||
@@ -8,7 +8,7 @@ pub mod socket;
|
||||
pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve};
|
||||
pub use keys::BrowserKey;
|
||||
pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow};
|
||||
pub use socket::EditTarget;
|
||||
pub use socket::{DownloadKind, EditTarget, PendingDownload};
|
||||
|
||||
use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot};
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -18,6 +18,8 @@ use wasm_bindgen::prelude::*;
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Client {
|
||||
state: ClientState,
|
||||
/// A download waiting for JavaScript to trigger (improvise-2mqz).
|
||||
pending_download: Option<PendingDownload>,
|
||||
}
|
||||
|
||||
/// An empty view slice and one empty cache per widget. Not exported: the
|
||||
@@ -92,6 +94,17 @@ impl Client {
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Take any pending download, returning it as JSON for JavaScript to handle.
|
||||
/// Returns `None` if there is no pending download (improvise-2mqz).
|
||||
#[wasm_bindgen]
|
||||
pub fn take_pending_download(&mut self) -> Result<Option<String>, JsError> {
|
||||
self.pending_download
|
||||
.take()
|
||||
.map(|d| serde_json::to_string(&d))
|
||||
.transpose()
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Everything the page draws, with every rendering decision already
|
||||
/// made. See [`render`].
|
||||
#[wasm_bindgen]
|
||||
@@ -243,6 +256,12 @@ impl Client {
|
||||
// Nothing to fold: a request moves no state of this client's, and
|
||||
// what it moves on the host comes back as a projection.
|
||||
KeyOutcome::Request(request) => Some(request.frame()),
|
||||
// Fold the commands locally (mode change), then send the request
|
||||
// for download bytes (improvise-2mqz).
|
||||
KeyOutcome::LocalThenRequest { cmds, request } => {
|
||||
self.state.fold_outgoing(&cmds);
|
||||
Some(request.frame())
|
||||
}
|
||||
KeyOutcome::Upstream(event) => Some(ClientFrame::key(event)),
|
||||
KeyOutcome::Ignored => None,
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ pub struct RenderModel {
|
||||
pub struct RenderMinibuffer {
|
||||
pub prompt: String,
|
||||
pub value: String,
|
||||
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
|
||||
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
|
||||
/// A colour, not a role: the TUI now draws a face per text-entry mode, and
|
||||
/// improvise-nn4s.4 moves the page onto the same faces (ADR 004).
|
||||
pub color: improvise_protocol::MinibufColor,
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option<Vec<RenderViewRow>> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, summary)| RenderViewRow {
|
||||
name: summary.name.clone(),
|
||||
name: summary.display.clone(),
|
||||
axes: improvise_protocol::viewmodel::view_axis_summary(summary),
|
||||
active: summary.name == state.view.active_view,
|
||||
selected: i == state.view.view_panel_cursor,
|
||||
@@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tile)| RenderTile {
|
||||
category: tile.category.clone(),
|
||||
category: tile.display.clone(),
|
||||
axis: tile.axis.plain_label(),
|
||||
selected: selecting && i == state.view.tile_cat_idx,
|
||||
})
|
||||
@@ -468,12 +468,13 @@ impl RenderCatRow {
|
||||
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
|
||||
match entry {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
display,
|
||||
item_count,
|
||||
expanded,
|
||||
axis,
|
||||
..
|
||||
} => Self {
|
||||
label: name.clone(),
|
||||
label: display.clone(),
|
||||
is_category: true,
|
||||
axis: axis.initial().to_string(),
|
||||
item_count: Some(*item_count),
|
||||
@@ -591,6 +592,7 @@ mod tests {
|
||||
fn group(name: &str, collapsed: bool) -> FrameEntry {
|
||||
FrameEntry::Group(improvise_protocol::FrameGroup {
|
||||
level: 0,
|
||||
depth: 0,
|
||||
name: name.into(),
|
||||
collapsed,
|
||||
})
|
||||
@@ -805,11 +807,13 @@ mod tests {
|
||||
CategoryTreeUpdate::Categories(vec![
|
||||
CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Region".into(),
|
||||
items: vec!["North".into(), "South".into()],
|
||||
axis: Axis::Row,
|
||||
},
|
||||
CategoryRow {
|
||||
name: "Measure".into(),
|
||||
display: "Measure".into(),
|
||||
items: vec!["Revenue".into()],
|
||||
axis: Axis::Column,
|
||||
},
|
||||
@@ -842,6 +846,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() {
|
||||
let mut client = init();
|
||||
let state = client.state_mut();
|
||||
state.fold(&Command::Project(Projection::CategoryTree(
|
||||
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
||||
name: "Region".into(),
|
||||
display: "Sales Area".into(),
|
||||
items: vec!["North".into()],
|
||||
axis: Axis::Row,
|
||||
}]),
|
||||
)));
|
||||
state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Overview".into(),
|
||||
axes: vec![],
|
||||
}])));
|
||||
state.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Sales Area".into(),
|
||||
axis: Axis::Row,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
}])));
|
||||
state.view.view_panel_open = true;
|
||||
state.view.active_view = "Default".into();
|
||||
let model = client.model();
|
||||
assert_eq!(model.categories[0].label, "Sales Area");
|
||||
assert_eq!(model.tiles[0].category, "Sales Area");
|
||||
let views = model.views.expect("the view panel is open");
|
||||
assert_eq!(views[0].name, "Overview");
|
||||
assert!(views[0].active, "the active view is still found by its key");
|
||||
}
|
||||
|
||||
fn client_with_panels() -> Client {
|
||||
let mut client = init();
|
||||
client
|
||||
@@ -849,10 +886,12 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::ViewList(vec![
|
||||
ViewSummary {
|
||||
name: "Default".into(),
|
||||
display: "Default".into(),
|
||||
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
|
||||
},
|
||||
ViewSummary {
|
||||
name: "Plan".into(),
|
||||
display: "Plan".into(),
|
||||
axes: vec![],
|
||||
},
|
||||
])));
|
||||
@@ -866,11 +905,13 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::TileBar(vec![
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Row,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Product".into(),
|
||||
display: "Product".into(),
|
||||
axis: Axis::None,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
@@ -1191,11 +1232,13 @@ mod tests {
|
||||
.fold(&Command::Project(Projection::TileBar(vec![
|
||||
TileEntry {
|
||||
category: "Quarter".into(),
|
||||
display: "Quarter".into(),
|
||||
axis: Axis::Page,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
display: "Region".into(),
|
||||
axis: Axis::Column,
|
||||
kind: improvise_protocol::CategoryKind::Regular,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,24 @@ use improvise_protocol::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A `DocumentBytes` or `CsvBytes` download held until JavaScript retrieves it
|
||||
/// through `Client::take_pending_download` (improvise-2mqz).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PendingDownload {
|
||||
pub filename: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub kind: DownloadKind,
|
||||
}
|
||||
|
||||
/// What kind of content is being downloaded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DownloadKind {
|
||||
/// The document in `.improv` format.
|
||||
Document,
|
||||
/// The active view as CSV.
|
||||
Csv,
|
||||
}
|
||||
|
||||
/// Everything an editor captures when it opens, held opaquely by the page.
|
||||
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -72,6 +90,7 @@ fn rejection_message(rejection: &Rejection) -> String {
|
||||
"this host has nowhere to write the document — it was not saved".into()
|
||||
}
|
||||
Rejection::SaveFailed => "the document could not be written".into(),
|
||||
Rejection::ExportFailed => "the view could not be exported".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +101,8 @@ impl Client {
|
||||
ClientFrame::Viewport { rows, cols }
|
||||
}
|
||||
|
||||
/// Fold one server frame, in arrival order.
|
||||
/// Fold one server frame in order. Downloads leave the revision unchanged
|
||||
/// and wait for JavaScript; other frames return the new revision.
|
||||
pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 {
|
||||
match frame {
|
||||
ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot),
|
||||
@@ -100,6 +120,22 @@ impl Client {
|
||||
state.notify(message);
|
||||
}
|
||||
ServerFrame::Accepted => self.state_mut().accepted(),
|
||||
// Download frames carry bytes the browser needs to trigger a download.
|
||||
// Store them for JavaScript to retrieve via `take_pending_download`.
|
||||
ServerFrame::DocumentBytes { filename, bytes } => {
|
||||
self.pending_download = Some(PendingDownload {
|
||||
filename,
|
||||
bytes,
|
||||
kind: DownloadKind::Document,
|
||||
});
|
||||
}
|
||||
ServerFrame::CsvBytes { filename, bytes } => {
|
||||
self.pending_download = Some(PendingDownload {
|
||||
filename,
|
||||
bytes,
|
||||
kind: DownloadKind::Csv,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.revision()
|
||||
}
|
||||
@@ -223,7 +259,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A rejection that refused an **edit** must say nothing was applied; the
|
||||
/// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8).
|
||||
/// save/export answers refused none. Evidence: clients.md (improvise-8uj8).
|
||||
#[test]
|
||||
fn every_rejection_message_says_what_it_refused() {
|
||||
let hash = surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879");
|
||||
@@ -238,9 +274,11 @@ mod tests {
|
||||
// Not "not applied": nothing was asked to apply. It must still
|
||||
// say the thing it *did* refuse did not happen.
|
||||
false => assert!(
|
||||
msg.contains("not saved") || msg.contains("could not be written"),
|
||||
"{rejection:?} refused a save but is worded as {msg:?}, \
|
||||
which does not say the document was not written"
|
||||
msg.contains("not saved")
|
||||
|| msg.contains("could not be written")
|
||||
|| msg.contains("could not be exported"),
|
||||
"{rejection:?} refused a save/export but is worded as {msg:?}, \
|
||||
which does not say the operation failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ fn a_grouped_row_axis_does_not_shift_the_write() {
|
||||
// two rows — a leading header is dropped by `AxisFrame::body_from`.
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item_in_group("Region", "East", "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);
|
||||
@@ -620,6 +621,103 @@ fn ctrl_s_in_the_browser_writes_the_hosts_document() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Codex on PR #329: committing a download closed the browser's prompt but
|
||||
/// left the host mirror in it, so a later projection could restore stale state.
|
||||
#[test]
|
||||
fn a_download_commit_keeps_the_host_session_mirror_in_lockstep() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{AppMode, ModelCommand, ServerFrame};
|
||||
|
||||
let mut host = host();
|
||||
let mut client = Client::new();
|
||||
let (id, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut client, &[snapshot]);
|
||||
|
||||
let open = client
|
||||
.key(&BrowserKey {
|
||||
key: "w".into(),
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
})
|
||||
.expect("Ctrl-W opens the save prompt");
|
||||
let opened = host.handle(id, open);
|
||||
deliver(&mut client, &opened);
|
||||
for key in ["d", "o", "c", ".", "i", "m", "p", "r", "o", "v"] {
|
||||
press(&mut host, id, &mut client, key);
|
||||
}
|
||||
assert!(matches!(
|
||||
client.state().view.mode,
|
||||
AppMode::SavePrompt { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
host.sessions().get(id).expect("attached").view().mode,
|
||||
client.state().view.mode,
|
||||
"setup: both ends must be in the prompt before Enter"
|
||||
);
|
||||
|
||||
let commit = client
|
||||
.key(&BrowserKey::new("Enter"))
|
||||
.expect("Enter composes a download request");
|
||||
assert!(
|
||||
matches!(
|
||||
&commit,
|
||||
ClientFrame::DownloadDocument { filename } if filename == "doc.improv"
|
||||
),
|
||||
"the USER RULING is a bytes request, got {commit:?}"
|
||||
);
|
||||
let answer = host.handle(id, commit);
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|frame| matches!(frame.msg, ServerFrame::DocumentBytes { .. })),
|
||||
"the request must still return bytes: {answer:?}"
|
||||
);
|
||||
deliver(&mut client, &answer);
|
||||
|
||||
let mirrored = host.sessions().get(id).expect("attached").view();
|
||||
assert_eq!(
|
||||
mirrored.mode,
|
||||
AppMode::Normal,
|
||||
"the host must close the prompt in its session mirror"
|
||||
);
|
||||
assert_eq!(
|
||||
mirrored.buffers.get("save").map(String::as_str),
|
||||
Some(""),
|
||||
"the host must clear the same minibuffer as the client"
|
||||
);
|
||||
|
||||
// Force a subsequent document projection from another session, then fold
|
||||
// what it addresses to the requester.
|
||||
let (bystander, _) = host.connect(10, 10);
|
||||
let write = caught_up(
|
||||
host.app(),
|
||||
ModelCommand::SetCell {
|
||||
view: "Default".into(),
|
||||
cell: client
|
||||
.record_cell_at(0, 0)
|
||||
.expect("North/Revenue is stored"),
|
||||
value: CellValue::Number(77.0),
|
||||
},
|
||||
);
|
||||
let projected = host.handle(bystander, ClientFrame::command(write));
|
||||
let for_requester: Vec<Addressed> = projected
|
||||
.into_iter()
|
||||
.filter(|frame| frame.to == id)
|
||||
.collect();
|
||||
assert!(
|
||||
!for_requester.is_empty(),
|
||||
"the regression must process a later projection"
|
||||
);
|
||||
deliver(&mut client, &for_requester);
|
||||
assert_eq!(
|
||||
&client.state().view,
|
||||
host.sessions().get(id).expect("attached").view(),
|
||||
"the later projection must leave the browser and host mirror in lockstep"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused frame must leave this client where the **host** says it is, and
|
||||
/// the host says so in its own mirror. See `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
|
||||
@@ -1595,6 +1595,9 @@ fn deliver(
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
improvise_protocol::ServerFrame::DocumentBytes { .. }
|
||||
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2258,6 +2261,7 @@ fn fold_host() -> App {
|
||||
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 month in ["Jan", "Feb", "Apr", "May"] {
|
||||
@@ -2272,7 +2276,7 @@ fn fold_host() -> App {
|
||||
}
|
||||
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_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
|
||||
@@ -2469,7 +2473,7 @@ fn advancing_the_editor_past_the_window_settles_where_the_tui_does() {
|
||||
let expanded = || {
|
||||
let mut app = fold_host();
|
||||
let view = app.model_state.workbook.active_view_mut();
|
||||
view.toggle_group_collapse("Month", "Q2");
|
||||
view.toggle_group_collapse("Month", &["Q2"]);
|
||||
app.rebuild_layout();
|
||||
app
|
||||
};
|
||||
@@ -3543,20 +3547,22 @@ fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App {
|
||||
for item in ["East", "West"] {
|
||||
wb.add_item_in_group("Region", item, "Coast").unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Region", 1);
|
||||
wb.add_item("Product", "Widget").unwrap();
|
||||
for item in ["Gadget", "Gizmo"] {
|
||||
wb.add_item_in_group("Product", item, "Kit").unwrap();
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth("Product", 1);
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
let view = wb.active_view_mut();
|
||||
view.set_axis("Region", Axis::Row);
|
||||
view.set_axis("Product", Axis::Row);
|
||||
view.set_axis("_Measure", Axis::Column);
|
||||
if coast_collapsed {
|
||||
view.toggle_group_collapse("Region", "Coast");
|
||||
view.toggle_group_collapse("Region", &["Coast"]);
|
||||
}
|
||||
if kit_collapsed {
|
||||
view.toggle_group_collapse("Product", "Kit");
|
||||
view.toggle_group_collapse("Product", &["Kit"]);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 12 + 8;
|
||||
@@ -3614,11 +3620,12 @@ fn folded_host() -> App {
|
||||
for item in ["East", "West"] {
|
||||
wb.add_item_in_group("Region", item, "Coast").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);
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Region", "Coast");
|
||||
.toggle_group_collapse("Region", &["Coast"]);
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 4 + 8;
|
||||
app.rebuild_layout();
|
||||
@@ -3727,6 +3734,9 @@ fn heard(
|
||||
}
|
||||
improvise_protocol::ServerFrame::Rejected(r) => client.refused(r),
|
||||
improvise_protocol::ServerFrame::Accepted => client.accepted(),
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
improvise_protocol::ServerFrame::DocumentBytes { .. }
|
||||
| improvise_protocol::ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
grid.clone()
|
||||
|
||||
@@ -113,6 +113,8 @@ impl MainThread {
|
||||
}
|
||||
}
|
||||
ServerFrame::Rejected(_) | ServerFrame::Accepted => {}
|
||||
// Download responses are not processed in this test context (improvise-2mqz).
|
||||
ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,11 +353,17 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
|
||||
}
|
||||
// The third column is `Profit`, which a formula owns: the page must not
|
||||
// offer it, so it carries no surrogate to offer (improvise-ir7l).
|
||||
for row in 0..main.grid.row_frame.len() {
|
||||
for (row, cells) in main
|
||||
.grid
|
||||
.cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(main.grid.row_frame.len())
|
||||
{
|
||||
assert!(
|
||||
matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })),
|
||||
matches!(cells[2].id, Some(CellId::Derived { .. })),
|
||||
"row {row}'s Profit cell must be Derived, got {:?}",
|
||||
main.grid.cells[row][2].id
|
||||
cells[2].id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -297,11 +297,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1790046670,
|
||||
"narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=",
|
||||
"lastModified": 1790185690,
|
||||
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6774f7bc253789b113a4f39285dc0fa100abeacc",
|
||||
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -368,11 +368,11 @@
|
||||
"nixpkgs": "nixpkgs_5"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790062494,
|
||||
"narHash": "sha256-Mds1vuoczq1fRm1rf+Iar8IKDBHNjOMo2JqqPWJUlng=",
|
||||
"lastModified": 1790234952,
|
||||
"narHash": "sha256-tjQa1vDoHJir4lasZTakz/xgmsfB6SRjeQqaIbefFOk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "b88764907c1b2de6c0e206d8d9d9634d91c81334",
|
||||
"rev": "ed34466c59b766481248959a21a6e46cf0c60468",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -47,13 +47,13 @@
|
||||
wasmBindgenCli = pkgs.buildWasmBindgenCli rec {
|
||||
src = pkgs.fetchCrate {
|
||||
pname = "wasm-bindgen-cli";
|
||||
version = "0.2.128";
|
||||
hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
|
||||
version = "0.2.129";
|
||||
hash = "sha256-pcecKQd7E8Opw6bkFoE569epUi7gh5qpQF1e5PJY6V8=";
|
||||
};
|
||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
inherit (src) pname version;
|
||||
hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
|
||||
hash = "sha256-vmUrWVU7kPJJxO5qIVeAkwQyWDELO1Z4Z5gitz2kco8=";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,6 +85,17 @@
|
||||
pkgs.jq
|
||||
];
|
||||
|
||||
# scripts/repo-stats runs code-maat (improvise-1xfb.5.1). The JRE is about
|
||||
# 0.6 GB, so it is kept out of ciInputs; only `nix-build` takes it.
|
||||
|
||||
codeMaatJar = pkgs.fetchurl {
|
||||
url = "https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar";
|
||||
hash = "sha256-QoeuIDSQGERFD3aVR7xEl6DaGm0cf6b63IWHBeZ0O18=";
|
||||
};
|
||||
codeMaat = pkgs.writeShellScriptBin "code-maat" ''
|
||||
exec ${pkgs.jre_headless}/bin/java -jar ${codeMaatJar} "$@"
|
||||
'';
|
||||
|
||||
# Playwright's downloaded browsers are generic-Linux builds that cannot
|
||||
# start on NixOS, so nixpkgs' build is used. harness/package.json pins
|
||||
# @playwright/test to this playwright-driver's version: they must match.
|
||||
@@ -114,6 +125,7 @@
|
||||
pkgs.global
|
||||
pkgs.universal-ctags
|
||||
beads.packages.${system}.default
|
||||
codeMaat
|
||||
|
||||
# Demo recording and release tooling
|
||||
pkgs.asciinema
|
||||
@@ -129,6 +141,13 @@
|
||||
// {
|
||||
improvise = cargoNix.workspaceMembers.improvise.build;
|
||||
default = self.packages.${system}.improvise;
|
||||
code-maat = codeMaat;
|
||||
# What the repo-stats pipeline runs besides the ci shell: one
|
||||
# installable, because check-ci-graph reads `nix shell <one> --command`.
|
||||
repo-stats-tools = pkgs.symlinkJoin {
|
||||
name = "repo-stats-tools";
|
||||
paths = [self.packages.${system}.improvise codeMaat];
|
||||
};
|
||||
};
|
||||
|
||||
# `nix flake check` builds every CI build: crane's checks plus the
|
||||
|
||||
@@ -11,6 +11,8 @@ _Measure: page, Revenue
|
||||
_Index: none
|
||||
_Dim: none
|
||||
collapsed: Region/Upland
|
||||
expanded(1): Month
|
||||
expanded(1): Region
|
||||
format: ,.0
|
||||
|
||||
## Category: Region
|
||||
|
||||
@@ -8,6 +8,7 @@ _Dim: none
|
||||
_Measure: page
|
||||
Region: row
|
||||
format: ,.0
|
||||
expanded(1): Region
|
||||
|
||||
## View: Tall
|
||||
_Index: none
|
||||
@@ -15,6 +16,7 @@ _Dim: none
|
||||
_Measure: column
|
||||
Region: row
|
||||
format: ,.0
|
||||
expanded(1): Region
|
||||
|
||||
## Category: _Measure
|
||||
- Revenue, Cost
|
||||
|
||||
Generated
+31
-14
@@ -187,6 +187,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"siphasher",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -238,9 +239,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",
|
||||
@@ -471,12 +472,27 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ucd-trie"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||
|
||||
[[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"
|
||||
@@ -485,9 +501,9 @@ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[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",
|
||||
@@ -498,19 +514,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",
|
||||
@@ -518,9 +535,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",
|
||||
@@ -531,18 +548,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",
|
||||
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stack-agnostic agent bootstrap for this repository.
|
||||
#
|
||||
# This script prepares any coding agent (Claude Code, Cursor, etc.) to work
|
||||
# on this repo. It handles:
|
||||
# 1. Nix flake input seeding (so `nix develop` works in cloud environments)
|
||||
# 2. bd (beads) installation and pin verification
|
||||
# 3. Issue tracker sync from the Dolt remote
|
||||
# 4. Optional cargo workspace warming (inside nix develop)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/agent-bootstrap.sh [--skip-cargo-warm] [--skip-nix-seed]
|
||||
#
|
||||
# The script is idempotent and safe to re-run. Nothing here writes to tracked
|
||||
# files, stages anything, or creates a commit.
|
||||
#
|
||||
# IMPORTANT: This repo requires `nix develop` (or direnv) for builds/tests.
|
||||
# Bare system cargo that fails edition2024 is a misconfigured session.
|
||||
#
|
||||
# See also: `.claude/hooks/session-start.sh` (Claude Code wrapper),
|
||||
# `.cursor/environment.json` (Cursor cloud config)
|
||||
set -uo pipefail
|
||||
|
||||
SKIP_CARGO_WARM=0
|
||||
SKIP_NIX_SEED=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-cargo-warm) SKIP_CARGO_WARM=1; shift ;;
|
||||
--skip-nix-seed) SKIP_NIX_SEED=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT="${AGENT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
|
||||
cd "$ROOT" || exit 0
|
||||
|
||||
log() { printf '[agent-bootstrap] %s\n' "$*"; }
|
||||
|
||||
nix_status=0
|
||||
|
||||
ensure_nix_daemon() {
|
||||
command -v nix >/dev/null 2>&1 || return 1
|
||||
|
||||
# Single-user Nix does not need a daemon; multi-user/cloud installs do.
|
||||
# If the store already answers, leave the environment alone.
|
||||
if nix --extra-experimental-features 'nix-command' store ping >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v nix-daemon >/dev/null 2>&1; then
|
||||
log "ERROR: Nix is installed, but the store is unavailable and nix-daemon is not on PATH"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "starting nix-daemon for bootstrap…"
|
||||
nix-daemon >/tmp/nix-daemon.log 2>&1 &
|
||||
|
||||
# The daemon is started asynchronously; wait briefly for its socket before
|
||||
# invoking the seed/build steps that depend on it.
|
||||
for _ in {1..50}; do
|
||||
if nix --extra-experimental-features 'nix-command' store ping --store daemon >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
log "ERROR: nix-daemon did not become ready; see /tmp/nix-daemon.log"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Start the daemon during cloud-agent installation, before any required Nix
|
||||
# operation. This is harmless for single-user Nix because nix-store --ping
|
||||
# succeeds without starting a daemon.
|
||||
if [ "$SKIP_NIX_SEED" -eq 0 ] || [ "$SKIP_CARGO_WARM" -eq 0 ]; then
|
||||
if ! ensure_nix_daemon; then
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 0. Seed nix flake inputs (required for cloud environments) ─────────────
|
||||
# GitHub tarball endpoints are blocked in cloud containers. This seeds the
|
||||
# flake inputs over git so `nix develop` can build the dev shell.
|
||||
if [ "$SKIP_NIX_SEED" -eq 0 ]; then
|
||||
if [ "$nix_status" -ne 0 ]; then
|
||||
log "ERROR: cannot seed flake inputs because Nix is unavailable"
|
||||
elif ! command -v nix >/dev/null 2>&1; then
|
||||
log "ERROR: nix not available; required flake input seeding cannot run"
|
||||
nix_status=1
|
||||
else
|
||||
# Set SSL cert for proxy environments
|
||||
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /root/.ccr/ca-bundle.crt ]; then
|
||||
export NIX_SSL_CERT_FILE=/root/.ccr/ca-bundle.crt
|
||||
fi
|
||||
|
||||
if [ ! -f "$ROOT/scripts/nix-seed-flake-inputs.sh" ]; then
|
||||
log "ERROR: required scripts/nix-seed-flake-inputs.sh is missing"
|
||||
nix_status=1
|
||||
else
|
||||
log "seeding nix flake inputs (GitHub tarballs blocked in cloud)…"
|
||||
if "$ROOT/scripts/nix-seed-flake-inputs.sh" >/tmp/nix-seed.log 2>&1; then
|
||||
log "flake inputs seeded"
|
||||
else
|
||||
log "ERROR: nix-seed-flake-inputs.sh failed; see /tmp/nix-seed.log"
|
||||
log " 'nix develop' is not validated for this session."
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 1. The bd on PATH must be the bd flake.nix pins ────────────────────────
|
||||
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[agent-bootstrap] ===== BEADS VERSION SKEW =====\n'
|
||||
printf '[agent-bootstrap] %s\n' "$@"
|
||||
printf '[agent-bootstrap] =============================\n\n'
|
||||
} >&2
|
||||
}
|
||||
|
||||
assert_bd_pin() {
|
||||
command -v bd >/dev/null 2>&1 || return 0
|
||||
|
||||
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
|
||||
|
||||
# Install a verified database at .beads/embeddeddolt.
|
||||
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 -f "$live" "$previous"; then
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if mv -f "$incoming" "$live"; then
|
||||
rm -rf "$previous"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$previous" ] && ! mv -f "$previous" "$live"; then
|
||||
log "ERROR: tracker left at $previous — move it back to $live by hand"
|
||||
fi
|
||||
rm -rf "$incoming"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── 2. bd on PATH ──────────────────────────────────────────────────────────
|
||||
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
|
||||
|
||||
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
|
||||
|
||||
# Export PATH for the calling environment if AGENT_ENV_FILE is set
|
||||
if [ -n "${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE:-}}" ]; then
|
||||
env_file="${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE}}"
|
||||
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$env_file"
|
||||
fi
|
||||
|
||||
if [ "$pin_status" -eq 0 ]; then
|
||||
assert_bd_pin || pin_status=2
|
||||
fi
|
||||
|
||||
# Do not run any tracker/config mutation through a bd binary whose version does
|
||||
# not match the repository pin.
|
||||
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
|
||||
bd metrics off >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── 3. Sync the issue tracker from the Dolt remote ─────────────────────────
|
||||
if [ "$pin_status" -eq 0 ] && 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:}"
|
||||
git remote add gh "$ORIGIN"
|
||||
;;
|
||||
esac
|
||||
ORIGIN="${ORIGIN%.git}"
|
||||
|
||||
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
|
||||
log "syncing issue tracker from $ORIGIN …"
|
||||
STAGE="$TMP/$DB_NAME"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
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
|
||||
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."
|
||||
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
|
||||
fi
|
||||
|
||||
# ── 4. Warm the build inside nix develop (optional) ────────────────────────
|
||||
# Uses nix develop to ensure the correct toolchain (rust from flake, wasm target, etc.)
|
||||
if [ "$SKIP_CARGO_WARM" -eq 0 ]; then
|
||||
if [ "$nix_status" -ne 0 ]; then
|
||||
log "ERROR: skipping required cargo warm because Nix bootstrap already failed"
|
||||
elif ! command -v nix >/dev/null 2>&1; then
|
||||
log "ERROR: nix not available; required cargo warm cannot run"
|
||||
nix_status=1
|
||||
else
|
||||
log "warming cargo build inside nix develop…"
|
||||
NIX_FLAGS=(--extra-experimental-features 'nix-command flakes')
|
||||
if nix "${NIX_FLAGS[@]}" develop --command cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
|
||||
log "workspace built (nix develop toolchain)"
|
||||
else
|
||||
log "ERROR: cargo build failed; see /tmp/cargo-warm.log"
|
||||
log " Required nix develop validation failed."
|
||||
nix_status=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5. Environment notes ───────────────────────────────────────────────────
|
||||
cat <<'NOTES'
|
||||
[agent-bootstrap] Environment notes (see context/cloud-agents.md):
|
||||
• This repo REQUIRES `nix develop` (or direnv) for builds and tests.
|
||||
Bare system cargo that fails edition2024 is a misconfigured session.
|
||||
• The bootstrap seeded flake inputs over git (GitHub tarballs are blocked
|
||||
in cloud). If nix develop still fails, check /tmp/nix-seed.log.
|
||||
• `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.
|
||||
• Use `scripts/kb` for knowledge base operations, not Prolog MCP.
|
||||
• Run commands inside `nix develop` or prefix with `nix develop --command`.
|
||||
NOTES
|
||||
|
||||
exit_status="$nix_status"
|
||||
if [ "$pin_status" -ne 0 ]; then
|
||||
exit_status="$pin_status"
|
||||
fi
|
||||
exit "$exit_status"
|
||||
@@ -101,6 +101,8 @@ ci_suites() {
|
||||
case "$1" in
|
||||
build) echo assemble-stamp ;;
|
||||
js) echo node-test ;;
|
||||
# Needs improvise and code-maat, which only this job has (tooling.md section 7).
|
||||
nix-build) echo repo-stats ;;
|
||||
# check-harness-lock must never read "could not resolve" as "stale" (improvise-zs2r).
|
||||
kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock agent-git ;;
|
||||
*) return 1 ;;
|
||||
|
||||
+46
-9
@@ -5,36 +5,47 @@ set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
|
||||
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews|--no-reviews]
|
||||
|
||||
--expect <sha> fail if the PR head is not this (prefix ok) at any poll
|
||||
--timeout <sec> give up after this long (default 1800)
|
||||
--interval <sec> seconds between polls (default 20)
|
||||
--reviews after checks complete, print reviews/comments (default)
|
||||
--no-reviews skip the reviews/comments printout
|
||||
|
||||
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS (and reviews fetched if --reviews)
|
||||
exit 1 a check failed, or the PR is not mergeable (any other known state)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for
|
||||
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
|
||||
|
||||
After exit 0, reviews and comments are printed (unless --no-reviews). Reviews are
|
||||
a separate API surface from comments; Codex/ChatGPT reviews arrive as *reviews*
|
||||
and are easy to miss if you only read the comment thread.
|
||||
EOF
|
||||
exit 4
|
||||
}
|
||||
|
||||
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20
|
||||
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20; SHOW_REVIEWS=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--expect) EXPECT="${2:-}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
||||
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage ;;
|
||||
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
||||
--expect) EXPECT="${2:-}"; shift 2 ;;
|
||||
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
||||
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
||||
--reviews) SHOW_REVIEWS=1; shift ;;
|
||||
--no-reviews) SHOW_REVIEWS=0; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PR" ] || usage
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# `gh` is shell-aliased to a different tool in this repo's shells, so the name
|
||||
# is never trusted: a real gh is located explicitly.
|
||||
fetch() {
|
||||
@@ -130,4 +141,30 @@ echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
|
||||
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
|
||||
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
|
||||
[ "$mergeable" = MERGEABLE ] || exit 1
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) ;; *) exit 1 ;; esac
|
||||
|
||||
# ── Reviews and comments ───────────────────────────────────────────────────
|
||||
# Checks are green and mergeable. Show reviews/comments so the agent sees any
|
||||
# feedback that needs answering before merge. This does not change the exit
|
||||
# code for success -- answering reviews is your responsibility. However, if
|
||||
# pr-reviews itself fails (usage error, network, etc.), we warn and exit 6.
|
||||
if [ "$SHOW_REVIEWS" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "pr-watch: fetching reviews and comments (--no-reviews to skip)…"
|
||||
reviews_exit=0
|
||||
if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then
|
||||
PR_REVIEWS_FIXTURE_DIR="$PR_WATCH_FIXTURE_DIR" "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
|
||||
else
|
||||
"$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
|
||||
fi
|
||||
echo ""
|
||||
if [ "$reviews_exit" -eq 0 ]; then
|
||||
echo "pr-watch: reviews shown above. Answer substantive feedback before merging."
|
||||
else
|
||||
echo "pr-watch: WARNING: pr-reviews failed (exit $reviews_exit). Reviews may not be shown." >&2
|
||||
echo "pr-watch: Run './scripts/pr-reviews $PR' manually to fetch reviews." >&2
|
||||
exit 6
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -20,8 +20,39 @@ run_case() {
|
||||
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
|
||||
}
|
||||
|
||||
# Like run_case but writes output to a caller-provided file.
|
||||
# Caller must NOT use command substitution (subshell loses ok/bad counters).
|
||||
# $1 name, $2 expected exit, $3 fixture dir, $4 output file, rest: extra args
|
||||
run_case_capture() {
|
||||
local name="$1" want="$2" dir="$3" outfile="$4"; shift 4
|
||||
PR_WATCH_FIXTURE_DIR="$dir" "$WATCH" 1 --interval 0 --timeout 1 "$@" >"$outfile" 2>&1
|
||||
local got=$?
|
||||
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
|
||||
}
|
||||
|
||||
mk() { mkdir -p "$1"; cat > "$1/$2"; }
|
||||
|
||||
# Create empty pr-reviews fixtures so pr-watch can call pr-reviews in fixture mode.
|
||||
# $1 dir, $2 head (must match the pr-watch fixture's headRefOid)
|
||||
mk_reviews_empty() {
|
||||
local dir="$1" head="$2"
|
||||
mkdir -p "$dir"
|
||||
printf '{"headRefOid":"%s","reviews":[]}\n' "$head" > "$dir/last.pr.json"
|
||||
echo '[]' > "$dir/last.inline.json"
|
||||
echo '[]' > "$dir/last.issue.json"
|
||||
}
|
||||
|
||||
# Create pr-reviews fixtures with a review. $1 dir, $2 head, $3 review commit
|
||||
mk_reviews_with_review() {
|
||||
local dir="$1" head="$2" review_commit="$3"
|
||||
mkdir -p "$dir"
|
||||
cat > "$dir/last.pr.json" <<EOF
|
||||
{"headRefOid":"$head","reviews":[{"submittedAt":"2026-09-25T12:00:00Z","state":"COMMENTED","author":{"login":"copilot"},"commit":{"oid":"$review_commit"},"body":"Test review body"}]}
|
||||
EOF
|
||||
echo '[]' > "$dir/last.inline.json"
|
||||
echo '[]' > "$dir/last.issue.json"
|
||||
}
|
||||
|
||||
# The state a fresh push is in: the PR exists, no check has registered yet.
|
||||
# A watcher keyed on "nothing pending" calls this finished and green.
|
||||
mk "$work/none" last.json <<'EOF'
|
||||
@@ -43,8 +74,43 @@ mk "$work/green" last.json <<'EOF'
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"},
|
||||
{"name":"skipped","status":"COMPLETED","conclusion":"SKIPPED"}]}
|
||||
EOF
|
||||
mk_reviews_empty "$work/green" "aaaaaaaaaaaa"
|
||||
run_case "all completed and green" 0 "$work/green"
|
||||
|
||||
# ── Reviews integration ────────────────────────────────────────────────────
|
||||
# pr-watch now calls pr-reviews after checks are green. Test that it works.
|
||||
mk "$work/green-reviews" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
|
||||
EOF
|
||||
mk_reviews_with_review "$work/green-reviews" "aaaaaaaaaaaa" "aaaaaaaaaaaa"
|
||||
run_case_capture "green with reviews shows review output" 0 "$work/green-reviews" "$work/out1.txt"
|
||||
if grep -q "pr-reviews:" "$work/out1.txt"; then
|
||||
ok "green with reviews prints pr-reviews output"
|
||||
else
|
||||
bad "green with reviews prints pr-reviews output" "no pr-reviews output" "pr-reviews: in output"
|
||||
fi
|
||||
|
||||
# --no-reviews skips the review printout
|
||||
run_case_capture "--no-reviews skips review output" 0 "$work/green-reviews" "$work/out2.txt" --no-reviews
|
||||
if grep -q "pr-reviews:" "$work/out2.txt"; then
|
||||
bad "--no-reviews skips review output" "pr-reviews in output" "no pr-reviews"
|
||||
else
|
||||
ok "--no-reviews actually skips review output"
|
||||
fi
|
||||
|
||||
# pr-reviews failure: if pr-reviews fails, pr-watch exits 6 (not 0)
|
||||
# Create a green fixture with MALFORMED review fixtures so pr-reviews fails
|
||||
mk "$work/green-bad-reviews" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
|
||||
EOF
|
||||
# Create malformed JSON that will cause jq to fail in pr-reviews
|
||||
echo 'NOT VALID JSON' > "$work/green-bad-reviews/last.pr.json"
|
||||
echo '[]' > "$work/green-bad-reviews/last.inline.json"
|
||||
echo '[]' > "$work/green-bad-reviews/last.issue.json"
|
||||
run_case "pr-reviews failure exits 6" 6 "$work/green-bad-reviews"
|
||||
|
||||
mk "$work/red" last.json <<'EOF'
|
||||
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"UNSTABLE","statusCheckRollup":[
|
||||
{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}
|
||||
@@ -112,6 +178,10 @@ for m in MERGEABLE CONFLICTING UNKNOWN; do
|
||||
fi
|
||||
sv="\"$s\""; [ "$s" = null ] && sv=null
|
||||
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
|
||||
# Add review fixtures for cases that exit 0 (pr-watch calls pr-reviews)
|
||||
if [ "$want" = 0 ]; then
|
||||
mk_reviews_empty "$work/table-$m-$s" "aaaaaaaaaaaa"
|
||||
fi
|
||||
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
|
||||
done
|
||||
done
|
||||
@@ -148,6 +218,7 @@ gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
|
||||
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
|
||||
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
|
||||
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
|
||||
mk_reviews_empty "$work/gap" "aaaaaaaaaaaa"
|
||||
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
|
||||
|
||||
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
|
||||
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Churn, coupling and doc-growth report over a window of history.
|
||||
|
||||
code-maat's analyses and this script's per-file churn go into one directory of
|
||||
CSVs; improvise imports them into one model and computes the report's tables.
|
||||
See context/code/tooling.md section 7.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# First match wins, so the order is part of the definition.
|
||||
AREAS = [
|
||||
("generated", re.compile(r"^(\.beads/|site/|Cargo\.nix$|roadmap\.org$)"
|
||||
r"|\.(lock|cast)$|(^|/)package-lock\.json$")),
|
||||
("repo-map", re.compile(r"^context/repo-map\.md$")),
|
||||
("kb.pl", re.compile(r"^prolog-sessions/kb\.pl$")),
|
||||
("context/code", re.compile(r"^context/code/")),
|
||||
("context/test-evidence", re.compile(r"^context/test-evidence/")),
|
||||
("other context", re.compile(r"^context/")),
|
||||
("other docs", re.compile(r"^(docs/.*\.md|README\.md|RELEASING\.md)$")),
|
||||
("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/|\.codex/)")),
|
||||
("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")),
|
||||
("web and harness", re.compile(r"^(web|harness)/")),
|
||||
("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")),
|
||||
("rust src", re.compile(r"\.(rs|pest)$")),
|
||||
]
|
||||
OTHER = "other"
|
||||
PROSE = {"context/code", "context/test-evidence", "other context", "other docs", "repo-map",
|
||||
"agent docs and skills"}
|
||||
CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"}
|
||||
AGENT_RULES = "agent docs and skills"
|
||||
|
||||
# One CSV per code-maat analysis, plus churn.csv. Each stem becomes an item of
|
||||
# the model's File category, which the .improvise script pages on.
|
||||
ANALYSES = ["coupling", "abs-churn", "age", "soc"]
|
||||
CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled",
|
||||
"crosses-crate", "agent-rules"]
|
||||
CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"]
|
||||
MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"]
|
||||
TABLES = ["by-area", "by-kind", "area-by-week", "by-day"]
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
class ImproviseFailed(Exception):
|
||||
"""improvise refused the pipeline's input: a product regression, not a statistic."""
|
||||
|
||||
|
||||
def area_of(path):
|
||||
for name, rx in AREAS:
|
||||
if rx.search(path):
|
||||
return name
|
||||
return OTHER
|
||||
|
||||
|
||||
def kind_of(area):
|
||||
return "prose" if area in PROSE else "code" if area in CODE else "other"
|
||||
|
||||
|
||||
def crate_of(path):
|
||||
"""The crate a path belongs to, or None for a path in no crate."""
|
||||
m = re.match(r"^crates/([^/]+)/", path)
|
||||
if m:
|
||||
return m.group(1)
|
||||
if path.startswith("harness/opfs-contract/"):
|
||||
return "opfs-contract"
|
||||
if re.match(r"^(src|tests|benches|examples)/", path) or path == "Cargo.toml":
|
||||
return "improvise"
|
||||
return None
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
|
||||
check=True).stdout
|
||||
|
||||
|
||||
def resolve_ref(repo, ref):
|
||||
"""The ref asked for; the default gh/main falls back to HEAD where it is absent."""
|
||||
for r in ([ref] if ref else ["gh/main", "HEAD"]):
|
||||
out = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", "--quiet",
|
||||
r + "^{commit}"], capture_output=True, text=True)
|
||||
if out.returncode == 0:
|
||||
return r, out.stdout.strip()
|
||||
sys.exit(f"repo-stats: cannot resolve {ref or 'gh/main or HEAD'}")
|
||||
|
||||
|
||||
def git2_log(repo, sha, since):
|
||||
"""The log in code-maat's git2 format: the ref's history, without merges."""
|
||||
return git(repo, "log", sha, "--no-merges", f"--since={since}", "--numstat",
|
||||
"--date=short", "--pretty=format:--%h--%ad--%aN", "--no-renames")
|
||||
|
||||
|
||||
def is_header(line):
|
||||
return line.startswith("--") and line.count("--") >= 3
|
||||
|
||||
|
||||
def churn_rows(log):
|
||||
"""One row per (commit, file). A commit is written `g<sha>`, because improvise
|
||||
reads an all-digit or exponent-shaped sha as a number (tooling.md section 7)."""
|
||||
rows, commit, date = [], None, None
|
||||
for line in log.split("\n"):
|
||||
if is_header(line):
|
||||
_, commit, date, _ = line.split("--", 3)
|
||||
elif "\t" in line:
|
||||
a, d, path = line.split("\t", 2)
|
||||
year, week, _ = datetime.date.fromisoformat(date).isocalendar()
|
||||
area = area_of(path)
|
||||
rows.append({"commit": "g" + commit, "date": date,
|
||||
"week": f"{year}-W{week:02d}", "entity": path, "area": area,
|
||||
"kind": kind_of(area), "added": 0 if a == "-" else int(a),
|
||||
"deleted": 0 if d == "-" else int(d)})
|
||||
return rows
|
||||
|
||||
|
||||
def write_csv(path, header, rows):
|
||||
with open(path, "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=header)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
|
||||
def read_csv(path):
|
||||
with open(path, newline="") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def code_maat(logfile, analysis, out, *extra):
|
||||
subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis,
|
||||
"-o", out, *extra], capture_output=True, text=True, check=True)
|
||||
|
||||
|
||||
def flag_coupling(path):
|
||||
"""code-maat's coupling.csv, plus the two flags code-maat has no notion of."""
|
||||
rows = read_csv(path)
|
||||
for r in rows:
|
||||
a, b = crate_of(r["entity"]), crate_of(r["coupled"])
|
||||
r["crosses-crate"] = "yes" if a and b and a != b else "no"
|
||||
both = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES
|
||||
r["agent-rules"] = "yes" if both else "no"
|
||||
write_csv(path, ["entity", "coupled", "degree", "average-revs", "crosses-crate",
|
||||
"agent-rules"], rows)
|
||||
|
||||
|
||||
def build_dir(out, log, rows, min_revs, today):
|
||||
"""The directory improvise imports: churn.csv and one CSV per analysis."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f:
|
||||
f.write(log + "\n")
|
||||
try:
|
||||
revs = ["--min-revs", str(min_revs), "--min-shared-revs", str(min_revs)]
|
||||
extra = {"coupling": revs, "age": ["--age-time-now", today]}
|
||||
for a in ANALYSES:
|
||||
code_maat(f.name, a, os.path.join(out, f"{a}.csv"), *extra.get(a, []))
|
||||
finally:
|
||||
os.unlink(f.name)
|
||||
flag_coupling(os.path.join(out, "coupling.csv"))
|
||||
|
||||
|
||||
def improvise(out, *args):
|
||||
exe = os.environ.get("IMPROVISE", "improvise")
|
||||
p = subprocess.run([exe, *args], cwd=out, capture_output=True, text=True)
|
||||
if p.returncode != 0 or "warning:" in p.stderr:
|
||||
raise ImproviseFailed(f"improvise {' '.join(args)} exited {p.returncode}:\n"
|
||||
f"{p.stderr}")
|
||||
|
||||
|
||||
def run_improvise(out):
|
||||
"""Import the directory into one model, a view per file included, then build
|
||||
the report's views and export them."""
|
||||
flags = [f"--category={c}" for c in CATEGORIES] + [f"--measure={m}" for m in MEASURES]
|
||||
improvise(out, "import", ".", "--no-wizard", *flags, "--name=Repo stats",
|
||||
"--formula=DeletionRatio = deleted / added FORMAT .3",
|
||||
"-o", "repo-stats.improv")
|
||||
os.makedirs(os.path.join(out, "report"), exist_ok=True)
|
||||
improvise(out, "script", os.path.join(HERE, "repo-stats.improvise"),
|
||||
"-f", "repo-stats.improv")
|
||||
return {t: read_rows(os.path.join(out, "report", f"{t}.csv")) for t in TABLES}
|
||||
|
||||
|
||||
def read_rows(path):
|
||||
with open(path, newline="") as f:
|
||||
return list(csv.reader(f))
|
||||
|
||||
|
||||
def gotchas(text):
|
||||
"""(number, lines, chars) for each numbered item of repo-map's Gotchas section."""
|
||||
out, cur, inside = [], None, False
|
||||
for line in text.split("\n"):
|
||||
if line.startswith("## "):
|
||||
inside = line.startswith("## Gotchas")
|
||||
continue
|
||||
if inside and line.startswith("---"):
|
||||
inside = False
|
||||
if not inside:
|
||||
continue
|
||||
m = re.match(r"^(\d+)\. ", line)
|
||||
if m:
|
||||
cur = [int(m.group(1)), 0, 0]
|
||||
out.append(cur)
|
||||
if cur is not None and line.strip():
|
||||
cur[1] += 1
|
||||
cur[2] += len(line)
|
||||
return [tuple(g) for g in out]
|
||||
|
||||
|
||||
def doc_sizes(repo, sha):
|
||||
files = [p for p in git(repo, "ls-tree", "-r", "--name-only", sha, "--",
|
||||
"context").split("\n") if p.endswith(".md")]
|
||||
show = lambda p: git(repo, "show", f"{sha}:{p}")
|
||||
lines = {p: len(show(p).splitlines()) for p in sorted(files)}
|
||||
gs = gotchas(show("context/repo-map.md")) if "context/repo-map.md" in files else []
|
||||
longest = max(gs, key=lambda g: g[2], default=None)
|
||||
evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l))
|
||||
for p in sorted(files) if p.startswith("context/test-evidence/")}
|
||||
return {
|
||||
"context_lines": lines,
|
||||
"repo_map": {"gotchas": len(gs), "longest": longest and
|
||||
{"number": longest[0], "lines": longest[1], "chars": longest[2]}},
|
||||
"test_evidence": {"entries": sum(evidence.values()), "per_file": evidence},
|
||||
}
|
||||
|
||||
|
||||
def report(repo, ref, since, min_revs, out):
|
||||
name, sha = resolve_ref(repo, ref)
|
||||
log = git2_log(repo, sha, since)
|
||||
commits = sum(1 for line in log.split("\n") if is_header(line))
|
||||
today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip()
|
||||
r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out,
|
||||
"docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []}
|
||||
rows = churn_rows(log)
|
||||
write_csv(os.path.join(out, "churn.csv"), CHURN_HEADER, rows)
|
||||
# Empty commits alone change no file: nothing to analyse, so no model is built.
|
||||
if rows:
|
||||
build_dir(out, log, rows, min_revs, today)
|
||||
r["tables"] = run_improvise(out)
|
||||
for a in ("coupling", "soc", "age"):
|
||||
r[a] = read_csv(os.path.join(out, f"{a}.csv"))
|
||||
return r
|
||||
|
||||
|
||||
def table(rows):
|
||||
"""A CSV improvise exported, as aligned text; the first column left-aligned."""
|
||||
n = max(len(r) for r in rows)
|
||||
widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(n)]
|
||||
return [(" " + " ".join((c.ljust if i == 0 else c.rjust)(widths[i])
|
||||
for i, c in enumerate(r))).rstrip() for r in rows]
|
||||
|
||||
|
||||
TITLES = {
|
||||
"by-area": "Churn by area (improvise)",
|
||||
"by-kind": "Prose, code and other; ProseToCode = prose / code (improvise)",
|
||||
"area-by-week": "Lines added by area and ISO week (improvise)",
|
||||
"by-day": "Lines added per day, churn.csv beside code-maat's abs-churn (improvise)",
|
||||
}
|
||||
|
||||
|
||||
def text(r, top):
|
||||
out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, "
|
||||
f"{r['commits']} non-merge commits"]
|
||||
if not r["tables"]:
|
||||
out += ["", "No file changed in the window, so no model was built."]
|
||||
for t in TABLES:
|
||||
if r["tables"].get(t):
|
||||
out += ["", TITLES[t]] + table(r["tables"][t])
|
||||
out += ["", "Doc sizes (lines)"]
|
||||
out += [f" {n:>6} {path}" for path, n in r["docs"]["context_lines"].items()]
|
||||
rm, te = r["docs"]["repo_map"], r["docs"]["test_evidence"]
|
||||
lg = rm["longest"]
|
||||
out.append(f" repo-map gotchas: {rm['gotchas']}" + (
|
||||
f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)"
|
||||
if lg else ""))
|
||||
out.append(f" test-evidence entries: {te['entries']}")
|
||||
out += ["", f"Change coupling (code-maat; top {top})"]
|
||||
for c in r["coupling"][:top]:
|
||||
flags = [f for f, k in (("CROSSES-CRATE", "crosses-crate"),
|
||||
("AGENT-RULES", "agent-rules")) if c[k] == "yes"]
|
||||
out.append(f" {c['degree']:>4}% {c['average-revs']:>4} revs {c['entity']}"
|
||||
f" <-> {c['coupled']}" + (f" [{', '.join(flags)}]" if flags else ""))
|
||||
out += ["", f"Sum of coupling (code-maat; top {top})"]
|
||||
out += [f" {s['soc']:>6} {s['entity']}" for s in r["soc"][:top]]
|
||||
out += ["", f"Code age in months (code-maat; first {top})"]
|
||||
out += [f" {a['age-months']:>4} {a['entity']}" for a in r["age"][:top]]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--ref", help="the ref to report on (default gh/main, else HEAD)")
|
||||
ap.add_argument("--since", default="7 days ago", help="git --since (default 7 days ago)")
|
||||
ap.add_argument("--min-revs", type=int, default=5,
|
||||
help="code-maat --min-revs and --min-shared-revs (default 5)")
|
||||
ap.add_argument("--top", type=int, default=15, help="rows per ranked table")
|
||||
ap.add_argument("--out", default="repo-stats",
|
||||
help="the directory for the CSVs, the model and its exports")
|
||||
ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
|
||||
ap.add_argument("-C", dest="repo", default=".", help="the repository (default .)")
|
||||
a = ap.parse_args()
|
||||
os.makedirs(a.out, exist_ok=True)
|
||||
ours = {f"{n}.csv" for n in ["churn"] + ANALYSES}
|
||||
foreign = sorted(f for f in os.listdir(a.out) if f.lower().endswith(".csv") and f not in ours)
|
||||
if foreign:
|
||||
print(f"repo-stats: {a.out} holds CSVs this script did not write, and "
|
||||
f"`improvise import` would merge them: {', '.join(foreign)}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
r = report(a.repo, a.ref, a.since, a.min_revs, os.path.abspath(a.out))
|
||||
except ImproviseFailed as e:
|
||||
print(f"repo-stats: {e}", file=sys.stderr)
|
||||
return 3
|
||||
print(json.dumps(r, indent=2) if a.json else text(r, a.top))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# check_the_checker for scripts/repo-stats (improvise-1xfb.5.1): the whole pipeline,
|
||||
# code-maat and improvise included, over a fixture repo whose every number is known.
|
||||
# See context/test-evidence/tooling.md.
|
||||
set -uo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STATS="$here/repo-stats"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); }
|
||||
|
||||
# The improvise under test: on PATH in CI, else this tree's debug build. No build
|
||||
# here: checkers.sh scrubs the linker's environment (context/code/tooling.md §7).
|
||||
if command -v improvise >/dev/null; then
|
||||
IMPROVISE="$(command -v improvise)"
|
||||
elif [ -x "$here/../target/debug/improvise" ]; then
|
||||
IMPROVISE="$here/../target/debug/improvise"
|
||||
else
|
||||
echo "repo-stats-test: no improvise; run \`cargo build --bin improvise\` first" >&2
|
||||
exit 1
|
||||
fi
|
||||
export IMPROVISE
|
||||
|
||||
# $1 name, $2 jq expression over $work/out.json, $3 the value it must print
|
||||
is() {
|
||||
local got
|
||||
got="$(jq -c "$2" "$work/out.json" 2>&1)"
|
||||
[ "$got" = "$3" ] && ok "$1" || bad "$1" "got $got, wanted $3"
|
||||
}
|
||||
|
||||
# $1 name, $2 table, $3 row label, $4 the row's cells after the label, as JSON
|
||||
row() {
|
||||
is "$1" ".tables[\"$2\"][]|select(.[0]==\"$3\")|.[1:]" "$4"
|
||||
}
|
||||
|
||||
# $1 name, $2 fixed-string the text report must contain
|
||||
says() {
|
||||
grep -Fq -- "$2" "$work/out.txt" && ok "$1" || bad "$1" "no '$2' in the text report"
|
||||
}
|
||||
|
||||
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
|
||||
export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=f@x GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=f@x
|
||||
repo="$work/repo"
|
||||
g() { git -C "$repo" "$@" >/dev/null; }
|
||||
|
||||
# $1 path, $2 line count, $3 tag. Rewriting with a new tag replaces every line.
|
||||
put() { mkdir -p "$(dirname "$repo/$1")"; seq -f "$3 %g" 1 "$2" > "$repo/$1"; }
|
||||
# $1 date; commits everything staged or not.
|
||||
commit() {
|
||||
g add -A
|
||||
GIT_AUTHOR_DATE="$1T12:00:00Z" GIT_COMMITTER_DATE="$1T12:00:00Z" g commit -q -m "at $1"
|
||||
}
|
||||
|
||||
git init -q -b main "$repo"
|
||||
|
||||
# Before the window: must count nowhere.
|
||||
put README.md 50 old; commit 2026-01-01
|
||||
|
||||
# Two commits co-change six files, so each pair couples at 100% over 2 revs.
|
||||
# src/ is the root crate's, so it crosses a boundary with both crates/.
|
||||
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 10 x1; done
|
||||
put src/main.rs 5 x1; put crates/a/tests/t.rs 4 x1
|
||||
put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1
|
||||
commit 2026-06-02
|
||||
for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 6 x2; done
|
||||
put src/main.rs 5 x2; put crates/a/tests/t.rs 4 x2
|
||||
put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2
|
||||
commit 2026-06-03
|
||||
|
||||
# One file in each remaining area. The repo map is 13 lines.
|
||||
put context/code/x.md 8 x3
|
||||
mkdir -p "$repo/context/test-evidence"
|
||||
printf '### t_one\ntext\n#### t_two\n## not an entry\n### t_three\n' \
|
||||
> "$repo/context/test-evidence/y.md"
|
||||
cat > "$repo/context/repo-map.md" <<'EOF'
|
||||
# Map
|
||||
|
||||
## Gotchas (read before editing)
|
||||
|
||||
1. **One.** short.
|
||||
2. **Two.** a much longer gotcha, with plenty of words in it to be the longest one
|
||||
and a second line.
|
||||
|
||||
3. **Three.** middling length.
|
||||
|
||||
---
|
||||
|
||||
4. not a gotcha, because the section has ended
|
||||
EOF
|
||||
put context/plan.md 2 x3
|
||||
put prolog-sessions/kb.pl 5 x3
|
||||
put scripts/foo.sh 7 x3; put .github/workflows/ci.yml 1 x3
|
||||
put web/app.js 9 x3; put harness/x.js 2 x3
|
||||
put Cargo.lock 11 x3
|
||||
put crates/a/src/grammar.pest 3 x3
|
||||
put .beads/issues.jsonl 2 x3; put site/index.html 1 x3
|
||||
put docs/guide.md 2 x3; put .codex/config.toml 1 x3
|
||||
commit 2026-06-04
|
||||
g tag before-shrink
|
||||
|
||||
# A side branch merged with a merge commit: the side commit counts, the merge does not.
|
||||
g checkout -q -b side
|
||||
put notes.txt 4 x5; commit 2026-06-05
|
||||
g checkout -q main
|
||||
GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \
|
||||
g merge -q --no-ff -m merge side
|
||||
|
||||
# A shrink in the next ISO week, so one area deletes and one row spans two weeks,
|
||||
# and a rename, which --no-renames counts as a delete and an add.
|
||||
put web/app.js 3 x4
|
||||
g mv scripts/foo.sh scripts/bar.sh
|
||||
commit 2026-06-09
|
||||
|
||||
# $1 output dir, $2 "text" to also write the text report, rest: extra args.
|
||||
run() {
|
||||
local out="$1" text="$2"; shift 2
|
||||
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" --json \
|
||||
> "$work/out.json" 2> "$work/err" \
|
||||
|| { bad "repo-stats $* ran" "$(cat "$work/err")"; return 1; }
|
||||
[ "$text" = text ] || return 0
|
||||
"$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" \
|
||||
> "$work/out.txt" 2>&1 \
|
||||
|| { bad "repo-stats $* ran (text)" "$(cat "$work/out.txt")"; return 1; }
|
||||
}
|
||||
|
||||
if run "$work/stats" text; then
|
||||
is "no gh/main in the fixture, so the ref is HEAD" '.ref' '"HEAD"'
|
||||
is "the merge commit is not counted, the side commit is" '.commits' '5'
|
||||
row "rust src is .rs and .pest outside tests" by-area "rust src" '["45","25","0.556"]'
|
||||
row "rust tests are the tests/ files" by-area "rust tests" '["8","4","0.500"]'
|
||||
row "agent docs and skills take .codex" by-area "agent docs and skills" '["13","6","0.462"]'
|
||||
row "docs/*.md is other docs" by-area "other docs" '["2","0","0.000"]'
|
||||
row "context/code" by-area "context/code" '["8","0","0.000"]'
|
||||
row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]'
|
||||
row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]'
|
||||
row "other context" by-area "other context" '["2","0","0.000"]'
|
||||
row "kb.pl" by-area "kb.pl" '["5","0","0.000"]'
|
||||
row "scripts/CI takes .github, and a rename is a delete and an add" \
|
||||
by-area "scripts/CI" '["15","7","0.467"]'
|
||||
row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]'
|
||||
row "a lock file, .beads/ and site/ are generated" by-area "generated" '["14","0","0.000"]'
|
||||
row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]'
|
||||
row "prose is the context areas, other docs and agent docs" by-kind prose '["43","6","0.140"]'
|
||||
row "code is rust, scripts/CI and web" by-kind code '["82","45","0.549"]'
|
||||
row "prose to code is prose over code" by-kind ProseToCode '["0.524","0.133",""]'
|
||||
is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]'
|
||||
row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]'
|
||||
is "every day's churn.csv total equals code-maat's abs-churn" \
|
||||
'[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true'
|
||||
row "a day's total is every file changed that day" by-day 2026-06-04 '["72","72"]'
|
||||
is "gotchas end with their section" '.docs.repo_map.gotchas' '3'
|
||||
is "the longest gotcha is measured over its lines" \
|
||||
'.docs.repo_map.longest|[.number,.lines]' '[2,2]'
|
||||
is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3'
|
||||
is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \
|
||||
'.coupling|length' '16'
|
||||
is "files of two crates, the root crate's included, cross a boundary" \
|
||||
'[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]|sort' \
|
||||
'[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/src/lib.rs","src/main.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","src/main.rs"],["crates/b/src/lib.rs","src/main.rs"]]'
|
||||
is "two agent-rule docs are flagged" \
|
||||
'[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \
|
||||
'[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]'
|
||||
says "the text report flags a crate crossing" "[CROSSES-CRATE]"
|
||||
says "the text report flags agent-rule docs" "[AGENT-RULES]"
|
||||
says "the text report prints improvise's ratio row" "ProseToCode"
|
||||
views="$(grep -c '^## View: ' "$work/stats/repo-stats.improv")"
|
||||
[ "$views" = 10 ] && ok "the model keeps a view per file and the report's four" \
|
||||
|| bad "the model keeps a view per file and the report's four" "$views views"
|
||||
# Sparse, not padded: a padded merge has the same numbers and views, but a
|
||||
# `-` item in every category and per-file views seated on the padding.
|
||||
dashes="$(grep -c '|-|' "$work/stats/repo-stats.improv")"
|
||||
[ "$dashes" = 0 ] && ok "the sparse model has no padding item" \
|
||||
|| bad "the sparse model has no padding item" "$dashes lines name |-|"
|
||||
churn_view="$(awk '/^## View: churn$/,/^$/' "$work/stats/repo-stats.improv")"
|
||||
for seat in "date: row" "agent-rules: none" "coupled: none" "crosses-crate: none"; do
|
||||
grep -qx -- "$seat" <<< "$churn_view" && ok "the churn view seats $seat" \
|
||||
|| bad "the churn view seats $seat" "$(tr '\n' ' ' <<< "$churn_view")"
|
||||
done
|
||||
fi
|
||||
|
||||
# An --out holding a CSV this script did not write is refused, since the folder
|
||||
# import would merge it. The script's own CSVs, from a rerun, are not foreign.
|
||||
mkdir -p "$work/foreign"; echo "x,y" > "$work/foreign/other.csv"
|
||||
"$STATS" -C "$repo" --since 2026-06-01 --out "$work/foreign" > /dev/null 2>&1
|
||||
got=$?
|
||||
[ "$got" = 2 ] && ok "a foreign CSV in --out is refused with exit 2" \
|
||||
|| bad "a foreign CSV in --out is refused with exit 2" "exit $got"
|
||||
|
||||
# The window is scoped to the ref: at the tag, the shrink has not happened.
|
||||
if run "$work/at-tag" json --ref before-shrink; then
|
||||
row "--ref scopes the log" by-area "web and harness" '["11","0","0.000"]'
|
||||
is "--ref names the ref it read" '.ref' '"before-shrink"'
|
||||
fi
|
||||
|
||||
# A window whose only commits are empty changes no file: exit 0, and no model.
|
||||
empty="$work/empty"
|
||||
git init -q -b main "$empty"
|
||||
repo="$empty"; put README.md 5 old; commit 2026-01-01
|
||||
GIT_AUTHOR_DATE=2026-06-02T12:00:00Z GIT_COMMITTER_DATE=2026-06-02T12:00:00Z \
|
||||
g commit -q --allow-empty -m empty
|
||||
if run "$work/empty-out" text; then
|
||||
is "an empty commit is counted" '.commits' '1'
|
||||
is "and builds no tables" '.tables' '{}'
|
||||
lines="$(wc -l < "$work/empty-out/churn.csv" | tr -d ' ')"
|
||||
[ "$lines" = 1 ] && ok "churn.csv is written with its header alone" \
|
||||
|| bad "churn.csv is written with its header alone" "$lines lines"
|
||||
says "the text report says why there are no tables" "No file changed in the window"
|
||||
fi
|
||||
repo="$work/repo"
|
||||
|
||||
# improvise refusing the pipeline is exit 3, whatever the numbers were. The
|
||||
# third stub imports cleanly and refuses only the scripts.
|
||||
mkdir -p "$work/bin"
|
||||
printf '#!/usr/bin/env bash\necho "Parse error: no" >&2\nexit 1\n' > "$work/bin/refuses"
|
||||
printf '#!/usr/bin/env bash\necho "warning: Skipped 3 of 3 records" >&2\nexit 0\n' \
|
||||
> "$work/bin/warns"
|
||||
printf '#!/usr/bin/env bash\n[ "$1" = script ] || exit 0\necho "Parse error: no" >&2\nexit 1\n' \
|
||||
> "$work/bin/refuses-a-script"
|
||||
chmod +x "$work/bin/refuses" "$work/bin/warns" "$work/bin/refuses-a-script"
|
||||
for stub in refuses warns refuses-a-script; do
|
||||
IMPROVISE="$work/bin/$stub" "$STATS" -C "$repo" --since 2026-06-01 --out "$work/$stub" \
|
||||
> /dev/null 2>&1
|
||||
got=$?
|
||||
[ "$got" = 3 ] && ok "an improvise that $stub fails the pipeline with exit 3" \
|
||||
|| bad "an improvise that $stub fails the pipeline with exit 3" "exit $got"
|
||||
done
|
||||
|
||||
printf '\nrepo-stats: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" = 0 ]
|
||||
@@ -0,0 +1,87 @@
|
||||
# The views scripts/repo-stats builds over the model it imported, run by
|
||||
# `improvise script` with the output directory as the working directory.
|
||||
# Every `export` here is a table the text report prints. See
|
||||
# context/code/tooling.md section 7.
|
||||
|
||||
# The prose-to-code ratio is a formula over the kind category's own items.
|
||||
add-formula kind "ProseToCode = prose / code FORMAT .3"
|
||||
|
||||
# Churn by area, with the deletion ratio (a _Measure formula from the import).
|
||||
create-view "By area"
|
||||
switch-view "By area"
|
||||
set-axis File page
|
||||
set-page File churn
|
||||
set-axis area row
|
||||
set-axis _Measure column
|
||||
set-axis kind none
|
||||
set-axis week none
|
||||
set-axis date none
|
||||
set-axis commit none
|
||||
set-axis entity none
|
||||
set-axis coupled none
|
||||
set-axis crosses-crate none
|
||||
set-axis agent-rules none
|
||||
toggle-prune-empty
|
||||
set-format .0
|
||||
export report/by-area.csv
|
||||
|
||||
# Prose, code and other, and the ratio of the first two.
|
||||
create-view "By kind"
|
||||
switch-view "By kind"
|
||||
set-axis File page
|
||||
set-page File churn
|
||||
set-axis kind row
|
||||
set-axis _Measure column
|
||||
set-axis area none
|
||||
set-axis week none
|
||||
set-axis date none
|
||||
set-axis commit none
|
||||
set-axis entity none
|
||||
set-axis coupled none
|
||||
set-axis crosses-crate none
|
||||
set-axis agent-rules none
|
||||
toggle-prune-empty
|
||||
set-format .0
|
||||
export report/by-kind.csv
|
||||
|
||||
# Lines added per area per ISO week.
|
||||
create-view "Area by week"
|
||||
switch-view "Area by week"
|
||||
set-axis File page
|
||||
set-page File churn
|
||||
set-axis area row
|
||||
set-axis week column
|
||||
set-axis _Measure page
|
||||
set-page _Measure added
|
||||
set-axis kind none
|
||||
set-axis date none
|
||||
set-axis commit none
|
||||
set-axis entity none
|
||||
set-axis coupled none
|
||||
set-axis crosses-crate none
|
||||
set-axis agent-rules none
|
||||
toggle-prune-empty
|
||||
set-format .0
|
||||
export report/area-by-week.csv
|
||||
|
||||
# Lines added per day, from both sources side by side: this script's churn
|
||||
# rows and code-maat's abs-churn. The two columns must agree.
|
||||
create-view "By day"
|
||||
switch-view "By day"
|
||||
set-axis date row
|
||||
set-axis File column
|
||||
set-axis _Measure page
|
||||
set-page _Measure added
|
||||
set-axis area none
|
||||
set-axis kind none
|
||||
set-axis week none
|
||||
set-axis commit none
|
||||
set-axis entity none
|
||||
set-axis coupled none
|
||||
set-axis crosses-crate none
|
||||
set-axis agent-rules none
|
||||
toggle-prune-empty
|
||||
set-format .0
|
||||
export report/by-day.csv
|
||||
|
||||
switch-view "By area"
|
||||
+180
-48
@@ -1,5 +1,6 @@
|
||||
use std::io::{self, Stdout};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -18,7 +19,8 @@ use ratatui::{
|
||||
|
||||
use crate::command::key;
|
||||
use crate::protocol::EditorSession;
|
||||
use crate::ui::app::{App, AppMode, MinibufColor};
|
||||
use crate::protocol::face::{AnsiSlot, Colour, Face, Slant, Theme, Weight};
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::ui::category_panel::CategoryContent;
|
||||
use crate::ui::formula_panel::FormulaContent;
|
||||
use crate::ui::grid::GridWidget;
|
||||
@@ -29,7 +31,6 @@ use crate::ui::text::truncate;
|
||||
use crate::ui::tile_bar::TileBar;
|
||||
use crate::ui::view_panel::ViewContent;
|
||||
use crate::ui::which_key::WhichKeyWidget;
|
||||
use crate::view::AxisColor;
|
||||
use improvise_command::ui::project;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -165,24 +166,54 @@ fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The one place `AxisColor` becomes a terminal color (improvise-rml); the
|
||||
/// accent itself is decided by `Axis::color`. See `context/code/tui.md`.
|
||||
pub(crate) fn axis_fg(color: AxisColor) -> Color {
|
||||
match color {
|
||||
AxisColor::Row => Color::Green,
|
||||
AxisColor::Column => Color::Blue,
|
||||
AxisColor::Page => Color::Magenta,
|
||||
AxisColor::Filter => Color::Yellow,
|
||||
AxisColor::Inactive => Color::DarkGray,
|
||||
/// The one place a face becomes a terminal style (ADR 004): the theme resolves
|
||||
/// it, and only this last translation is the TUI's. `context/code/tui.md`.
|
||||
pub(crate) fn face_style(face: Face) -> Style {
|
||||
static SHIPPED: OnceLock<Theme> = OnceLock::new();
|
||||
let face = SHIPPED.get_or_init(Theme::shipped).resolve(face);
|
||||
let mut style = Style::default();
|
||||
if let Some(fg) = terminal_colour(face.foreground) {
|
||||
style = style.fg(fg);
|
||||
}
|
||||
if let Some(bg) = terminal_colour(face.background) {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
let modifiers = [
|
||||
(face.weight == Weight::Bold, Modifier::BOLD),
|
||||
(face.slant == Slant::Italic, Modifier::ITALIC),
|
||||
(face.underline, Modifier::UNDERLINED),
|
||||
(face.reverse, Modifier::REVERSED),
|
||||
];
|
||||
modifiers
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.fold(style, |style, (_, m)| style.add_modifier(m))
|
||||
}
|
||||
|
||||
/// Map the semantic minibuffer accent to a concrete terminal color.
|
||||
fn minibuf_fg(color: MinibufColor) -> Color {
|
||||
match color {
|
||||
MinibufColor::Green => Color::Green,
|
||||
MinibufColor::Cyan => Color::Cyan,
|
||||
MinibufColor::Yellow => Color::Yellow,
|
||||
/// `None` is "state nothing", so the terminal's own colour shows through.
|
||||
fn terminal_colour(colour: Colour) -> Option<Color> {
|
||||
match colour {
|
||||
Colour::Default => None,
|
||||
Colour::Named(slot) => Some(match slot {
|
||||
AnsiSlot::Black => Color::Black,
|
||||
AnsiSlot::Red => Color::Red,
|
||||
AnsiSlot::Green => Color::Green,
|
||||
AnsiSlot::Yellow => Color::Yellow,
|
||||
AnsiSlot::Blue => Color::Blue,
|
||||
AnsiSlot::Magenta => Color::Magenta,
|
||||
AnsiSlot::Cyan => Color::Cyan,
|
||||
AnsiSlot::White => Color::Gray,
|
||||
AnsiSlot::BrightBlack => Color::DarkGray,
|
||||
AnsiSlot::BrightRed => Color::LightRed,
|
||||
AnsiSlot::BrightGreen => Color::LightGreen,
|
||||
AnsiSlot::BrightYellow => Color::LightYellow,
|
||||
AnsiSlot::BrightBlue => Color::LightBlue,
|
||||
AnsiSlot::BrightMagenta => Color::LightMagenta,
|
||||
AnsiSlot::BrightCyan => Color::LightCyan,
|
||||
AnsiSlot::BrightWhite => Color::White,
|
||||
}),
|
||||
Colour::Indexed(n) => Some(Color::Indexed(n)),
|
||||
Colour::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,30 +262,6 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color
|
||||
inner
|
||||
}
|
||||
|
||||
/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new
|
||||
/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`.
|
||||
fn mode_style(mode: &AppMode) -> Style {
|
||||
let bg = match mode {
|
||||
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
|
||||
AppMode::CommandMode { .. } => Color::Yellow,
|
||||
AppMode::TileSelect => Color::Magenta,
|
||||
AppMode::Normal
|
||||
| AppMode::RecordsNormal
|
||||
| AppMode::FormulaEdit { .. }
|
||||
| AppMode::FormulaPanel
|
||||
| AppMode::CategoryPanel
|
||||
| AppMode::CategoryAdd { .. }
|
||||
| AppMode::ItemAdd { .. }
|
||||
| AppMode::ViewPanel
|
||||
| AppMode::ImportWizard
|
||||
| AppMode::ExportPrompt { .. }
|
||||
| AppMode::SavePrompt { .. }
|
||||
| AppMode::Help
|
||||
| AppMode::Quit => Color::DarkGray,
|
||||
};
|
||||
Style::default().fg(Color::Black).bg(bg)
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame, app: &App) {
|
||||
let size = f.area();
|
||||
|
||||
@@ -442,12 +449,9 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
.unwrap_or("");
|
||||
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
|
||||
f.render_widget(
|
||||
Paragraph::new(text).style(
|
||||
Style::default()
|
||||
.fg(minibuf_fg(mb.color))
|
||||
.bg(Color::Indexed(235))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Paragraph::new(text).style(face_style(
|
||||
Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::ModeTextEntry),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
} else {
|
||||
@@ -484,7 +488,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
|
||||
Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -639,6 +643,43 @@ mod bottom_bar_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The first cell of the bottom row, where the prompt's first glyph lands.
|
||||
fn prompt_cell(app: &App) -> ratatui::buffer::Cell {
|
||||
let mut terminal = Terminal::new(TestBackend::new(40, 1)).expect("test backend");
|
||||
terminal
|
||||
.draw(|f| draw_bottom_bar(f, Rect::new(0, 0, 40, 1), app))
|
||||
.expect("draw");
|
||||
terminal.backend().buffer().content()[0].clone()
|
||||
}
|
||||
|
||||
/// ADR 004 clause 8's one declared exception (the user, Q1): the
|
||||
/// category-add prompt draws as item-add does. `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_category_add_prompt_draws_as_the_item_add_prompt_does() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::item_add("Region".into());
|
||||
let item = prompt_cell(&app);
|
||||
app.view_state.mode = AppMode::category_add();
|
||||
let category = prompt_cell(&app);
|
||||
assert_eq!(
|
||||
category.symbol(),
|
||||
"n",
|
||||
"the prompt is not where it was looked for"
|
||||
);
|
||||
assert_eq!(item.fg, ratatui::style::Color::Green);
|
||||
assert_eq!(category.style(), item.style());
|
||||
}
|
||||
|
||||
/// The status line wears the mode's face, not the minibuffer's.
|
||||
#[test]
|
||||
fn the_status_line_is_drawn_in_the_mode_face() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::TileSelect;
|
||||
let cell = prompt_cell(&app);
|
||||
assert_eq!(cell.bg, ratatui::style::Color::Magenta);
|
||||
assert_eq!(cell.fg, ratatui::style::Color::Black);
|
||||
}
|
||||
|
||||
/// A refusal that keeps the user in a minibuffer stands while they fix it, and
|
||||
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
@@ -1187,3 +1228,94 @@ mod content_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod face_tests {
|
||||
use super::*;
|
||||
use crate::protocol::face::TextEntry;
|
||||
use crate::protocol::render::WizardFieldKind;
|
||||
use crate::view::Axis;
|
||||
|
||||
/// `mode_style`'s table as it stood before faces (improvise-zx8t).
|
||||
fn mode_bg_before_faces(mode: &AppMode) -> Color {
|
||||
match mode {
|
||||
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
|
||||
AppMode::CommandMode { .. } => Color::Yellow,
|
||||
AppMode::TileSelect => Color::Magenta,
|
||||
AppMode::Normal
|
||||
| AppMode::RecordsNormal
|
||||
| AppMode::FormulaEdit { .. }
|
||||
| AppMode::FormulaPanel
|
||||
| AppMode::CategoryPanel
|
||||
| AppMode::CategoryAdd { .. }
|
||||
| AppMode::ItemAdd { .. }
|
||||
| AppMode::ViewPanel
|
||||
| AppMode::ImportWizard
|
||||
| AppMode::ExportPrompt { .. }
|
||||
| AppMode::SavePrompt { .. }
|
||||
| AppMode::Help
|
||||
| AppMode::Quit => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR 004 clause 8: the shipped theme draws every migrated site exactly as
|
||||
/// its literal did. `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_shipped_theme_reproduces_every_migrated_literal() {
|
||||
let fg = |c| Style::default().fg(c);
|
||||
let axes = [
|
||||
(Axis::Row, Color::Green),
|
||||
(Axis::Column, Color::Blue),
|
||||
(Axis::Page, Color::Magenta),
|
||||
(Axis::Filter, Color::Yellow),
|
||||
(Axis::None, Color::DarkGray),
|
||||
];
|
||||
for (axis, color) in axes {
|
||||
assert_eq!(face_style(Face::Axis(axis)), fg(color), "{axis:?}");
|
||||
}
|
||||
let kinds = [
|
||||
(WizardFieldKind::Category, Color::Green),
|
||||
(WizardFieldKind::Measure, Color::Cyan),
|
||||
(WizardFieldKind::TimeCategory, Color::Magenta),
|
||||
(WizardFieldKind::Label, Color::DarkGray),
|
||||
];
|
||||
for (kind, color) in kinds {
|
||||
assert_eq!(face_style(Face::WizardField(kind)), fg(color), "{kind:?}");
|
||||
}
|
||||
for mode in AppMode::all() {
|
||||
let before = fg(Color::Black).bg(mode_bg_before_faces(&mode));
|
||||
assert_eq!(face_style(Face::of_mode(&mode)), before, "{mode:?}");
|
||||
}
|
||||
let prompts = [
|
||||
(TextEntry::Edit, Color::Green),
|
||||
(TextEntry::ItemAdd, Color::Green),
|
||||
(TextEntry::Formula, Color::Cyan),
|
||||
(TextEntry::Command, Color::Yellow),
|
||||
(TextEntry::Export, Color::Yellow),
|
||||
(TextEntry::Save, Color::Yellow),
|
||||
];
|
||||
for (entry, color) in prompts {
|
||||
let before = fg(color)
|
||||
.bg(Color::Indexed(235))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
assert_eq!(face_style(Face::MinibufferOf(entry)), before, "{entry:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_face_reaches_the_terminal_with_every_attribute_it_states() {
|
||||
let resolved = |face| Theme::shipped().resolve(face);
|
||||
for face in Face::all() {
|
||||
let r = resolved(face);
|
||||
let style = face_style(face);
|
||||
assert_eq!(style.fg, terminal_colour(r.foreground), "{}", face.name());
|
||||
assert_eq!(style.bg, terminal_colour(r.background), "{}", face.name());
|
||||
assert_eq!(
|
||||
style.add_modifier.contains(Modifier::BOLD),
|
||||
r.weight == Weight::Bold,
|
||||
"{}",
|
||||
face.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-10
@@ -6,13 +6,14 @@ use ratatui::{
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::face::Face;
|
||||
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
|
||||
use crate::ui::app::Panel as PanelKind;
|
||||
use crate::ui::panel::PanelContent;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn axis_display(axis: Axis) -> (String, Color) {
|
||||
(axis.to_string(), crate::draw::axis_fg(axis.color()))
|
||||
fn axis_display(axis: Axis) -> (String, Style) {
|
||||
(axis.to_string(), crate::draw::face_style(Face::Axis(axis)))
|
||||
}
|
||||
|
||||
/// The category panel draws one flattened tree and nothing else: the axis
|
||||
@@ -72,14 +73,15 @@ impl PanelContent for CategoryContent {
|
||||
|
||||
match &self.tree[index] {
|
||||
CatTreeEntry::Category {
|
||||
name,
|
||||
display,
|
||||
item_count,
|
||||
expanded,
|
||||
axis,
|
||||
..
|
||||
} => {
|
||||
let indicator = if *expanded { "▼" } else { "▶" };
|
||||
let (axis_str, axis_color) = axis_display(*axis);
|
||||
let name_part = format!("{indicator} {name} ({item_count})");
|
||||
let (axis_str, axis_style) = axis_display(*axis);
|
||||
let name_part = format!("{indicator} {display} ({item_count})");
|
||||
let axis_part = format!(" [{axis_str}]");
|
||||
|
||||
buf.set_string(inner.x, y, &name_part, base_style);
|
||||
@@ -88,11 +90,7 @@ impl PanelContent for CategoryContent {
|
||||
inner.x + name_part.width() as u16,
|
||||
y,
|
||||
&axis_part,
|
||||
if is_selected {
|
||||
base_style
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
},
|
||||
if is_selected { base_style } else { axis_style },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +115,7 @@ mod tests {
|
||||
let cache = CategoryTreeCache {
|
||||
categories: vec![CategoryRow {
|
||||
name: name.to_string(),
|
||||
display: name.to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
}],
|
||||
@@ -132,6 +131,29 @@ mod tests {
|
||||
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_category_header_draws_its_display_not_its_key() {
|
||||
let cache = CategoryTreeCache {
|
||||
categories: vec![CategoryRow {
|
||||
name: "Region".to_string(),
|
||||
display: "Sales Area".to_string(),
|
||||
axis: Axis::Row,
|
||||
items: vec!["x".to_string()],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
Panel::new(
|
||||
CategoryContent::new(&cache, &Default::default()),
|
||||
&AppMode::Normal,
|
||||
0,
|
||||
)
|
||||
.render(area, &mut buf);
|
||||
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
|
||||
assert!(row.contains("Sales Area"), "{row}");
|
||||
assert!(!row.contains("Region"), "{row}");
|
||||
}
|
||||
|
||||
/// improvise-8l0c — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
|
||||
|
||||
@@ -860,6 +860,8 @@ mod tests {
|
||||
m.add_item_in_group("Region", "West", "Inland").unwrap();
|
||||
m.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
m.add_item_in_group("Month", "Apr", "Q2").unwrap();
|
||||
m.active_view_mut().set_expand_depth("Region", 1);
|
||||
m.active_view_mut().set_expand_depth("Month", 1);
|
||||
m.add_item("Payer", "Alice").unwrap();
|
||||
// Pinned so the byte-exact frames below characterize the LAYOUT and not
|
||||
// the view default, which grew two decimals with improvise-e0ex.
|
||||
|
||||
@@ -7,7 +7,8 @@ use ratatui::{
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind};
|
||||
use crate::protocol::face::Face;
|
||||
use crate::protocol::{WizardCache, WizardStepKind};
|
||||
use crate::ui::text::wrap;
|
||||
|
||||
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
|
||||
@@ -22,17 +23,6 @@ impl<'a> ImportWizardWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a proposed field's kind to its accent, as `axis_fg` does for
|
||||
/// `AxisColor` (gotcha 26). The decision is the protocol's; the color is not.
|
||||
fn field_kind_fg(kind: WizardFieldKind) -> Color {
|
||||
match kind {
|
||||
WizardFieldKind::Category => Color::Green,
|
||||
WizardFieldKind::Measure => Color::Cyan,
|
||||
WizardFieldKind::TimeCategory => Color::Magenta,
|
||||
WizardFieldKind::Label => Color::DarkGray,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_w = area.width.min(80);
|
||||
@@ -139,7 +129,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
}
|
||||
let is_sel = i == self.wizard.cursor;
|
||||
|
||||
let kind_color = field_kind_fg(proposal.kind);
|
||||
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
|
||||
|
||||
let accept_str = if proposal.accepted {
|
||||
"[\u{2713}]"
|
||||
@@ -159,7 +149,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if proposal.accepted {
|
||||
Style::default().fg(kind_color)
|
||||
kind_style
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
+26
-5
@@ -7,6 +7,7 @@ use ratatui::{
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::protocol::TileBarCache;
|
||||
use crate::protocol::face::Face;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::view::Axis;
|
||||
|
||||
@@ -44,8 +45,11 @@ impl<'a> TileBar<'a> {
|
||||
tile_cat_idx,
|
||||
}
|
||||
}
|
||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
(axis.short_label(), crate::draw::axis_fg(axis.color()))
|
||||
fn axis_display(axis: Axis) -> (&'static str, Style) {
|
||||
(
|
||||
axis.short_label(),
|
||||
crate::draw::face_style(Face::Axis(axis)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
.iter()
|
||||
.map(|tile| {
|
||||
let (axis_symbol, _) = TileBar::axis_display(tile.axis);
|
||||
format!(" [{} {axis_symbol}] ", tile.category)
|
||||
format!(" [{} {axis_symbol}] ", tile.display)
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
|
||||
@@ -101,7 +105,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
break;
|
||||
}
|
||||
|
||||
let (_, axis_color) = TileBar::axis_display(tiles[i].axis);
|
||||
let (_, axis_style) = TileBar::axis_display(tiles[i].axis);
|
||||
let is_selected = selected_cat_idx == Some(i);
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
@@ -109,7 +113,7 @@ impl<'a> Widget for TileBar<'a> {
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(axis_color)
|
||||
axis_style
|
||||
};
|
||||
|
||||
buf.set_string(x, area.y, &labels[i], style);
|
||||
@@ -186,6 +190,23 @@ mod tests {
|
||||
assert_eq!(was, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_draws_its_display_not_its_key() {
|
||||
let mut app = app_with_two_categories();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.model
|
||||
.category_mut("Region")
|
||||
.unwrap()
|
||||
.set_display("Sales Area");
|
||||
let cache = project::tile_bar_of(&app);
|
||||
let area = Rect::new(0, 0, 80, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf);
|
||||
let text = buf_text(&buf);
|
||||
assert!(text.contains("Sales Area"), "{text}");
|
||||
assert!(!text.contains("Region"), "{text}");
|
||||
}
|
||||
|
||||
/// Pins the rendered line. The equivalence test above says the *inputs*
|
||||
/// are unchanged by the migration; this says the drawing of them is.
|
||||
#[test]
|
||||
|
||||
+21
-1
@@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> {
|
||||
};
|
||||
|
||||
let prefix = if is_active_view { "▶ " } else { " " };
|
||||
let name_text = format!("{prefix}{}", summary.name);
|
||||
let name_text = format!("{prefix}{}", summary.display);
|
||||
let y = inner.y + index as u16;
|
||||
buf.set_string(inner.x, y, &name_text, style);
|
||||
|
||||
@@ -98,6 +98,7 @@ mod tests {
|
||||
let cache = ViewListCache {
|
||||
views: vec![ViewSummary {
|
||||
name: view_name.to_string(),
|
||||
display: view_name.to_string(),
|
||||
axes: vec![("Region".to_string(), Axis::Row)],
|
||||
}],
|
||||
};
|
||||
@@ -119,6 +120,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() {
|
||||
use crate::protocol::ViewSummary;
|
||||
use crate::ui::panel::Panel;
|
||||
use ratatui::{buffer::Buffer, widgets::Widget};
|
||||
let cache = ViewListCache {
|
||||
views: vec![ViewSummary {
|
||||
name: "Default".to_string(),
|
||||
display: "Overview".to_string(),
|
||||
axes: vec![],
|
||||
}],
|
||||
};
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf);
|
||||
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
|
||||
assert!(row.contains("▶ Overview"), "{row}");
|
||||
}
|
||||
|
||||
fn app_with_two_views() -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ fn grouped_item_order_survives_save() {
|
||||
let mut w = Workbook::new("test");
|
||||
w.add_category("C").unwrap();
|
||||
w.add_item_in_group("C", "first", "G").unwrap();
|
||||
w.active_view_mut().set_expand_depth("C", 1);
|
||||
w.add_item("C", "second").unwrap();
|
||||
let r = parse_md(&format_md(&w)).unwrap();
|
||||
assert_eq!(
|
||||
@@ -429,8 +430,9 @@ fn slash_in_hidden_name_survives_roundtrip() {
|
||||
w.add_category("C").unwrap();
|
||||
w.add_item("C", "A/B").unwrap();
|
||||
w.add_item_in_group("C", "x", "G/H").unwrap();
|
||||
w.active_view_mut().set_expand_depth("C", 1);
|
||||
w.active_view_mut().hide_item("C", "A/B");
|
||||
w.active_view_mut().toggle_group_collapse("C", "G/H");
|
||||
w.active_view_mut().toggle_group_collapse("C", &["G/H"]);
|
||||
let text = format_md(&w);
|
||||
let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}"));
|
||||
assert!(
|
||||
@@ -438,7 +440,7 @@ fn slash_in_hidden_name_survives_roundtrip() {
|
||||
"hidden lost:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
r.active_view().is_group_collapsed("C", "G/H"),
|
||||
r.active_view().is_group_collapsed("C", &["G/H"]),
|
||||
"collapsed lost:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use improvise::model::cell::{CellKey, CellValue};
|
||||
use improvise::ui::app::{App, AppMode, ViewCursor};
|
||||
use improvise::ui::grid::GridWidget;
|
||||
use improvise::view::{Axis, GridLayout};
|
||||
use improvise::view::{Axis, GridLayout, GroupFold};
|
||||
use improvise::workbook::Workbook;
|
||||
use improvise_command::ui::project;
|
||||
use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState};
|
||||
@@ -31,10 +31,12 @@ fn two_level_rows() -> Workbook {
|
||||
m.add_item("Type", "Clothing").unwrap();
|
||||
m.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
m.add_item_in_group("Month", "Feb", "Q1").unwrap();
|
||||
m.active_view_mut().set_expand_depth("Month", 1);
|
||||
m.add_item("Month", "Sep").unwrap();
|
||||
m.add_item("Recipient", "Carol").unwrap();
|
||||
m.add_item_in_group("Recipient", "Alice", "Family").unwrap();
|
||||
m.add_item_in_group("Recipient", "Bob", "Family").unwrap();
|
||||
m.active_view_mut().set_expand_depth("Recipient", 1);
|
||||
m.active_view_mut().set_axis("Recipient", Axis::Row);
|
||||
m.add_item("_Measure", "Revenue").unwrap();
|
||||
m.add_item("_Measure", "Cost").unwrap();
|
||||
@@ -188,10 +190,111 @@ fn browser_model(cache: &GridCache) -> RenderModel {
|
||||
|
||||
#[test]
|
||||
fn the_two_front_ends_draw_the_same_row_header() {
|
||||
let mut wb = two_level_rows();
|
||||
let (_app, cache) = whole_grid(&mut wb);
|
||||
assert_row_headers_agree(&mut two_level_rows());
|
||||
}
|
||||
|
||||
let tui = tui_row_headers(&wb, &cache);
|
||||
/// improvise-felo.6: Year > Quarter > Month in ONE category under a grouped one;
|
||||
/// Y24 open two levels, Q2 and Y25 closed. Short names fit a TUI band cell.
|
||||
fn a_date_hierarchy() -> Workbook {
|
||||
let mut m = Workbook::new("Test");
|
||||
m.add_category("Recipient").unwrap();
|
||||
m.add_category("Date").unwrap();
|
||||
m.add_item_in_group("Recipient", "Alice", "Fam").unwrap();
|
||||
m.add_item("Recipient", "Carol").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("Jan25", "Q9")] {
|
||||
m.add_item_in_group("Date", month, quarter).unwrap();
|
||||
}
|
||||
m.add_item("Date", "Undated").unwrap();
|
||||
let date = m.model.category_mut("Date").unwrap();
|
||||
for (quarter, year) in [("Q1", "Y24"), ("Q2", "Y24"), ("Q9", "Y25")] {
|
||||
date.add_group(improvise::model::category::Group::new(quarter).with_parent(year));
|
||||
}
|
||||
m.add_item("_Measure", "Revenue").unwrap();
|
||||
let view = m.active_view_mut();
|
||||
view.set_axis("Recipient", Axis::Row);
|
||||
view.set_axis("Date", Axis::Row);
|
||||
view.set_axis("_Measure", Axis::Column);
|
||||
view.set_expand_depth("Recipient", 1);
|
||||
view.set_group_fold(
|
||||
"Date",
|
||||
"Y24",
|
||||
GroupFold::Expanded(std::num::NonZeroUsize::new(2).unwrap()),
|
||||
);
|
||||
view.set_group_fold("Date", "Q2", GroupFold::Collapsed);
|
||||
for r in ["Alice", "Carol"] {
|
||||
for d in ["Jan", "Feb", "Apr", "Jan25", "Undated"] {
|
||||
m.model.set_cell(
|
||||
coord(&[("Recipient", r), ("Date", d), ("_Measure", "Revenue")]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// See `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_two_front_ends_draw_the_same_nested_hierarchy_on_rows() {
|
||||
let mut wb = a_date_hierarchy();
|
||||
let (_app, cache) = whole_grid(&mut wb);
|
||||
let groups: Vec<String> = browser_rows(&cache)
|
||||
.into_iter()
|
||||
.filter_map(|r| match r {
|
||||
RenderRow::Group { label, .. } => Some(label),
|
||||
RenderRow::Data { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
groups,
|
||||
["Fam", "Y24", "Q1", "Q2", "Y25", "Y24", "Q1", "Q2", "Y25"],
|
||||
"precondition: a year AND its quarters head their runs, under each recipient"
|
||||
);
|
||||
assert_row_headers_agree(&mut wb);
|
||||
}
|
||||
|
||||
/// The column side of the same hierarchy: the band paints each column's
|
||||
/// innermost group, coalesced by header, at both ends.
|
||||
#[test]
|
||||
fn the_two_front_ends_draw_the_same_nested_hierarchy_on_columns() {
|
||||
let mut wb = a_date_hierarchy();
|
||||
wb.active_view_mut().transpose_axes();
|
||||
let (_app, cache) = whole_grid(&mut wb);
|
||||
let model = browser_model(&cache);
|
||||
let lines = tui_lines(&wb, &cache);
|
||||
let rule = header_rule(&lines);
|
||||
let levels = model.col_levels.len();
|
||||
let named: Vec<(Option<&str>, usize)> = model
|
||||
.col_groups
|
||||
.iter()
|
||||
.map(|g| (g.label.as_deref(), g.span))
|
||||
.collect();
|
||||
assert!(
|
||||
named.iter().any(|(l, _)| *l == Some("Q1")) && named.iter().any(|(l, _)| *l == Some("Y25")),
|
||||
"precondition: a quarter run and a folded year run: {named:?}"
|
||||
);
|
||||
let expected: Vec<String> = model
|
||||
.col_groups
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
let head = match &g.label {
|
||||
Some(name) => format!("{} {name}", indicator(g.collapsed)),
|
||||
None => String::new(),
|
||||
};
|
||||
std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&tui_header_cells(&lines[rule - levels - 1], &cache),
|
||||
&expected,
|
||||
"the TUI's group line and the browser's runs disagree"
|
||||
);
|
||||
}
|
||||
|
||||
/// The row-header comparison, row by row: both ends, one cache.
|
||||
fn assert_row_headers_agree(wb: &mut Workbook) {
|
||||
let (_app, cache) = whole_grid(wb);
|
||||
|
||||
let tui = tui_row_headers(wb, &cache);
|
||||
let browser = browser_rows(&cache);
|
||||
|
||||
assert_eq!(
|
||||
@@ -376,6 +479,7 @@ fn leading_group_rows() -> Workbook {
|
||||
m.add_category("Recipient").unwrap();
|
||||
m.add_item_in_group("Recipient", "Alice", "Fam").unwrap();
|
||||
m.add_item_in_group("Recipient", "Bob", "Fam").unwrap();
|
||||
m.active_view_mut().set_expand_depth("Recipient", 1);
|
||||
m.add_item("Recipient", "Carol").unwrap();
|
||||
m.add_item("_Measure", "Revenue").unwrap();
|
||||
m.active_view_mut().set_axis("Recipient", Axis::Row);
|
||||
@@ -397,7 +501,7 @@ fn a_group_header_first_on_its_axis_reaches_both_front_ends() {
|
||||
let mut wb = leading_group_rows();
|
||||
if collapse {
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Recipient", "Fam");
|
||||
.toggle_group_collapse("Recipient", &["Fam"]);
|
||||
}
|
||||
let (_app, cache) = whole_grid(&mut wb);
|
||||
|
||||
@@ -455,7 +559,7 @@ fn the_collapse_state_crosses_as_a_fact_not_a_glyph() {
|
||||
let mut wb = two_level_rows();
|
||||
if collapse {
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("Recipient", "Family");
|
||||
.toggle_group_collapse("Recipient", &["Family"]);
|
||||
}
|
||||
let (_app, cache) = whole_grid(&mut wb);
|
||||
|
||||
|
||||
+26
@@ -155,9 +155,35 @@ function onFrame(text) {
|
||||
console.error("improvise: frame rejected", text, err);
|
||||
return;
|
||||
}
|
||||
// Check for pending downloads (improvise-2mqz). The WASM client stores
|
||||
// download bytes from DocumentBytes/CsvBytes frames; we trigger the download
|
||||
// here via Blob + <a download>.
|
||||
try {
|
||||
const downloadJson = client.take_pending_download();
|
||||
if (downloadJson) {
|
||||
const download = JSON.parse(downloadJson);
|
||||
triggerDownload(download.filename, download.bytes, download.kind);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("improvise: download failed", err);
|
||||
}
|
||||
repaint();
|
||||
}
|
||||
|
||||
/** Trigger a browser download from bytes (improvise-2mqz). */
|
||||
function triggerDownload(filename, bytes, kind) {
|
||||
const mimeType = kind === "Document" ? "text/plain" : "text/csv";
|
||||
const blob = new Blob([new Uint8Array(bytes)], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Transports.
|
||||
|
||||
/** The in-page worker: the standalone deployment, no server process. */
|
||||
|
||||
@@ -91,6 +91,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
"on_key_event",
|
||||
"render_model",
|
||||
"set_viewport",
|
||||
// improvise-2mqz: download frames stay in the Rust fold until the page
|
||||
// takes the bytes and hands them to Blob + <a download>.
|
||||
"take_pending_download",
|
||||
"typed_cell",
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user