Merge gh/main into fix/lm5e-effect-fields
Conflict was context/test-evidence/command.md only: improvise-gjat (PR #144) and improvise-lm5e appended different sections at the same point. Both sides kept — the entries are disjoint, 16 from lm5e and one from gjat — and verified line-by-line that neither side lost content, because a clean-looking textual resolution is exactly how evidence entries go missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+3
-2
File diff suppressed because one or more lines are too long
@@ -115,3 +115,43 @@ What it still cannot see, and should be closed if the welcome text moves again:
|
||||
`draw.rs::draw_welcome` builds its lines as a local array inside a ratatui
|
||||
render function, so nothing can walk them. Its `:formula` line was corrected by
|
||||
hand.
|
||||
|
||||
## 7 — a script's command is built against the cursor it will run against
|
||||
|
||||
`improvise cmd` and `improvise script` used to resolve every command through
|
||||
`CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()`:
|
||||
row 0, col 0, and a grid of zero rows and zero columns. The interactive arm,
|
||||
which builds `CursorState::from_ctx(ctx)`, was never reached from a script.
|
||||
|
||||
So every `Move` (`move-selection`, `scroll-rows`, `page-scroll`, the four
|
||||
`jump-*`) and `enter-advance` snapshotted a zeroed cursor at **parse** time,
|
||||
clamped to `(0, 0)`, and emitted `SetSelected(0, 0)`. That is worse than a
|
||||
no-op: it *resets* the cursor. Nothing could position it, so every
|
||||
cursor-reading command — `clear-cell`, `yank`, `paste`, `cycle-axis-at-cursor`,
|
||||
`hide-selected-row-item`, `edit-or-drill`, `drill-into-cell`,
|
||||
`toggle-group-under-cursor`, the three `delete-*-at-cursor`,
|
||||
`commit-and-advance` — was pinned to the origin (improvise-6uva). It was
|
||||
introduced with `CursorState` and the two-arm registry in April and predates the
|
||||
browser work by five months.
|
||||
|
||||
**A registry with two construction arms silently demotes every caller of the
|
||||
weaker arm, and nothing type-checks which arm a caller deserves.** That is the
|
||||
shape to remember, and it has a second instance in the same fix: the
|
||||
`register_pure` entries had an interactive arm that refused **empty** args while
|
||||
their pure arm accepted them, so `:w` (save to the current path) parsed one way
|
||||
and refused the other. Both arms are now `parse`, which is what "needs no
|
||||
interactive context" was supposed to mean;
|
||||
`a_pure_commands_two_arms_agree_on_no_arguments` is the guard.
|
||||
|
||||
The repair is `parse::segments` plus `parse::parse_segment_interactive`, and the
|
||||
loop re-parses **per segment**, because command *n* must see command *n-1*'s
|
||||
cursor — which means construction cannot be hoisted out of the loop. Line
|
||||
atomicity is preserved by keeping the existing whole-line `parse_line` as a
|
||||
**syntax gate** before anything in the line runs: a line that parsed before
|
||||
still parses, and a line that did not still does not, so the only behavioural
|
||||
change is which cursor the commands see.
|
||||
|
||||
The TUI's `:` line had the same defect and is fixed the same way, with one
|
||||
limitation that is not new: `ExecuteCommand` returns one effect batch, so the
|
||||
segments of `:a . b` all see the pre-batch cursor, exactly as a
|
||||
`Binding::Sequence` does (gotchas 21 and 23).
|
||||
|
||||
@@ -154,6 +154,72 @@ composes both: the prompt and buffer on the left, any pending `status_msg`
|
||||
right-aligned. The alternatives and why they lost are in
|
||||
`context/test-evidence/tui.md`.
|
||||
|
||||
### `fill_line` — which segment yields is a per-call-site decision
|
||||
|
||||
Three rows in the TUI are one left segment and one right segment sharing a line:
|
||||
the title bar, `minibuffer_line` and `draw_status`. `fill_line` composes them,
|
||||
and it owes two things that it did not originally do — it must measure in
|
||||
**display cells** (it padded with `left.len()`, bytes, and the title's own `·`
|
||||
is two of them, so every document's right segment sat one cell in from its edge)
|
||||
and it must **truncate**, because concatenating and letting ratatui clip clips
|
||||
the *right*.
|
||||
|
||||
What clipping the right costs depends on the row, which is why `Keeps` is a
|
||||
parameter and not a rule:
|
||||
|
||||
- **The title bar keeps the right.** Its left segment is decoration whose length
|
||||
is unbounded — a document name plus a filename — and its right segment is the
|
||||
fixed `" ?:help :q quit "`, the only thing on screen telling a stuck user how
|
||||
to get out. Unconditional left priority made the escape hatch the first thing
|
||||
sacrificed, at 100 columns with an ordinary document name (improvise-t14d).
|
||||
- **Both bottom-bar rows keep the left**, which is what they already did. There
|
||||
the left is the user's own typing plus the cursor (`minibuffer_line`) or the
|
||||
status message (`draw_status`), and the right is a view badge or the message's
|
||||
own margin. Right priority there would let a long message eat the text being
|
||||
edited — the same mistake in the other direction.
|
||||
|
||||
So the rule is not "the right wins" but "the affordance wins over the
|
||||
decoration", and which is which is local. Truncation goes through
|
||||
`ui::text::truncate` (improvise-8l0c), so an overflow is a `…` rather than a
|
||||
mid-token clip of `?:`.
|
||||
|
||||
### One key table on screen, and it is the one derived from the keymap
|
||||
|
||||
The TUI stacks the tile bar (row 3) directly above the status bar (row 4), and
|
||||
the import wizard's overlay sits above the status bar too, so a mode can put
|
||||
**two** key tables on screen at once. When it does, they disagree — different
|
||||
notation, different content, and in one case a flat contradiction.
|
||||
|
||||
The rule is not "the overlay wins" or "the status bar wins". It is **keep
|
||||
whichever table is derived from the thing it describes** (improvise-rr8n):
|
||||
|
||||
- **Tile select: the status bar wins.** `hint_line(&mode)` is read off the
|
||||
keymap, so it cannot go stale, and it is a strict superset of what the tile
|
||||
bar's literal carried — `Space`, `h`/`l` and `Tab` were only ever on the
|
||||
derived line. The tile bar's own hints are therefore **deleted**, both of
|
||||
them, and `TileBar::render` now advertises no keys at all. The one it drew in
|
||||
Normal was false: it named `Ctrl+↑↓←→` as "to move tiles" while those four
|
||||
keys are bound to `enter-tile-select`, which moves nothing.
|
||||
- **The import wizard: the overlay wins.** `hint_line(&AppMode::ImportWizard)`
|
||||
is derived too, but from the *wrong input* — the wizard's real keys are
|
||||
resolved per step inside `HandleWizardKey`, and one static line off the mode's
|
||||
keymap is wrong on four of its eight steps, including telling the user `Esc`
|
||||
cancels on `Done` where `Esc` goes back. So `draw_status` suppresses its hint
|
||||
fallback for that one mode and lets the overlay's per-step hints stand alone.
|
||||
|
||||
Two things worth keeping from how this survived. `every_hint_key_resolves_in_its_mode`
|
||||
checks `AppMode::hint_text()` against the keymaps and **cannot see a string
|
||||
literal in a widget**, which is the same blind spot improvise-wk18 names for
|
||||
`help.rs`. And the widget's own characterization test renders at 80 columns,
|
||||
where the hint does not fit and is never drawn — so the test that pins this
|
||||
widget's output was structurally incapable of showing either hint. A fixture
|
||||
width is part of what a test can see.
|
||||
|
||||
The longer-term shape the ticket names is still open: the wizard's per-step keys
|
||||
belong in `advertised()` keyed by step, which would make them derived like
|
||||
everything else and let the status bar carry them correctly instead of being
|
||||
switched off.
|
||||
|
||||
### The caches are built here, per frame
|
||||
|
||||
`project::formulas_of`, `category_tree_of`, `view_list_of`, `tile_bar_of`,
|
||||
|
||||
+4
-2
@@ -194,11 +194,11 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
|
||||
|
||||
26. **Axis presentation lives on `Axis`, colors resolve in `draw.rs`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `color() -> AxisColor` and `next()` for the Row→Column→Page→None rotation. `AxisColor` is a semantic palette: `improvise-core` must not name a ratatui type, so `draw.rs::axis_fg` does the one mapping to a terminal color, exactly as `minibuf_fg` does for `MinibufColor` (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer.
|
||||
|
||||
27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis.
|
||||
27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis. **What a virtual view must *be* is model-side and has one home** (improvise-gd28): `view::{records_spine, records_steps, drill_steps}` return `Vec<ViewStep>` — pure data naming no `Effect`, no `App` and no front end — and `cmd/grid.rs::steps_into_active_view` is the whole of *how the TUI gets there*, twenty lines a browser answers its own way. Before the split, the drill's filter placement and the records spine were spelled out twice in `cmd/grid.rs` with nothing keeping them in agreement. `DrillState` deliberately did **not** move: it holds an `Rc` and its `pending_edits` are uncommitted writes, so gotcha 30's session-local tag already places it. Reasoning: `context/code/core.md` §11.
|
||||
|
||||
28. **The browser backend is sync on purpose, with an async seam.** `OpfsFs` (`improvise-io/src/opfs.rs`, wasm32 only) implements the same synchronous `Storage` trait as `PhysicalFs`, because `FileSystemFileHandle.createSyncAccessHandle()` yields a handle whose read/write/truncate/flush **are** blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So `Storage` never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: `open()`, `attach(path)` (must precede any read/write of that document — acquiring a handle is async even though using it is not), `refresh()` (repopulates the index `list` answers from), and `commit_deletes()`. Consequence: **`delete` is visible immediately but lands in OPFS at the end of the batch** — it drops the name from the index and queues a `removeEntry`. Handles are exclusive per file, so `attach` closes any handle it replaces and `detach` releases the lock. `storage_contract` in `storage.rs` is the behaviour every backend owes; it runs against both host backends and is the written-down spec `OpfsFs` must satisfy in a browser, where the harness cannot reach. **And the premise is dedicated-worker-only, which is narrower than this paragraph used to read.** `createSyncAccessHandle` is spec'd `[Exposed=DedicatedWorker]`, and measured in a module `SharedWorker` on Chromium and Firefox it is **absent** — not rejected, not flagged, not transient: `getDirectory()` and `getFileHandle()` both succeed and the method is simply not on the object (improvise-12ha). So `OpfsFs` cannot be hosted by a `SharedWorker` as written, and "a browser worker" in this paragraph means a *dedicated* one everywhere it appears. `OpfsFs::open`'s own guard does not enforce that — it `dyn_into`s `WorkerGlobalScope`, which a `SharedWorkerGlobalScope` **satisfies**, so a shared worker sails past a check whose message names the right requirement and fails later as a raw `TypeError` deep in the glue (improvise-7gip). The consequence for multi-tab is that the machinery everyone reaches for first is the storage seam, not `Sessions`: `Sessions`, the `Addressed` filter and the one `frame` pair are all ready, and none of that helps, because the no is entirely on the handle.
|
||||
|
||||
29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. Two gaps remain, both filed: a view-scoped `ModelCommand` names no view, so a connected client's `SetAxis` lands on the host's view (improvise-lltt — deferred on a *condition*, that no shipped client emits `SwitchView`, while the browser already emits `TransposeAxes`), and `Initial View` no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed.
|
||||
29. **"Which view is active" is stored twice, and the copies are now allowed to differ.** `Workbook::active_view` is the document's **default** — what a freshly connected client opens, and what `.improv` writes as `Initial View`. `ViewState::active_view` is **authoritative for that client**, and it is what keys `ViewState::cursors`. The second copy exists because `reduce_view` runs on a client that holds no `Workbook` (improvise-cqi); the *split* is improvise-jytm answering improvise-g0f, on the user's ruling that **the model is shared and the UI is not**. Two clients may sit on different views of one document. Three things follow. **`App::resolve_view` is the one answer** to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in `rebuild_layout`; `view()`, `view_mut()`, `view_name()`, `project::grid_in` and `ClientSession::view_in` all go through it, and a second copy of that fallback was the first thing review found. **The guard was replaced, not deleted** — `the_two_active_view_names_never_diverge` became `the_documents_default_view_outlives_this_clients_switches` plus `a_fresh_session_opens_the_documents_default_view`, because a deliberate split with no guard is how one silently regresses into the other. And **a deleted view is the case the split creates**: `DeleteView` can take away the view another client is on, so `Sessions::adopt_deleted_views` moves that mirror to the default and tells it, and `effect::DeleteView` does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. **Testing any of it needs a bystander that can be moved**: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting `ToAll` twice. One gap remains: `Initial View` no longer follows the TUI user (improvise-azhb). The other — a view-scoped `ModelCommand` naming no view — is **closed** (improvise-lltt), and this paragraph described it as an open gap for a day after: every view-scoped variant carries `view: String` and `model_effects` maps it to `ViewTarget::Named`, so a connected client's `SetAxis` lands on the view it names. `ViewTarget::Active` resolves through `App::view_mut()` and is therefore the *host's* view — but **the wire never constructs one**, which is what closed it. Follow mode is deliberately not foreclosed.
|
||||
|
||||
30. **Two tests gate `ViewState` membership.** `ViewState` is the *portable* session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is **portable** (plain data from the allowlisted crates, no `Rc`/`Arc`) and **snapshot-safe** (a fresh client inheriting the value is correct). `wizard` fails both — `ImportWizard` is an `improvise-io` type, and a half-open dialog is not something to inherit — and `drill_state` fails both — it holds an `Rc`, and `pending_edits` are uncommitted writes belonging to whoever staged them. Both live on `App` under the **session-local** residue tag (improvise-h0b). The `ViewState: Send` static assertion in `app.rs`'s tests is the compile-time proxy for portability: `Rc` is not `Send`, so a re-added shared pointer fails the build (the `Deserialize` derive from gotcha 25 now rejects it too, serde's `rc` feature being off). `Send` does *not* catch the origin-crate or snapshot-safety halves — check those by hand.
|
||||
|
||||
@@ -237,6 +237,8 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
|
||||
|
||||
44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec<FrameEntry>` interleaving `Group(FrameGroup)` headers with `Item(Vec<String>)` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec<String>` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**: the nearest preceding header *unless* an `AxisEntry::GroupEnd` closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. `GroupEnd` draws nothing and exists only so membership is not inherited. Both of the gaps this sentence used to list are **closed**, and it named them as open for a day after: `group_runs` coalesces by **header identity** (`std::ptr::eq`), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); and `AxisFrame::first` carries **no** `serde(default)` — its doc comment says why, since zero is a claim and not an absence (improvise-1wl0). `doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)` is about exactly this paragraph, twice now. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot.
|
||||
|
||||
**`RecordHash` is document-scoped; `AggregateHash` is layout-scoped and cannot be otherwise** (improvise-gd28). A `RecordHash` falls back to `DataStore`'s maintained index and so resolves against the **document**, which is why a connected client's `SetCell` works from any view. An aggregate has no existence outside the layout that derives it, so there is no index to fall back to and none can be built — `App::resolve_aggregate` therefore answers only for the **host's** active view, which since improvise-jytm is not the client's. **That, and not ADR 001 layer 2, is what blocks browser drill.** The ticket's blocking argument was right about the *commit* (`ApplyAndClearDrill` is addressed to one session, gotcha 38) and wrong about the *entry*: entering a drill needs no commit, and `CreateView`/`SetAxis`/`SetPageSelection` are already unaddressed `ModelCommand`s that name their view. The gap is one level down from where it was looked for — **the mutation and its operand have different scopes**: the mutation configures the shared `_Drill` and is addressed to nobody, while the operand exists only inside one session's layout. So the fix is not a new mechanism but the `view: String` convention every other view-scoped variant already follows, resolved against a layout derived for that name (improvise-obtu). Two things travel with it: `improvise-fdk1` is **not** needed, because `edit_or_drill` declines records-mode editing out loud before reaching a cell, so a browser cannot edit inside a drill grid at all; and closing it makes improvise-t219 live, `_Drill` being one shared view in `Workbook::views` that two clients drilling would contend for — the same masking relationship improvise-6uva has with improvise-up8g. Guarded by `an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view`, whose **control** is the load-bearing half — the same surrogate resolves once the host is moved onto that view, so a blanket `None` cannot pass.
|
||||
|
||||
45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist.
|
||||
|
||||
**Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag for any `EffectKind::Model(_)`, all three `ModelChange` levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction `layout_version` does — a `LoadModel` that cannot read its path leaves the flag raised, costing one redundant write — and `Save` clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command.
|
||||
|
||||
@@ -135,3 +135,106 @@ spawned child at a stable path.
|
||||
Runs `add-category Region` to create the document. It asserts the file exists
|
||||
afterwards rather than trusting the seed, because every test that follows would
|
||||
otherwise misreport as "the command did nothing" when the seed was what failed.
|
||||
|
||||
### a_script_can_move_the_cursor_and_the_next_command_sees_it
|
||||
|
||||
`improvise cmd` and `improvise script` resolved every command through
|
||||
`CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()`
|
||||
(row 0, col 0, zero rows, zero columns). The interactive arm, which builds
|
||||
`CursorState::from_ctx(ctx)`, was never reached from a script, so every `Move`
|
||||
snapshotted a zeroed cursor at *parse* time, clamped to `(0, 0)` and emitted
|
||||
`SetSelected(0, 0)` — worse than a no-op, since it **resets** the cursor
|
||||
(improvise-6uva). The reasoning is `context/code/cli.md` section 7.
|
||||
|
||||
Reproduced at `a9cad15` before the fix, on a seeded document with `Region` on
|
||||
row carrying `North, South, East`: `move-selection 2 0 . hide-selected-row-item`
|
||||
and a bare `hide-selected-row-item` both wrote `hidden: Region/North`. After the
|
||||
fix the first writes `hidden: Region/East`.
|
||||
|
||||
**Why the test has two halves.** Asserting only that the moved script hides
|
||||
`East` cannot separate "the move worked" from "the origin happens to be East".
|
||||
The second document asserts the origin is `North`, so the first assertion is
|
||||
discriminating, and its message says what breaks if that ever changes — three
|
||||
identical replays of a fixture whose moves are silent no-ops prove nothing,
|
||||
which is how this survived five months.
|
||||
|
||||
Two faults were injected, one per layer, and both fail this test:
|
||||
|
||||
- **`parse_segment_interactive` calls `ctx.registry.parse` instead of
|
||||
`.interactive`** — the bug itself, one level down.
|
||||
`left: "hidden: Region/North" right: "hidden: Region/East"`.
|
||||
- **`run_headless_commands` builds the segment up front with `parse_line`**
|
||||
rather than per segment against a live `CmdContext` — the composition half.
|
||||
Same failure. The unit test in `parse.rs` passes under this second one, which
|
||||
is why both tests exist.
|
||||
|
||||
### a_script_cursor_move_survives_the_line_that_made_it
|
||||
|
||||
The same property across two `-f` arguments rather than one dot-separated line,
|
||||
because `improvise cmd` takes several and `improvise script` is one per line:
|
||||
the cursor is `App` state and a line boundary is not a reset.
|
||||
|
||||
### a_segment_is_built_against_the_cursor_it_will_run_against
|
||||
|
||||
The unit test for the layer that owns the bug. It seats a `CmdContext` at
|
||||
`(1, 0)` in a two-by-two grid and asserts `move-selection 0 1` executes to
|
||||
`SetSelected(1, 1)`.
|
||||
|
||||
Its second half is what keeps it honest: it builds the *same* command through
|
||||
`registry.parse` and asserts that one gives `SetSelected(0, 0)`. If the pure arm
|
||||
ever learns the cursor, the first assertion stops discriminating and this half
|
||||
fails first, saying so.
|
||||
|
||||
### a_pure_commands_two_arms_agree_on_no_arguments
|
||||
|
||||
A second instance of the same shape, found by the first fix rather than
|
||||
predicted by the ticket. `CmdRegistry::register_pure` built an interactive arm
|
||||
that refused **empty** args while its own pure arm accepted them, so `w` with no
|
||||
path — save to the current document — parsed one way and refused the other. Once
|
||||
a script re-parsed through the interactive arm, `improvise cmd 'w'` and `:w`
|
||||
both broke; the suite caught it as
|
||||
`a_failed_colon_write_lands_in_normal_where_its_error_is_drawn` reporting
|
||||
`":w — w: this command requires arguments"`.
|
||||
|
||||
Both arms are now `parse`, which is what "needs no interactive context" was
|
||||
supposed to mean. Restoring the empty-args guard fails this test on the first
|
||||
`expect`: the interactive arm refuses a bare `w` the pure arm accepts.
|
||||
|
||||
### segments_drops_comments_and_blanks
|
||||
|
||||
`segments` is the split half of `parse_line_with`, extracted so the headless
|
||||
loop can re-parse one segment at a time. It answers the same three cases the
|
||||
whole-line parser did — a `#` comment, a `//` comment and a blank line are no
|
||||
commands — plus the empty segment in `a . . b`.
|
||||
|
||||
### an_unknown_segment_is_an_error_not_a_silent_skip
|
||||
|
||||
`parse_segment_interactive` returns `Ok(None)` for a segment with no word, which
|
||||
is a shape an unknown *word* must not be able to reach: a silently skipped
|
||||
command in a script is the partial-and-silent failure this repo keeps finding.
|
||||
The error also names the word, matching what `CmdRegistry::parse` does.
|
||||
|
||||
### an_explicit_coordinate_still_beats_the_cursor
|
||||
|
||||
Found by the ChatGPT Codex review of PR #151 at head `64bcb82`, and correct: the
|
||||
first version of improvise-6uva's fix introduced a regression it was not looking
|
||||
for. Four registry entries — `clear-cell`, `yank`, `paste`, `drill-into-cell` —
|
||||
were registered with `register`, and their **interactive** arms took `|_args,
|
||||
ctx|`: they discarded the arguments and used `ctx.cell_key()`. That was harmless
|
||||
while a script only ever reached the *pure* arm, which reads the coordinates. The
|
||||
moment the headless loop re-parsed interactively, `clear-cell Region/East` passed
|
||||
the syntax gate and then cleared whatever the cursor was sitting on — the origin,
|
||||
`Region/North`.
|
||||
|
||||
So improvise-6uva's own lesson had a **third** instance in its own fix: two
|
||||
construction arms that mean different things demote whichever caller reaches the
|
||||
weaker one, and here the "weaker" one was the arm that had the context. The four
|
||||
arms now go through `key_from_args_or_cursor`: explicit coordinates win, the
|
||||
cursor is the fallback, which is also what the keymap path wants since a binding
|
||||
carries no arguments.
|
||||
|
||||
The fixture writes three cells and clears the one the cursor is **not** on, so
|
||||
the assertion separates "cleared the named cell" from "cleared the cursor cell"
|
||||
— clearing `North` by name would have passed against the broken code. Restoring
|
||||
the arg-discarding arm fails it: `the cursor cell was cleared instead of the
|
||||
named one: ["Region=East = 3", "Region=South = 2"]`.
|
||||
|
||||
@@ -4672,3 +4672,37 @@ cannot tell the category field from the item field.
|
||||
|
||||
`:add-category Region` must name `Region`; the assertion said only
|
||||
`AddCategory`.
|
||||
### a_sequence_of_moves_folds_in_order_and_the_last_write_wins
|
||||
|
||||
Replaces `law_sequence_associativity` (improvise-gjat), which could not fail. That
|
||||
test built three effect vectors from one `CmdContext` and asserted
|
||||
`effects_debug((a ++ b) ++ c) == effects_debug(a ++ (b ++ c))` — the associativity
|
||||
of `Vec::extend`, true for any deterministic `execute`, and
|
||||
`doctrine(fold_tests_must_test_order_not_the_axiom)` names exactly this shape. It
|
||||
never applied an effect, so no command behaviour could move it.
|
||||
|
||||
The property that can fail is the one production actually relies on: a
|
||||
`Binding::Sequence` flattens into **one** effect vec (gotcha 21), the batch folds
|
||||
onto the `App` in order, and each `Move` composed against the *same* ctx emits an
|
||||
**absolute** `SetSelected` — so the moves do not compose and the last write wins.
|
||||
The test folds one batch per ordering and asserts the resulting cursor, with
|
||||
`c, b, a` landing somewhere `a, b, c` does not, so an implementation that ignores
|
||||
order is separated from one that honours it.
|
||||
|
||||
Two faults were injected, and both leave the old assertion passing:
|
||||
|
||||
- **`Move::execute` returns `Vec::new()` when the computed position equals the
|
||||
current one** — a plausible "skip a move that moves nothing" optimisation, and
|
||||
one that would break `tests/lockstep.rs`, since the browser resolver mirrors
|
||||
`Move` line for line including where it clamps (gotcha 47). The old test still
|
||||
passed (`c` contributed nothing to either grouping, so the two remained equal);
|
||||
the new one failed `left: (0, 1) right: (0, 0)` — `b` had become the last write.
|
||||
- **`App::apply_effects` folds the batch in reverse.** The old test passed
|
||||
untouched, because it formats the vector and never applies it; the new one
|
||||
failed `left: (1, 0) right: (0, 0)`.
|
||||
|
||||
The second fault is why the batch is flattened rather than applied one command at
|
||||
a time. A first version of this test called `apply_effects` per command; each
|
||||
single-`Move` batch reverses to the same cursor, so the reversed fold was
|
||||
invisible and the test passed against it. One batch is also the shape a
|
||||
`Binding::Sequence` really has.
|
||||
|
||||
@@ -644,3 +644,195 @@ Fault injected 2026-09-21: restoring the name-only comparison in
|
||||
`AxisFrame::group_runs` fails this with `left: [(Some("Q1"), 4)]` while the five
|
||||
existing agreement tests all pass — the ungrouped `Sep` in their fixture is
|
||||
precisely why they cannot see it.
|
||||
|
||||
---
|
||||
|
||||
## `src/draw.rs::title_bar_tests` — the title bar's escape hatch
|
||||
|
||||
improvise-t14d. `fill_line` composed a left and a right segment into one row and
|
||||
did **neither** of the two things that composition needs. It padded with
|
||||
`left.len() + right.len()` — **bytes** — and it never truncated, so on overflow
|
||||
it simply concatenated and left ratatui to clip. ratatui clips the *right*, and
|
||||
the right segment of the title bar is `" ?:help :q quit "`: the only thing on
|
||||
screen telling a stuck user how to get help or get out.
|
||||
|
||||
**The priority was backwards.** The left segment is decoration — the product
|
||||
name, the document name, the filename, a dirty marker — and its length is
|
||||
unbounded because a document name is. The right segment is a fixed 17 cells and
|
||||
is the escape hatch. Giving the unbounded decoration unconditional priority over
|
||||
the fixed affordance means the affordance is the first thing sacrificed, and it
|
||||
is sacrificed at ordinary widths: measured through a real `TestBackend`, a
|
||||
document named `Quarterly Regional Sales Analysis` stored as
|
||||
`quarterly-regional-sales-2026.improv` loses `:q quit` **at 100 columns**.
|
||||
|
||||
The byte fault is separate and universal. The title's own format string spells
|
||||
`·`, two bytes and one cell, so `left.len()` over-counts by one for *every*
|
||||
document — the padding is one column short and the right segment sits one cell
|
||||
in from the edge it is anchored to, before any non-ASCII document name makes it
|
||||
worse.
|
||||
|
||||
`Keeps` is the fix's shape: which segment survives is a per-call-site decision,
|
||||
not a global rule. The title bar keeps the **right** (the hatch outranks the
|
||||
name). The two bottom-bar call sites keep the **left**, which is what they did
|
||||
before — there the left is the user's own typing (`minibuffer_line`) or the
|
||||
status message (`draw_status`), and the right is a view badge, so right-priority
|
||||
would have let a long message eat the text the user is editing along with the
|
||||
cursor. Widths are `ui::text::truncate`'s display cells throughout, so the
|
||||
overflow is a graceful `…` rather than a mid-token clip of `?:`.
|
||||
|
||||
### the_escape_hatch_outlives_a_long_document_name
|
||||
|
||||
The defect itself, at the five widths the ticket measured (36, 44, 52, 60, 100).
|
||||
Rendered through a `TestBackend` and read off the terminal buffer, not off
|
||||
`fill_line`'s return value: the loss happens in ratatui's clip, so a helper's
|
||||
return value cannot see it. **A test at 80 columns with a short fixture name
|
||||
passes against the broken code** — title length is the variable, not width.
|
||||
|
||||
Fault injected: `Keeps::Right` → `Keeps::Left` at the `draw_title` call site.
|
||||
Fails at width 36 with `" improvise · Quarterly Regional S"`; the other four
|
||||
tests in the module pass, so this one and only this one names the priority.
|
||||
|
||||
### the_document_name_still_leads_the_row
|
||||
|
||||
The other direction, which the escape-hatch assertion alone cannot supply: a fix
|
||||
that dropped the left segment entirely, or that put the hatch first, satisfies
|
||||
it. At 80 columns with a short name nothing is truncated, so this pins that the
|
||||
name leads, the hatch follows, and the filename survives intact.
|
||||
|
||||
### a_multibyte_title_does_not_shift_the_right_segment
|
||||
|
||||
The byte-versus-cell fault. Asserts the row *ends with* the right segment — the
|
||||
edge it is anchored to — for an ASCII name, an accented one and a CJK one. The
|
||||
ASCII case is the discriminating one and the surprise: it fails against the
|
||||
broken code too, because the `·` is in the format string rather than in the
|
||||
document's name.
|
||||
|
||||
Fault injected: `width - left.width() - right.width()` →
|
||||
`width.saturating_sub(left.len() + right.len())`, keeping the truncation. Fails
|
||||
on `"Sales"` with the right segment one cell in from the edge, and takes
|
||||
`a_composed_line_is_exactly_its_width` with it. The escape-hatch test does not
|
||||
see it, which is why the two faults need two tests.
|
||||
|
||||
### a_composed_line_is_exactly_its_width
|
||||
|
||||
The property both directions owe, over 0..40 columns, three segment pairs and
|
||||
both `Keeps` arms: a wide-character left segment, an empty left, and an empty
|
||||
right. Width 0 and the band where the two segments cannot both fit are the cases
|
||||
`fill_line`'s arithmetic can get wrong — it subtracts rather than saturating, so
|
||||
an off-by-one here is a panic and not a cosmetic slip.
|
||||
|
||||
### the_kept_side_is_the_side_that_survives
|
||||
|
||||
`Keeps::Left` has no other witness in this module: every rendering test above
|
||||
drives the title bar, which keeps the right. Without this, both arms could be
|
||||
the same code and the suite would stay green — which is what the enum exists to
|
||||
prevent.
|
||||
|
||||
Fault injected: the `Keeps::Left` arm rewritten to the body of the `Keeps::Right`
|
||||
arm. This test fails; the other four pass.
|
||||
|
||||
---
|
||||
|
||||
## `src/ui/tile_bar.rs` + `src/draw.rs` — one hint table on screen, and it is the derived one
|
||||
|
||||
improvise-rr8n. Two hand-written key tables were drawn **beside** derived ones,
|
||||
in a different notation, and one of them was outright false.
|
||||
|
||||
**The tile bar's non-TileSelect hint named keys that do something else.** It read
|
||||
`" Ctrl+↑↓←→ to move tiles"`, while `Ctrl-Left/Right/Up/Down` are bound in Normal
|
||||
to `enter-tile-select` — pressing one *enters* tile mode and moves nothing. It
|
||||
was also the only persistent on-screen documentation of those keys, and it spelt
|
||||
`Ctrl+` where `keymap::key_label`, and therefore every derived hint and help row,
|
||||
spells `Ctrl-`.
|
||||
|
||||
**Nothing could have caught it.** `every_hint_key_resolves_in_its_mode` checks
|
||||
`AppMode::hint_text()` against the keymaps; a string literal in a widget is
|
||||
invisible to it. And the shipped fixture `renders_each_tile_with_its_axis_label`
|
||||
renders at **80 columns**, where the hint does not fit (`x + hint.width() <=
|
||||
area.x + area.width` is false) and is never drawn — so the one test that pins
|
||||
this widget's output could not see either hint at all. That is why the new tests
|
||||
render at 140.
|
||||
|
||||
**The fix deletes rather than rewrites, and that departs from the ticket's AC.**
|
||||
improvise-rr8n proposed rewriting the string to `" Ctrl-↑↓←→ or T to pick a
|
||||
tile"` and suppressing the *status bar's* hint in TileSelect. Measured against
|
||||
the ticket's own second finding, that is backwards:
|
||||
|
||||
```
|
||||
tile bar (hand-written): [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel
|
||||
status bar (derived): hl←→:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back
|
||||
```
|
||||
|
||||
The derived line is a strict **superset** — it carries `Space`, `h`/`l` and
|
||||
`Tab`, which the tile bar's omits — and it cannot go stale, being read off the
|
||||
keymap. Suppressing it would have deleted information and kept the table that
|
||||
had just been shown to rot. So the tile bar now advertises nothing, and in
|
||||
Normal the derived line already says `T:tiles`; only the `Ctrl-`arrow spelling
|
||||
is lost, and it was never documented correctly.
|
||||
|
||||
**The wizard goes the other way, and there the ticket is right.** Its overlay
|
||||
draws correct per-step hints, and the status bar drew one static
|
||||
`Space:toggle c:cycle Enter:next Esc:cancel` under it — wrong on four of eight
|
||||
steps, including an outright contradiction on `Done`, where the bar says `Esc`
|
||||
cancels and `Esc` goes *back*. It is static because it is derived from the
|
||||
`ImportWizard` keymap, while the wizard's real keys are resolved per step inside
|
||||
`HandleWizardKey`; so the derivation is sound and its *input* is the wrong thing.
|
||||
`draw_status` therefore suppresses the hint fallback for that one mode.
|
||||
|
||||
The asymmetry is the point: **keep whichever table is derived from the thing it
|
||||
describes.** For tile select that is the status line; for the wizard it is the
|
||||
overlay.
|
||||
|
||||
### the_tile_bar_advertises_no_keys_of_its_own
|
||||
|
||||
Renders the bar in Normal and TileSelect at 140 columns and requires that neither
|
||||
row names `Ctrl`, `Enter`, `Esc`, `←→` or `move tiles`. An absence assertion, so
|
||||
it is paired with the two below that require the information to still exist
|
||||
elsewhere.
|
||||
|
||||
Two faults injected, both caught here and by nothing else:
|
||||
|
||||
- The original `else` branch restored — fails in Normal on `"Ctrl"`, quoting the
|
||||
whole false line.
|
||||
- Only the `else` branch deleted, keeping the TileSelect literal (the narrow
|
||||
reading of finding 1) — fails in TileSelect on `"Enter"`. This is the rival
|
||||
that matters, because it is the fix a reader of finding 1 alone would write,
|
||||
and it leaves two disagreeing dialects stacked on adjacent rows.
|
||||
|
||||
Note it would also fail against the ticket's own proposed replacement string,
|
||||
which still contains `Ctrl`. That is deliberate rather than accidental: the
|
||||
property asserted is "this widget names no keys", not "this widget's keys are
|
||||
currently right".
|
||||
|
||||
### the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to
|
||||
|
||||
The counterweight. Deleting a `set_string` at the end of a render function is
|
||||
the kind of edit that takes a neighbour with it, so this pins the tiles and the
|
||||
`Tiles:` prefix at 80 and 140. It also covers the dead `x += 1` the deletion
|
||||
orphaned — clippy caught that one, but the test is what says the drawing still
|
||||
lands.
|
||||
|
||||
### the_wizard_overlay_is_the_only_key_hint_on_screen
|
||||
|
||||
The wizard half. Requires the bottom row in `ImportWizard` to name none of
|
||||
`Space:toggle`, `c:cycle`, `Esc:cancel`. Fails against the unfixed code quoting
|
||||
the full row, including the `Esc:cancel` that contradicts the overlay.
|
||||
|
||||
### suppressing_the_wizard_hint_leaves_the_rest_of_the_bar
|
||||
|
||||
The direction the suppression could overshoot: the mode label `IMPORT` and a real
|
||||
`status_msg` are not hints and must survive. A fix that blanked the bar in that
|
||||
mode, or that suppressed the *message* along with the hint, fails here.
|
||||
|
||||
### the_status_bar_still_advertises_the_tile_keys
|
||||
|
||||
The counterweight to the tile bar deletion, and the test that rejects the
|
||||
ticket's own AC: Normal must still say `T:tiles`, and TileSelect must still carry
|
||||
`Enter/Space:cycle`, `r:row` and `Esc/Tab:back`.
|
||||
|
||||
Fault injected: suppressing the hint fallback unconditionally
|
||||
(`true => String::new()`) rather than for `ImportWizard` alone. All three wizard
|
||||
tests pass — including both of the ones *aimed* at the suppression — and only
|
||||
this one fails. That is the shape design-principles §7 names: the assertion next
|
||||
door is satisfied by the wrong fix, and the discriminating input is the mode the
|
||||
change was not supposed to touch.
|
||||
|
||||
@@ -193,19 +193,13 @@ impl CmdRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
/// Register a command that needs no interactive context: with args it delegates
|
||||
/// to `parse`, and without them it errors.
|
||||
/// Register a command that needs no interactive context: BOTH arms are
|
||||
/// `parse`, so neither can demote the other — `context/code/command.md`.
|
||||
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
||||
self.entries.push(CmdEntry {
|
||||
name: prototype.name(),
|
||||
parse: Box::new(parse),
|
||||
interactive: Box::new(move |args, _ctx| {
|
||||
if args.is_empty() {
|
||||
Err("this command requires arguments".into())
|
||||
} else {
|
||||
parse(args)
|
||||
}
|
||||
}),
|
||||
interactive: Box::new(move |args, _ctx| parse(args)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -384,52 +384,45 @@ mod tests {
|
||||
assert_eq!(first, second, "ToStart(Row) should be idempotent");
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn law_sequence_associativity() {
|
||||
fn a_sequence_of_moves_folds_in_order_and_the_last_write_wins() {
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
assert!(ctx.row_count() >= 2 && ctx.col_count() >= 2);
|
||||
|
||||
let mk_a = || {
|
||||
let mk = |kind: MoveKind, cmd_name: &'static str| {
|
||||
Move {
|
||||
kind: MoveKind::Relative(1, 0),
|
||||
kind,
|
||||
cursor: CursorState::from_ctx(&ctx),
|
||||
cmd_name: "move-selection",
|
||||
cmd_name,
|
||||
}
|
||||
.execute(&ctx)
|
||||
};
|
||||
let mk_b = || {
|
||||
Move {
|
||||
kind: MoveKind::Relative(0, 1),
|
||||
cursor: CursorState::from_ctx(&ctx),
|
||||
cmd_name: "move-selection",
|
||||
}
|
||||
.execute(&ctx)
|
||||
};
|
||||
let mk_c = || {
|
||||
Move {
|
||||
kind: MoveKind::ToStart(true),
|
||||
cursor: CursorState::from_ctx(&ctx),
|
||||
cmd_name: "jump-first-row",
|
||||
}
|
||||
.execute(&ctx)
|
||||
let a = || mk(MoveKind::Relative(1, 0), "move-selection");
|
||||
let b = || mk(MoveKind::Relative(0, 1), "move-selection");
|
||||
let c = || mk(MoveKind::ToStart(true), "jump-first-row");
|
||||
|
||||
// A `Binding::Sequence` flattens into ONE effect vec (gotcha 21), so the
|
||||
// fold under test is a single batch.
|
||||
let seq = |steps: Vec<Vec<Box<dyn Effect>>>| {
|
||||
let batch: Vec<Box<dyn Effect>> = steps.into_iter().flatten().collect();
|
||||
assert!(!batch.is_empty());
|
||||
let mut app = crate::ui::app::App::new(two_cat_model(), None);
|
||||
app.apply_effects(batch);
|
||||
app.cursor().selected
|
||||
};
|
||||
|
||||
let mut ab_c = mk_a();
|
||||
ab_c.extend(mk_b());
|
||||
ab_c.extend(mk_c());
|
||||
assert_eq!(seq(vec![a()]), (1, 0));
|
||||
assert_eq!(seq(vec![b()]), (0, 1));
|
||||
assert_eq!(seq(vec![c()]), (0, 0));
|
||||
|
||||
let mut bc = mk_b();
|
||||
bc.extend(mk_c());
|
||||
let mut a_bc = mk_a();
|
||||
a_bc.extend(bc);
|
||||
|
||||
assert_eq!(
|
||||
effects_debug(&ab_c),
|
||||
effects_debug(&a_bc),
|
||||
"Sequence concatenation should be associative"
|
||||
);
|
||||
assert_eq!(seq(vec![a(), b(), c()]), (0, 0));
|
||||
assert_eq!(seq(vec![c(), b(), a()]), (1, 0));
|
||||
assert_eq!(seq(vec![b(), a(), c()]), (0, 0));
|
||||
assert_eq!(seq(vec![c(), a(), b()]), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,15 @@ use super::text_buffer::*;
|
||||
use super::tile::*;
|
||||
use super::wizard::*;
|
||||
|
||||
/// Explicit coordinates win; the cursor is the fallback — `context/code/cli.md`.
|
||||
fn key_from_args_or_cursor(args: &[String], ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
if args.is_empty() {
|
||||
ctx.cell_key().clone().ok_or("no cell at cursor".into())
|
||||
} else {
|
||||
Ok(parse_cell_key_from_args(args))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the default command registry with all commands.
|
||||
/// Registry names MUST match the `Cmd::name()` return value.
|
||||
pub fn default_registry() -> CmdRegistry {
|
||||
@@ -46,8 +55,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
|args, ctx| {
|
||||
let key = key_from_args_or_cursor(args, ctx)?;
|
||||
Ok(Box::new(ClearCellCommand { key }))
|
||||
},
|
||||
);
|
||||
@@ -240,8 +249,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
|args, ctx| {
|
||||
let key = key_from_args_or_cursor(args, ctx)?;
|
||||
Ok(Box::new(YankCell { key }))
|
||||
},
|
||||
);
|
||||
@@ -257,8 +266,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
|args, ctx| {
|
||||
let key = key_from_args_or_cursor(args, ctx)?;
|
||||
Ok(Box::new(PasteCell { key }))
|
||||
},
|
||||
);
|
||||
@@ -308,8 +317,8 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
}))
|
||||
},
|
||||
|_args, ctx| {
|
||||
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||
|args, ctx| {
|
||||
let key = key_from_args_or_cursor(args, ctx)?;
|
||||
Ok(Box::new(DrillIntoCell { key }))
|
||||
},
|
||||
);
|
||||
|
||||
@@ -359,7 +359,13 @@ impl Cmd for ExecuteCommand {
|
||||
return vec![effect::change_mode(AppMode::Normal)];
|
||||
}
|
||||
|
||||
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
|
||||
// Built against the live cursor, exactly as a script's is — improvise-6uva.
|
||||
let built: Result<Vec<Box<dyn Cmd>>, String> = crate::command::parse::segments(&raw)
|
||||
.into_iter()
|
||||
.map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(|built| built.into_iter().flatten().collect());
|
||||
match built {
|
||||
Ok(cmds) => {
|
||||
// Leave the command line **first**, before anything that can fail:
|
||||
// an abort must stop the chain, never the bookkeeping (gotcha 21).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands,
|
||||
//! `Category/Item` coordinate pairs, `"quoted strings"`.
|
||||
|
||||
use super::cmd::{Cmd, CmdRegistry, default_registry};
|
||||
use super::cmd::{Cmd, CmdContext, CmdRegistry, default_registry};
|
||||
|
||||
/// Parse a line into commands using the default registry.
|
||||
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
|
||||
@@ -33,6 +33,40 @@ pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result<Vec<Box<dyn
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
/// The command segments of a line, with comments and blank segments dropped.
|
||||
pub fn segments(line: &str) -> Vec<&str> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
|
||||
return Vec::new();
|
||||
}
|
||||
split_on_dot(line)
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build one segment's command against a LIVE context — `context/code/cli.md`.
|
||||
pub fn parse_segment_interactive(
|
||||
segment: &str,
|
||||
ctx: &CmdContext,
|
||||
) -> Result<Option<Box<dyn Cmd>>, String> {
|
||||
let tokens = tokenize(segment.trim());
|
||||
let Some(word) = tokens.first() else {
|
||||
return Ok(None);
|
||||
};
|
||||
ctx.registry
|
||||
.interactive(word, &tokens[1..], ctx)
|
||||
.map(Some)
|
||||
.map_err(|err| {
|
||||
if err.starts_with("Unknown command") {
|
||||
err
|
||||
} else {
|
||||
format!("{word}: {err}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a line on ` . ` separators (dot must be a standalone word,
|
||||
/// surrounded by whitespace or at line boundaries). Respects quoted strings.
|
||||
fn split_on_dot(line: &str) -> Vec<&str> {
|
||||
@@ -229,4 +263,88 @@ mod tests {
|
||||
assert!(parse_line("add-items").is_err());
|
||||
assert!(parse_line("add-items Region").is_err());
|
||||
}
|
||||
|
||||
// ── a segment is built against the cursor it will run against ────────
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_segment_is_built_against_the_cursor_it_will_run_against() {
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.selected = (1, 0);
|
||||
assert!(ctx.row_count() >= 2 && ctx.col_count() >= 2);
|
||||
|
||||
let built = parse_segment_interactive("move-selection 0 1", &ctx)
|
||||
.expect("move-selection parses")
|
||||
.expect("a segment with a word builds a command");
|
||||
let dbg = format!("{:?}", built.execute(&ctx));
|
||||
assert!(
|
||||
dbg.contains("SetSelected(1, 1)"),
|
||||
"a segment built against the live cursor must move FROM it, not \
|
||||
from the origin (improvise-6uva); got: {dbg}"
|
||||
);
|
||||
|
||||
// The pure arm is what a script used to go through, and it is what the
|
||||
// interactive arm must not be mistaken for.
|
||||
let pure = reg
|
||||
.parse("move-selection", &["0".to_string(), "1".to_string()])
|
||||
.expect("the pure arm parses too");
|
||||
assert!(
|
||||
format!("{:?}", pure.execute(&ctx)).contains("SetSelected(0, 0)"),
|
||||
"if the pure arm ever learns the cursor, the assertion above stops \
|
||||
discriminating"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_drops_comments_and_blanks() {
|
||||
assert_eq!(segments("# a comment"), Vec::<&str>::new());
|
||||
assert_eq!(segments("// also a comment"), Vec::<&str>::new());
|
||||
assert_eq!(segments(" "), Vec::<&str>::new());
|
||||
assert_eq!(segments("a . . b"), vec!["a", "b"]);
|
||||
assert_eq!(segments("move-selection 2 0"), vec!["move-selection 2 0"]);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_pure_commands_two_arms_agree_on_no_arguments() {
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
|
||||
// `w` with no path saves to the current document. The interactive arm
|
||||
// used to refuse exactly that, which is the same demotion one level down.
|
||||
let by_parse = reg
|
||||
.parse("w", &[])
|
||||
.expect("the pure arm accepts a bare `w`");
|
||||
let by_ctx = parse_segment_interactive("w", &ctx)
|
||||
.expect("so must the interactive arm")
|
||||
.expect("a segment with a word builds a command");
|
||||
assert_eq!(by_parse.name(), by_ctx.name());
|
||||
assert_eq!(
|
||||
format!("{:?}", by_parse.execute(&ctx)),
|
||||
format!("{:?}", by_ctx.execute(&ctx)),
|
||||
"the two arms of a pure command must build the same thing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_segment_is_an_error_not_a_silent_skip() {
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
let m = two_cat_model();
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let err = parse_segment_interactive("no-such-command", &ctx)
|
||||
.expect_err("an unknown word is refused");
|
||||
assert!(err.contains("no-such-command"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ invariant(headless_cmd_and_script_are_one_function,'run_headless_script DELEGATE
|
||||
invariant(the_creating_columns_insert_and_so_suppress_the_advance,'A +cat or +measure commit that NAMES something inserts a column at the cursor own index, so an advance computed from the PRE-commit layout is wrong for both -- CursorState::col_count is stale because apply_effects rebuilds only after the batch (gotcha 23). inserts_a_column covers both creating columns and takes the committed VALUE, because an empty name creates nothing and is genuinely at the right edge. created_name is the one home of that rule, read by add_measure, add_category and the predicate.').
|
||||
invariant(a_reported_share_cannot_overflow_its_scale,'gp_tree::fit::importance_ranking sums and divides in f64 and narrows only the resulting share, which is bounded by 100 and therefore always finite (improvise-k844). TWO overflows, not one: 100.0*imp[j] associates left so a lone importance over f32::MAX/100 is inf before the divide that would have returned it to range; and the f32 TOTAL itself overflows when several are large, which reassociating to 100.0*(imp[j]/total) turns from NaN% into a SILENT 0% FOR EVERY FEATURE. f64 closes both, since no sum of finite f32s can overflow it. The residual guard answers only an importance that ARRIVES non-finite -- an infinite total apportions nothing, so the ranking is empty rather than NaN, and note NaN <= 0.0 is false so the pre-existing early return passed NaN straight through. Reachable PAST improvise-pv3g because both callers score importance in the target OWN units, making it an MSE difference that grows as the target square.').
|
||||
invariant(viewport_extent_is_external_on_both_front_ends,'The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem. run_headless_commands reads crossterm::terminal::size() into app.term_{width,height} and CmdContext::{visible_rows,visible_cols} derive from it: 80x24 gives visible_rows 16, 200x60 gives 52. Measured on a 40-row grid, the SAME command sequence -- one PageDown then clear-cell at the cursor -- deleted R12 at 80x24 and R39 at 200x60. So Client::set_viewport is not a browser-introduced input; it is the browser SPELLING of one the TUI has always had, and gotcha 47 \"not derivable\" is true of both front ends. Any ADR treating extent as a browser problem produces a log that replays in a browser and not in a terminal. Cheapest of the five external inputs to close -- one pair of integers with an existing wire spelling, ClientFrame::Viewport -- but it must land in the SAME CHANGE as improvise-6uva, which currently masks it. A test must drive TWO sizes; one cannot see this.').
|
||||
invariant(aggregate_hash_is_layout_scoped,'A RecordHash falls back to DataStore maintained index and so resolves against the DOCUMENT, which is why a connected client SetCell works from any view. An AggregateHash has no such index AND CANNOT HAVE ONE -- an aggregate has no existence outside the layout that derives it. So App::resolve_aggregate answers only for the HOST active view, which since improvise-jytm is not the client. Guarded by an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view, whose CONTROL is the load-bearing half: the same surrogate resolves once the host is moved onto that view, so a blanket None -- a typo in the hash, an empty layout -- cannot pass.').
|
||||
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
|
||||
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
|
||||
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
|
||||
@@ -365,6 +366,8 @@ doctrine(a_reported_number_can_overflow_while_every_input_is_finite,'The gp crat
|
||||
doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\".').
|
||||
doctrine(an_observation_is_a_function_of_the_trajectory_not_the_state,'improvise-ilvl claimed \"an observation is a pure function of state\". It is not -- layout_version, cursor_epoch and model_state.dirty all differ between two routes to an IDENTICAL document (0/0/false against 2/2/true). The weaker true claim, that they are reproducible FROM THE LOG, is all replay needs. But the stronger false one is exactly what would justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that is wrong silently in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for EQUALITY, so a compacted replay is not basis-compatible with any client holding a claim, and dirty is the worker OPFS persist trigger so the same document reached by a different route persists a different number of times.').
|
||||
doctrine(a_determinism_check_can_pass_because_the_divergent_path_is_unreachable,'Three identical replays of a 26-line script fixture proved the script path deterministic and proved NOTHING, because the fixture cursor moves were silent no-ops (improvise-6uva) -- the divergence had no way to occur. The defect surfaced only when clear-cell FAILED TO PARSE for want of coordinates. Sharper form of \"a green suite is not evidence\": a passing determinism or idempotence check must be shown to be capable of FAILING, by driving the input the check exists to discriminate. It is also how one defect MASKS another -- improvise-up8g viewport hazard is invisible in improvise script precisely because 6uva discards the cursor, so fixing 6uva makes up8g live and they must be closed together.').
|
||||
doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An agent injected two rivals and reverted one onto the WRONG OCCURRENCE: replace(\".is_measure()\",\".is_index_or_dim()\",1) hits the FIRST match in the file, which was drill_constraint pre-existing predicate rather than the injected one. The commit shipped with TWO faults where it had demonstrated one. Every gate re-run afterwards PASSED over the broken logic -- clippy, fmt, check-evidence, both node suites, the eight-crate wasm gate, the full Playwright run -- because the one gate that could see it, cargo test --workspace, had been measured BEFORE the injections and never again. The PR body therefore carried a figure that was checkable and false. Caught by ChatGPT Codex review. THE RULE: the targeted test going green says nothing about what the revert did on its way past, so the full suite must be re-run after every revert.').
|
||||
doctrine(a_fixture_that_names_its_own_precondition_must_read_it_back,'A test asserting that a session was shown something must READ THE VIEW OFF THE SESSION, not hard-code the name. A first version derived its grid from grid_in(&app,\"Other\",..), so the session, the switch and apply_checked could ALL BE DELETED and every assertion still passed -- it proved a grid built for a NAME while its name claimed a grid a SESSION WAS SHOWN. The repair reads sessions.get(id).view().active_view, asserts it MOVED, and asserts it DIFFERS from the host: the assert_ne! is what pins the premise, since assert_eq!(seen,\"Other\") alone passes if the host moved too. Found by review inside a PR whose whole subject was a scoping distinction -- the characteristic defect appearing in the change that documents it.').
|
||||
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
|
||||
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
|
||||
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
|
||||
@@ -515,6 +518,7 @@ decision('improvise-tg27',a_reserve_is_a_floor_and_must_reflow_not_widen,'header
|
||||
decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ONE entry point for every check_the_checker, called verbatim by CI and by the local gate so there is no second invocation to drift. It pins a bash >= 5 (ambient first, nix only when the ambient one is older, exit 3 when neither), SCRUBS THE ENVIRONMENT to an allowlist (env -i plus PATH/HOME/TMPDIR/NIX_*/GITHUB_*, extensible via CHECKERS_KEEP), and refuses any shell shebang in scripts/ that is not #!/usr/bin/env bash. The pinned bash goes on the FRONT of PATH so scripts the suite invokes get it too -- which is where the fault actually lived. CI logs \"checkers: bash 5.2 at /usr/bin/bash\": ambient, no nix, zero startup.').
|
||||
decision('improvise-f9qf',nix_shebang_declined_on_availability_not_speed,'Measured warm, 20 invocations: ambient bash -c true 5.1ms; nix shell nixpkgs#bash --command 61.8ms; nix-shell -p bash --run, THE MANPAGE FORM THE USER POINTED AT, 675.2ms -- 132x, and that inside git merge machinery for beads-merge-driver.sh. But the DECIDER is availability: nix is installed on ONLY the kb job. build, test, wasm, js, harness-lint, browser and pages-deploy have none, so a nix shebang needs install-nix-action added to three more jobs, paid every run, to reach a bash those runners already ship. zsh declined too -- not a shebang change (word splitting, 1-based arrays, globbing), sixteen scripts to read, and no zsh on the runner either.').
|
||||
decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise script and improvise cmd resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (0,0,0,0) -- never the interactive arm that builds CursorState::from_ctx. So every Move command and enter-advance snapshots a ZEROED cursor AT PARSE TIME, clamps to (0,0) and emits SetSelected(0,0): worse than a no-op, it RESETS the cursor, and no cursor-reading command is reachable from a script. Broken by 35946af (2026-04-04), a performance refactor introducing the register/register_pure/register_nullary split, FIVE MONTHS BEFORE the first wasm-client commit -- so the web refactor is innocent. Both directions demonstrated: at the parent 649d80c a move-then-clear cleared row 2 and a bare clear cleared row 0; at HEAD both clear row 0. THE SHAPE: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.').
|
||||
decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.').
|
||||
wire_carries(command,both_directions,'user intent up, projection commands down').
|
||||
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
|
||||
wire_carries(command,both_directions,'user intent client->server, projections server->client').
|
||||
@@ -781,3 +785,4 @@ citation_resolves(decision,A):-decision(B,A,C).
|
||||
citation_body(A,B):-sub_atom(A,C,1,D,')'),!,C>0,C<200,sub_atom(A,0,C,E,B).
|
||||
trim_left(A,B):-sub_atom(A,0,1,C,' ')->sub_atom(A,1,D,0,E),trim_left(E,B);B=A.
|
||||
decision_name(A,B):-citation_body(A,C),sub_atom(C,D,1,E,','),!,F is D+1,sub_atom(C,F,G,0,H),trim_left(H,B).
|
||||
work_done('improvise-gd28','Virtual-view shape extracted to improvise-core as ViewStep/records_spine/records_steps/drill_steps; cmd/grid.rs keeps only steps_into_active_view, twenty lines of effect translation. No test expectation moved and none was added -- the existing drill tests (Filter placement asserted on the effect Debug, per-view cursor, formula drill) already pinned it. PR 140.').
|
||||
|
||||
+198
-5
@@ -24,11 +24,13 @@ use crate::ui::grid::GridWidget;
|
||||
use crate::ui::help::HelpWidget;
|
||||
use crate::ui::import_wizard_ui::ImportWizardWidget;
|
||||
use crate::ui::panel::Panel;
|
||||
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;
|
||||
|
||||
struct TuiContext<'a> {
|
||||
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
|
||||
@@ -169,8 +171,29 @@ fn minibuf_fg(color: MinibufColor) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_line(left: String, right: &str, width: u16) -> String {
|
||||
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
|
||||
/// Which segment survives when the two cannot both fit. The title bar's escape
|
||||
/// hatch outranks the document name (improvise-t14d) — `context/code/tui.md`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Keeps {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
fn fill_line(left: String, right: &str, width: u16, keeps: Keeps) -> String {
|
||||
let width = width as usize;
|
||||
let (left, right) = match keeps {
|
||||
Keeps::Right => {
|
||||
let right = truncate(right, width);
|
||||
let left = truncate(&left, width - right.width());
|
||||
(left, right)
|
||||
}
|
||||
Keeps::Left => {
|
||||
let left = truncate(&left, width);
|
||||
let right = truncate(right, width - left.width());
|
||||
(left, right)
|
||||
}
|
||||
};
|
||||
let pad = " ".repeat(width - left.width() - right.width());
|
||||
format!("{left}{pad}{right}")
|
||||
}
|
||||
|
||||
@@ -272,7 +295,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
let file = doc.stored_as.map(|n| format!(" ({n})")).unwrap_or_default();
|
||||
let title = format!(" improvise · {}{}{} ", doc.name, file, dirty);
|
||||
let right = " ?:help :q quit ";
|
||||
let line = fill_line(title, right, area.width);
|
||||
let line = fill_line(title, right, area.width, Keeps::Right);
|
||||
f.render_widget(
|
||||
Paragraph::new(line).style(
|
||||
Style::default()
|
||||
@@ -396,7 +419,7 @@ fn minibuffer_line(prompt: &str, buf: &str, status_msg: &str, width: u16) -> Str
|
||||
true => String::new(),
|
||||
false => format!(" {status_msg} "),
|
||||
};
|
||||
fill_line(left, &right, width)
|
||||
fill_line(left, &right, width, Keeps::Left)
|
||||
}
|
||||
|
||||
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||
@@ -431,6 +454,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
let msg = match app.view_state.status_msg.is_empty() {
|
||||
false => app.view_state.status_msg.clone(),
|
||||
// The wizard overlay draws its own per-step hints; one static table
|
||||
// under it contradicts them on four of eight steps (improvise-rr8n).
|
||||
true if matches!(app.view_state.mode, AppMode::ImportWizard) => String::new(),
|
||||
true => app.hint_text(),
|
||||
};
|
||||
|
||||
@@ -445,7 +471,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
|
||||
" {}{search_part} {msg}",
|
||||
crate::protocol::mode_label(&app.view_state.mode)
|
||||
);
|
||||
let line = fill_line(left, &view_badge, area.width);
|
||||
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
|
||||
@@ -561,6 +587,61 @@ mod bottom_bar_tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The wizard overlay draws correct per-step hints; the status bar stacked a
|
||||
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_wizard_overlay_is_the_only_key_hint_on_screen() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::ImportWizard;
|
||||
let row = bottom_row(&app, 100);
|
||||
for token in ["Space:toggle", "c:cycle", "Esc:cancel"] {
|
||||
assert!(
|
||||
!row.contains(token),
|
||||
"the status bar names {token:?} under an overlay that contradicts \
|
||||
it on four of eight steps: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppressing the hint must not blank the bar: the mode label and any
|
||||
/// status message still belong there. improvise-rr8n.
|
||||
#[test]
|
||||
fn suppressing_the_wizard_hint_leaves_the_rest_of_the_bar() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::ImportWizard;
|
||||
assert!(
|
||||
bottom_row(&app, 100).contains("IMPORT"),
|
||||
"the mode label is not a hint and must survive"
|
||||
);
|
||||
|
||||
app.view_state.status_msg = "no array found in that JSON".to_string();
|
||||
assert!(
|
||||
bottom_row(&app, 100).contains("no array found in that JSON"),
|
||||
"a real status message must still reach the bar"
|
||||
);
|
||||
}
|
||||
|
||||
/// The counterweight to deleting the tile bar's own hint: the tile keys are
|
||||
/// still advertised, by the derived line. improvise-rr8n.
|
||||
#[test]
|
||||
fn the_status_bar_still_advertises_the_tile_keys() {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
assert!(
|
||||
bottom_row(&app, 200).contains("T:tiles"),
|
||||
"Normal must still say how to reach tile select"
|
||||
);
|
||||
|
||||
app.view_state.mode = AppMode::TileSelect;
|
||||
let row = bottom_row(&app, 200);
|
||||
for token in ["Enter/Space:cycle", "r:row", "Esc/Tab:back"] {
|
||||
assert!(
|
||||
row.contains(token),
|
||||
"TileSelect lost {token:?}, which the tile bar's own hint never \
|
||||
carried in full: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with_minibuffer_and_message() -> App {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.mode = AppMode::category_add();
|
||||
@@ -687,3 +768,115 @@ mod key_conversion_tests {
|
||||
assert_eq!(got, key::KeyModifiers::ALT);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::{Keeps, draw_title, fill_line};
|
||||
use crate::ui::app::App;
|
||||
use crate::workbook::Workbook;
|
||||
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
|
||||
use std::path::PathBuf;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// The title row as the terminal really receives it: ratatui clips the
|
||||
/// right, so a helper's return value cannot see the defect.
|
||||
fn title_row(app: &App, width: u16) -> String {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("test backend");
|
||||
terminal
|
||||
.draw(|f| draw_title(f, Rect::new(0, 0, width, 1), app))
|
||||
.expect("draw");
|
||||
terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn app_named(name: &str, file: &str) -> App {
|
||||
App::new(Workbook::new(name), Some(PathBuf::from(file)))
|
||||
}
|
||||
|
||||
/// improvise-t14d — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_escape_hatch_outlives_a_long_document_name() {
|
||||
let app = app_named(
|
||||
"Quarterly Regional Sales Analysis",
|
||||
"quarterly-regional-sales-2026.improv",
|
||||
);
|
||||
for width in [36u16, 44, 52, 60, 100] {
|
||||
let row = title_row(&app, width);
|
||||
assert!(
|
||||
row.contains("?:help") && row.contains(":q quit"),
|
||||
"the escape hatch is gone at width {width}: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name must still lead where it fits, or a fix that simply dropped it
|
||||
/// would satisfy the assertion above. improvise-t14d.
|
||||
#[test]
|
||||
fn the_document_name_still_leads_the_row() {
|
||||
let app = app_named("Sales", "sales.improv");
|
||||
let row = title_row(&app, 80);
|
||||
let name_at = row.find("Sales").expect("the name is drawn");
|
||||
let hatch_at = row.find("?:help").expect("the hatch is drawn");
|
||||
assert!(
|
||||
name_at < hatch_at,
|
||||
"the name leads and the hatch follows: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains("(sales.improv)"),
|
||||
"a name that fits is not truncated: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Display cells, not bytes — the ASCII case is the discriminating one.
|
||||
/// improvise-t14d, `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn a_multibyte_title_does_not_shift_the_right_segment() {
|
||||
for name in ["Sales", "Ventes régionales", "日本語"] {
|
||||
let app = app_named(name, "m.improv");
|
||||
let row = title_row(&app, 80);
|
||||
assert!(
|
||||
row.ends_with(":q quit "),
|
||||
"the right segment must sit on the right edge for {name:?}: {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whichever segment yields, the composed line is exactly the width it was
|
||||
/// given — the property both call-site directions owe. improvise-t14d.
|
||||
#[test]
|
||||
fn a_composed_line_is_exactly_its_width() {
|
||||
for width in 0u16..40 {
|
||||
for (left, right) in [
|
||||
(" improvise · Ünïcödé ", " ?:help :q quit "),
|
||||
("", " badge "),
|
||||
("日本語 is a wide left segment", ""),
|
||||
] {
|
||||
for keeps in [Keeps::Left, Keeps::Right] {
|
||||
let line = fill_line(left.to_string(), right, width, keeps);
|
||||
assert_eq!(
|
||||
line.width(),
|
||||
width as usize,
|
||||
"fill_line({left:?}, {right:?}, {width}, {keeps:?}) = {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Keeps::Left` is the other direction, and nothing else in this module
|
||||
/// separates it from `Keeps::Right`. improvise-t14d.
|
||||
#[test]
|
||||
fn the_kept_side_is_the_side_that_survives() {
|
||||
let (left, right) = ("LEFTLEFTLEFT", "RIGHTRIGHT");
|
||||
assert_eq!(fill_line(left.to_string(), right, 12, Keeps::Left), left);
|
||||
assert!(
|
||||
fill_line(left.to_string(), right, 12, Keeps::Right).ends_with(right),
|
||||
"the right segment is kept whole when it is the one that wins"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-19
@@ -344,29 +344,43 @@ fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()>
|
||||
let mut changed = false;
|
||||
|
||||
for line in cmds {
|
||||
match command::parse_line(line) {
|
||||
Ok(parsed_cmds) => {
|
||||
for cmd in &parsed_cmds {
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
cmd.execute(&ctx)
|
||||
};
|
||||
app.apply_effects(effects);
|
||||
changed |= app.model_state.dirty;
|
||||
// The status line is the only channel a command has to
|
||||
// report anything — `context/code/cli.md`.
|
||||
let said = std::mem::take(&mut app.view_state.status_msg);
|
||||
if !said.trim().is_empty() {
|
||||
eprintln!("{said}");
|
||||
}
|
||||
if app.last_batch_aborted() {
|
||||
exit_code = 1;
|
||||
// The whole line is syntax-checked before any of it runs, exactly as
|
||||
// before; only CONSTRUCTION moved — `context/code/cli.md`.
|
||||
if let Err(e) = command::parse_line(line) {
|
||||
eprintln!("Parse error: {e}");
|
||||
exit_code = 1;
|
||||
continue;
|
||||
}
|
||||
for segment in command::parse::segments(line) {
|
||||
let mut failed = None;
|
||||
let effects = {
|
||||
// Built against the cursor it is about to run against, so a
|
||||
// command that snapshots one sees command n-1's (improvise-6uva).
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
match command::parse::parse_segment_interactive(segment, &ctx) {
|
||||
Ok(Some(cmd)) => cmd.execute(&ctx),
|
||||
Ok(None) => Vec::new(),
|
||||
Err(e) => {
|
||||
failed = Some(e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
};
|
||||
if let Some(e) = failed {
|
||||
eprintln!("Parse error: {e}");
|
||||
exit_code = 1;
|
||||
break;
|
||||
}
|
||||
app.apply_effects(effects);
|
||||
changed |= app.model_state.dirty;
|
||||
// The status line is the only channel a command has to
|
||||
// report anything — `context/code/cli.md`.
|
||||
let said = std::mem::take(&mut app.view_state.status_msg);
|
||||
if !said.trim().is_empty() {
|
||||
eprintln!("{said}");
|
||||
}
|
||||
if app.last_batch_aborted() {
|
||||
exit_code = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-13
@@ -126,21 +126,10 @@ impl<'a> Widget for TileBar<'a> {
|
||||
// Draw overflow indicator if tiles remain after the visible area
|
||||
if last_drawn + 1 < labels.len() && x < area.x + area.width {
|
||||
buf.set_string(x, area.y, "▶", Style::default().fg(Color::DarkGray));
|
||||
x += 1;
|
||||
}
|
||||
|
||||
// Hint
|
||||
if matches!(self.mode, AppMode::TileSelect) {
|
||||
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
|
||||
if x + hint.width() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
} else {
|
||||
let hint = " Ctrl+↑↓←→ to move tiles";
|
||||
if x + hint.width() as u16 <= area.x + area.width {
|
||||
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
}
|
||||
// The bar draws tiles and advertises no keys: the status line's hint is
|
||||
// derived from the keymap and this one was not (improvise-rr8n).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,4 +207,45 @@ mod tests {
|
||||
" Tiles: [_Index ·] [_Dim ·] [_Measure Col] [Region Row] [Product Col]"
|
||||
);
|
||||
}
|
||||
|
||||
/// The tile bar advertised keys that do something else (improvise-rr8n) —
|
||||
/// `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn the_tile_bar_advertises_no_keys_of_its_own() {
|
||||
let app = app_with_two_categories();
|
||||
let cache = project::tile_bar_of(&app);
|
||||
for mode in [AppMode::Normal, AppMode::TileSelect] {
|
||||
// Wide enough that a hint would fit: the shipped 80-column fixture
|
||||
// is too narrow to draw one, which is how the false claim survived.
|
||||
let area = Rect::new(0, 0, 140, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
TileBar::new(&cache, &mode, 0).render(area, &mut buf);
|
||||
let text = buf_text(&buf);
|
||||
for token in ["Ctrl", "Enter", "Esc", "←→", "move tiles"] {
|
||||
assert!(
|
||||
!text.contains(token),
|
||||
"the tile bar names {token:?} in {mode:?}, and nothing checks \
|
||||
that claim against the keymap: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiles themselves are what the bar is for, and deleting the hint must
|
||||
/// not delete them. improvise-rr8n.
|
||||
#[test]
|
||||
fn the_tile_bar_still_draws_its_tiles_at_every_width_it_used_to() {
|
||||
let app = app_with_two_categories();
|
||||
let cache = project::tile_bar_of(&app);
|
||||
for width in [80u16, 140] {
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
TileBar::new(&cache, &AppMode::TileSelect, 0).render(area, &mut buf);
|
||||
let text = buf_text(&buf);
|
||||
assert!(
|
||||
text.contains("[Region Row]") && text.contains("Tiles:"),
|
||||
"at width {width} the bar lost its tiles: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,3 +266,94 @@ fn a_run_that_changed_the_document_still_writes_it() {
|
||||
"the -f document lost an edit because `:w` elsewhere cleared the flag: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A document whose row axis carries three items, so a cursor move has
|
||||
/// somewhere to go and the three landing places are distinguishable.
|
||||
fn three_rows(scratch: &Scratch, name: &str) -> PathBuf {
|
||||
let doc = scratch.path(name);
|
||||
run_cmd(&["add-category Region"], &doc);
|
||||
run_cmd(&["add-items Region North South East"], &doc);
|
||||
doc
|
||||
}
|
||||
|
||||
fn hidden_line(doc: &Path) -> String {
|
||||
std::fs::read_to_string(doc)
|
||||
.expect("the document")
|
||||
.lines()
|
||||
.find(|l| l.starts_with("hidden:"))
|
||||
.unwrap_or("(nothing hidden)")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_script_can_move_the_cursor_and_the_next_command_sees_it() {
|
||||
let scratch = Scratch::new("cursor");
|
||||
let moved = three_rows(&scratch, "moved.improv");
|
||||
let still = three_rows(&scratch, "still.improv");
|
||||
|
||||
run_cmd(&["move-selection 2 0 . hide-selected-row-item"], &moved);
|
||||
run_cmd(&["hide-selected-row-item"], &still);
|
||||
|
||||
assert_eq!(
|
||||
hidden_line(&still),
|
||||
"hidden: Region/North",
|
||||
"without a move the cursor is at the origin; if this changes, the other \
|
||||
half of this test stops meaning anything"
|
||||
);
|
||||
assert_eq!(
|
||||
hidden_line(&moved),
|
||||
"hidden: Region/East",
|
||||
"`move-selection 2 0` must move the cursor two rows down before the \
|
||||
next command reads it (improvise-6uva)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn a_script_cursor_move_survives_the_line_that_made_it() {
|
||||
let scratch = Scratch::new("cursor-lines");
|
||||
let doc = three_rows(&scratch, "lines.improv");
|
||||
run_cmd(&["move-selection 1 0", "hide-selected-row-item"], &doc);
|
||||
assert_eq!(hidden_line(&doc), "hidden: Region/South");
|
||||
}
|
||||
|
||||
fn cell_lines(doc: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(doc)
|
||||
.expect("the document")
|
||||
.lines()
|
||||
.filter(|l| l.contains(" = "))
|
||||
.map(|l| l.trim().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/cli.md`.
|
||||
#[test]
|
||||
fn an_explicit_coordinate_still_beats_the_cursor() {
|
||||
let scratch = Scratch::new("explicit");
|
||||
let doc = scratch.path("cells.improv");
|
||||
run_cmd(&["add-category Region"], &doc);
|
||||
run_cmd(&["add-items Region North South East"], &doc);
|
||||
run_cmd(
|
||||
&[
|
||||
"set-cell 1 Region/North",
|
||||
"set-cell 2 Region/South",
|
||||
"set-cell 3 Region/East",
|
||||
],
|
||||
&doc,
|
||||
);
|
||||
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
|
||||
|
||||
// The cursor is at the origin, which is North. Clearing East by name must
|
||||
// clear East, not whatever the cursor happens to be sitting on.
|
||||
run_cmd(&["clear-cell Region/East"], &doc);
|
||||
let left = cell_lines(&doc);
|
||||
assert!(
|
||||
left.iter().any(|l| l.contains("North")),
|
||||
"the cursor cell was cleared instead of the named one: {left:?}"
|
||||
);
|
||||
assert!(
|
||||
!left.iter().any(|l| l.contains("East")),
|
||||
"the named cell survived: {left:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user