Merge remote-tracking branch 'gh/main' into fix/6yvg-dz8c-json-float
# Conflicts: # context/test-evidence/command.md # context/test-evidence/tooling.md
This commit is contained in:
@@ -10,26 +10,40 @@ scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec
|
||||
```
|
||||
|
||||
```
|
||||
exit 0 every check COMPLETED and none failed
|
||||
exit 1 a check failed, or the PR is CONFLICTING
|
||||
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
|
||||
CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 1 a check failed, or the PR is not mergeable (CONFLICTING, BLOCKED,
|
||||
DIRTY, BEHIND, DRAFT, or a state it does not know)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running
|
||||
exit 3 timed out with checks still running, mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
```
|
||||
|
||||
**Exit 0 now means mergeable, not only green.** GitHub computes mergeability
|
||||
lazily and reports `UNKNOWN` for a while after the base moves. It also reports
|
||||
`BLOCKED` for the moment before a required check that `needs:` another job has
|
||||
registered. The watch waits out both, so you do not need to re-check
|
||||
`mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required
|
||||
check that never ran. Why each
|
||||
state counts the way it does: `context/code/tooling.md`, "pr-watch and
|
||||
mergeability" (improvise-edpl).
|
||||
|
||||
**Pass `--expect <sha>` whenever you know which commit you are asking about.**
|
||||
It is the difference between "this PR is green" and "the thing I pushed is
|
||||
green", and a PR here was once merged at a head that predated its own fix.
|
||||
|
||||
## Do not write the loop yourself
|
||||
|
||||
Three signals look like "finished" and are not:
|
||||
Four signals look like "finished" and are not:
|
||||
|
||||
- **"nothing pending"** — also true when no check has registered yet. Require a
|
||||
**non-zero total** as well.
|
||||
- **`conclusion == null`** — `conclusion` is a *string*, empty while running.
|
||||
Test `status == "COMPLETED"`.
|
||||
- **a verdict from before the head moved** — re-check the head, or `--expect`.
|
||||
- **`mergeable: UNKNOWN`** — GitHub has not computed it yet. It is not a
|
||||
pass, and PRs #172 and #179 both went green on it.
|
||||
|
||||
`gh` is shell-aliased to a different tool in these shells; `pr-watch` locates a
|
||||
real one itself. Do not reintroduce a bare `gh` call in a script here.
|
||||
|
||||
@@ -73,7 +73,7 @@ defect is a test that passes.
|
||||
## Shepherding
|
||||
|
||||
- Wait on checks with `scripts/pr-watch <pr> --expect <sha>`. Never hand-roll a
|
||||
poll; see the `pr-watch` skill for the three ways they report green while wrong.
|
||||
poll; see the `pr-watch` skill for the four ways they report green while wrong.
|
||||
- Review before merging. Copilot's quota may be exhausted and its request is a
|
||||
**silent no-op**; an absent review and an unrequested one both read as
|
||||
"pending". Dispatch an adversarial reviewer and give it the head SHA, the base,
|
||||
|
||||
+50
-19
@@ -161,7 +161,7 @@ limitation that is not new: `ExecuteCommand` returns one effect batch, so the
|
||||
segments of `:a . b` all see the pre-batch cursor, exactly as a
|
||||
`Binding::Sequence` does (gotchas 21 and 23).
|
||||
|
||||
## 8 — the interactive arm is `parse`, then context; it is never a second parser
|
||||
## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser
|
||||
|
||||
#151 fixed three instances of *a registry with two construction arms demotes
|
||||
whichever caller reaches the weaker one*, one patch each. A post-merge review
|
||||
@@ -175,31 +175,59 @@ and the key from the cursor. So a script that wrote 5 to `Region/East`
|
||||
The shape rather than the instances is what changed. `CmdRegistry::register`
|
||||
used to take two free functions, `parse(args)` and `interactive(args, ctx)`,
|
||||
and nothing related them; each command re-derived its arguments twice and every
|
||||
disagreement was silent. It now takes three pieces, and the interactive arm is
|
||||
**built from `parse`**, not beside it:
|
||||
disagreement was silent. The interactive arm is now **built from `parse`**, not
|
||||
beside it:
|
||||
|
||||
- `parse(args) -> C` — the only function that reads arguments a caller supplied.
|
||||
- `contextualize(C, &CmdContext) -> C` — fills the fields that are context and
|
||||
never argument (a `Move`'s origin cursor, a `MovePanelCursor`'s bound). It is
|
||||
handed the built command and **not the args**, so it cannot discard them.
|
||||
- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused.
|
||||
This is where "no coordinates means the cursor" lives: `clear-cell` bare, and
|
||||
`commit-cell-edit <mode>` bare, which is what the keymap binds.
|
||||
- For `register_at_cursor` only, the registry itself then sets the command's
|
||||
**cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the
|
||||
live one. That is the whole of what the interactive arm adds to a parsed
|
||||
command, and no registration supplies it.
|
||||
|
||||
Two consequences hold by construction rather than by test. **Whatever `parse`
|
||||
accepts, the interactive arm accepts** — `contextualize` is infallible — so the
|
||||
§7 gate cannot pass a segment the loop then refuses. And **an accepted argument
|
||||
list cannot be overridden by the cursor**, because the only function that sees
|
||||
both args and context never runs on an accepted list. `register_pure` and
|
||||
`register_nullary` already had one arm; they are the `contextualize = identity,
|
||||
fallback = None` case.
|
||||
Three consequences hold by construction rather than by test. **Whatever `parse`
|
||||
accepts, the interactive arm accepts** — nothing after `parse` can fail — so the
|
||||
§7 gate cannot pass a segment the loop then refuses. **An accepted argument list
|
||||
cannot be overridden by the cursor**, because the only function that sees both
|
||||
args and context never runs on an accepted list. And **no field `parse` set can
|
||||
be overwritten from context**, because nothing a registration writes can name
|
||||
one: the cursor slot is typed `&mut CursorState`, and no command parses a
|
||||
`CursorState` out of its arguments. `register_pure` and `register_nullary`
|
||||
already had one arm; they are `register` with no fallback.
|
||||
|
||||
That last one is improvise-yf7p, and it is why the registry lost a parameter.
|
||||
#171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the
|
||||
built command and not the args, and stated honestly that it could still
|
||||
**overwrite** a parsed field — naming the field was "a visible line in review".
|
||||
Review then injected five such overwrites on commands other than the one with a
|
||||
test (yank, paste and drill-into-cell taking their key from the cursor over the
|
||||
parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor
|
||||
clamping its parsed delta to ±1) and the whole workspace stayed green. A hole
|
||||
guarded by review discipline plus one command's test is not guarded. Every
|
||||
contextualizer in the tree fell into one of two shapes, and each had an answer
|
||||
that removes the function rather than checking it:
|
||||
|
||||
- **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one
|
||||
`CursorState` field, always `CursorState::from_ctx`. So the registry does it,
|
||||
through a slot whose *type* is the guard: an `AtCursor` impl pointing at a
|
||||
`CellKey` or a delta is a compile error, which was tried.
|
||||
- **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s
|
||||
`currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s
|
||||
cursor and bound). These copied context into the command only to read it back;
|
||||
`execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus`
|
||||
got below. Those three are `register`/`register_nullary` now, so their
|
||||
interactive arm is exactly `parse`.
|
||||
|
||||
What `execute` does with a parsed field is still the command's own logic and is
|
||||
tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and
|
||||
`move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or
|
||||
the delta is ignored there. The difference is that the registry is no longer a
|
||||
second place such a bug can be written.
|
||||
|
||||
What is still representable, stated rather than hidden:
|
||||
|
||||
- `contextualize` can overwrite a field `parse` set. It would have to name the
|
||||
field, which is a visible line in review, where the old `|_args, ctx|` was an
|
||||
invisible omission. `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`
|
||||
fails if `commit-cell-edit`'s does.
|
||||
- A `fallback` sees the args `parse` refused and could ignore some of them.
|
||||
`commit-cell-edit`'s refuses anything but a lone mode, so the malformed
|
||||
`commit-cell-edit editing 5` is an error on the `:` line — which has no pure
|
||||
@@ -207,9 +235,12 @@ What is still representable, stated rather than hidden:
|
||||
- The gate is **stricter** than the loop wherever a fallback exists: a bare
|
||||
`clear-cell` is refused in a script. That is the safe direction — the whole
|
||||
line is refused — and it is unchanged from before #151.
|
||||
- A new command could declare a *second* `CursorState` field and parse it from
|
||||
args; `cursor_mut` could then point at the wrong one. No command has one, and
|
||||
a parsed cursor would be a new idea deserving its own argument.
|
||||
|
||||
`toggle-panel-and-focus` did not fit `contextualize`, because its pure arm
|
||||
defaulted the omitted flags to `true` and a contextualizer cannot tell an
|
||||
`toggle-panel-and-focus` did not fit #171's `contextualize` either, because its
|
||||
pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an
|
||||
omitted flag from an explicit one. So the omission is now in the type —
|
||||
`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is
|
||||
resolved in `execute` against the same context. That is the honest home: the
|
||||
|
||||
+81
-7
@@ -553,6 +553,78 @@ status line.
|
||||
one, and the limit is checked in exactly one place — `Model::add_category` — so
|
||||
nothing in the effect restates it.
|
||||
|
||||
### A model effect that cannot do what it names refuses: `App::refuse`, `on_view` and `refuse_and_stop` (improvise-bok4, cz8d)
|
||||
|
||||
`AddCategory` was the only wire-reachable model effect that aborted when it
|
||||
failed. The others did one of two things:
|
||||
|
||||
- **Narrated.** `AddItem`, `AddItemInGroup`, `AddFormula`, `LoadModel` and
|
||||
`ImportJsonHeadless` wrote a sentence to the *host's* `status_msg` and
|
||||
carried on.
|
||||
- **Swallowed.** Every view-addressed effect (`SetAxis`, `SetPageSelection`,
|
||||
`ToggleGroup`, `HideItem`, `ShowItem`, `TransposeAxes`, `CycleAxis`,
|
||||
`SetNumberFormat`, `TogglePruneEmpty`) did nothing when `view_mut_of` found
|
||||
no such view. `DeleteView` discarded "cannot delete the last view" with
|
||||
`let _`, and answered `Ok` for a view the document did not have
|
||||
(improvise-40j4). `AddFormula` stored a formula for a category that was gone,
|
||||
through `let _ = add_item(…)` (improvise-40j4).
|
||||
|
||||
Either way the batch ran to its end, and `Host::mutating_frame` reported the
|
||||
frame **accepted**. That is clause 4's partial-and-silent shape, one layer below
|
||||
the record tier that `broken_chain` guards. The reviewer measured two cases.
|
||||
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]` was accepted with
|
||||
half of it a no-op. `[RemoveCategory("Region"), AddItem { category: "Region" }]`
|
||||
was accepted too, and its reason went only to the host's own status line, so
|
||||
the client was told nothing at all.
|
||||
|
||||
**One writer, and the abort is separate (improvise-cz8d).** improvise-c5ip
|
||||
(PR #185) had meanwhile added `App::refuse`, which writes the reason, sets
|
||||
`batch_refused` and does **not** abort. The combination is now:
|
||||
|
||||
- **`App::refuse` is the only writer** of a refusal. bok4's own `refused()`
|
||||
helper, which aborted, is deleted.
|
||||
- **`on_view(app, target, write)`** writes the named view, or calls
|
||||
`App::refuse` naming the missing one.
|
||||
- **`Host::mutating_frame` rejects on `last_batch_refused() ||
|
||||
last_batch_aborted()`.** That is sound because each `ModelCommand` maps to a
|
||||
one-effect batch, so either flag belongs to the mutation that raised it.
|
||||
- **Aborting is an explicit extra step**, taken only where a later effect in a
|
||||
key-path batch assumes the failed one happened. `refuse_and_stop` does it for
|
||||
`LoadModel` and every `ImportJsonHeadless` arm, so `load x . q` and
|
||||
`import-json x . q` do not quit having loaded nothing. `AddCategory` keeps its
|
||||
own inline abort (gotcha 21). The item, formula and view effects only refuse:
|
||||
the frame is rolled back by the refusal alone. On the key path, a failed
|
||||
`AddItem`, `AddFormula` or view edit no longer stops the rest of its batch,
|
||||
which is what it did before bok4.
|
||||
|
||||
In a client's frame, any refusal becomes `Rejected(MutationRefused)`. The frame
|
||||
is undone whole, and the client is told, which is the channel the narrated
|
||||
reason never had.
|
||||
|
||||
**This takes the ticket's first option, scoped to the effects the wire can
|
||||
reach.** Failure is *reported* by the effect that knows it failed. The second
|
||||
option, growing `broken_chain` to ask whether every view, category and item
|
||||
target still resolves, would have been a second description of which targets
|
||||
each effect needs. That second copy would be free to disagree with the effect
|
||||
itself, which is gotcha 24's reason for observing rather than predicting.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **A failed load is refused**, where it used to be accepted with a host status
|
||||
line. So `a_load_that_fails_is_not_a_rebase` now witnesses the failure by the
|
||||
refusal, not by `status_msg`. The frame's rollback also rolls the host's
|
||||
status line back.
|
||||
- **Every refusal site has a test that fails when it is reverted** to a
|
||||
status-only write (improvise-9fpl). Most are one table,
|
||||
`every_failing_model_command_refuses_its_whole_frame`. That matters because
|
||||
these are exactly the lines a merge conflict could silently drop.
|
||||
- **Not covered:** a no-op *inside* a view that does resolve. Examples are
|
||||
`SetAxis` on a category the view does not have, `HideItem` of an absent
|
||||
item, and `RemoveItem`/`RemoveCategory`/`RemoveFormula` of something already
|
||||
gone. The removals are idempotent by intent. The others are the remaining
|
||||
instances of this class, and each needs its own `View` method to say whether
|
||||
it changed anything.
|
||||
|
||||
### `AddFormula` and non-`_Measure` targets
|
||||
|
||||
For non-`_Measure` targets the item is added to the category so it appears in the
|
||||
@@ -1112,7 +1184,9 @@ emit `RebuildLayout`) first (gotcha 23).
|
||||
|
||||
`display_value_of` is the value an editor opened on `key` starts with —
|
||||
unformatted, and so **not** `CellDisplay::text` (design-principles §4: display
|
||||
rounding is view-only and must not feed back into the model). Crate-visible so
|
||||
rounding is view-only and must not feed back into the model). It is also **not**
|
||||
`CellValue`'s `Display`, which rounds to four places: both arms spell the value
|
||||
with `CellValue::edit_text` (improvise-umi4, `core.md` §17). Crate-visible so
|
||||
`project::cell_display` can put it on the wire as `CellDisplay::edit_text`; a
|
||||
browser that un-formatted the drawn text instead would be a second derivation, and
|
||||
a lossy one.
|
||||
@@ -2492,12 +2566,12 @@ available for the rare command that needs to enumerate all views, such as the vi
|
||||
panel.
|
||||
|
||||
The interactive arm (keymap dispatch, scripts, the `:` line) is built **from**
|
||||
`parse`: `register` takes `parse`, a `contextualize` that fills context-only
|
||||
fields of the command `parse` built without seeing the args, and an optional
|
||||
`fallback` reached only for argument lists `parse` refused. So an accepted
|
||||
argument list is never overridden by context (improvise-2bac; the argument is
|
||||
`context/code/cli.md` §8). `register_pure` is the case with nothing to
|
||||
contextualize and no fallback: both arms are `parse`.
|
||||
`parse`: `register` takes `parse` and an optional `fallback` reached only for
|
||||
argument lists `parse` refused, and `register_at_cursor` additionally has the
|
||||
registry set the command's `CursorState` to the live cursor. No registration
|
||||
writes any other field, so an accepted argument list is never overridden by
|
||||
context (improvise-2bac, improvise-yf7p; the argument is `context/code/cli.md`
|
||||
§8). `register_pure` is the case with no fallback: both arms are `parse`.
|
||||
|
||||
`write_buffer` is the effect that writes a named buffer — the mirror of
|
||||
`read_buffer`, and the only place that knows "search" is not an entry in the buffers
|
||||
|
||||
@@ -1200,3 +1200,35 @@ off the `## Data` line and off `serde_json` and requires them equal. A second
|
||||
home that happens to agree is harmless; one that drifts fails there, and
|
||||
re-copying the tokens into `format.rs` with one letter changed was injected to
|
||||
prove it does.
|
||||
|
||||
## 17. An editor opens on the value, not on its `Display` (improvise-umi4)
|
||||
|
||||
`CellValue`'s `Display` is a **screen** spelling: a non-whole number is written
|
||||
`{:.4}`, so `1.23456789` reads `1.2346`. Every editor seed used to be that
|
||||
string (`display_value_in`'s pivot arm through `get_cell().to_string()`, its
|
||||
records arm through `records_display`, and `project::cell_display`'s records
|
||||
arm spelling it again), so pressing `i` and then `Enter` with nothing typed
|
||||
stored `1.2346`. The screen looked the same before and after, which is what
|
||||
made it silent data loss rather than a visible bug. The browser seeds from the
|
||||
projected `CellDisplay::edit_text`, which came from the same call, so it had
|
||||
the same bug.
|
||||
|
||||
`CellValue::edit_text` is the one home of the seed. A number is written as the
|
||||
**shortest decimal that parses back to the same `f64`**, which is what Rust's
|
||||
`f64` `Display` already guarantees (no precision argument, no exponent). The
|
||||
brief pointed at improvise-gp's `number_text` as the implementation to reuse.
|
||||
It is not a second implementation of this one: it takes an `f32` and searches
|
||||
fixed precisions for the shortest *f64* expansion of it, because a threshold
|
||||
must route the same under f32 and f64 evaluation. For an `f64` the standard
|
||||
library's spelling is the same answer with no search. A non-finite number takes
|
||||
§16's token (`inf`, `-inf`, `nan`), which `typed()` also reads back, so the
|
||||
editor and the `.improv` format spell it one way.
|
||||
|
||||
Text and errors keep `Display`. Text is written unchanged, and an error cell has
|
||||
no edit text on the wire anyway. Display itself is untouched: the grid still
|
||||
rounds (gotcha 8), only the seed changed.
|
||||
|
||||
What this does **not** fix: a text cell whose text reads as a number. `"007"`
|
||||
seeds `"007"`, and `typed()` classifies that as `Number(7.0)`. The seed is
|
||||
exact, but the classifier has no way to know the cell was text. That is
|
||||
improvise-ixxx.
|
||||
|
||||
@@ -230,11 +230,33 @@ guard below (improvise-c7mu). It removes three things a scan must not see.
|
||||
composers, so no verdict was wrong yet. It also had a mirror: a comment
|
||||
explaining that a `NOT_COMPOSED` variant is held back would have raised a
|
||||
false "stale entry" alarm.
|
||||
- **Test items inside a file.** A line scan that relies on rustfmt putting an
|
||||
item's closing brace at the attribute's own indentation. It treats
|
||||
`cfg(any(test, …))` as test code and `cfg(not(test))` as production.
|
||||
Over-stripping **fails** the test rather than passing it, which is the safe
|
||||
direction.
|
||||
- **Test items inside a file.** Each test-only attribute's item is bounded by
|
||||
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
|
||||
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
|
||||
follows. The first version was a line scan that relied on rustfmt putting an
|
||||
item's closing brace at the attribute's own indentation. The #172 review
|
||||
found three shapes it never bounded:
|
||||
- an item ending in `];` or `);`;
|
||||
- a `#[cfg(test)]` statement spanning lines;
|
||||
- a one-line `#[cfg(test)] use x;`.
|
||||
|
||||
In each case the scan read on to the end of the file and **hid every line of
|
||||
production code after it**, which is the silent direction for the forward
|
||||
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
|
||||
|
||||
**Which `cfg` is test-only is evaluated, not substring-matched.** The
|
||||
predicate is read off the file's *text*, because a `feature` value is a
|
||||
literal and blanked in the code. It is evaluated in three-valued logic for a
|
||||
build without `test`: `test` is false, and so is `feature = "test-support"`,
|
||||
which this repo never ships (improvise-26dm). Everything else a scan cannot
|
||||
know, such as a target or an ordinary feature, is unknown. An item is
|
||||
test-only when its predicate is **false**. So
|
||||
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
|
||||
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
|
||||
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
|
||||
heading a file makes the whole file test code. One appearing anywhere else
|
||||
is a panic, because this scan cannot bound an inline module's inner
|
||||
attribute.
|
||||
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
|
||||
`x/` test code, even though nothing inside the file says so. Before
|
||||
improvise-c7mu, this checker's own file and `improvise-io`'s
|
||||
@@ -622,6 +644,13 @@ a value.
|
||||
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
|
||||
client over the same grid, and they disagreed by a comma.
|
||||
|
||||
**It is the value's exact spelling, not its `Display`** (improvise-umi4).
|
||||
`CellValue`'s `Display` writes a non-whole number `{:.4}`, so this field
|
||||
carried `1.2346` for a stored `1.23456789`, and `i` then `Enter` with nothing
|
||||
typed stored the rounded number in both front ends. Every arm that seeds an
|
||||
editor now goes through `CellValue::edit_text` (`core.md` §17). That is the
|
||||
shortest decimal that parses back to the same `f64`.
|
||||
|
||||
`numeric` and `error` are decided by the server for the same reason: it is
|
||||
the side that knows which it was, and re-deriving client-side is how the two
|
||||
ends drift apart. `CellDisplay` is deliberately not *drawn from* a `CellValue`
|
||||
@@ -636,8 +665,9 @@ that `text` was formatted from: `evaluate_aggregated` over the view's
|
||||
because the browser's `yy` has to copy what the TUI's `YankCell` copies, and
|
||||
neither string on the cell can be turned back into it:
|
||||
|
||||
- `edit_text` is `CellValue::to_string()`, which writes a non-whole number
|
||||
`{:.4}` — `1.23456789` becomes `1.2346`.
|
||||
- `edit_text` was `CellValue::to_string()`, which writes a non-whole number
|
||||
`{:.4}`, so `1.23456789` became `1.2346`. improvise-umi4 has since made it
|
||||
exact, but the next bullet still holds.
|
||||
- Re-parsing any text with `typed()` turns number-like text into a number —
|
||||
`Text("007")` becomes `Number(7.0)`.
|
||||
- In a pivot grid `edit_text` is the *stored* cell (`get_cell`), so an
|
||||
@@ -647,7 +677,8 @@ So the value is **projected**, not re-derived (gotcha 47): a client that
|
||||
wants the value asks for the value. It is kept separate from `edit_text`
|
||||
rather than replacing it, because the two answer different questions — what
|
||||
an editor starts with is a *string*, deliberately the one the TUI's buffer
|
||||
holds, and changing it would change what the TUI's own editor shows.
|
||||
holds. (That string was itself lossy until improvise-umi4. The fix changed what
|
||||
the TUI's own editor shows, which was the point.)
|
||||
|
||||
In a records grid the TUI does **not** yank this value: `YankCell` and
|
||||
`PasteCell` resolve the synthetic `(_Index, _Dim)` key, so the TUI yanks
|
||||
|
||||
@@ -259,6 +259,72 @@ the deploy's and pass. `README.md` therefore states the rule and deliberately
|
||||
**does not list the jobs**: a second copy with nothing holding it to the first
|
||||
is what went stale, twice.
|
||||
|
||||
### pr-watch and mergeability (improvise-edpl, improvise-zlj2)
|
||||
|
||||
`scripts/pr-watch` is what the orchestrator's merge procedure stands on, and
|
||||
until improvise-edpl its verdict failed only on `mergeable == CONFLICTING`.
|
||||
GitHub computes mergeability lazily: for a while after the base moves it reports
|
||||
`UNKNOWN` for both `mergeable` and `mergeStateStatus`. PRs #172 and #179 both
|
||||
exited 0 at `UNKNOWN/UNKNOWN … 0 failed`. A PR that has *just become*
|
||||
conflicting is in exactly that window, so it could pass the watch. It is the
|
||||
same shape as improvise-b4ks: an uncertain state read as success.
|
||||
|
||||
The rule now:
|
||||
|
||||
- **`UNKNOWN` in either field is not an answer, so it is waited on.** On
|
||||
timeout it exits 3, the same code as a check still running. It never exits 0.
|
||||
- **`MERGEABLE/BLOCKED` with no failed check is waited on too** (improvise-zlj2).
|
||||
A required check that never runs therefore times out at exit 3, and does not
|
||||
exit 1.
|
||||
- **A known answer ends the wait early**: a failed check (exit 1 whatever
|
||||
mergeability says, since waiting would turn a known red into a timeout), or
|
||||
`mergeable == CONFLICTING`.
|
||||
- **The pass condition is an allowlist**: `mergeable == MERGEABLE` *and*
|
||||
`mergeStateStatus` in `CLEAN`, `UNSTABLE` or `HAS_HOOKS`. Anything else exits
|
||||
1. That includes `null` and any state GitHub adds later, so the check fails
|
||||
closed.
|
||||
|
||||
Why each state is where it is. The `main` ruleset (`gh api
|
||||
repos/fiddlerwoaroof/improvise/rules/branches/main`, read 2026-09-22) requires
|
||||
three status checks. It requires no approvals, and its required checks are not
|
||||
strict.
|
||||
|
||||
- `BLOCKED` with a `MERGEABLE` flag means a required check has not passed. A
|
||||
failing one is already in the rollup, so the script has exited 1 before it
|
||||
reads the state. What is left is a required check that **has not registered
|
||||
yet**, and every healthy push passes through that state. The required
|
||||
`Browser harness (OPFS contract + shell)` check comes from the `browser` job,
|
||||
which `needs: build`. GitHub creates its check run only after `build`
|
||||
completes. For a second or three per push, then, every registered check is
|
||||
`COMPLETED/SUCCESS`, the required one does not exist, and GitHub reports
|
||||
`MERGEABLE/BLOCKED`. The first version of this rule refused `BLOCKED`, and so
|
||||
exited 1 on a healthy PR whenever a poll landed in that gap. The reviewer put
|
||||
that at 5–15% of watches at the 20s interval. An "unrun" required check and a
|
||||
"not yet registered" one look the same from the rollup, so the state is
|
||||
waited on. A check that genuinely never runs costs a timeout, exit 3, not a
|
||||
false exit 1. An earlier version of this section said that nothing else
|
||||
produces `BLOCKED` here. That was wrong: the `needs:` gap produces it.
|
||||
- `DIRTY` can wear `MERGEABLE` for a moment while the two fields settle. It is
|
||||
refused.
|
||||
- `BEHIND` needs strict required checks, which this repo does not set. `DRAFT`
|
||||
cannot be merged. Both are refused.
|
||||
- `UNSTABLE` means a non-required check is unhappy. It stays a pass, as it was
|
||||
before. It is safe because the loop only reads the state once every
|
||||
registered check is `COMPLETED`, and any failed one has already exited 1. So
|
||||
whatever `UNSTABLE` is reporting, it is not a check in the rollup that is
|
||||
still running or red.
|
||||
- `HAS_HOOKS` is GitHub Enterprise's "mergeable, with pre-receive hooks". It is
|
||||
harmless to allow.
|
||||
|
||||
The suite enumerates the whole `mergeable × mergeStateStatus` table, plus an
|
||||
unshipped value and `null`, rather than hand-picking cases. Why: improvise-90ea.
|
||||
|
||||
The suite also plays the `needs:` gap through, poll by poll: build running, then
|
||||
build done with no harness check, then the harness running, then `CLEAN`. That
|
||||
sequence must exit 0. The rollup it starts from is PR #189's, copied from
|
||||
`gh pr view --json`. Each earlier poll is derived from it with `jq`, by dropping
|
||||
entries or rewinding them to `IN_PROGRESS`.
|
||||
|
||||
### Why the Pages deploy fails on "Found 0 artifact(s)" (improvise-864a)
|
||||
|
||||
`actions/deploy-pages@v5` intermittently fails a push to `main` with *No
|
||||
|
||||
@@ -298,8 +298,9 @@ The unit-level form of the headless test, against `CmdRegistry::interactive`
|
||||
directly: cursor on `Type=Food, Month=Jan`, a decoy `edit` buffer, and an
|
||||
explicit `5` for `Type/Clothing Month/Feb`. It asserts the built command's key
|
||||
and value. Rival (a) above — a `contextualize` that overwrites the parsed
|
||||
fields — fails it; this is the one hole the new registry shape leaves
|
||||
representable, so it is the one that needs a test.
|
||||
fields — failed it. improvise-yf7p then removed `contextualize` altogether
|
||||
(`context/code/cli.md` §8), so rival (a) no longer compiles; the test stays as
|
||||
the behavioural statement of what the signature now guarantees.
|
||||
|
||||
### every_argument_list_the_gate_accepts_the_run_accepts_too
|
||||
|
||||
|
||||
@@ -4392,6 +4392,37 @@ Records mode is deliberately not covered. There the TUI yanks `None` and its
|
||||
case there would assert the bug. Since improvise-6p8q's fix it is covered, by
|
||||
`yy_then_p_in_records_mode_writes_the_record_the_host_writes` below.
|
||||
|
||||
### `an_untouched_browser_edit_stores_the_identical_value`
|
||||
|
||||
**improvise-umi4**, the browser half. The client seeds `i` from the projected
|
||||
`CellDisplay::edit_text` (`resolve.rs::edit_or_drill`), which was
|
||||
`App::display_value_in` and so `CellValue`'s rounded `Display`. The client
|
||||
resolves `i` and `Enter` itself, and what it composed is applied to the host
|
||||
through `apply_on` (`Checked::against` plus `apply_command`). The stored value
|
||||
is then compared by `f64::to_bits`. The fixture puts the value at row 1,
|
||||
column 1 of a 2×2 pivot grid, with values that `Display` rounds (the list in
|
||||
`context/test-evidence/command.md`). A records grid is not driven, because the
|
||||
browser declines records editing (`edit_or_drill`).
|
||||
|
||||
Failed before the fix on `Number(1.23456789)` stored as `Number(1.2346)`.
|
||||
**Rival:** `CellValue::edit_text`'s number arm set back to `Display`. The test
|
||||
failed on the same case.
|
||||
|
||||
The test is in-process, so it cannot see the JSON wire. A float that does not
|
||||
survive `serde_json` is improvise-6oy4, a separate fix.
|
||||
|
||||
### `every_projected_edit_text_commits_back_to_its_value`
|
||||
|
||||
The projection contract behind the test above, for **both** grid kinds. Every
|
||||
`Record` cell's `edit_text`, fed through `typed()`, must give back the cell's
|
||||
`value`. It runs over the whole projected grid in pivot mode and in records
|
||||
mode (entered with `R`). A guard asserts that the precise value was projected
|
||||
at all. This test exists because `project::cell_display`'s records arm spelled
|
||||
`edit_text` itself, apart from `display_value_in`, and no key-path test reaches
|
||||
that arm from a browser. **Rival:** that arm restored to `Some(text.clone())`,
|
||||
the display text. It failed with `records=true: edit text "1.2346" does not
|
||||
commit back to Some(Number(1.23456789))`.
|
||||
|
||||
### yy_then_p_in_records_mode_writes_the_record_the_host_writes
|
||||
|
||||
improvise-6p8q in lockstep. The yank test in this file deliberately stayed on a
|
||||
|
||||
@@ -3140,10 +3140,13 @@ Injected fault: hardcode the `category_panel_open` propagator to `true`. Fails.
|
||||
|
||||
### toggle_panel_visibility_reads_the_panel_it_names
|
||||
|
||||
The behavioural half, and the one that reaches the registry. Its interactive arm
|
||||
passes `currently_open` straight through, so with exactly one panel open the command
|
||||
built for each of the three names carries its own panel's flag. Both injections above
|
||||
fail here too.
|
||||
The behavioural half, and the one that reaches the registry. With exactly one panel
|
||||
open, the command built for each of the three names and executed must emit
|
||||
`SetPanelOpen` for **its own** panel, flipping **its own** flag. Both injections above
|
||||
fail here too. Since improvise-yf7p the flag is read in `execute` rather than copied
|
||||
in by a registry contextualizer, so the assertion moved from the built command's
|
||||
`Debug` to its effects; it also names the panel, so an `execute` that ignores the
|
||||
parsed panel (`ctx.panel_open(Panel::Formula)`) fails it — injected and run.
|
||||
|
||||
### a_focused_open_panel_closes_and_a_closed_one_opens
|
||||
|
||||
@@ -4728,19 +4731,52 @@ Its mirror, and the one that pins the wrap: from the first item `[` must land on
|
||||
`SetPanelCursor` carries a panel *and* a cursor and neither was checked. Both
|
||||
are now named, so moving the right cursor on the wrong panel fails.
|
||||
|
||||
### move_panel_cursor_moves_from_the_named_panels_own_cursor
|
||||
|
||||
improvise-yf7p. `move-panel-cursor formula 2` through the registry's interactive
|
||||
arm, with the formula cursor at **1** of five formulas and the category and view
|
||||
cursors at 0, must land on **3**. Before yf7p the cursor and bound were copied in
|
||||
by a registry `contextualize`, and review showed one that clamped the parsed delta
|
||||
to `delta.signum()` left the whole workspace green: every test moved by ±1, where
|
||||
a clamp is invisible. They are read in `execute` now, and this test is what sees a
|
||||
bug there. Rival `self.delta.signum()` in `execute` — injected, run, fails (lands
|
||||
on 2). Reading another panel's cursor fails too, because the fixture seats them at
|
||||
different values — the index-0 fixture could not separate them.
|
||||
|
||||
### delete_formula_at_cursor_with_formulas
|
||||
|
||||
Asserts the `RemoveFormula` names `Profit` in `Type` — the formula the cursor is
|
||||
on — rather than merely that a formula was removed.
|
||||
Asserts the `RemoveFormula` names the formula the cursor is on, rather than merely
|
||||
that a formula was removed. PR #148 named the field, but its fixture was **one**
|
||||
formula with the cursor at **0**, where "the formula under the cursor" and "the
|
||||
first formula" are the same formula (improvise-y1av). The rival it could not see —
|
||||
`DeleteFormulaAtCursor` reading index `0` instead of `ctx.formula_cursor` — passed
|
||||
the **whole workspace**, so `d` in the formula panel deleting the wrong formula had
|
||||
no guard anywhere. Now two formulas, cursor at **1**: it must remove `Tax` and must
|
||||
not name `Profit`. Rival `let cursor = 0usize.min(..)` injected, full suite run:
|
||||
this is the only test that fails.
|
||||
|
||||
### switch_view_at_cursor_with_valid_cursor
|
||||
|
||||
Names `Default`, the view under the cursor.
|
||||
Names the view under the cursor. Same fixture defect as above (improvise-y1av):
|
||||
`two_cat_model` has one view, so the cursor at 0 named `Default` whether or not
|
||||
the command read it. Now `Default, Other` with the cursor at **1**, asserting
|
||||
`Other`. Rival `get(cursor).and(first())` — in bounds only, so the out-of-bounds
|
||||
arm is unchanged and `switch_view_at_cursor_out_of_bounds_returns_empty` cannot be
|
||||
what catches it — injected and run: fails here, in
|
||||
`delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor`, and in the two
|
||||
`wasm-client/tests/lockstep.rs` tests that were previously the only guard.
|
||||
|
||||
### delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor
|
||||
|
||||
improvise-y1av. `Default, Other, Third` with the cursor at **1**: deletes `Other`
|
||||
and steps the panel cursor back to 0. The same rival as above fails it.
|
||||
|
||||
### delete_view_at_cursor_zero_does_not_adjust_cursor
|
||||
|
||||
Names `Default` too. Its existing negative half — no `SetPanelCursor` at
|
||||
position 0 — is unchanged.
|
||||
position 0 — is unchanged. Position 0 is this test's subject, so it cannot
|
||||
separate "the view under the cursor" from "the first view" and does not try to;
|
||||
the test above does.
|
||||
|
||||
### commit_category_add_with_name_produces_add_effect
|
||||
|
||||
@@ -5006,6 +5042,31 @@ run alone with the ones before it removed, and each fails: the diff returns
|
||||
test passes. See
|
||||
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
|
||||
|
||||
### ui::host::tests::a_frame_writing_a_view_its_own_first_command_deleted_is_refused
|
||||
|
||||
improvise-bok4. This is the reviewer's first measured case:
|
||||
`[DeleteView("Scratch"), SetNumberFormat { view: "Scratch" }]`. Before the fix
|
||||
the asker's reply was `[Commands([ForgetView("Scratch")]), Commands([Project…])]`
|
||||
and then `Accepted`. The second command had done nothing, because
|
||||
`view_mut_of` found no view and every view-addressed effect swallowed the
|
||||
`None`. Now `on_view` refuses, the abort becomes
|
||||
`Rejected(MutationRefused)`, and the assertion that `Scratch` still exists
|
||||
proves the deletion was undone with the rest of the frame.
|
||||
|
||||
Its sibling, `a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused`,
|
||||
is the reviewer's second case: `[RemoveCategory("Region"), AddItem { category:
|
||||
"Region" }]`. It was accepted as well, with `AddItem`'s error written only to
|
||||
the host's `status_msg`. Both tests failed first against the unfixed effects.
|
||||
|
||||
`deleting_the_documents_last_view_is_refused_rather_than_accepted` covers the
|
||||
one-command case, which `DeleteView`'s `let _` swallowed. The fault injected
|
||||
was to put the `let _` back. The test then failed with a projection batch
|
||||
where the refusal should have been, and it was reverted.
|
||||
|
||||
The control is `a_frame_whose_commands_all_land_is_accepted`, the same two
|
||||
commands in an order where both land. Without it, a host that refused every
|
||||
two-command frame would pass all three tests.
|
||||
|
||||
### ui::reduce::vocabulary_is_complete::every_effect_is_on_the_wire_or_has_a_verdict
|
||||
|
||||
improvise-h3y7, the forward direction of the vocabulary derivation. It is the
|
||||
@@ -5045,6 +5106,34 @@ bracket-matched `#[…]` before reading the name.
|
||||
a doc comment, two attributes (one with nested brackets), and a wrapped call.
|
||||
With the whitespace skip removed it fails with `[]`.
|
||||
|
||||
### `an_untouched_pivot_edit_stores_the_identical_value`
|
||||
### `an_untouched_records_edit_stores_the_identical_value`
|
||||
|
||||
**improvise-umi4.** `App::display_value_in` seeded the edit buffer with
|
||||
`CellValue`'s `Display`, which writes a non-whole number `{:.4}`. So `i` then
|
||||
`Enter` with nothing typed stored `1.2346` over `1.23456789`, in pivot and in
|
||||
records mode, and the screen looked the same before and after.
|
||||
|
||||
Both tests go through the real key path (`App::handle_key`, `i`, `Enter`) on a
|
||||
2×2 grid. The cursor sits on (West, Revenue), which is row 1, column 1 in pivot
|
||||
mode and row 1, column 3 (`[Region, +cat, Cost, Revenue, +]`) in records mode,
|
||||
so "the cell under the cursor" is not "the first cell". The values are chosen
|
||||
so that `Display` and the value disagree: `1.23456789`, `0.1 + 0.2`,
|
||||
`98765.4321987`, `123456789012345.67` (under the `1e15` integer cutoff, so it
|
||||
takes the rounding arm), `-0.00001234` (rounds to `-0.0000`), `-0.0`,
|
||||
`-inf`, plus a text value. The comparison is `f64::to_bits`, so `-0.0` against
|
||||
`0.0` counts as a difference.
|
||||
|
||||
Both failed before the fix, on `Number(1.23456789)` stored as `Number(1.2346)`.
|
||||
**Rival injected:** `CellValue::edit_text`'s number arm set back to
|
||||
`self.to_string()`, i.e. the display seed restored in its one home. Both tests
|
||||
failed again on the same case. The fix was then restored and the suite re-run.
|
||||
|
||||
A text cell that reads as a number (`Text("007")`) is deliberately **not** in
|
||||
the list. Its seed is exact (`"007"`), but `typed()` classifies the committed
|
||||
text as `Number(7.0)`. Probed here: both tests fail on it in both modes. That
|
||||
is the classifier, not the seed, and it is improvise-ixxx.
|
||||
|
||||
### yy_then_p_in_records_mode_copies_between_real_records
|
||||
|
||||
improvise-6p8q. At `gh/main` `9e78051` it failed at the yank: `left: None,
|
||||
@@ -5111,6 +5200,53 @@ the first message does not mention drilling. At `0ed2e41` it failed with the
|
||||
old text. The rival is restoring that text. Real drill paste, which means
|
||||
staging a typed `CellValue`, is still open as improvise-48jd.
|
||||
|
||||
### ui::host::tests::every_failing_model_command_refuses_its_whole_frame
|
||||
|
||||
improvise-9fpl, found by PR #181's review. Eight of bok4's refusal sites had no
|
||||
test: reverting any one of them to a status-only write failed nothing. They
|
||||
were `AddItemInGroup`, `AddFormula`'s parse error, and all six
|
||||
`ImportJsonHeadless` arms. Two improvise-40j4 cases were added to the same
|
||||
table: `DeleteView` of an absent view, and `AddFormula` into an absent
|
||||
category.
|
||||
|
||||
Each case sends a frame of two commands. The first, `AddItem { Region,
|
||||
"Landed" }`, lands. The second fails. The test asserts that the asker hears
|
||||
exactly `[Rejected(MutationRefused)]` and that `Landed` is gone. The fixture
|
||||
has a second view, `Scratch`, because with one view `DeleteView("Ghost")` is
|
||||
refused as "the last view" and the absent-name case is never reached. The
|
||||
first run found exactly that: the case passed before the fix.
|
||||
|
||||
Faults injected by a script (`/tmp/wire2_inject2.py`, not committed): each of
|
||||
the 14 refusal sites was reverted **alone** to a status-only write, after the
|
||||
merge with #185. Every one failed at least one test. The eight that were
|
||||
unwitnessed fail this test, `DeleteView`'s last-view arm fails
|
||||
`deleting_the_documents_last_view_is_refused_rather_than_accepted`, `on_view`
|
||||
fails `a_frame_writing_a_view_its_own_first_command_deleted_is_refused`, and
|
||||
`LoadModel` fails both `a_load_that_fails_is_not_a_rebase` and
|
||||
`a_failed_load_or_import_does_not_run_the_quit_after_it`. The file was
|
||||
restored and compared.
|
||||
|
||||
### ui::host::tests::a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused
|
||||
|
||||
improvise-40j4. `[RemoveCategory("Region"), AddFormula { "Total = 1", Region }]`
|
||||
was accepted, and the document held a formula for a category that did not
|
||||
exist; `format_md` wrote it and `parse_md` reloaded it. `AddFormula` ran
|
||||
`let _ = add_item(…)` and then `add_formula` anyway. The test failed first with
|
||||
the frame accepted. `AddFormula` now refuses when `add_item` errs, before
|
||||
storing anything.
|
||||
|
||||
### ui::app::tests::a_failed_load_or_import_does_not_run_the_quit_after_it
|
||||
|
||||
improvise-cz8d. After #185, `App::refuse` does not abort. So on gh/main a failed
|
||||
`load` refused, and the `force-quit` after it in the same batch still ran:
|
||||
`load absent.improv . q` quit having loaded nothing. bok4's aborting helper had
|
||||
prevented that, and the merge would have silently lost it. The test binds
|
||||
`[load|import-json <absent>, force-quit]` as one sequence and asserts that the
|
||||
refusal is recorded and the mode is not `Quit`. `import-json` failed first,
|
||||
before `refuse_and_stop` was applied to its arms. For `load`, the injected fault
|
||||
is removing its abort (`refuse_and_stop` back to `app.refuse`), and the test
|
||||
then fails with "`load absent.improv . q` quit having loaded nothing".
|
||||
|
||||
## JSON import numbers (improvise-6yvg)
|
||||
|
||||
### `a_json_import_stores_its_numbers_bit_for_bit`
|
||||
|
||||
@@ -1050,3 +1050,51 @@ Faults injected, one per half, each compiling only `improvise-protocol`:
|
||||
A field added to an existing variant is caught too, but not in isolation,
|
||||
because every constructor of that variant also fails. Reverted with
|
||||
`git restore`.
|
||||
|
||||
### source_scan::tests::a_test_only_item_hides_nothing_after_it
|
||||
|
||||
improvise-59q8, found by the PR #172 review. The scanner's line-based
|
||||
`strip_test_items` looked for `}` at the attribute's indentation, or for an item
|
||||
line ending in `;`. So three shapes were never bounded, and it dropped
|
||||
**everything to the end of the file**:
|
||||
- a test-only `const … = &[\n … \n];`;
|
||||
- a `#[cfg(test)] let x = foo(\n 1,\n);` statement;
|
||||
- a one-line `#[cfg(test)] use foo::bar;`, where the attribute's own line is
|
||||
also the item line.
|
||||
|
||||
In `vocabulary_is_complete` that silently hides every effect after such an
|
||||
item. Each shape is a case here, asserting that the production code after it
|
||||
(`Hidden1`, `Visible`) survives. Against the line scan all three failed; the
|
||||
first printed `"impl X {\n"` and nothing else. They now pass because items are
|
||||
bounded by balanced brackets on the blanked code.
|
||||
`an_unbounded_test_only_item_is_refused_not_swallowed` pins the other half of
|
||||
the acceptance criteria: an item that cannot be bounded panics instead of
|
||||
consuming the rest of the file. Against the line scan it failed, because
|
||||
nothing panicked.
|
||||
|
||||
### source_scan::tests::a_test_only_cfg_is_recognised_whatever_its_spelling
|
||||
|
||||
improvise-59q8, the other direction: test code counted as production, which
|
||||
makes `vocabulary_is_composed` weaker. `cfg(all(test, not(target_arch =
|
||||
"wasm32")))` was kept because the old rule rejected any line containing
|
||||
`not(`. A leading `#![cfg(test)]` was kept because only `#[cfg(` was
|
||||
recognised. An attribute split across lines was not seen at all. All three
|
||||
failed against the old scanner. The predicate is now parsed and evaluated in
|
||||
three-valued logic (`context/code/protocol.md` §2). The controls
|
||||
(`cfg(not(test))`, `cfg(any(test, feature = "serde"))` and `cfg(target_arch =
|
||||
…)`) must stay production, so a scanner that dropped every `cfg` item could
|
||||
not pass.
|
||||
|
||||
A consequence to know about: `cfg(any(test, feature = "x"))` for an ordinary
|
||||
feature is now production. Only `feature = "test-support"`, the one test
|
||||
feature this repo has, counts as a test build.
|
||||
`test_only_items_and_modules_are_not_production` was updated to that spelling.
|
||||
|
||||
### source_scan::tests::a_c_string_literal_is_blanked
|
||||
|
||||
improvise-59q8's minor case. In `cr#"x" Command::InRawC(2) "y"#` the `r` has a
|
||||
word character before it, so the literal was lexed as two ordinary strings,
|
||||
and the text between the inner quotes leaked. It failed as
|
||||
`InRawC survived`. `c"…"` and `cr#"…"#` are now literals. A first version of
|
||||
this test used a C string with no inner quotes and passed against the bug,
|
||||
because the ordinary lexing happened to blank it too.
|
||||
|
||||
@@ -611,6 +611,77 @@ its `.base`, `.ledger`, `.removed` and `.stamped` siblings and `.lock`. `git
|
||||
status --untracked-files=all` and `git add -A --dry-run` both show none of
|
||||
them, and `git check-ignore -v` names `.gitignore:40` (`prolog-sessions/*`).
|
||||
|
||||
### The pr-watch mergeability cases — `scripts/pr-watch-test.sh`
|
||||
|
||||
improvise-edpl. The cases are injected faults in themselves. Like the other
|
||||
`scripts/` suites, none of their names is a Rust `fn`, so the case list is the
|
||||
evidence. The reasoning is in `context/code/tooling.md`, "pr-watch and
|
||||
mergeability".
|
||||
|
||||
**The bug, reproduced before the fix.** Against the script as it stood at
|
||||
`ae96b8c`, 19 of the 34 new cases failed and all 19 exited **0**.
|
||||
`UNKNOWN that resolves to CONFLICTING fails` is PR #172's shape exactly: every
|
||||
check green, `UNKNOWN/UNKNOWN` on the first poll, `CONFLICTING/DIRTY` on the
|
||||
second. The old script broke out of the loop on the first poll and passed. The
|
||||
fixtures are hand-built. They are the subset of the `gh pr view --json` shape
|
||||
that the script reads: `headRefOid`, the two mergeability fields, and the
|
||||
rollup's `status` and `conclusion`. They are not a captured payload. A stub that
|
||||
errors instead would have exited 4, a wider shape than the fault.
|
||||
|
||||
**Rival 1: treat `UNKNOWN` as an answer.** The wait condition was replaced with
|
||||
`if true`. 13 cases fail: every `UNKNOWN` row of the table,
|
||||
`MERGEABLE/UNKNOWN`, the timeout case, and
|
||||
`UNKNOWN that resolves to MERGEABLE/CLEAN passes`. That last one exits 1 under
|
||||
this rival, so the fix cannot pass by refusing everything uncertain. It has to
|
||||
wait. `UNKNOWN that resolves to CONFLICTING fails` still passes under this
|
||||
rival, because the allowlist refuses `UNKNOWN` as well. Rival 2 does not
|
||||
separate it either: its first poll is `UNKNOWN`, which rival 2 still waits on.
|
||||
What separates it is the original fault, which combined both rivals. An earlier
|
||||
version of this entry said rival 2 separated it. That was wrong.
|
||||
|
||||
**Rival 2: the old verdict.** The allowlist was replaced with `exit 0` after the
|
||||
`MERGEABLE` test. Five cases fail: `MERGEABLE/BEHIND`, `DIRTY`, `DRAFT`,
|
||||
`SOMETHING_NEW` and `null`. These are the states that look mergeable by the flag
|
||||
and are not. At `128a2a0` it was six, with `BLOCKED` among them. Since
|
||||
improvise-zlj2, `BLOCKED` is waited on, so this rival never reaches the verdict
|
||||
for it. The entry below covers that.
|
||||
|
||||
`a failed check fails without waiting on mergeability` runs at `--timeout 0`.
|
||||
A script that waited on `UNKNOWN` before reading the rollup would report that
|
||||
known red as exit 3.
|
||||
|
||||
### The pr-watch needs-gap and pinned-head cases — `scripts/pr-watch-test.sh`
|
||||
|
||||
improvise-zlj2 and improvise-h3ni. These were found by review of PR #186 at
|
||||
`128a2a0`, which was ruled do-not-merge.
|
||||
|
||||
**zlj2: a healthy PR exited 1.** The required `Browser harness` check run
|
||||
exists only after the `build` job completes (`needs: build`). In that gap
|
||||
GitHub reports `MERGEABLE/BLOCKED` while every registered check is green, and
|
||||
the #186 rule refused `BLOCKED`. The rollup in the new cases is PR #189's,
|
||||
captured verbatim with `gh pr view --json`. It has thirteen entries with every
|
||||
field GitHub returns. Each gap state drops or rewinds entries of that rollup
|
||||
with `jq`. Against `128a2a0` three cases failed, all with exit 1:
|
||||
|
||||
- `the needs-gap before the required check registers is not a failure`: the
|
||||
healthy sequence should exit 0.
|
||||
- `a required check that never registers is a timeout`: should exit 3.
|
||||
- `table: MERGEABLE/BLOCKED`: should exit 3, not 1.
|
||||
|
||||
**Rival for zlj2:** remove `MERGEABLE/BLOCKED` from the wait. The same three
|
||||
cases fail with exit 1. That rival is `128a2a0`'s code.
|
||||
|
||||
**h3ni: nothing pinned the head across the UNKNOWN wait.** The case is
|
||||
`a head that moves during the UNKNOWN wait is not a pass`. Its polls are
|
||||
UNKNOWN@a, then UNKNOWN@b, then CLEAN@b, and it must exit 2. It passed on the
|
||||
first run, so it is a guard for behaviour the script already has, not a
|
||||
reproduction.
|
||||
|
||||
**Rival for h3ni:** re-pin the head whenever `mergeable` is UNKNOWN. That case
|
||||
alone fails, exiting 0. All 44 others pass. That is the review's point: the
|
||||
older `a head that moves mid-wait is not a pass` starts from MERGEABLE, so it
|
||||
cannot see this rival.
|
||||
|
||||
## Every JSON parser reads numbers exactly (improvise-dz8c)
|
||||
|
||||
### `every_crate_that_can_parse_json_enables_float_roundtrip`
|
||||
|
||||
@@ -473,6 +473,131 @@ mod tests {
|
||||
"bulk entry lands at the start of the new row"
|
||||
);
|
||||
}
|
||||
|
||||
/// Values whose `Display` (four decimals) and value disagree, plus text.
|
||||
fn values_display_rounds() -> Vec<crate::model::cell::CellValue> {
|
||||
use crate::model::cell::CellValue::{Number, Text};
|
||||
vec![
|
||||
Number(1.23456789),
|
||||
Number(0.1 + 0.2),
|
||||
Number(98765.4321987),
|
||||
Number(123456789012345.67),
|
||||
Number(-0.00001234),
|
||||
Number(-0.0),
|
||||
Number(f64::NEG_INFINITY),
|
||||
Text("0.1 + 0.2".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Two regions, two measures, with `value` at (West, Revenue): never index 0.
|
||||
fn grid_holding(value: crate::model::cell::CellValue, records: bool) -> crate::ui::app::App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::App;
|
||||
use crate::view::Axis;
|
||||
|
||||
let mut wb = Workbook::new("Seed");
|
||||
wb.add_category("Region").unwrap();
|
||||
for region in ["North", "West"] {
|
||||
wb.add_item("Region", region).unwrap();
|
||||
}
|
||||
for measure in ["Cost", "Revenue"] {
|
||||
wb.add_item("_Measure", measure).unwrap();
|
||||
}
|
||||
let key = |r: &str, m: &str| {
|
||||
CellKey::new(vec![
|
||||
("Region".into(), r.into()),
|
||||
("_Measure".into(), m.into()),
|
||||
])
|
||||
};
|
||||
wb.model
|
||||
.set_cell(key("North", "Cost"), CellValue::Number(1.0));
|
||||
wb.model
|
||||
.set_cell(key("North", "Revenue"), CellValue::Number(2.0));
|
||||
wb.model
|
||||
.set_cell(key("West", "Cost"), CellValue::Number(3.0));
|
||||
wb.model.set_cell(key("West", "Revenue"), value);
|
||||
{
|
||||
let v = wb.active_view_mut();
|
||||
match records {
|
||||
true => {
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
v.set_axis("Region", Axis::None);
|
||||
v.set_axis("_Measure", Axis::None);
|
||||
}
|
||||
false => {
|
||||
v.set_axis("Region", Axis::Row);
|
||||
v.set_axis("_Measure", Axis::Column);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
|
||||
app
|
||||
}
|
||||
|
||||
/// Open the editor on (West, Revenue) and commit it untouched; the stored value.
|
||||
fn no_op_commit(records: bool, value: crate::model::cell::CellValue) {
|
||||
use crate::model::cell::CellKey;
|
||||
use improvise_protocol::key::KeyCode;
|
||||
|
||||
let mut app = grid_holding(value.clone(), records);
|
||||
let target = CellKey::new(vec![
|
||||
("Region".into(), "West".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
let at = match records {
|
||||
true => (1, 3),
|
||||
false => (1, 1),
|
||||
};
|
||||
app.cursor_mut().selected = at;
|
||||
if records {
|
||||
app.view_state.mode = AppMode::RecordsNormal;
|
||||
assert_eq!(
|
||||
app.layout.record_col(at.1),
|
||||
Some(&RecordCol::Measure("Revenue".into())),
|
||||
"the cursor is not on the Revenue column"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.model_state.workbook.model.get_cell(&target),
|
||||
Some(&value),
|
||||
"the fixture does not hold the value under test"
|
||||
);
|
||||
press(&mut app, KeyCode::Char('i'));
|
||||
assert!(
|
||||
app.view_state.mode.is_editing(),
|
||||
"`i` did not open an editor"
|
||||
);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
let stored = app.model_state.workbook.model.get_cell(&target).cloned();
|
||||
let same = match (&stored, &value) {
|
||||
(Some(CellValue::Number(a)), CellValue::Number(b)) => a.to_bits() == b.to_bits(),
|
||||
(stored, value) => stored.as_ref() == Some(value),
|
||||
};
|
||||
assert!(
|
||||
same,
|
||||
"{} mode: i then Enter on {value:?} stored {stored:?}",
|
||||
if records { "records" } else { "pivot" }
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_pivot_edit_stores_the_identical_value() {
|
||||
for value in values_display_rounds() {
|
||||
no_op_commit(false, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn an_untouched_records_edit_stores_the_identical_value() {
|
||||
for value in values_display_rounds() {
|
||||
no_op_commit(true, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::fmt::Debug;
|
||||
|
||||
use crate::command::key::KeyCode;
|
||||
|
||||
use super::navigation::CursorState;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::app::AppMode;
|
||||
@@ -139,13 +141,15 @@ pub trait Cmd: Debug + Send + Sync {
|
||||
/// Factory that constructs a Cmd from text arguments (headless/script).
|
||||
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
||||
|
||||
/// Fills the context-only fields of a command `parse` already built. It never
|
||||
/// sees the args, so it cannot discard them — `context/code/cli.md` §8.
|
||||
pub type ContextualizeFn<C> = fn(C, &CmdContext) -> C;
|
||||
|
||||
/// Builds a command from context, reached ONLY for args `parse` refused.
|
||||
pub type FallbackFn<C> = fn(&[String], &CmdContext) -> Result<C, String>;
|
||||
|
||||
/// A command whose one context-only field is the cursor it moves from. The
|
||||
/// registry fills it, and no registration can — `context/code/cli.md` §8.
|
||||
pub trait AtCursor: Cmd {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState;
|
||||
}
|
||||
|
||||
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
||||
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
||||
|
||||
@@ -186,25 +190,48 @@ impl CmdRegistry {
|
||||
name
|
||||
}
|
||||
|
||||
/// Register a command whose interactive arm is `parse` then `contextualize`, so
|
||||
/// args `parse` accepts are honoured whole — `context/code/cli.md` §8.
|
||||
/// Register a command whose interactive arm is `parse`, reaching `fallback`
|
||||
/// only where `parse` refused — `context/code/cli.md` §8.
|
||||
pub fn register<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
contextualize: ContextualizeFn<C>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |_, _| {});
|
||||
}
|
||||
|
||||
/// As [`register`](Self::register), then the interactive arm moves the
|
||||
/// command's cursor to the live one. The cursor is the only field it writes.
|
||||
pub fn register_at_cursor<C: AtCursor + 'static>(
|
||||
&mut self,
|
||||
prototype: &C,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
) {
|
||||
self.push(prototype.name(), parse, fallback, |cmd, ctx| {
|
||||
*cmd.cursor_mut() = CursorState::from_ctx(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn push<C: Cmd + 'static>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
parse: fn(&[String]) -> Result<C, String>,
|
||||
fallback: Option<FallbackFn<C>>,
|
||||
at_cursor: fn(&mut C, &CmdContext),
|
||||
) {
|
||||
self.entries.push(CmdEntry {
|
||||
name: prototype.name(),
|
||||
name,
|
||||
parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box<dyn Cmd>)),
|
||||
interactive: Box::new(move |args, ctx| {
|
||||
let built = match (parse(args), fallback) {
|
||||
(Ok(cmd), _) => Ok(contextualize(cmd, ctx)),
|
||||
(Err(_), Some(fallback)) => fallback(args, ctx),
|
||||
(Err(err), None) => Err(err),
|
||||
let mut built = match (parse(args), fallback) {
|
||||
(Ok(cmd), _) => cmd,
|
||||
(Err(_), Some(fallback)) => fallback(args, ctx)?,
|
||||
(Err(err), None) => return Err(err),
|
||||
};
|
||||
built.map(|c| Box::new(c) as Box<dyn Cmd>)
|
||||
at_cursor(&mut built, ctx);
|
||||
Ok(Box::new(built) as Box<dyn Cmd>)
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ pub(super) mod test_helpers {
|
||||
display_value: {
|
||||
let key = layout.cell_key(sr, sc);
|
||||
key.as_ref()
|
||||
.and_then(|k| workbook.model.get_cell(k).cloned())
|
||||
.map(|v| v.to_string())
|
||||
.and_then(|k| workbook.model.get_cell(k))
|
||||
.map(crate::model::cell::CellValue::edit_text)
|
||||
.unwrap_or_default()
|
||||
},
|
||||
visible_rows: 20,
|
||||
@@ -117,6 +117,22 @@ pub(super) mod test_helpers {
|
||||
wb
|
||||
}
|
||||
|
||||
/// `two_cat_model` with one `_Measure` formula per target, in order.
|
||||
pub fn with_formulas(targets: &[&str]) -> Workbook {
|
||||
let mut wb = two_cat_model();
|
||||
for target in targets {
|
||||
wb.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: format!("{target} = 0"),
|
||||
target: target.to_string(),
|
||||
target_category: "_Measure".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
|
||||
format!("{:?}", effects)
|
||||
}
|
||||
|
||||
@@ -192,11 +192,7 @@ mod tests {
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
@@ -208,11 +204,7 @@ mod tests {
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = format!("{:?}", effects[0]);
|
||||
@@ -231,11 +223,7 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.category_panel_open = true;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -256,11 +244,7 @@ mod tests {
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::FormulaPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -281,11 +265,7 @@ mod tests {
|
||||
ctx.category_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: true,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -305,11 +285,7 @@ mod tests {
|
||||
ctx.formula_panel_open = true;
|
||||
ctx.view_panel_open = true;
|
||||
ctx.mode = &AppMode::ViewPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: true,
|
||||
category_open: false,
|
||||
view_open: true,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let dbg = effects_debug(&effects);
|
||||
@@ -329,11 +305,7 @@ mod tests {
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.category_panel_open = true;
|
||||
ctx.mode = &AppMode::CategoryPanel;
|
||||
let cmd = CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: true,
|
||||
view_open: false,
|
||||
};
|
||||
let cmd = CyclePanelFocus;
|
||||
let effects = cmd.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
@@ -344,15 +316,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_panel_cursor_down_from_zero() {
|
||||
let m = two_cat_model();
|
||||
let m = with_formulas(&["A", "B"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: 1,
|
||||
current: 0,
|
||||
max: 5,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
@@ -363,17 +333,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn move_panel_cursor_moves_from_the_named_panels_own_cursor() {
|
||||
let m = with_formulas(&["A", "B", "C", "D", "E"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_cursor = 1;
|
||||
let args = ["formula".to_string(), "2".to_string()];
|
||||
let effects = reg
|
||||
.interactive("move-panel-cursor", &args, &ctx)
|
||||
.expect("move-panel-cursor resolves")
|
||||
.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("SetPanelCursor { panel: Formula, cursor: 3 }"),
|
||||
"must move from the formula cursor (1) by the parsed delta (2), got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_panel_cursor_clamps_at_zero() {
|
||||
let m = two_cat_model();
|
||||
let m = with_formulas(&["A", "B"]);
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: -1,
|
||||
current: 0,
|
||||
max: 5,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
@@ -388,46 +376,55 @@ mod tests {
|
||||
let cmd = MovePanelCursor {
|
||||
panel: effect::Panel::Formula,
|
||||
delta: 1,
|
||||
current: 0,
|
||||
max: 0,
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn delete_formula_at_cursor_with_formulas() {
|
||||
let mut m = two_cat_model();
|
||||
m.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: "Profit = Revenue - Cost".to_string(),
|
||||
target: "Profit".to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
for (target, raw) in [("Profit", "Profit = 1"), ("Tax", "Tax = 2")] {
|
||||
m.model.add_formula(crate::formula::ast::Formula {
|
||||
raw: raw.to_string(),
|
||||
target: target.to_string(),
|
||||
target_category: "Type".to_string(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
}
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.formula_cursor = 1;
|
||||
let effects = DeleteFormulaAtCursor.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("RemoveFormula { target: \"Profit\", target_category: \"Type\" }"),
|
||||
"must remove the formula AT THE CURSOR, got: {dbg}"
|
||||
dbg.contains("RemoveFormula { target: \"Tax\", target_category: \"Type\" }"),
|
||||
"must remove the formula AT THE CURSOR (Tax, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
!dbg.contains("\"Profit\""),
|
||||
"removed the first formula, not the one under the cursor: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn switch_view_at_cursor_with_valid_cursor() {
|
||||
let m = two_cat_model();
|
||||
let mut m = two_cat_model();
|
||||
m.create_view("Other");
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let ctx = make_ctx(&m, &layout, ®);
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.view_panel_cursor = 1;
|
||||
let effects = SwitchViewAtCursor.execute(&ctx);
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(
|
||||
dbg.contains("SwitchView(\"Default\")"),
|
||||
"must switch to the view UNDER THE CURSOR, got: {dbg}"
|
||||
dbg.contains("SwitchView(\"Other\")"),
|
||||
"must switch to the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||
}
|
||||
@@ -504,6 +501,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn delete_view_at_cursor_deletes_the_view_under_a_nonzero_cursor() {
|
||||
let mut m = two_cat_model();
|
||||
m.create_view("Other");
|
||||
m.create_view("Third");
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
let mut ctx = make_ctx(&m, &layout, ®);
|
||||
ctx.view_panel_cursor = 1;
|
||||
let dbg = effects_debug(&DeleteViewAtCursor.execute(&ctx));
|
||||
assert!(
|
||||
dbg.contains("DeleteView(\"Other\")"),
|
||||
"must delete the view UNDER THE CURSOR (Other, index 1), got: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("SetPanelCursor { panel: View, cursor: 0 }"),
|
||||
"a delete off position 0 steps the cursor back, got: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_view_at_cursor_zero_does_not_adjust_cursor() {
|
||||
let m = two_cat_model();
|
||||
@@ -559,16 +577,15 @@ impl Cmd for TogglePanelAndFocus {
|
||||
#[derive(Debug)]
|
||||
pub struct TogglePanelVisibility {
|
||||
pub panel: Panel,
|
||||
pub currently_open: bool,
|
||||
}
|
||||
impl Cmd for TogglePanelVisibility {
|
||||
fn name(&self) -> &'static str {
|
||||
"toggle-panel-visibility"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
vec![Box::new(effect::SetPanelOpen {
|
||||
panel: self.panel,
|
||||
open: !self.currently_open,
|
||||
open: !ctx.panel_open(self.panel),
|
||||
})]
|
||||
}
|
||||
}
|
||||
@@ -576,20 +593,16 @@ impl Cmd for TogglePanelVisibility {
|
||||
/// Tab through open panels Formula → Category → View, wrapping, starting from the
|
||||
/// focused one. One open panel self-transitions; none open is a no-op.
|
||||
#[derive(Debug)]
|
||||
pub struct CyclePanelFocus {
|
||||
pub formula_open: bool,
|
||||
pub category_open: bool,
|
||||
pub view_open: bool,
|
||||
}
|
||||
pub struct CyclePanelFocus;
|
||||
impl Cmd for CyclePanelFocus {
|
||||
fn name(&self) -> &'static str {
|
||||
"cycle-panel-focus"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let order = [
|
||||
(self.formula_open, AppMode::FormulaPanel),
|
||||
(self.category_open, AppMode::CategoryPanel),
|
||||
(self.view_open, AppMode::ViewPanel),
|
||||
(ctx.formula_panel_open, AppMode::FormulaPanel),
|
||||
(ctx.category_panel_open, AppMode::CategoryPanel),
|
||||
(ctx.view_panel_open, AppMode::ViewPanel),
|
||||
];
|
||||
// Index of the first candidate: the panel after the focused one,
|
||||
// or the first panel when invoked from a non-panel mode.
|
||||
@@ -614,16 +627,17 @@ impl Cmd for CyclePanelFocus {
|
||||
pub struct MovePanelCursor {
|
||||
pub panel: Panel,
|
||||
pub delta: i32,
|
||||
pub current: usize,
|
||||
pub max: usize,
|
||||
}
|
||||
impl Cmd for MovePanelCursor {
|
||||
fn name(&self) -> &'static str {
|
||||
"move-panel-cursor"
|
||||
}
|
||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let cursor = self.current;
|
||||
let max = self.max;
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let (cursor, max) = match self.panel {
|
||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||
};
|
||||
if max == 0 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
@@ -27,19 +27,21 @@ fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.cell_key().clone().ok_or("no cell at cursor".into())
|
||||
}
|
||||
|
||||
/// A `Move` built from args takes the live cursor as its origin.
|
||||
fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move {
|
||||
Move {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
..cmd
|
||||
impl AtCursor for Move {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
/// A commit built from args still advances from the live cursor.
|
||||
fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance {
|
||||
CommitAndAdvance {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
..cmd
|
||||
impl AtCursor for EnterAdvance {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
impl AtCursor for CommitAndAdvance {
|
||||
fn cursor_mut(&mut self) -> &mut CursorState {
|
||||
&mut self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +63,6 @@ fn commit_at_cursor(
|
||||
})
|
||||
}
|
||||
|
||||
/// For a command with no context-only fields: `parse`'s result is used as built.
|
||||
fn as_parsed<C>(cmd: C, _ctx: &CmdContext) -> C {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Build the default command registry with all commands.
|
||||
/// Registry names MUST match the `Cmd::name()` return value.
|
||||
pub fn default_registry() -> CmdRegistry {
|
||||
@@ -90,7 +87,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(ClearCellCommand {
|
||||
key: cursor_key(ctx)?,
|
||||
@@ -121,7 +117,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse);
|
||||
|
||||
// ── Navigation (unified Move) ──────────────────────────────────────
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Relative(0, 0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -137,13 +133,12 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "move-selection",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
// Jump-to-edge commands: first/last row/col
|
||||
macro_rules! reg_jump {
|
||||
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
|
||||
$r.register(
|
||||
$r.register_at_cursor(
|
||||
&Move {
|
||||
kind: if $to_end {
|
||||
MoveKind::ToEnd($is_row)
|
||||
@@ -164,7 +159,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: $name,
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
};
|
||||
@@ -173,7 +167,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
reg_jump!(r, true, true, "jump-last-row");
|
||||
reg_jump!(r, false, false, "jump-first-col");
|
||||
reg_jump!(r, false, true, "jump-last-col");
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Relative(0, 0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -188,10 +182,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "scroll-rows",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&Move {
|
||||
kind: MoveKind::Page(0),
|
||||
cursor: CursorState::default(),
|
||||
@@ -206,10 +199,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
cmd_name: "page-scroll",
|
||||
})
|
||||
},
|
||||
move_from_cursor,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&EnterAdvance {
|
||||
cursor: CursorState::default(),
|
||||
},
|
||||
@@ -227,9 +219,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
},
|
||||
})
|
||||
},
|
||||
|_, ctx| EnterAdvance {
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -246,7 +235,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(YankCell {
|
||||
key: cursor_key(ctx)?,
|
||||
@@ -265,7 +253,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(PasteCell {
|
||||
key: cursor_key(ctx)?,
|
||||
@@ -318,7 +305,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
key: parse_cell_key_from_args(args),
|
||||
})
|
||||
},
|
||||
as_parsed,
|
||||
Some(|_, ctx| {
|
||||
Ok(DrillIntoCell {
|
||||
key: cursor_key(ctx)?,
|
||||
@@ -356,77 +342,30 @@ pub fn default_registry() -> CmdRegistry {
|
||||
});
|
||||
Ok(TogglePanelAndFocus { panel, target })
|
||||
},
|
||||
as_parsed,
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
&TogglePanelVisibility {
|
||||
panel: Panel::Formula,
|
||||
currently_open: false,
|
||||
},
|
||||
|args| {
|
||||
require_args(args, 1)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
Ok(TogglePanelVisibility {
|
||||
panel,
|
||||
currently_open: false,
|
||||
})
|
||||
},
|
||||
|cmd, ctx| TogglePanelVisibility {
|
||||
currently_open: ctx.panel_open(cmd.panel),
|
||||
..cmd
|
||||
},
|
||||
None,
|
||||
);
|
||||
r.register(
|
||||
&CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
},
|
||||
|_| {
|
||||
Ok(CyclePanelFocus {
|
||||
formula_open: false,
|
||||
category_open: false,
|
||||
view_open: false,
|
||||
})
|
||||
},
|
||||
|_, ctx| CyclePanelFocus {
|
||||
formula_open: ctx.formula_panel_open,
|
||||
category_open: ctx.category_panel_open,
|
||||
view_open: ctx.view_panel_open,
|
||||
Ok(TogglePanelVisibility { panel })
|
||||
},
|
||||
None,
|
||||
);
|
||||
r.register_nullary(|| Box::new(CyclePanelFocus));
|
||||
r.register(
|
||||
&MovePanelCursor {
|
||||
panel: Panel::Formula,
|
||||
delta: 0,
|
||||
current: 0,
|
||||
max: 0,
|
||||
},
|
||||
|args| {
|
||||
require_args(args, 2)?;
|
||||
let panel = parse_panel(&args[0])?;
|
||||
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||
Ok(MovePanelCursor {
|
||||
panel,
|
||||
delta,
|
||||
current: 0,
|
||||
max: 0,
|
||||
})
|
||||
},
|
||||
|cmd, ctx| {
|
||||
let (current, max) = match cmd.panel {
|
||||
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||
};
|
||||
MovePanelCursor {
|
||||
current,
|
||||
max,
|
||||
..cmd
|
||||
}
|
||||
Ok(MovePanelCursor { panel, delta })
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -493,7 +432,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
|
||||
// ── Commit ───────────────────────────────────────────────────────────
|
||||
// These take a mode-name arg as args[0]; the command never inspects ctx.mode.
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&CommitAndAdvance {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
@@ -514,10 +453,9 @@ pub fn default_registry() -> CmdRegistry {
|
||||
edit_mode,
|
||||
})
|
||||
},
|
||||
commit_from_cursor,
|
||||
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)),
|
||||
);
|
||||
r.register(
|
||||
r.register_at_cursor(
|
||||
&CommitAndAdvance {
|
||||
key: CellKey::new(vec![]),
|
||||
value: String::new(),
|
||||
@@ -526,7 +464,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
edit_mode: AppMode::editing(),
|
||||
},
|
||||
|_| Err("commit-and-advance-right requires context".into()),
|
||||
commit_from_cursor,
|
||||
Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)),
|
||||
);
|
||||
r.register(
|
||||
@@ -538,7 +475,6 @@ pub fn default_registry() -> CmdRegistry {
|
||||
let kind = parse_commit_kind(&args[0], &AppMode::Normal)?;
|
||||
Ok(CommitBuffer { kind })
|
||||
},
|
||||
as_parsed,
|
||||
Some(|args, ctx| {
|
||||
require_args(args, 1)?;
|
||||
let kind = parse_commit_kind(&args[0], ctx.mode)?;
|
||||
@@ -863,12 +799,16 @@ mod panel_open_tests {
|
||||
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
|
||||
for asked in Panel::ALL {
|
||||
let args = [asked.name().to_string()];
|
||||
let cmd = ctx
|
||||
let effects = ctx
|
||||
.registry
|
||||
.interactive("toggle-panel-visibility", &args, &ctx)
|
||||
.expect("toggle-panel-visibility resolves");
|
||||
let dbg = format!("{cmd:?}");
|
||||
let expected = format!("currently_open: {}", asked == opened);
|
||||
.expect("toggle-panel-visibility resolves")
|
||||
.execute(&ctx);
|
||||
let dbg = format!("{effects:?}");
|
||||
let expected = format!(
|
||||
"SetPanelOpen {{ panel: {asked:?}, open: {} }}",
|
||||
asked != opened
|
||||
);
|
||||
assert!(
|
||||
dbg.contains(&expected),
|
||||
"with only {opened:?} open, toggling {asked:?} read another \
|
||||
|
||||
@@ -761,14 +761,14 @@ impl App {
|
||||
match crate::view::synthetic_record_info(key) {
|
||||
Some((idx, col)) => self
|
||||
.staged_drill_edit(layout, idx, col)
|
||||
.or_else(|| layout.resolve_display(key))
|
||||
.or_else(|| layout.records_value(idx, col).map(|v| v.edit_text()))
|
||||
.unwrap_or_default(),
|
||||
None => self
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(key)
|
||||
.map(|v| v.to_string())
|
||||
.map(CellValue::edit_text)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -2990,6 +2990,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-cz8d: a refusal need not abort, so a failed document swap must
|
||||
/// ask for the abort itself. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_failed_load_or_import_does_not_run_the_quit_after_it() {
|
||||
use improvise_protocol::keymap::Keymap;
|
||||
|
||||
for (command, path) in [("load", "absent.improv"), ("import-json", "absent.json")] {
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.storage = Box::new(crate::storage::MemoryFs::new());
|
||||
let key = KeyCode::Char('x');
|
||||
let mods = KeyModifiers::NONE;
|
||||
let mut km = Keymap::new();
|
||||
km.bind_seq(
|
||||
key,
|
||||
mods,
|
||||
vec![(command, vec![path.to_string()]), ("force-quit", vec![])],
|
||||
);
|
||||
let effects = {
|
||||
let ctx = app.cmd_context(key, mods);
|
||||
app.keymap_set.dispatch_transient(&km, &ctx, key, mods)
|
||||
}
|
||||
.expect("both steps resolve in the registry");
|
||||
app.apply_effects(effects);
|
||||
|
||||
assert!(
|
||||
app.last_batch_refused(),
|
||||
"setup: `{command}` must actually have failed, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
assert_ne!(
|
||||
app.view_state.mode,
|
||||
AppMode::Quit,
|
||||
"`{command} {path} . q` quit having loaded nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A prefix is consumed whether or not the next key matches, so a stray key
|
||||
/// after `g` does nothing rather than acting as a fresh Normal binding.
|
||||
#[test]
|
||||
|
||||
@@ -44,6 +44,31 @@ pub enum ViewTarget {
|
||||
Named(String),
|
||||
}
|
||||
|
||||
/// Refuse AND stop the batch, for a failed document swap: what follows it in
|
||||
/// a `:` line assumes it happened, so `load x . q` must not quit (cz8d).
|
||||
fn refuse_and_stop(app: &mut App, why: impl Into<String>) {
|
||||
app.refuse(why);
|
||||
app.abort_effects = true;
|
||||
}
|
||||
|
||||
/// Write the view a geometry effect names, or refuse: a view the document no
|
||||
/// longer has is a failure to report, not a no-op to swallow (improvise-bok4).
|
||||
fn on_view(app: &mut App, target: &ViewTarget, write: impl FnOnce(&mut crate::view::View)) {
|
||||
let missing = match app.view_mut_of(target) {
|
||||
Some(view) => {
|
||||
write(view);
|
||||
None
|
||||
}
|
||||
None => Some(match target {
|
||||
ViewTarget::Named(name) => format!("No view named \"{name}\""),
|
||||
ViewTarget::Active => "No active view".to_string(),
|
||||
}),
|
||||
};
|
||||
if let Some(why) = missing {
|
||||
app.refuse(why);
|
||||
}
|
||||
}
|
||||
|
||||
/// A discrete state change produced by a command.
|
||||
/// Effects know how to apply themselves to the App.
|
||||
pub trait Effect: Debug {
|
||||
@@ -183,11 +208,13 @@ impl Effect for AddFormula {
|
||||
// grid; _Measure targets arrive via `measure_item_names()`.
|
||||
if !crate::model::category::CategoryKind::of_name(&formula.target_category)
|
||||
.is_measure()
|
||||
{
|
||||
let _ = app
|
||||
&& let Err(e) = app
|
||||
.model_state
|
||||
.workbook
|
||||
.add_item(&formula.target_category, &formula.target);
|
||||
.add_item(&formula.target_category, &formula.target)
|
||||
{
|
||||
// Its category may be gone: store no formula for it (40j4).
|
||||
return app.refuse(e.to_string());
|
||||
}
|
||||
app.model_state.workbook.model.add_formula(formula);
|
||||
}
|
||||
@@ -252,9 +279,9 @@ impl Effect for EnterEditAtCursor {
|
||||
pub struct TogglePruneEmpty(pub ViewTarget);
|
||||
impl Effect for TogglePruneEmpty {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.0) {
|
||||
on_view(app, &self.0, |v| {
|
||||
v.prune_empty = !v.prune_empty;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -318,7 +345,14 @@ impl Effect for CreateView {
|
||||
pub struct DeleteView(pub String);
|
||||
impl Effect for DeleteView {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let _ = app.model_state.workbook.delete_view(&self.0);
|
||||
// `delete_view` answers Ok for a name it lacks, which is a failure to
|
||||
// report, not a deletion (improvise-40j4).
|
||||
if !app.model_state.workbook.views.contains_key(&self.0) {
|
||||
return app.refuse(format!("No view named \"{}\"", self.0));
|
||||
}
|
||||
if let Err(e) = app.model_state.workbook.delete_view(&self.0) {
|
||||
return app.refuse(e.to_string());
|
||||
}
|
||||
// This client may have been the one on it. Idempotent for every other
|
||||
// view; the mirrors owe the same move (`Sessions::adopt_deleted_views`).
|
||||
app.view_state.active_view = app.view_name().to_string();
|
||||
@@ -402,9 +436,9 @@ pub struct SetAxis {
|
||||
}
|
||||
impl Effect for SetAxis {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.view) {
|
||||
on_view(app, &self.view, |v| {
|
||||
v.set_axis(&self.category, self.axis);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -419,9 +453,9 @@ pub struct SetPageSelection {
|
||||
}
|
||||
impl Effect for SetPageSelection {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.view) {
|
||||
on_view(app, &self.view, |v| {
|
||||
v.set_page_selection(&self.category, &self.item);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -436,9 +470,9 @@ pub struct ToggleGroup {
|
||||
}
|
||||
impl Effect for ToggleGroup {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.view) {
|
||||
on_view(app, &self.view, |v| {
|
||||
v.toggle_group_collapse(&self.category, &self.group);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -453,9 +487,9 @@ pub struct HideItem {
|
||||
}
|
||||
impl Effect for HideItem {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.view) {
|
||||
on_view(app, &self.view, |v| {
|
||||
v.hide_item(&self.category, &self.item);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -470,9 +504,9 @@ pub struct ShowItem {
|
||||
}
|
||||
impl Effect for ShowItem {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.view) {
|
||||
on_view(app, &self.view, |v| {
|
||||
v.show_item(&self.category, &self.item);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -483,9 +517,9 @@ impl Effect for ShowItem {
|
||||
pub struct TransposeAxes(pub ViewTarget);
|
||||
impl Effect for TransposeAxes {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(v) = app.view_mut_of(&self.0) {
|
||||
on_view(app, &self.0, |v| {
|
||||
v.transpose_axes();
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -497,9 +531,9 @@ pub struct CycleAxis(pub ViewTarget, pub String);
|
||||
impl Effect for CycleAxis {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let category = self.1.clone();
|
||||
if let Some(v) = app.view_mut_of(&self.0) {
|
||||
on_view(app, &self.0, |v| {
|
||||
v.cycle_axis(&category);
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -511,9 +545,9 @@ pub struct SetNumberFormat(pub ViewTarget, pub String);
|
||||
impl Effect for SetNumberFormat {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let format = self.1.clone();
|
||||
if let Some(v) = app.view_mut_of(&self.0) {
|
||||
on_view(app, &self.0, |v| {
|
||||
v.number_format = format;
|
||||
}
|
||||
});
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::LayoutGeometry)
|
||||
@@ -934,9 +968,7 @@ impl Effect for LoadModel {
|
||||
app.install_workbook(loaded);
|
||||
app.view_state.status_msg = format!("Loaded from {}", self.0.display());
|
||||
}
|
||||
Err(e) => {
|
||||
app.refuse(format!("Load error: {e}"));
|
||||
}
|
||||
Err(e) => refuse_and_stop(app, format!("Load error: {e}")),
|
||||
}
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
@@ -993,7 +1025,7 @@ impl Effect for ImportJsonHeadless {
|
||||
let bytes = match app.storage.read(&self.path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
app.refuse(format!("Cannot read '{}': {e}", self.path.display()));
|
||||
refuse_and_stop(app, format!("Cannot read '{}': {e}", self.path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1004,7 +1036,7 @@ impl Effect for ImportJsonHeadless {
|
||||
(recs, raw)
|
||||
}
|
||||
Err(e) => {
|
||||
app.refuse(format!("CSV error: {e}"));
|
||||
refuse_and_stop(app, format!("CSV error: {e}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1012,21 +1044,21 @@ impl Effect for ImportJsonHeadless {
|
||||
let content = match app.storage.read(&self.path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
app.refuse(format!("Cannot read '{}': {e}", self.path.display()));
|
||||
refuse_and_stop(app, format!("Cannot read '{}': {e}", self.path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let value: serde_json::Value = match serde_json::from_slice(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
app.refuse(format!("JSON parse error: {e}"));
|
||||
refuse_and_stop(app, format!("JSON parse error: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match json_import_records(&value, self.array_path.as_deref()) {
|
||||
Ok(recs) => (recs, value),
|
||||
Err(msg) => {
|
||||
app.refuse(msg);
|
||||
refuse_and_stop(app, msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1065,7 +1097,7 @@ impl Effect for ImportJsonHeadless {
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
app.refuse(format!("Import error: {e}"));
|
||||
refuse_and_stop(app, format!("Import error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +166,12 @@ impl Host {
|
||||
if let Some(reason) = reduce::broken_chain(&self.app, cmd.command()) {
|
||||
return self.undo_frame(id, undo, mirrors, reason);
|
||||
}
|
||||
// Only a mutation runs `apply_effects`, and only `apply_effects`
|
||||
// resets the flag — so after anything else it is a STALE answer.
|
||||
// Only a mutation runs `apply_effects`, which resets both flags — so
|
||||
// after anything else they are STALE. A refusal need not abort (cz8d).
|
||||
let mutation = matches!(cmd.command(), Command::Model { .. });
|
||||
out.extend(self.command(id, cmd));
|
||||
if mutation && self.app.last_batch_aborted() {
|
||||
let failed = self.app.last_batch_refused() || self.app.last_batch_aborted();
|
||||
if mutation && failed {
|
||||
return self.undo_frame(id, undo, mirrors, Rejection::MutationRefused);
|
||||
}
|
||||
}
|
||||
@@ -1541,10 +1542,12 @@ mod tests {
|
||||
.any(|f| matches!(f.msg, ServerFrame::Snapshot(_))),
|
||||
"a failed load cost every client its cursors: {frames:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app().view_state.status_msg.starts_with("Load error"),
|
||||
"the fixture did not actually fail the load: {:?}",
|
||||
host.app().view_state.status_msg
|
||||
// Since improvise-bok4 a failed load is REFUSED, so the refusal is the
|
||||
// witness that it failed; the host's status line is rolled back with it.
|
||||
assert_eq!(
|
||||
to(&frames, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"the fixture did not actually fail the load"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1778,4 +1781,283 @@ mod tests {
|
||||
let json = serde_json::to_string(&inbound).unwrap();
|
||||
assert_eq!(serde_json::from_str::<ClientFrame>(&json).unwrap(), inbound);
|
||||
}
|
||||
|
||||
/// Two model commands, each caught up against the document as the client
|
||||
/// saw it, so no basis tier can refuse the frame first (improvise-bok4).
|
||||
fn two_command_frame(host: &Host, first: ModelCommand, then: ModelCommand) -> ClientFrame {
|
||||
ClientFrame::Commands(vec![
|
||||
crate::ui::reduce::caught_up(host.app(), first),
|
||||
crate::ui::reduce::caught_up(host.app(), then),
|
||||
])
|
||||
}
|
||||
|
||||
/// improvise-bok4, the view-addressed case: the second command's view is
|
||||
/// gone. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn a_frame_writing_a_view_its_own_first_command_deleted_is_refused() {
|
||||
let mut host = host();
|
||||
host.app_mut().model_state.workbook.create_view("Scratch");
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::DeleteView("Scratch".into()),
|
||||
ModelCommand::SetNumberFormat {
|
||||
view: "Scratch".into(),
|
||||
format: ",.2f".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"half the frame did nothing and was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.views
|
||||
.contains_key("Scratch"),
|
||||
"the refused frame's deletion must be undone"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-bok4, the category-addressed case: it narrated its failure into
|
||||
/// the HOST's status line and told the client nothing.
|
||||
#[test]
|
||||
fn a_frame_adding_an_item_to_a_category_its_first_command_removed_is_refused() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::RemoveCategory("Region".into()),
|
||||
ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "East".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"half the frame did nothing and was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
host.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.category("Region")
|
||||
.is_some(),
|
||||
"the refused frame's removal must be undone"
|
||||
);
|
||||
}
|
||||
|
||||
/// The one-command case of the same class: deleting the last view was
|
||||
/// swallowed with `let _`, so the frame was accepted having done nothing.
|
||||
#[test]
|
||||
fn deleting_the_documents_last_view_is_refused_rather_than_accepted() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let only = host.app().view_name().to_string();
|
||||
|
||||
let answer = host.model(asker, ModelCommand::DeleteView(only.clone()));
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"a deletion the workbook refused was reported accepted: {answer:?}"
|
||||
);
|
||||
assert!(host.app().model_state.workbook.views.contains_key(&only));
|
||||
}
|
||||
|
||||
/// The control: two commands that both land are accepted, so the refusals
|
||||
/// above are about a failure and not about the frame's shape.
|
||||
#[test]
|
||||
fn a_frame_whose_commands_all_land_is_accepted() {
|
||||
let mut host = host();
|
||||
host.app_mut().model_state.workbook.create_view("Scratch");
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::SetNumberFormat {
|
||||
view: "Scratch".into(),
|
||||
format: ",.2f".into(),
|
||||
},
|
||||
ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "East".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert!(
|
||||
to(&answer, asker).contains(&ServerFrame::Accepted),
|
||||
"a frame with nothing to refuse was refused: {answer:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-40j4: the formula was stored for a category the frame's own
|
||||
/// first command removed, and it survived save and reload.
|
||||
#[test]
|
||||
fn a_frame_adding_a_formula_to_a_category_its_first_command_removed_is_refused() {
|
||||
let mut host = host();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
|
||||
let answer = host.handle(
|
||||
asker,
|
||||
two_command_frame(
|
||||
&host,
|
||||
ModelCommand::RemoveCategory("Region".into()),
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = 1".into(),
|
||||
target_category: "Region".into(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"a formula for a vanished category was accepted: {answer:?}"
|
||||
);
|
||||
let model = &host.app().model_state.workbook.model;
|
||||
assert!(
|
||||
model.category("Region").is_some(),
|
||||
"the removal must be undone"
|
||||
);
|
||||
assert!(
|
||||
model.formulas().iter().all(|f| f.target != "Total"),
|
||||
"and the formula must not be stored"
|
||||
);
|
||||
}
|
||||
|
||||
/// A host whose storage holds one file per way `ImportJsonHeadless` can
|
||||
/// fail, plus nothing at the two paths that must fail to read.
|
||||
fn host_with_import_fixtures() -> Host {
|
||||
let storage = MemoryFs::new();
|
||||
let files: [(&str, &[u8]); 4] = [
|
||||
// Every field numeric, so no category is accepted and the build fails.
|
||||
("measures.json", br#"[{"x": 1}, {"x": 2}]"#),
|
||||
("bad.json", b"{not json"),
|
||||
("nested.json", br#"{"data": [{"Region": "North"}]}"#),
|
||||
// A record that is not UTF-8 is a CSV read error, not a warning.
|
||||
("bad.csv", b"Region,Revenue\n\xff\xfe,1\n"),
|
||||
];
|
||||
for (name, bytes) in files {
|
||||
crate::storage::Storage::write(&storage, Path::new(name), bytes)
|
||||
.expect("fixture writes");
|
||||
}
|
||||
// A second view, so deleting an absent one is not refused as "the last".
|
||||
let mut wb = workbook();
|
||||
wb.create_view("Scratch");
|
||||
let mut app = App::new(wb, None);
|
||||
app.storage = Box::new(storage);
|
||||
app.rebuild_layout();
|
||||
Host::new(app)
|
||||
}
|
||||
|
||||
fn import(path: &str, array_path: Option<&str>) -> ModelCommand {
|
||||
ModelCommand::ImportJsonHeadless {
|
||||
path: PathBuf::from(path),
|
||||
model_name: None,
|
||||
array_path: array_path.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-9fpl: one case per refusal #181 converted that no other test
|
||||
/// witnesses. Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn every_failing_model_command_refuses_its_whole_frame() {
|
||||
let cases: Vec<(&str, ModelCommand)> = vec![
|
||||
(
|
||||
"AddItemInGroup into an absent category",
|
||||
ModelCommand::AddItemInGroup {
|
||||
category: "Ghost".into(),
|
||||
item: "East".into(),
|
||||
group: "Coastal".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"AddFormula that does not parse",
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = (".into(),
|
||||
target_category: "_Measure".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of an absent JSON",
|
||||
import("absent.json", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of an absent CSV",
|
||||
import("absent.csv", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of a CSV that is not text",
|
||||
import("bad.csv", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless of JSON that does not parse",
|
||||
import("bad.json", None),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless at a path with no array",
|
||||
import("nested.json", Some("nope")),
|
||||
),
|
||||
(
|
||||
"ImportJsonHeadless that builds no model",
|
||||
import("measures.json", None),
|
||||
),
|
||||
// improvise-40j4: `delete_view` answers Ok for a name it lacks.
|
||||
(
|
||||
"DeleteView of a view the document lacks",
|
||||
ModelCommand::DeleteView("Ghost".into()),
|
||||
),
|
||||
(
|
||||
"AddFormula into a category the document lacks",
|
||||
ModelCommand::AddFormula {
|
||||
raw: "Total = 1".into(),
|
||||
target_category: "Ghost".into(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (what, failing) in cases {
|
||||
let mut host = host_with_import_fixtures();
|
||||
let (asker, _) = host.connect(10, 10);
|
||||
let landed = ModelCommand::AddItem {
|
||||
category: "Region".into(),
|
||||
item: "Landed".into(),
|
||||
};
|
||||
|
||||
let answer = host.handle(asker, two_command_frame(&host, landed, failing));
|
||||
|
||||
assert_eq!(
|
||||
to(&answer, asker),
|
||||
vec![ServerFrame::Rejected(Rejection::MutationRefused)],
|
||||
"{what}: the frame was not refused: {answer:?}"
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.app()
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.category("Region")
|
||||
.is_some_and(|c| c.ordered_item_names().contains(&"Landed")),
|
||||
"{what}: the half of the frame that landed was not undone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ fn cell_display(
|
||||
// with `parse::<f64>()`, which called a coordinate `2024` a number.
|
||||
let text = value.as_ref().map(|v| v.to_string()).unwrap_or_default();
|
||||
return CellDisplay {
|
||||
edit_text: Some(text.clone()),
|
||||
edit_text: Some(value.as_ref().map(CellValue::edit_text).unwrap_or_default()),
|
||||
text,
|
||||
numeric: matches!(value, Some(CellValue::Number(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
|
||||
@@ -134,6 +134,17 @@ impl CellValue {
|
||||
}
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
/// The text an editor opens on, which commits back to this value unchanged.
|
||||
/// `Display` rounds for the screen; this must not (`context/code/core.md` §17).
|
||||
pub fn edit_text(&self) -> String {
|
||||
match self {
|
||||
CellValue::Number(n) => nonfinite_token(*n).map_or_else(|| n.to_string(), String::from),
|
||||
CellValue::Text(_) | CellValue::Error(_) => self.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CellValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
|
||||
@@ -656,13 +656,6 @@ impl GridLayout {
|
||||
axis_label(&self.col_items, col)
|
||||
}
|
||||
|
||||
/// Display string for a synthetic records-mode key; `None` for a pivot
|
||||
/// key.
|
||||
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
|
||||
let (row, col) = synthetic_record_info(key)?;
|
||||
self.records_display(row, col)
|
||||
}
|
||||
|
||||
/// Display text for (row, col) in either mode.
|
||||
pub fn display_text(
|
||||
&self,
|
||||
@@ -1045,7 +1038,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_resolve_display_returns_values() {
|
||||
fn records_mode_records_display_returns_values() {
|
||||
let mut wb = records_workbook();
|
||||
let v = wb.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
@@ -1057,13 +1050,11 @@ mod tests {
|
||||
|
||||
// A measure column resolves to that measure's value
|
||||
let value_col = cols.iter().position(|c| c == "Revenue").unwrap();
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
assert_eq!(layout.resolve_display(&key).as_deref(), Some("100"));
|
||||
assert_eq!(layout.records_display(0, value_col).as_deref(), Some("100"));
|
||||
|
||||
// Category column resolves to the coordinate value
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
let key = layout.cell_key(0, region_col).unwrap();
|
||||
let display = layout.resolve_display(&key).unwrap();
|
||||
let display = layout.records_display(0, region_col).unwrap();
|
||||
assert!(
|
||||
!display.is_empty(),
|
||||
"Region column should resolve to a value"
|
||||
|
||||
@@ -82,7 +82,7 @@ pub struct CellDisplay {
|
||||
/// **typed** it, not as it is drawn. The difference corrupts data (§3).
|
||||
pub edit_text: Option<String>,
|
||||
/// The value the host evaluated here, unrounded and untyped-by-text: what
|
||||
/// `yank` copies. Neither `text` nor `edit_text` can be re-parsed into it (§3).
|
||||
/// `yank` copies. `text` cannot be re-parsed into it, nor can text like `007` (§3).
|
||||
pub value: Option<CellValue>,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,28 +11,28 @@ pub fn production_code(roots: &[PathBuf]) -> Vec<(PathBuf, String)> {
|
||||
for root in roots {
|
||||
collect_rust(root, &mut files);
|
||||
}
|
||||
let code: Vec<(PathBuf, String)> = files
|
||||
let read: Vec<(PathBuf, Source)> = files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let text = std::fs::read_to_string(&file).expect("a listed source reads");
|
||||
let code = code_only(&text);
|
||||
(file, code)
|
||||
let source = Source::of(&text, &file);
|
||||
(file, source)
|
||||
})
|
||||
.collect();
|
||||
let test_modules: BTreeSet<PathBuf> = code
|
||||
let test_modules: BTreeSet<PathBuf> = read
|
||||
.iter()
|
||||
.flat_map(|(file, code)| test_module_paths(file, code))
|
||||
.flat_map(|(file, source)| test_module_paths(file, source))
|
||||
.collect();
|
||||
code.into_iter()
|
||||
read.into_iter()
|
||||
.filter(|(file, _)| !test_modules.iter().any(|m| is_module_file(m, file)))
|
||||
.map(|(file, code)| (file, strip_test_items(&code)))
|
||||
.map(|(file, source)| (file, source.production()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One file's text as [`production_code`] cleans it. Which *files* are test
|
||||
/// modules needs their declaring file too, so only `production_code` knows.
|
||||
pub fn production_text(text: &str) -> String {
|
||||
strip_test_items(&code_only(text))
|
||||
Source::of(text, Path::new("<text>")).production()
|
||||
}
|
||||
|
||||
fn collect_rust(dir: &Path, into: &mut Vec<PathBuf>) {
|
||||
@@ -55,83 +55,319 @@ fn is_module_file(m: &Path, file: &Path) -> bool {
|
||||
file == m.with_extension("rs") || file.starts_with(m)
|
||||
}
|
||||
|
||||
/// One file, read once. Its `cfg` predicates are read off the TEXT, whose
|
||||
/// `feature` values are literals; every bracket off the code (improvise-59q8).
|
||||
struct Source {
|
||||
code: Vec<char>,
|
||||
/// Test-only items, as `(attribute start, item start, item end)`.
|
||||
items: Vec<(usize, usize, usize)>,
|
||||
/// A leading `#![cfg(test)]`: the whole file is test code.
|
||||
whole_file: bool,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn of(text: &str, file: &Path) -> Self {
|
||||
let code: Vec<char> = code_only(text).chars().collect();
|
||||
let text: Vec<char> = text.chars().collect();
|
||||
let (items, whole_file) = test_only_items(&text, &code, file);
|
||||
Self {
|
||||
code,
|
||||
items,
|
||||
whole_file,
|
||||
}
|
||||
}
|
||||
|
||||
/// The code with every test-only item removed.
|
||||
fn production(&self) -> String {
|
||||
if self.whole_file {
|
||||
return String::new();
|
||||
}
|
||||
let mut kept = String::with_capacity(self.code.len());
|
||||
let mut at = 0;
|
||||
for &(start, _, end) in &self.items {
|
||||
kept.extend(&self.code[at..start]);
|
||||
at = end;
|
||||
}
|
||||
kept.extend(&self.code[at..]);
|
||||
kept
|
||||
}
|
||||
|
||||
fn item_code(&self, start: usize, end: usize) -> String {
|
||||
self.code[start..end].iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The out-of-line modules `file` declares under a test-only `cfg`, as paths
|
||||
/// without extension (`#[cfg(test)] mod x;` is `<dir>/x`).
|
||||
fn test_module_paths(file: &Path, code: &str) -> Vec<PathBuf> {
|
||||
fn test_module_paths(file: &Path, source: &Source) -> Vec<PathBuf> {
|
||||
let dir = match file.file_stem().and_then(|s| s.to_str()) {
|
||||
Some("lib" | "main" | "mod") => file.parent().map(Path::to_path_buf),
|
||||
Some(stem) => file.parent().map(|p| p.join(stem)),
|
||||
None => None,
|
||||
}
|
||||
.expect("a source file has a directory and a stem");
|
||||
let lines: Vec<&str> = code.lines().map(str::trim).collect();
|
||||
let mut found = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !is_test_cfg(line) {
|
||||
continue;
|
||||
}
|
||||
let item = lines[i + 1..]
|
||||
.iter()
|
||||
.find(|l| !l.starts_with("#[") && !l.is_empty());
|
||||
if let Some(name) = item.and_then(|l| out_of_line_mod(l)) {
|
||||
found.push(dir.join(name));
|
||||
}
|
||||
}
|
||||
found
|
||||
source
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(_, start, end)| out_of_line_mod(source.item_code(start, end).trim()))
|
||||
.map(|name| dir.join(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `mod x;`, `pub mod x;`, `pub(crate) mod x;` → `x`.
|
||||
fn out_of_line_mod(line: &str) -> Option<&str> {
|
||||
let rest = line.split_once("mod ")?.1.strip_suffix(';')?;
|
||||
let prefix = line.split_once("mod ")?.0.trim();
|
||||
(prefix.is_empty() || prefix.starts_with("pub")).then_some(rest.trim())
|
||||
fn out_of_line_mod(item: &str) -> Option<String> {
|
||||
let (prefix, rest) = item.split_once("mod ")?;
|
||||
let name = rest.strip_suffix(';')?.trim();
|
||||
let prefix = prefix.trim();
|
||||
(prefix.is_empty() || prefix.starts_with("pub")).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// A `cfg` attribute that compiles its item only under `test`. `cfg(not(test))`
|
||||
/// is production, and `"test-support"` is a literal, already blanked.
|
||||
fn is_test_cfg(line: &str) -> bool {
|
||||
let line = line.trim();
|
||||
line.starts_with("#[cfg(")
|
||||
&& !line.contains("not(")
|
||||
&& line
|
||||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||||
.any(|word| word == "test")
|
||||
/// Every test-only item in a file, bounded by balanced brackets on the code
|
||||
/// rather than by indentation. An item it cannot bound is a panic, never EOF.
|
||||
fn test_only_items(
|
||||
text: &[char],
|
||||
code: &[char],
|
||||
file: &Path,
|
||||
) -> (Vec<(usize, usize, usize)>, bool) {
|
||||
let mut items = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < code.len() {
|
||||
let Some((inner, end)) = attribute_at(code, k) else {
|
||||
k += 1;
|
||||
continue;
|
||||
};
|
||||
let predicate: String = text[k..end].iter().collect();
|
||||
if !is_test_only(&predicate) {
|
||||
k = end;
|
||||
continue;
|
||||
}
|
||||
if inner {
|
||||
// An inner attribute heads the file or it heads an inline module;
|
||||
// only the first is a shape this scan can honour.
|
||||
let leading = code[..k].iter().all(|c| c.is_whitespace());
|
||||
assert!(
|
||||
leading,
|
||||
"{}: an inner test-only cfg could not be bounded",
|
||||
file.display()
|
||||
);
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
let start = past_attributes(code, end);
|
||||
let item_end = item_end(code, start).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{}: a test-only item at char {k} could not be bounded",
|
||||
file.display()
|
||||
)
|
||||
});
|
||||
items.push((k, start, item_end));
|
||||
k = item_end;
|
||||
}
|
||||
(items, false)
|
||||
}
|
||||
|
||||
/// Drop every test-only item. rustfmt puts an item's closing brace at the
|
||||
/// attribute's own indentation, which is what makes this a line scan (§2).
|
||||
fn strip_test_items(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut kept = String::new();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
match is_test_cfg(line) {
|
||||
false => {
|
||||
kept.push_str(line);
|
||||
kept.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
true => {
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
let close = format!("{}}}", " ".repeat(indent));
|
||||
i += 1;
|
||||
while i < lines.len() && lines[i].trim_start().starts_with("#[") {
|
||||
i += 1;
|
||||
}
|
||||
match lines.get(i).is_some_and(|l| l.trim_end().ends_with(';')) {
|
||||
true => i += 1,
|
||||
false => {
|
||||
while i < lines.len() && lines[i].trim_end() != close {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
/// An attribute starting at `k`: whether it is inner (`#!`) and where it ends.
|
||||
fn attribute_at(code: &[char], k: usize) -> Option<(bool, usize)> {
|
||||
if code[k] != '#' || (k > 0 && (code[k - 1].is_alphanumeric() || code[k - 1] == '_')) {
|
||||
return None;
|
||||
}
|
||||
let inner = code.get(k + 1) == Some(&'!');
|
||||
let open = k + 1 + usize::from(inner);
|
||||
(code.get(open) == Some(&'[')).then(|| matching(code, open).map(|close| (inner, close + 1)))?
|
||||
}
|
||||
|
||||
/// The index of the bracket closing the one at `open`, counting all three kinds.
|
||||
fn matching(code: &[char], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (k, &c) in code.iter().enumerate().skip(open) {
|
||||
match c {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
')' | ']' | '}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(k);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
kept
|
||||
None
|
||||
}
|
||||
|
||||
/// Skip whitespace and any further outer attributes on the same item.
|
||||
fn past_attributes(code: &[char], mut k: usize) -> usize {
|
||||
loop {
|
||||
while code.get(k).is_some_and(|c| c.is_whitespace()) {
|
||||
k += 1;
|
||||
}
|
||||
let next = (k < code.len()).then(|| attribute_at(code, k)).flatten();
|
||||
match next {
|
||||
Some((false, end)) => k = end,
|
||||
_ => return k,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the item starting at `start` ends: a `;` at depth zero, or the `}`
|
||||
/// closing its body. `None` at the end of the file, which is not an end.
|
||||
fn item_end(code: &[char], start: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
let mut k = start;
|
||||
while k < code.len() {
|
||||
match code[k] {
|
||||
'(' | '[' | '{' => depth += 1,
|
||||
';' if depth == 0 => return Some(k + 1),
|
||||
// The enclosing block closed first: the item was its last expression.
|
||||
')' | ']' | '}' if depth == 0 => return Some(k),
|
||||
')' | ']' => depth -= 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 && !continues(code, k + 1) {
|
||||
return Some(k + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the item goes on after a `}` at depth zero: `S { .. };`, a method
|
||||
/// call on a block, or an `if` with an `else`.
|
||||
fn continues(code: &[char], after: usize) -> bool {
|
||||
let rest: String = code[after..]
|
||||
.iter()
|
||||
.skip_while(|c| c.is_whitespace())
|
||||
.take(5)
|
||||
.collect();
|
||||
rest.starts_with([';', '.', '?']) || rest.starts_with("else")
|
||||
}
|
||||
|
||||
/// A `cfg` attribute whose predicate no shipped build satisfies. Evaluated,
|
||||
/// not substring-matched (improvise-59q8): `context/code/protocol.md` §2.
|
||||
fn is_test_only(attribute: &str) -> bool {
|
||||
let tokens = cfg_tokens(attribute);
|
||||
match tokens.as_slice() {
|
||||
[Token::Word(cfg), Token::Open, rest @ ..] if cfg == "cfg" => {
|
||||
let mut at = 0;
|
||||
predicate(rest, &mut at) == Truth::No
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a `cfg` predicate is in a build without `test`. `Unknown` for what a
|
||||
/// source scan cannot know, such as a target or an ordinary feature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Truth {
|
||||
Yes,
|
||||
No,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Token {
|
||||
Word(String),
|
||||
Str(String),
|
||||
Open,
|
||||
Close,
|
||||
Comma,
|
||||
Equals,
|
||||
}
|
||||
|
||||
/// The tokens of an attribute's text, from its first word: `#`, `!` and the
|
||||
/// brackets around it are dropped.
|
||||
fn cfg_tokens(attribute: &str) -> Vec<Token> {
|
||||
let inner = attribute
|
||||
.trim_start_matches('#')
|
||||
.trim_start_matches('!')
|
||||
.trim()
|
||||
.trim_start_matches('[')
|
||||
.trim_end()
|
||||
.trim_end_matches(']');
|
||||
let chars: Vec<char> = inner.chars().collect();
|
||||
let mut tokens = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < chars.len() {
|
||||
let c = chars[k];
|
||||
match c {
|
||||
'(' => tokens.push(Token::Open),
|
||||
')' => tokens.push(Token::Close),
|
||||
',' => tokens.push(Token::Comma),
|
||||
'=' => tokens.push(Token::Equals),
|
||||
'"' => {
|
||||
let end = quoted_end(&chars, k + 1, '"');
|
||||
tokens.push(Token::Str(chars[k + 1..end - 1].iter().collect()));
|
||||
k = end;
|
||||
continue;
|
||||
}
|
||||
c if c.is_alphanumeric() || c == '_' => {
|
||||
let word: String = chars[k..]
|
||||
.iter()
|
||||
.take_while(|c| c.is_alphanumeric() || **c == '_')
|
||||
.collect();
|
||||
k += word.chars().count();
|
||||
tokens.push(Token::Word(word));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// One predicate, advancing `at` past it. `test` is false; so is the
|
||||
/// `test-support` feature, which this repo never ships (improvise-26dm).
|
||||
fn predicate(tokens: &[Token], at: &mut usize) -> Truth {
|
||||
let Some(Token::Word(word)) = tokens.get(*at) else {
|
||||
return Truth::Unknown;
|
||||
};
|
||||
*at += 1;
|
||||
match (word.as_str(), tokens.get(*at)) {
|
||||
(op @ ("all" | "any" | "not"), Some(Token::Open)) => {
|
||||
*at += 1;
|
||||
let mut args = Vec::new();
|
||||
while !matches!(tokens.get(*at), Some(Token::Close) | None) {
|
||||
args.push(predicate(tokens, at));
|
||||
if tokens.get(*at) == Some(&Token::Comma) {
|
||||
*at += 1;
|
||||
}
|
||||
}
|
||||
*at += 1;
|
||||
combine(op, &args)
|
||||
}
|
||||
(key, Some(Token::Equals)) => {
|
||||
*at += 1;
|
||||
let value = match tokens.get(*at) {
|
||||
Some(Token::Str(v)) => v.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
*at += 1;
|
||||
match (key, value.as_str()) {
|
||||
("feature", "test-support") => Truth::No,
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
("test", _) => Truth::No,
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleene's three-valued `all`, `any` and `not`.
|
||||
fn combine(op: &str, args: &[Truth]) -> Truth {
|
||||
match op {
|
||||
"all" if args.contains(&Truth::No) => Truth::No,
|
||||
"all" if args.iter().all(|a| *a == Truth::Yes) => Truth::Yes,
|
||||
"any" if args.contains(&Truth::Yes) => Truth::Yes,
|
||||
"any" if args.iter().all(|a| *a == Truth::No) => Truth::No,
|
||||
"not" => match args.first() {
|
||||
Some(Truth::Yes) => Truth::No,
|
||||
Some(Truth::No) => Truth::Yes,
|
||||
_ => Truth::Unknown,
|
||||
},
|
||||
_ => Truth::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with comments, string, byte-string, raw-string and char literals
|
||||
@@ -166,8 +402,8 @@ fn literal_or_comment_end(c: &[char], i: usize) -> Option<usize> {
|
||||
(Some('/'), Some('/')) => Some(line_end(c, i)),
|
||||
(Some('/'), Some('*')) => Some(block_end(c, i)),
|
||||
(Some('"'), _) => Some(quoted_end(c, i + 1, '"')),
|
||||
(Some('b'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
|
||||
(Some('r' | 'b'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
|
||||
(Some('b' | 'c'), Some('"')) if !word_before => Some(quoted_end(c, i + 2, '"')),
|
||||
(Some('r' | 'b' | 'c'), Some('r' | '#' | '"')) if !word_before => raw_end(c, i),
|
||||
(Some('\''), _) => char_end(c, i),
|
||||
_ => None,
|
||||
}
|
||||
@@ -211,9 +447,10 @@ fn quoted_end(c: &[char], mut k: usize, quote: char) -> usize {
|
||||
c.len()
|
||||
}
|
||||
|
||||
/// `r"…"`, `r#"…"#`, `br#"…"#`. `None` when the `r` is only an identifier.
|
||||
/// `r"…"`, `r#"…"#`, `br#"…"#`, `cr#"…"#`. `None` when the `r` is only an
|
||||
/// identifier.
|
||||
fn raw_end(c: &[char], i: usize) -> Option<usize> {
|
||||
let mut k = i + usize::from(c[i] == 'b');
|
||||
let mut k = i + usize::from(matches!(c[i], 'b' | 'c'));
|
||||
if c.get(k) != Some(&'r') {
|
||||
return None;
|
||||
}
|
||||
@@ -283,21 +520,20 @@ mod tests {
|
||||
"}\n",
|
||||
"#[cfg(test)]\n",
|
||||
"mod out_of_line;\n",
|
||||
"#[cfg(any(test, feature = \"x\"))]\n",
|
||||
"#[cfg(any(test, feature = \"test-support\"))]\n",
|
||||
"pub mod gated;\n",
|
||||
"#[cfg(not(test))]\n",
|
||||
"fn production_only() {}\n",
|
||||
"fn also_kept() {}\n",
|
||||
);
|
||||
let code = code_only(text);
|
||||
let stripped = strip_test_items(&code);
|
||||
let stripped = production_text(text);
|
||||
for kept in ["fn kept", "fn production_only", "fn also_kept"] {
|
||||
assert!(stripped.contains(kept), "{kept} was dropped: {stripped}");
|
||||
}
|
||||
assert!(!stripped.contains("gone_inline"), "{stripped}");
|
||||
let lib = Path::new("/c/src/lib.rs");
|
||||
assert_eq!(
|
||||
test_module_paths(lib, &code),
|
||||
test_module_paths(lib, &Source::of(text, lib)),
|
||||
vec![
|
||||
PathBuf::from("/c/src/out_of_line"),
|
||||
PathBuf::from("/c/src/gated")
|
||||
@@ -305,7 +541,7 @@ mod tests {
|
||||
);
|
||||
let nested = Path::new("/c/src/ui/reduce.rs");
|
||||
assert_eq!(
|
||||
test_module_paths(nested, &code)[0],
|
||||
test_module_paths(nested, &Source::of(text, nested))[0],
|
||||
PathBuf::from("/c/src/ui/reduce/out_of_line")
|
||||
);
|
||||
assert!(is_module_file(
|
||||
@@ -322,6 +558,112 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// improvise-59q8, the shapes that HID production code: a test-only item
|
||||
/// whose end the line scan could not find. Evidence: test-evidence/protocol.md.
|
||||
#[test]
|
||||
fn a_test_only_item_hides_nothing_after_it() {
|
||||
let cases = [
|
||||
(
|
||||
"a multi-line item ending in `];`",
|
||||
concat!(
|
||||
"impl X {\n",
|
||||
" #[cfg(test)]\n",
|
||||
" const NAMES: &[&str] = &[\n",
|
||||
" \"a\",\n",
|
||||
" ];\n",
|
||||
" fn prod() { let c = Command::Hidden1(1); }\n",
|
||||
"}\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"a multi-line statement ending in `);`",
|
||||
concat!(
|
||||
"fn f() {\n",
|
||||
" #[cfg(test)]\n",
|
||||
" let x = foo(\n",
|
||||
" 1,\n",
|
||||
" );\n",
|
||||
" let c = Command::Hidden1(1);\n",
|
||||
"}\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
(
|
||||
"a one-line `#[cfg(test)] use`",
|
||||
concat!(
|
||||
"#[cfg(test)] use foo::bar;\n",
|
||||
"fn prod() { Command::Hidden1(1) }\n",
|
||||
"fn after() { Command::Visible(2) }\n",
|
||||
),
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let kept = production_text(text);
|
||||
for name in ["Hidden1", "Visible"] {
|
||||
assert!(kept.contains(name), "{what}: {name} was hidden: {kept:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-59q8, the shapes that COUNTED test code as production.
|
||||
#[test]
|
||||
fn a_test_only_cfg_is_recognised_whatever_its_spelling() {
|
||||
let cases = [
|
||||
(
|
||||
"`all(test, not(..))`",
|
||||
"#[cfg(all(test, not(target_arch = \"wasm32\")))]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
(
|
||||
"an inner `#![cfg(test)]` heading the file",
|
||||
"#![cfg(test)]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
(
|
||||
"an attribute split across lines",
|
||||
"#[cfg(\n test\n)]\nfn t() { Command::TestOnly(1) }\n",
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let kept = production_text(text);
|
||||
assert!(
|
||||
!kept.contains("TestOnly"),
|
||||
"{what} was read as production: {kept:?}"
|
||||
);
|
||||
}
|
||||
// The controls: a predicate a shipped build can satisfy is production.
|
||||
for text in [
|
||||
"#[cfg(not(test))]\nfn p() { Command::Shipped(1) }\n",
|
||||
"#[cfg(any(test, feature = \"serde\"))]\nfn p() { Command::Shipped(1) }\n",
|
||||
"#[cfg(target_arch = \"wasm32\")]\nfn p() { Command::Shipped(1) }\n",
|
||||
] {
|
||||
assert!(
|
||||
production_text(text).contains("Shipped"),
|
||||
"{text:?} was dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-59q8's minor case: a C string literal leaked its contents.
|
||||
#[test]
|
||||
fn a_c_string_literal_is_blanked() {
|
||||
// The raw one quotes inside itself, so lexing it as ordinary strings
|
||||
// leaves the text BETWEEN the inner quotes in the open.
|
||||
let kept = code_only(
|
||||
"let a = c\"Command::InC(1)\"; let b = cr#\"x\" Command::InRawC(2) \"y\"#;\n",
|
||||
);
|
||||
for gone in ["InC", "InRawC"] {
|
||||
assert!(!kept.contains(gone), "{gone} survived: {kept}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A test-only item the scan cannot bound is REFUSED, never read to the
|
||||
/// end of the file, which is what silently hid every shape above.
|
||||
#[test]
|
||||
#[should_panic(expected = "could not be bounded")]
|
||||
fn an_unbounded_test_only_item_is_refused_not_swallowed() {
|
||||
let _ = production_text("#[cfg(test)]\nfn broken() {\n Command::X(1)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_literals_are_blanked_and_code_is_kept() {
|
||||
let text = concat!(
|
||||
|
||||
@@ -1830,3 +1830,123 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
|
||||
"client and host yanked different values over an aggregate"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
|
||||
/// so "the cell under the cursor" is not "the first cell".
|
||||
fn precise_host(value: improvise_protocol::CellValue) -> App {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "North").unwrap();
|
||||
wb.add_item("Region", "South").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.active_view_mut().set_axis("Region", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
for (region, measure, v) in [
|
||||
("North", "Cost", improvise_protocol::CellValue::Number(1.0)),
|
||||
(
|
||||
"North",
|
||||
"Revenue",
|
||||
improvise_protocol::CellValue::Number(2.0),
|
||||
),
|
||||
("South", "Cost", improvise_protocol::CellValue::Number(3.0)),
|
||||
("South", "Revenue", value),
|
||||
] {
|
||||
wb.model.set_cell(region_measure(region, measure), v);
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.term_height = 2 + 8;
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
app
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn an_untouched_browser_edit_stores_the_identical_value() {
|
||||
use improvise_protocol::CellValue::{Number, Text};
|
||||
let window = (2, 2);
|
||||
for value in [
|
||||
Number(1.23456789),
|
||||
Number(0.1 + 0.2),
|
||||
Number(98765.4321987),
|
||||
Number(123456789012345.67),
|
||||
Number(-0.00001234),
|
||||
Number(-0.0),
|
||||
Number(f64::NEG_INFINITY),
|
||||
Text("0.1 + 0.2".into()),
|
||||
] {
|
||||
let mut host = precise_host(value.clone());
|
||||
let mut client = client_ready_to_edit(&host, window);
|
||||
for key in ["i", "Enter"] {
|
||||
let event = BrowserKey::new(key).to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("{key}: the client must answer this key locally");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut host, &cmds);
|
||||
}
|
||||
assert!(
|
||||
!client.view.mode.is_editing(),
|
||||
"Enter did not commit, so nothing was written and this proves nothing"
|
||||
);
|
||||
let stored = host
|
||||
.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(®ion_measure("South", "Revenue"))
|
||||
.cloned();
|
||||
let same = match (&stored, &value) {
|
||||
(Some(Number(a)), Number(b)) => a.to_bits() == b.to_bits(),
|
||||
(stored, value) => stored.as_ref() == Some(value),
|
||||
};
|
||||
assert!(same, "i then Enter on {value:?} stored {stored:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-umi4) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn every_projected_edit_text_commits_back_to_its_value() {
|
||||
use improvise_protocol::CellValue::Number;
|
||||
let value = Number(1.23456789);
|
||||
for records in [false, true] {
|
||||
let mut app = precise_host(value.clone());
|
||||
if records {
|
||||
reduce_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
assert_eq!(app.layout.is_records_mode(), records, "wrong fixture mode");
|
||||
let grid = grid_of(
|
||||
&app,
|
||||
Viewport {
|
||||
row_offset: 0,
|
||||
col_offset: 0,
|
||||
rows: 8,
|
||||
cols: 8,
|
||||
},
|
||||
);
|
||||
let cells: Vec<_> = grid
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|cell| matches!(cell.id, Some(improvise_protocol::CellId::Record(_))))
|
||||
.collect();
|
||||
assert!(
|
||||
cells.iter().any(|cell| cell.value.as_ref() == Some(&value)),
|
||||
"records={records}: the precise value was never projected, so this proves nothing"
|
||||
);
|
||||
for cell in cells {
|
||||
let text = cell.edit_text.clone().unwrap_or_default();
|
||||
assert_eq!(
|
||||
improvise_protocol::typed(&text),
|
||||
cell.value,
|
||||
"records={records}: edit text {text:?} does not commit back to {:?}",
|
||||
cell.value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-15
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Waits for a PR's checks to finish and says what happened. Every rule in here
|
||||
# is one this repo got wrong first; see context/code/pr-watch.md.
|
||||
# is one this repo got wrong first; see context/code/tooling.md section 3.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
@@ -11,10 +11,12 @@ usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>
|
||||
--timeout <sec> give up after this long (default 1800)
|
||||
--interval <sec> seconds between polls (default 20)
|
||||
|
||||
exit 0 every check COMPLETED and none failed
|
||||
exit 1 a check failed, or the PR is not mergeable
|
||||
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
|
||||
state of CLEAN, UNSTABLE or HAS_HOOKS
|
||||
exit 1 a check failed, or the PR is not mergeable (any other known state)
|
||||
exit 2 the head moved during the wait, or is not --expect
|
||||
exit 3 timed out with checks still running
|
||||
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
|
||||
or still BLOCKED with every registered check green
|
||||
exit 4 usage or environment error
|
||||
EOF
|
||||
exit 4
|
||||
@@ -86,13 +88,28 @@ while :; do
|
||||
total="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]?] | length')"
|
||||
running="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length')"
|
||||
|
||||
failed="$(printf '%s' "$json" | jqf \
|
||||
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
|
||||
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
|
||||
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
|
||||
|
||||
# UNKNOWN and green-but-BLOCKED are waited on: see context/code/tooling.md,
|
||||
# "pr-watch and mergeability" (improvise-edpl, improvise-zlj2).
|
||||
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
|
||||
break
|
||||
if [ "$failed" -gt 0 ] || [ "$mergeable" = CONFLICTING ]; then break; fi
|
||||
case "$mergeable/$state" in
|
||||
UNKNOWN/*|*/UNKNOWN|MERGEABLE/BLOCKED) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
now=$(date +%s)
|
||||
if [ $((now - started)) -ge "$TIMEOUT" ]; then
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
|
||||
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with mergeability $mergeable/$state" >&2
|
||||
else
|
||||
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
@@ -100,15 +117,10 @@ done
|
||||
|
||||
printf '%s' "$json" | jqf '.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | " \(.conclusion) \(.name)"'
|
||||
|
||||
failed="$(printf '%s' "$json" | jqf \
|
||||
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
|
||||
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
|
||||
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
|
||||
|
||||
echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
|
||||
|
||||
[ "$failed" -eq 0 ] || exit 1
|
||||
# UNSTABLE means a non-required check is unhappy; it is not a merge blocker on
|
||||
# its own, so only an outright non-mergeable state fails here.
|
||||
[ "$mergeable" != "CONFLICTING" ] || exit 1
|
||||
exit 0
|
||||
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
|
||||
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
|
||||
[ "$mergeable" = MERGEABLE ] || exit 1
|
||||
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
|
||||
|
||||
@@ -73,5 +73,96 @@ run_case "a head that moves mid-wait is not a pass" 2 "$work/moved" --timeout 60
|
||||
run_case "--expect refuses a different head" 2 "$work/green" --expect bbbbbbbb
|
||||
run_case "--expect accepts a prefix of the head" 0 "$work/green" --expect aaaa
|
||||
|
||||
# improvise-edpl. GitHub computes mergeability lazily: for a while after the
|
||||
# base moves it reports UNKNOWN, and PRs #172 and #179 both exited 0 there.
|
||||
# This is the shape both had: every check green, mergeability unresolved.
|
||||
green_rollup='[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]'
|
||||
red_rollup='[{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]'
|
||||
# $1 dir, $2 file, $3 mergeable, $4 mergeStateStatus, $5 rollup (JSON values)
|
||||
state() {
|
||||
mkdir -p "$1"
|
||||
printf '{"headRefOid":"aaaaaaaaaaaa","mergeable":%s,"mergeStateStatus":%s,"statusCheckRollup":%s}\n' \
|
||||
"$3" "$4" "$5" > "$1/$2"
|
||||
}
|
||||
|
||||
state "$work/unk-conflict" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-conflict" last.json '"CONFLICTING"' '"DIRTY"' "$green_rollup"
|
||||
run_case "UNKNOWN that resolves to CONFLICTING fails" 1 "$work/unk-conflict" --timeout 60
|
||||
|
||||
state "$work/unk-clean" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-clean" 2.json '"MERGEABLE"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-clean" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
|
||||
run_case "UNKNOWN that resolves to MERGEABLE/CLEAN passes" 0 "$work/unk-clean" --timeout 60
|
||||
|
||||
state "$work/unk-forever" last.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
run_case "UNKNOWN until the timeout is a timeout, not a pass" 3 "$work/unk-forever"
|
||||
|
||||
# A failed check fails whatever mergeability says; waiting on it would turn a
|
||||
# known red into a timeout.
|
||||
state "$work/unk-red" last.json '"UNKNOWN"' '"UNKNOWN"' "$red_rollup"
|
||||
run_case "a failed check fails without waiting on mergeability" 1 "$work/unk-red" --timeout 0
|
||||
|
||||
# The whole table, enumerated rather than hand-picked, every check green. The
|
||||
# rule it states is in context/code/tooling.md, "pr-watch and mergeability".
|
||||
for m in MERGEABLE CONFLICTING UNKNOWN; do
|
||||
for s in BEHIND BLOCKED CLEAN DIRTY DRAFT HAS_HOOKS UNKNOWN UNSTABLE SOMETHING_NEW null; do
|
||||
if [ "$m" = CONFLICTING ]; then want=1
|
||||
elif [ "$m" = UNKNOWN ] || [ "$s" = UNKNOWN ]; then want=3
|
||||
else case "$s" in CLEAN|UNSTABLE|HAS_HOOKS) want=0 ;; BLOCKED) want=3 ;; *) want=1 ;; esac
|
||||
fi
|
||||
sv="\"$s\""; [ "$s" = null ] && sv=null
|
||||
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
|
||||
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
|
||||
done
|
||||
done
|
||||
|
||||
# improvise-zlj2. The required "Browser harness" check comes from a job that
|
||||
# `needs: build`, so its check run does not exist until build finishes. In that
|
||||
# gap every registered check is green and GitHub says MERGEABLE/BLOCKED. The
|
||||
# rollup below is PR #189's, verbatim; the gap states drop or rewind entries.
|
||||
cat > "$work/rollup.jsonl" <<'EOF'
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:53Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406461","name":"Build all bundles","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047406233","name":"plan","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482378","name":"build-local-artifacts (${{ join(matrix.targets, ', ') }})","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:42:22Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406506","name":"Test and lint (host)","startedAt":"2026-09-23T04:40:16Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482733","name":"build-global-artifacts","startedAt":"2026-09-23T04:40:32Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:40Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406409","name":"wasm32 check (browser bundle crates)","startedAt":"2026-09-23T04:40:15Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047482801","name":"host","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:25Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406415","name":"Browser JS unit tests (node --test)","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:32Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261564/job/107047483313","name":"announce","startedAt":"2026-09-23T04:40:33Z","status":"COMPLETED","workflowName":"Release"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:41:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406264","name":"Knowledge base consistency","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:40:39Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047406373","name":"Lint the harness crate","startedAt":"2026-09-23T04:40:14Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:17Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107047753280","name":"Browser harness (OPFS contract + shell)","startedAt":"2026-09-23T04:41:56Z","status":"COMPLETED","workflowName":"CI"}
|
||||
{"__typename":"CheckRun","completedAt":"2026-09-23T04:44:18Z","conclusion":"SKIPPED","detailsUrl":"https://github.com/fiddlerwoaroof/improvise/actions/runs/35819261571/job/107048259684","name":"Deploy to GitHub Pages","startedAt":"2026-09-23T04:44:18Z","status":"COMPLETED","workflowName":"CI"}
|
||||
EOF
|
||||
harness='Browser harness (OPFS contract + shell)'
|
||||
deploy='Deploy to GitHub Pages'
|
||||
# $1 dir, $2 file, $3 mergeable, $4 state, $5 jq filter over the rollup array
|
||||
gap_state() {
|
||||
state "$1" "$2" "\"$3\"" "\"$4\"" "$(jq -cs "$5" "$work/rollup.jsonl")"
|
||||
}
|
||||
build_running='map(if .name == "Build all bundles" then .status = "IN_PROGRESS" | .conclusion = "" else . end)'
|
||||
not_yet="map(select(.name != \"$harness\" and .name != \"$deploy\"))"
|
||||
harness_running="map(if .name == \"$harness\" then .status = \"IN_PROGRESS\" | .conclusion = \"\" else . end) | map(select(.name != \"$deploy\"))"
|
||||
gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
|
||||
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
|
||||
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
|
||||
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
|
||||
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
|
||||
|
||||
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
|
||||
run_case "a required check that never registers is a timeout" 3 "$work/gap-never"
|
||||
|
||||
# improvise-h3ni. The head must stay pinned across the UNKNOWN wait, or a
|
||||
# verdict about b is reported as a verdict about a.
|
||||
state "$work/unk-moved" 1.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-moved" 2.json '"UNKNOWN"' '"UNKNOWN"' "$green_rollup"
|
||||
state "$work/unk-moved" last.json '"MERGEABLE"' '"CLEAN"' "$green_rollup"
|
||||
for f in 2.json last.json; do
|
||||
sed 's/aaaaaaaaaaaa/bbbbbbbbbbbb/' "$work/unk-moved/$f" > "$work/unk-moved/$f.new"
|
||||
mv -f "$work/unk-moved/$f.new" "$work/unk-moved/$f"
|
||||
done
|
||||
run_case "a head that moves during the UNKNOWN wait is not a pass" 2 "$work/unk-moved" --timeout 60
|
||||
|
||||
printf '\npr-watch-test: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
Reference in New Issue
Block a user