Merge commit '202e7eac637a95c8ac0814cb65a5739bcd3afe33' into tooling/lqs0-audit-exits
This commit is contained in:
Generated
+1
@@ -897,6 +897,7 @@ dependencies = [
|
||||
"pest_derive",
|
||||
"pest_meta",
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+69
-31
@@ -996,20 +996,39 @@ folded when the resolver runs — the trap that reads as "the cursor lands on
|
||||
`Plan` and the document switches to `Default`", silent and wrong in the half a
|
||||
user does not watch.
|
||||
|
||||
The second clause of rule 1 is what makes the formula panel look inconsistent
|
||||
and is in fact the rule holding. `ModeKey::ViewPanel` binds `Enter` to
|
||||
`switch-view-at-cursor`; `ModeKey::FormulaPanel` binds `a`/`n`/`o` and
|
||||
`d`/`Delete` and **no `Enter` at all**. So a view-row click switches and a
|
||||
formula-row click only navigates. Opening the editor on a formula click would
|
||||
be an activation *invented for the pointer that the keyboard does not have* —
|
||||
a second derivation of what a formula row means, which is the disease §5 names.
|
||||
**The guard for this must be aimed at the keymap, not at the click**: asserting
|
||||
that the click emits no `FormulaEdit` only restates the author's reading, and a
|
||||
reviewer demonstrated the gap by adding `Enter -> enter-mode formula-edit` to
|
||||
`FormulaPanel` and watching the whole workspace stay green while the click and
|
||||
the keyboard disagreed. `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`
|
||||
asserts the binding's absence and names `click_formula_row` as what must change
|
||||
if it ever appears.
|
||||
The second clause of rule 1 is why a formula-row click opens the editor. Until
|
||||
improvise-km71, `ModeKey::FormulaPanel` bound `a`/`n`/`o` and `d`/`Delete` and
|
||||
**no `Enter` at all**, so a formula-row click only navigated, and opening the
|
||||
editor on a click would have been an activation *invented for the pointer that
|
||||
the keyboard did not have*. km71 bound `Enter` (and `e`) to
|
||||
`edit-formula-at-cursor`, so the rule now yields the editor on the clicked row,
|
||||
and `click_formula_row` ends with `edit_formula_at(index)` exactly as
|
||||
`click_view_row` ends with `switch_view_at(index)`. **The guard is aimed at what
|
||||
`Enter` resolves to, not at the keymap and not at the click**:
|
||||
`a_formula_row_click_ends_with_what_enter_does_on_that_row` resolves `Enter` at
|
||||
cursor 1 and asserts the click on row 1, made with the cursor at 0, *ends with*
|
||||
those commands. The earlier guard asserted only that `Enter` was unbound, which
|
||||
tied the two by a comment; this one fails if either side changes without the
|
||||
other, and it separates "reads the clicked index" from "reads the cursor",
|
||||
because the two point at different rows. The superseded test was
|
||||
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
|
||||
|
||||
Editing needs the client to name *which* formula, and `FormulaCache` used to
|
||||
carry only the raw text, from which neither the target nor its category is
|
||||
recoverable without reimplementing `parse_formula` on the client — the gap
|
||||
`decision(improvise-e24v)` recorded for `delete-formula-at-cursor`. km71 closed
|
||||
it the gotcha 47 way, by **enriching the projection**: `FormulaCache::formulas`
|
||||
is `Vec<FormulaEntry { id: FormulaId, raw }>`. The id is opaque on purpose
|
||||
(`context/code/core.md` §14). `delete-formula-at-cursor` is still declined in
|
||||
the browser; it now needs only its resolver.
|
||||
|
||||
One divergence is pinned rather than closed: a rename. The host's
|
||||
`ReplaceFormula` rewrites the status to say what was renamed and which formulas
|
||||
still name the old target; the client cannot know the old target (the id is
|
||||
opaque) and folds the optimistic "Formula updated".
|
||||
`renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host` asserts
|
||||
both sides of that, as `a_formula_the_client_cannot_parse_diverges_on_the_status_line`
|
||||
does for a parse error.
|
||||
|
||||
**The browser assertion that PR #60 dropped stays dropped, and improvise-qf57
|
||||
is the decision rather than the oversight.** `#grid input` `toHaveCount(0)` was
|
||||
@@ -1026,6 +1045,9 @@ running the spec: `#grid input` `toHaveCount(0)` **passes**, and the run fails
|
||||
on the `#mode` assertion below it. Restoring it would add an assertion that
|
||||
cannot fail — the same defect this file already records one line down, where an
|
||||
explanatory `assert!` was removed for never being able to be the failing one.
|
||||
Since improvise-km71 the activation is real rather than invented, so the spec's
|
||||
`#mode` assertion reads `"FORMULA"` and it also asserts that `#minibuffer` holds
|
||||
the clicked formula's text; the argument about `#grid input` is unchanged.
|
||||
|
||||
**Rule 2 — a click is all of its intents or none of them** (improvise-hkr8).
|
||||
Each `click_*` returns `Option<Vec<Command>>` and `click` does the single
|
||||
@@ -1450,14 +1472,24 @@ so another client's write to *that same record* bumped the counter in the
|
||||
client's `BasisCache`. The commit then claimed the fresh counter and was
|
||||
accepted, and the other write was silently overwritten. If the other client
|
||||
cleared the cell instead, the commit re-created it. An `EditTarget` is
|
||||
`{ cell, version }`: the record's counter as the client had it when the editor
|
||||
opened. `BasisCache::claim_as_read` states that version beside the *current*
|
||||
document tiers.
|
||||
`{ document_generation, cell, version, opened }`: the document and the record's
|
||||
counter as the client had them when the editor opened, and the seed it opened
|
||||
on (improvise-ixxx, above). `BasisCache::claim_as_read`
|
||||
states both beside the *current* `layout_version`.
|
||||
|
||||
The document tiers stay current on purpose. The rows renumbering under the
|
||||
editor, which was 2wu3's case, moves `layout_version`. Claiming that at open
|
||||
would refuse every such commit as `LayoutMoved`, although the identity already
|
||||
makes the commit safe. So the result is:
|
||||
**The two document tiers are not alike, and only the layout stays current.**
|
||||
The rows renumbering under the editor, which was 2wu3's case, moves
|
||||
`layout_version`. Claiming that at open would refuse every such commit as
|
||||
`LayoutMoved`, although the identity already makes the commit safe. The
|
||||
generation is the opposite case (improvise-vndi). A `RecordHash` is derived
|
||||
from coordinates, so after another client's `LoadModel` the same record can
|
||||
resolve in the incoming document, and a fresh `DataStore`'s counter can equal
|
||||
the one the editor read. Claiming the current generation then got the commit
|
||||
**accepted**: the loaded document's value, which this user never saw, was
|
||||
overwritten. Nothing composed against the outgoing document names anything in
|
||||
the incoming one, which is `Rejection::DocumentReplaced`'s own contract, so the
|
||||
generation is captured at open and the swap refuses the commit. So the result is:
|
||||
- the document was replaced: `DocumentReplaced`;
|
||||
- the record moved: `RecordMoved`;
|
||||
- the record is gone: `RecordGone`;
|
||||
- only the rows moved: the commit is accepted.
|
||||
@@ -1469,10 +1501,11 @@ row. So the record resolves, and its counter has moved. `RecordGone` needs a
|
||||
record that neither the store nor the layout resolves.
|
||||
|
||||
**`EditTarget` is the one object an editor captures at open, and it is a
|
||||
struct so it can grow.** improvise-ixxx needs the seed captured at open too (a
|
||||
commit whose buffer equals the seed writes nothing). That is a `seed: String`
|
||||
field beside `cell` and `version`, and the page does not change, because the
|
||||
target is opaque to it.
|
||||
struct so it can grow.** Two fields have joined `cell` and `version` since:
|
||||
the seed as `opened` (improvise-ixxx: a commit whose buffer equals the seed
|
||||
writes nothing) and the generation (improvise-vndi). The page did not change
|
||||
for either, because the target is opaque to it. `typed_target_frame` asks the
|
||||
two in that order: an untouched editor writes nothing, so it is never refused.
|
||||
|
||||
`typed_cell_command` is the one composition shared by the mouse path
|
||||
(`typed_target_frame`) and the keyboard path (`keys::commit`). Writing it twice is
|
||||
@@ -1573,8 +1606,10 @@ Four smaller decisions that would be re-derived wrongly:
|
||||
Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks
|
||||
**above** it, so the prompt is neither covered nor displaced.
|
||||
|
||||
`RenderCell::edit_text` is the stored value, not the formatted one: seeding an
|
||||
editor from `text` and committing turns `1200` into the string `"1,200"`. The
|
||||
An editor is seeded from `edit_seed(target)`, the stored value the target
|
||||
captured, never from `text`: seeding from the formatted text and committing
|
||||
turns `1200` into the string `"1,200"`. `RenderCell` carries no `edit_text`
|
||||
since improvise-ugj4, because a paint can be stale while an editor is open. The
|
||||
`numeric`/`error` flags are the server's answers, echoed — the client must not
|
||||
re-derive them from the text, which is how the two ends drift.
|
||||
|
||||
@@ -1617,7 +1652,7 @@ The browser half of improvise-cr3, kept as thin as it can be made, now talking
|
||||
to either of two peers (improvise-djm). **The boundary is JSON strings in and
|
||||
out, so this file holds no protocol knowledge at all**: it never inspects a
|
||||
`Command`, never builds a `CellId`, never decides whether a cell is editable or
|
||||
whether a typed string is a number. Five calls are the whole harness:
|
||||
whether a typed string is a number. These calls are the whole harness:
|
||||
|
||||
| call | what it is |
|
||||
|---|---|
|
||||
@@ -1625,6 +1660,8 @@ whether a typed string is a number. Five calls are the whole harness:
|
||||
| `client.on_frame(json)` | fold one frame, in arrival order |
|
||||
| `client.render_model()` | everything to paint, already decided |
|
||||
| `client.edit_target(r, c)` | what an editor opened here edits (the record, the version seen, and the seed it opened on): an opaque string the page holds, or `null` |
|
||||
| `client.edit_seed(target)` | the text the target's editor opens on: the seed it captured, never the paint (improvise-ugj4) |
|
||||
| `client.edited_target(target)` | the same target marked edited, so retyping the seed still writes (improvise-jibp) |
|
||||
| `client.typed_cell(target, s)` | the frame for "the user typed s into that cell", by identity (improvise-2wu3), or none if `s` is still the target's seed (improvise-ixxx) |
|
||||
| `client.on_key_event(k,c,a,s,m)` | the frame for a keypress, folded locally first where this client may answer it |
|
||||
|
||||
@@ -1832,9 +1869,10 @@ Neither arm leaves one armed.
|
||||
- **`editable` is the server's answer relayed through Rust**: a stored cell. An
|
||||
aggregate is a drill target and drill is not on the wire yet, so it is simply
|
||||
not offered.
|
||||
- The inline editor seeds from `RenderCell::edit_text` — the STORED value, not
|
||||
the drawn one: `td.textContent` has been through the number format, so a stored
|
||||
1200 reads `"1,200"` and committing that stores the *string* `"1,200"`. Enter
|
||||
- The inline editor seeds from `client.edit_seed(target)` — the STORED value the
|
||||
target captured, not the drawn one: `td.textContent` has been through the number
|
||||
format, so a stored 1200 reads `"1,200"` and committing that stores the *string*
|
||||
`"1,200"`. Enter
|
||||
removes the focused `<input>`, which fires `blur`, which calls `close` again:
|
||||
harmless, but only by accident.
|
||||
- **The title bar is three fields and one joining.** `doc_name`, `doc_dirty` and
|
||||
|
||||
@@ -3538,6 +3538,56 @@ unlisted command that diverges fails the test, and so does a listed one that
|
||||
does not diverge. A stale entry therefore cannot linger, and a new registration
|
||||
is covered the day it lands.
|
||||
|
||||
## Editing a formula: the capture is on the mode, read in `execute` (improvise-km71)
|
||||
|
||||
`edit-formula-at-cursor` (Enter or `e` in the formula panel) opens
|
||||
`AppMode::FormulaEdit` with `slot: FormulaSlot::Existing { id, was }` and seeds
|
||||
the `formula` buffer with the formula's `raw`. That slot is the capture: which
|
||||
formula, and what it read when the editor opened. Nothing at commit time looks
|
||||
at the cursor again.
|
||||
|
||||
**Where `commit-buffer formula` reads the slot matters, and the registry is the
|
||||
wrong place.** The obvious move was to resolve it in `parse_commit_kind`, the
|
||||
way `Item` takes its category from `AppMode::ItemAdd`. That does not work, and
|
||||
it fails silently: the interactive arm is built **from `parse`**
|
||||
(`context/code/cli.md` §8), and `parse` accepts `formula` under
|
||||
`AppMode::Normal`, so the mode-reading fallback is never reached. `Item` only
|
||||
works that way because `parse` *refuses* `item`. The first build of km71 did
|
||||
exactly this, and every edit became an add. `add_formula` upserts by target, so
|
||||
an edit that kept its target still landed in place, and only the rename tests
|
||||
and the concurrency tests showed it. Making the interactive arm read the mode
|
||||
first would break improvise-yf7p's guarantee that an accepted argument list is
|
||||
never overridden by context. So `CommitBuffer::execute` asks
|
||||
`CommitKind::in_mode(ctx.mode)`. That is a read of context in `execute`, which
|
||||
is the answer yf7p gave to `toggle-panel-visibility`. A script line stays an
|
||||
add, and cannot name an edit, because the slot does not exist outside the
|
||||
editor.
|
||||
|
||||
**`effect::ReplaceFormula` refuses with `refuse_and_stop`, not `refuse`.** The
|
||||
Enter binding is `[commit-buffer formula, clear-buffer formula]`, and
|
||||
`CommitBuffer` puts the change back to `FormulaPanel` *after* the mutation. A
|
||||
refusal that stops the batch therefore leaves the editor open with the user's
|
||||
text and the reason in the minibuffer line (`draw_bottom_bar` draws the status
|
||||
beside the minibuffer). That covers all four refusals: the formula is gone, it
|
||||
was rewritten since the editor opened (`was`), the new text does not parse, or
|
||||
the new target belongs to another formula, or the rename is of a formula
|
||||
outside `_Measure` (improvise-kb1d, below). `AddFormula`'s parse error stops the
|
||||
batch the same way since improvise-v4hg (#213), so a bad *new* formula also
|
||||
leaves the editor open with its text; adding and editing now agree.
|
||||
|
||||
**A target change outside `_Measure` is refused (improvise-kb1d).** A regular
|
||||
category's formula target is a real item of that category, not a derived one.
|
||||
`replace_formula` added the new target item and left the old one, so renaming
|
||||
`Total = East + West [Region]` to `Sum` left `Total` as an empty item and
|
||||
appended `Sum` at the end. Renaming the item in place was the alternative and
|
||||
was not taken: an item's name is its identity in cells, views, hidden lists and
|
||||
groups, so that rename is the whole of improvise-sboz, not a small fix. Editing
|
||||
such a formula without changing its target still works.
|
||||
|
||||
On success the command's optimistic "Formula updated" stands, except on a
|
||||
rename, where the effect rewrites the status to name the old and new targets
|
||||
and any formula that still mentions the old one (`context/code/core.md` §18).
|
||||
|
||||
## A wire write reaches only a cell the host projects as a Record (improvise-ciw8)
|
||||
|
||||
`App::resolve_record` answers "which record does this surrogate name". Its
|
||||
@@ -3557,6 +3607,18 @@ the refusal marks the batch, `Host::mutating_frame` undoes the frame, the asker
|
||||
gets `Rejected(MutationRefused)`, and the reason is on the host's status line.
|
||||
The question it asks is the projection's own, never a second derivation of it:
|
||||
|
||||
- A coordinate that names a **group** rather than an item is refused, in any
|
||||
view (improvise-b114). A group has no cell of its own, so writing one stores a
|
||||
non-item coordinate. This rule asks the document, not the layout, and runs
|
||||
first, because a fold in the measure half hides from both rules below. Take
|
||||
`{Month=Feb, _Measure=Money}`, where `Money` groups Revenue and Cost. Its
|
||||
record `{Month=Feb}` is stored, so the second rule below accepts it and never
|
||||
asks the projection. And with `Money` expanded the host draws no such cell at
|
||||
all, so the third rule has no position to ask. A fold on the row axis is
|
||||
refused here too, so the `Aggregate` arm below now guards only the None-axis
|
||||
aggregate. It also closes improvise-xur5: a fold's record `{Month=Q2}` written
|
||||
with a measure the host draws nowhere (hidden, unknown or absent) has no
|
||||
position for the third rule to ask, and was accepted.
|
||||
- A cell a formula owns is refused. That is `cell_id`'s own `Derived` rule,
|
||||
`formula_for(&key)`. It is asked directly because a stored record may sit on
|
||||
no position in the host's current view.
|
||||
|
||||
@@ -1292,3 +1292,57 @@ What this does **not** fix: a text cell whose text reads as a number. `"007"`
|
||||
seeds `"007"`, and `typed()` classifies that as `Number(7.0)`. The seed is
|
||||
exact, but the classifier has no way to know the cell was text. That is
|
||||
improvise-ixxx.
|
||||
|
||||
## 18. Which formula an edit means (improvise-km71)
|
||||
|
||||
A `Formula` has no stored id. It lives in `Model::formulas: Vec<Formula>` and is
|
||||
identified, in practice and by `add_formula`'s upsert, by `(target,
|
||||
target_category)`. **`FormulaId` is that pair behind one opaque type**: its
|
||||
fields are private, `Formula::id()` is its only constructor, and nothing
|
||||
outside `improvise-formula` can read a name out of it. That is deliberate. The
|
||||
stable-identity work (improvise-sboz, ADR 005) will make a formula's identity
|
||||
something other than its names, and every signature that already says
|
||||
`FormulaId` — `Model::formula`, `Model::replace_formula`,
|
||||
`ModelCommand::ReplaceFormula`, `FormulaSlot::Existing`, `FormulaEntry` — then
|
||||
changes by changing this one struct. A name pair spread across those signatures
|
||||
would have made the swap a sweep.
|
||||
|
||||
**The index is not a candidate.** A position is what `formula_cursor` is, and
|
||||
the editor must not recompute its target from the cursor at commit
|
||||
(improvise-ixxx/2wu3's lesson): another client deleting an earlier formula
|
||||
shifts every index after it, and the commit would land on a neighbour. It would
|
||||
also put a position on the wire, which `every_model_command_field_is_position_free`
|
||||
makes a compile error.
|
||||
|
||||
**An edit is its own operation, never remove-then-add and never
|
||||
`add_formula`.** `add_formula` upserts on `(target, target_category)`: fed an
|
||||
edit that changes the target, it appends a second formula and leaves the old
|
||||
one; fed an edit onto another formula's target, it silently replaces *that*
|
||||
formula. `Model::replace_formula(id, formula)` writes into the slot `id` names,
|
||||
so the position (and with it `_Measure`'s item order, since
|
||||
`measure_item_names` appends formula targets in formula order) is kept, and it
|
||||
**refuses** when the new target belongs to a different formula.
|
||||
|
||||
**A target change is a rename of the same formula.** The id travels with the
|
||||
edit, so `Profit = …` edited to `Gross = …` is the formula that was `Profit`,
|
||||
in the same place. That is the least surprising reading of "edit this row", and
|
||||
it is what ADR 005 proposes. What a rename cannot carry under name identity is
|
||||
everything else that names the old target: other formulas' references, a
|
||||
view's hidden list, a `measure_agg` entry. Those cannot follow without rewriting
|
||||
`raw` text the parser does not print back, so the edit says so out loud:
|
||||
`ReplaceFormula` reports which formulas still mention the old name
|
||||
(`Formula::mentions`, over the expression and its `WHERE`), and their cells
|
||||
show the `ref` error rather than a silent wrong number. Under a stable id those
|
||||
references are what sboz makes follow. **This holds for `_Measure` targets only.**
|
||||
Outside `_Measure` a target is a real item, and a rename is refused until sboz
|
||||
(improvise-kb1d, `context/code/command.md`). A rename also leaves stored cells under
|
||||
the old `_Measure` name in `## Data`, invisible because the formula shadowed them.
|
||||
|
||||
**Concurrency is checked twice.** `FormulaSlot::Existing { id, was }` is
|
||||
captured when the editor opens: the id must still resolve, and the formula must
|
||||
still read `was`. Either failing means another client removed, renamed or
|
||||
rewrote it meanwhile, and the edit is refused with the batch stopped, so the
|
||||
editor stays open over the user's text (gotcha 21). `was` is not redundant
|
||||
with a stable id: a concurrent *replace* keeps the id and changes the text,
|
||||
which only `was` can see.
|
||||
|
||||
|
||||
@@ -488,8 +488,12 @@ The serde derives that existed only for that path went with it: `Workbook`,
|
||||
`DataStore`'s manual impls and the formula AST. So did indexmap's `serde` feature
|
||||
in `improvise-core`, `improvise-io` and the root crate, and `improvise-formula`'s
|
||||
serde dependency. Each was removed and the whole workspace rebuilt, so the
|
||||
compiler named every other consumer. Two remain, and both are the wire:
|
||||
compiler named every other consumer. Two remained, and both are the wire:
|
||||
`Axis` (whose `rename_all = "lowercase"` is its wire spelling) and `PageAxis`.
|
||||
A third arrived with improvise-km71 by the same rule: `FormulaId`, which crosses
|
||||
the wire in `ModelCommand::ReplaceFormula`, `FormulaSlot` and `FormulaEntry`.
|
||||
So `improvise-formula` depends on serde again, for that one type. No derive went
|
||||
back on the formula AST.
|
||||
`CellValue` was never a candidate, since it is carried on the wire too. JSON
|
||||
*import* (`StartImportWizard`, `ImportJsonHeadless`) is a separate feature and is
|
||||
untouched.
|
||||
@@ -512,6 +516,30 @@ change. `recomputing_formulas_leaves_the_model_fingerprint_alone` pins this.
|
||||
`view_only_effects_leave_shared_state_alone` now runs over a formula-bearing
|
||||
fixture, for the same reason.
|
||||
|
||||
**That fix was canonical within one instance, not across two (improvise-djcn).**
|
||||
It fixed `Model`'s own maps but not the maps inside its fields. `DataStore` has
|
||||
`index`, `records` and the symbol table, and `View` has `page_selections`,
|
||||
`hidden_items` and `collapsed_groups`. All still printed through a derived
|
||||
`Debug`, in hash order. Each `HashMap` gets its own `RandomState`, so two equal
|
||||
documents built separately printed differently in 32 of 32 runs.
|
||||
- The effect-classification tests compare one instance with itself before and
|
||||
after, so they were unaffected.
|
||||
- `a_model_command_lands_where_its_keypress_lands` compares **two** `App`s. It
|
||||
passed only because its fixture had no cells and no multi-entry view maps.
|
||||
|
||||
Now:
|
||||
- `DataStore`, `SymbolTable` and `View` have hand-written `Debug` impls. Each
|
||||
sorts wherever its storage is a hash map.
|
||||
- `DataStore` resolves symbols to their strings. A `Symbol`'s number is interning
|
||||
order, not content, so printing it would make two equal stores differ again.
|
||||
- `SymbolTable` prints its interned strings, sorted.
|
||||
- `cells` keeps its `IndexMap` order, because that order is part of the document
|
||||
(gotcha 18).
|
||||
- Every hand-written `Debug`, `Model`'s included, **destructures** `self`. A field
|
||||
added later is then a compile error there, where a `debug_struct` that names
|
||||
fields one by one would skip it silently. The pc3b version of `Model`'s impl
|
||||
had exactly that hole.
|
||||
|
||||
`parse_number_cell` returns `CellValue::Error` naming the offending text rather
|
||||
than a fabricated `0.0`. No token the current grammar admits actually fails
|
||||
`f64::from_str` (overflow saturates to ±inf), but if the two ever drift the
|
||||
|
||||
+33
-12
@@ -84,6 +84,20 @@ naming it would produce a mode with nothing to add to. `Quit` and
|
||||
`start-import-wizard` — which do work beyond changing the mode, so
|
||||
`enter-mode` would be a way to reach half of each.
|
||||
|
||||
### `AppMode::FormulaEdit` carries what it will commit to (improvise-km71)
|
||||
|
||||
`FormulaEdit { minibuf, slot }` is one mode for two editors. `slot` is
|
||||
`FormulaSlot::New` for `a`/`n`/`o` (`enter-mode formula-edit`, which
|
||||
`from_name` still builds) or `FormulaSlot::Existing { id, was }` for
|
||||
`edit-formula-at-cursor`. It is one variant and not a second `AppMode` because
|
||||
the keys, the buffer and the panel are identical: a second variant would add a
|
||||
`ModeKey`, a keymap and a hint for no difference in what the keys do. The slot
|
||||
is `ItemAdd`'s `category` again, a capture made when the mode is entered. Like
|
||||
`ItemAdd` it is not nameable from `from_name`, since a script line has no
|
||||
formula to open. It must be portable and snapshot-safe (§1 above). `FormulaId`
|
||||
is plain serde data, and a fresh client that inherits an open edit inherits
|
||||
exactly the claim the editor made. The commit re-checks that claim.
|
||||
|
||||
### `Panel`
|
||||
|
||||
`Panel::ALL`/`name()`/`key()` drive the toggle keys for Normal *and* all
|
||||
@@ -164,9 +178,12 @@ attempt in a fourth place is the disease.
|
||||
- The **model half** is `ModelCommand`: every `EffectKind::Model(..)` effect,
|
||||
one variant apiece, carrying that effect's own fields (improvise-kmgs).
|
||||
|
||||
The count is 29 model effects, minus three: `SwitchView`, which `Command`
|
||||
already names and routes, and `ApplyAndClearDrill`/`AcceptWizardModel`, which
|
||||
are **not addressable** (improvise-3x3u). That leaves **26**. A variant with
|
||||
The count is 27 model effects, minus two: `ApplyAndClearDrill` and
|
||||
`AcceptWizardModel`, which are **not addressable** (improvise-3x3u). That
|
||||
leaves **25**. `SwitchView`, `ViewBack` and `ViewForward` are no longer model
|
||||
effects at all (improvise-jytm), so they are not subtracted. This paragraph
|
||||
said "29 minus three, 26" until improvise-km71 added `ReplaceFormula`, and
|
||||
that figure was already stale: the enum had 24. A variant with
|
||||
no data mirrors a nullary effect whose operand is document state the
|
||||
authoritative `App` already holds.
|
||||
|
||||
@@ -699,7 +716,9 @@ for that, beside `typed()`. Every commit path reaches it:
|
||||
- **Browser mouse.** The seed is a field of #188's `EditTarget` (`opened`),
|
||||
captured by `edit_target_at` from the cache's `edit_text` beside the record
|
||||
and its version. `typed_target_frame` starts with the `untouched` check, and
|
||||
the page passes no seed (`typed_cell(target, text)`).
|
||||
the page passes no seed (`typed_cell(target, text)`). The `<input>` shows
|
||||
`edit_seed(target)`, so what the page shows and what it compares against are
|
||||
one capture (improvise-ugj4).
|
||||
|
||||
**The seed is captured at open, never recomputed at commit.** Where the editor
|
||||
opened is where `OpenedEditor` is written. For the TUI that is `EditOrDrill`
|
||||
@@ -742,14 +761,16 @@ deliberately do **not** mark it:
|
||||
|
||||
The comparison stays exact: whitespace is content, and nothing trims.
|
||||
|
||||
**The mouse editor tracks no `touched`.** Its edits happen in a DOM `<input>`
|
||||
that Rust never sees, so on that path `untouched` compares content alone, and
|
||||
the 2054 case survives there (improvise-jibp). The fix belongs with
|
||||
`EditTarget`, for example an `input` listener that reports the first edit. A
|
||||
second gap sits on the same path. The `<input>` is still seeded from the
|
||||
**painted** `editText`, while `EditTarget` captures the **cache**. So a paint
|
||||
that went stale while another editor was open shows old text against a newer
|
||||
seed and version (improvise-ugj4).
|
||||
**The mouse editor reports its first edit.** Its edits happen in a DOM
|
||||
`<input>` that Rust never sees, so the page's `input` listener, registered
|
||||
`{ once: true }`, swaps its target for `edited_target(target)`, which is
|
||||
`EditTarget::edited`: the same capture with `touched` set (improvise-jibp).
|
||||
Before that, the mouse path compared content alone, so the 2054 case survived
|
||||
there. The `<input>` used to be seeded from the **painted** `editText` while
|
||||
`EditTarget` captured the **cache**. `repaint` returns early while an editor is
|
||||
open, so a paint that went stale then showed old text against a newer seed and
|
||||
version (improvise-ugj4). The page now seeds from `edit_seed(target)`, and
|
||||
`RenderCell` no longer carries `edit_text`, so the paint cannot be asked.
|
||||
|
||||
This does **not** change classification. `007` typed fresh is still
|
||||
`Number(7)`, and `a_touched_browser_commit_still_writes` pins that.
|
||||
|
||||
@@ -379,3 +379,48 @@ again.
|
||||
hint is width-constrained and the history lives on help page 5 under "View
|
||||
history" with both halves. That is the #149 lesson — a hint that does not fit is
|
||||
never drawn, so the test pinning the widget cannot see it.
|
||||
|
||||
## A status message lasts until the next key (improvise-cq3d)
|
||||
|
||||
`draw_status` draws `status_msg` *instead of* the mode's derived hint, and
|
||||
nothing in production ever cleared `status_msg`: it was only replaced by the next
|
||||
message. So after the first "Saved", "Loaded" or error of a session, the derived
|
||||
key hint (the one key table on screen, improvise-rr8n) was never drawn again, in
|
||||
any mode.
|
||||
|
||||
**The rule: a message expires at the start of the next keypress.** `draw.rs::press`
|
||||
applies `SetStatus("")` and then dispatches the key, so:
|
||||
|
||||
- a message a key produces is drawn after that key, whatever it is, errors
|
||||
included. It stays until the user presses something, which is the moment they
|
||||
have acted on it. An error is never wiped before it has been drawn.
|
||||
- a message that arrives outside a key, such as one from startup or an import,
|
||||
also stands until the next key.
|
||||
- the next key clears it *before* its own effects run. So a key that writes a
|
||||
new message shows that message, and a key that writes none shows the hint.
|
||||
|
||||
**Rejected alternatives.**
|
||||
|
||||
- *Expire on mode change.* This fails the ticket's own case: a message written in
|
||||
Normal would stand in Normal indefinitely.
|
||||
- *Expire on the next successful command.* Effects carry no notion of success,
|
||||
and a failed command would leave the stale message in place. That is exactly
|
||||
the case where the user most needs the hint back.
|
||||
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
|
||||
test's middle step exists to catch that.
|
||||
- *Compose message and hint on one line.* A long message still eats the hint, and
|
||||
the stale message still stands forever.
|
||||
|
||||
**Why here and not in `App::dispatch_key`.** `dispatch_key` is shared with the
|
||||
lockstep harness (`reduce_key`), which compares `status_msg` on the host and the
|
||||
browser after every key. Expiring there without changing the browser's resolver
|
||||
too would make the two ends disagree. The expiry is also a presentation decision
|
||||
about the TUI's one-line status row, so it belongs to the TUI front end.
|
||||
|
||||
**The browser already behaves this way.** Its `status_msg` is state that is never
|
||||
cleared either, but `web/app.js` shows it in a toast that hides after
|
||||
`TOAST_MS` (`web/shell.js::createToasts`), and `#hint` is a separate element that
|
||||
is always drawn. So a browser user sees a message briefly and never loses the
|
||||
hint. The TUI now does the same, on a keypress rather than a timer, because a
|
||||
terminal status row has one line to share and a keypress is the event the TUI
|
||||
already has.
|
||||
|
||||
@@ -1485,39 +1485,29 @@ view, or with a click on the row already under the cursor, the bug is invisible
|
||||
— the same reason `a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row`
|
||||
clicks Product rather than `_Index`.
|
||||
|
||||
### a_formula_row_click_navigates_and_activates_nothing
|
||||
### a_formula_row_click_navigates_and_opens_the_editor_on_that_row
|
||||
|
||||
The negative arm, and it is a statement about the *rule* rather than about the
|
||||
formula panel. The rule is "go there, then do what `Enter` does there";
|
||||
`ModeKey::FormulaPanel`'s keymap binds `a`/`n`/`o` to `enter-mode formula-edit`
|
||||
and `d`/`Delete` to delete, and **no `Enter` at all**. So the rule yields
|
||||
navigation and nothing else, and that is the rule applying rather than the fix
|
||||
stopping half way.
|
||||
Formerly `a_formula_row_click_navigates_and_activates_nothing`. That was the
|
||||
negative arm of the rule "go there, then do what `Enter` does there", true while
|
||||
`ModeKey::FormulaPanel` bound no `Enter`. improvise-km71 bound `Enter` (and
|
||||
`e`) to `edit-formula-at-cursor`, so the same rule now yields the editor on the
|
||||
clicked row. The test asserts the exact command vector. The clicked row is
|
||||
`Margin` at index 1 and the cursor stays at 0, so a click that read the cursor
|
||||
would open `Profit`.
|
||||
|
||||
This test asserts the *exact* command vector, which is the whole of what it can
|
||||
say. It once carried a second, explanatory `assert!` that no `FormulaEdit` mode
|
||||
change appears — **which could never have been the failing assertion**, because
|
||||
the `assert_eq!` above it pins the same three commands and aborts first. A
|
||||
reviewer demonstrated it by injecting a `ChangeMode(FormulaEdit)` and watching
|
||||
the panic land on the `assert_eq!` with the explanatory message never printed.
|
||||
It was a comment wearing an `assert!`, and the argument now lives in
|
||||
`context/code/clients.md` where a comment run cannot hold it.
|
||||
The old version once carried an explanatory `assert!` that no `FormulaEdit`
|
||||
mode change appeared, which could never have been the failing assertion,
|
||||
because the `assert_eq!` above it pinned the same commands and aborted first.
|
||||
That lesson stands: an `assert!` after an exact `assert_eq!` on the same vector
|
||||
is a comment.
|
||||
|
||||
### the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does
|
||||
|
||||
The guard that the removed assertion was pretending to be. The asymmetry — a
|
||||
view-row click activates, a formula-row click does not — is a fact about the
|
||||
**keymap**, and nothing tied the click to it. The reviewer proved the gap by
|
||||
adding `Enter -> enter-mode formula-edit` to `ModeKey::FormulaPanel` and running
|
||||
the whole workspace: **1381 passing, nothing failed**, while `click_formula_row`
|
||||
still activated nothing. The click and the keyboard then disagree about what a
|
||||
formula row does, silently.
|
||||
|
||||
Injecting that same binding now fails this test and only this test. The failure
|
||||
message names `click_formula_row` as what must change, because the person who
|
||||
adds an `Enter` binding is not the person reading `resolve.rs`'s click arms. The
|
||||
positive half (`ViewPanel` *does* bind `Enter`) is there so the test cannot pass
|
||||
by asserting that no panel binds anything.
|
||||
The guard that tied the click to the keymap was
|
||||
`the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`. A reviewer
|
||||
showed that nothing else tied them: adding an `Enter` binding to `FormulaPanel`
|
||||
left 1381 tests passing while `click_formula_row` still activated nothing. That
|
||||
test is replaced by `a_formula_row_click_ends_with_what_enter_does_on_that_row`,
|
||||
below, which compares the click with what `Enter` actually resolves to rather
|
||||
than asserting a binding's absence.
|
||||
|
||||
#### The view and formula row clicks in the browser (improvise-iioi)
|
||||
|
||||
@@ -4695,6 +4685,29 @@ laundering invisible (the edge) is also the one that makes a wrong-direction
|
||||
jump visible, so moving off the edge fixed one blind spot and opened the other,
|
||||
and each needs its own question.
|
||||
|
||||
### a_formula_row_click_ends_with_what_enter_does_on_that_row
|
||||
|
||||
improvise-km71, replacing `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does`.
|
||||
The old guard asserted that `Enter` was unbound, which tied the click to the
|
||||
keyboard only by the message in its `assert!`. This one resolves `Enter` at
|
||||
cursor 1 and asserts that the click on row 1, made with the cursor at 0, ends
|
||||
with the same commands. Rival: `click_formula_row` activates at
|
||||
`state.view.formula_cursor` instead of the clicked index. This test and
|
||||
`a_formula_row_click_navigates_and_opens_the_editor_on_that_row` both fail. The
|
||||
cursor and the index differ in the fixture, which is what lets them fail.
|
||||
|
||||
### renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host
|
||||
|
||||
improvise-km71. `editing_a_formula_from_the_panel_agrees_with_the_host` alone
|
||||
cannot separate a browser that composes `ReplaceFormula` from one that composes
|
||||
`AddFormula`: the upsert lands a same-target edit in place on the twin too.
|
||||
Rival: `CommitKind::of` ignores the slot. Both lockstep tests fail. The edit
|
||||
test fails only on the status line ("Formula added" against "Formula
|
||||
updated"). This test fails on the **document**, which is the property. It also
|
||||
pins a real divergence: on a rename the host says what was renamed and the
|
||||
client cannot, because `FormulaId` is opaque and it does not know the old
|
||||
target.
|
||||
|
||||
**improvise-w7qd, and the floor was the hole.** As first written the test
|
||||
covered `move-selection` and a four-entry jump table, behind `pressed >= 14`.
|
||||
Two things passed the whole workspace, both found in re-review of #180 at
|
||||
@@ -4976,11 +4989,50 @@ sends no write, and a touched one does". It wraps `Worker.prototype.postMessage`
|
||||
opens the editor on cell 4, and presses `Enter`. No `SetCell` may be posted.
|
||||
Then it types `1` and presses `Enter`, and exactly one must be.
|
||||
|
||||
**Known gap, filed as improvise-ugj4.** The `<input>` is still seeded from the
|
||||
painted `editText`, while `EditTarget` captures the cache. A paint made stale
|
||||
while another editor was open (`repaint` returns early while `editing` is set)
|
||||
therefore shows old text against a newer seed and version.
|
||||
**improvise-jibp** is the other residue: the mouse editor has no `touched` flag.
|
||||
Both gaps this entry used to list are closed: the stale-paint seed
|
||||
(improvise-ugj4) and the missing `touched` flag (improvise-jibp). See the
|
||||
entries that follow.
|
||||
|
||||
### `a_mouse_target_marked_edited_writes_its_own_seed`
|
||||
|
||||
**improvise-jibp**, the Rust half. A target on the seed `"1200"` commits
|
||||
`"1200"` and composes nothing: that is the control. `edited()` must leave the
|
||||
seed where it was, and the same text committed through the edited target must
|
||||
compose a write. **Rival:** `edited()` leaving `touched` false fails this test
|
||||
and `the_page_opens_its_editor_on_what_the_target_captured`, and nothing else.
|
||||
|
||||
### `the_page_opens_its_editor_on_what_the_target_captured`
|
||||
|
||||
**improvise-ugj4 and improvise-jibp**, through the wasm-bindgen surface the
|
||||
page calls. The cell is drawn `"1,200.50"` and stored `"1200.5"`, and
|
||||
`edit_seed` must return `"1200.5"`. A projected write then moves the cell to
|
||||
`"777"`. A target taken afterwards seeds `"777"`, and the first target still
|
||||
seeds `"1200.5"`, because a target is one capture. `typed_cell(later, "777")`
|
||||
composes nothing, and the same text through `edited_target(later)` composes a
|
||||
write. **Rival:** as above, `edited()` leaving `touched` false fails here. I
|
||||
wrote no rival for an `edit_seed` that re-reads the cache. `EditTarget` names a
|
||||
record, not a position, so that rival would need a lookup the client does not
|
||||
have.
|
||||
|
||||
### a mouse editor typed back to its own seed still writes — `harness/tests/contract.spec.js`
|
||||
|
||||
**improvise-jibp**, the page half. The editor opens on cell 4, and the test
|
||||
types the seed over the selection and presses Enter. Exactly one `SetCell` must
|
||||
be posted. Before the fix the page sent `typed_cell(target, seed)` with an
|
||||
untouched target, which composes nothing. **Rival:** delete the page's `input`
|
||||
listener. This test fails, and the rest of `contract.spec.js` passes (16).
|
||||
|
||||
### an editor opened after an unpainted write shows and commits what it captured — `harness/tests/contract.spec.js`
|
||||
|
||||
**improvise-ugj4.** The test opens an editor on cell 0, so `repaint` stops.
|
||||
While it is open, it delivers a synthetic frame that moves cell 4 to `777`. It
|
||||
builds that frame from the last `Cells` entry the page actually received for
|
||||
cell 4, changing only `text`, `edit_text` and `value`, so the record and the
|
||||
version are real. Then it opens cell 4. The `<input>` must show `777`, and
|
||||
Enter with no edit must post no `SetCell`. **Rival:** seed the `<input>` from the
|
||||
paint (`drawn.replaceAll(",", "")`). This test fails on the value, and so does
|
||||
"an untouched mouse editor sends no write, and a touched one does", because the
|
||||
drawn text is not the seed. The other 15 pass.
|
||||
|
||||
### `a_browser_editor_typed_back_to_its_seed_still_writes`
|
||||
|
||||
@@ -5046,3 +5098,22 @@ on row 1. The test presses `i`, types `9`, and asserts the first editor really
|
||||
is touched. Enter must then land on (2, 0), and the folded `opened_editor` must
|
||||
be exactly `OpenedEditor::on("800")`. **Injection:** `resolve::advanced`
|
||||
carrying the previous editor's `touched` flag. This test fails alone.
|
||||
|
||||
### an_edit_opened_before_a_document_swap_is_refused_in_the_new_document
|
||||
|
||||
improvise-vndi, through a real `Host` over `MemoryFs`. A opens an inline editor
|
||||
on North/Revenue, which shows 1200. B sends `LoadModel` for a document that
|
||||
differs only in that cell, which holds 5555. Every name, the view included, is
|
||||
kept, per gotcha 39's fixture warning: a rename would relay a `SwitchView` and
|
||||
hide the bug. A folds the rebase `Snapshot` with the editor still open, as
|
||||
`web/app.js` does, and then commits "42". The `EditTarget` held `{cell,
|
||||
version}` and `claim_as_read` paired that version with the *current*
|
||||
generation. `RecordHash` is coordinate-derived, so the same record resolved
|
||||
in the new document at a matching counter, and the host **accepted** the
|
||||
commit. It failed at the `DocumentReplaced` assertion: the answer was
|
||||
`Accepted`, and 5555 had become 42.
|
||||
|
||||
Rival injected: `claim_as_read` ignoring the captured generation and claiming
|
||||
the current one. That is the pre-fix behaviour, restated after the fix. Only
|
||||
this test failed in the whole workspace. The layout tier staying current is
|
||||
still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`.
|
||||
|
||||
@@ -205,6 +205,28 @@ Row, swapping the axes and rotating Row→Column land on the same view, and an
|
||||
equivalence test that cannot distinguish the wrong effect from the right one
|
||||
proves nothing (`doctrine(check_the_checker)`).
|
||||
|
||||
**The fixture needs cells and multi-entry view maps too (improvise-djcn).** The
|
||||
test compares `format!("{:?}", workbook)` across **two** `App`s. It passed only
|
||||
because the fixture had no cells and every view map had at most one entry, and a
|
||||
`HashMap` of one entry has only one order. The fixture now carries:
|
||||
- three cells;
|
||||
- two page selections;
|
||||
- two categories with hidden items, one of them with two;
|
||||
- two categories with collapsed groups.
|
||||
|
||||
That fixture was red in 3 of 3 runs on the old `Debug` impls: the by-key and
|
||||
by-command fingerprints differed only in hash order. It was green after the fix.
|
||||
|
||||
Each half of the fix was checked by restoring the derived `Debug` on its own:
|
||||
- `View`'s derived `Debug` alone: 3 of 3 runs red.
|
||||
- `DataStore` and `SymbolTable`'s derived `Debug` alone: 3 of 3 runs red.
|
||||
|
||||
So neither half is carried by the other. The exhaustive destructure in `Model`'s
|
||||
`Debug` has no test of its own, because it is a compile-time guard. It was
|
||||
checked by adding a `probe_field` to `Model` and to `Model::new`. The build then
|
||||
failed with `E0027: pattern does not mention field probe_field` at the `Debug`
|
||||
impl, and nowhere else.
|
||||
|
||||
### a_stale_wire_model_command_cannot_be_applied
|
||||
|
||||
A wire `Command::Model` cannot reach the document without its basis being checked
|
||||
@@ -5655,6 +5677,46 @@ across two independently built ones. If a future refactor made the fixture
|
||||
share one map, or share one `RandomState`, the second assertion fails. Without
|
||||
this test the law would then pass everything and look healthy.
|
||||
|
||||
### changing_the_target_renames_the_formula_in_its_own_place
|
||||
|
||||
improvise-km71. This is the test that separates "edit" from "add". The rival
|
||||
was the first build: `commit-buffer formula` was resolved in `parse_commit_kind`,
|
||||
where the registry's parse-first rule never reaches the mode, so every edit
|
||||
became an `AddFormula`. `committing_an_edit_replaces_the_formula_in_place`
|
||||
**passed** under that rival, because `add_formula` upserts by
|
||||
`(target, target_category)` and an edit that keeps its target lands in the same
|
||||
slot. Injected on purpose (the commit emits `AddFormula` for the replace kind):
|
||||
this test, the rename-status test, the collision test, the parse-error test and
|
||||
both concurrency-refusal tests fail, and the in-place test passes. A rename is the input the upsert cannot fake. The fixture has three
|
||||
formulas with the cursor on index 1, so "the one under the cursor" is not also
|
||||
"the first" or "the last".
|
||||
|
||||
### an_edit_lands_on_its_formula_after_an_earlier_one_is_deleted_meanwhile
|
||||
|
||||
improvise-km71. The capture is made when the editor opens and never re-read
|
||||
from the cursor. Rival: `ReplaceFormula` resolves its formula from
|
||||
`formula_cursor` at commit. The fixture deletes `Profit` (index 0) while the
|
||||
editor is open on `Tax` (index 1), so cursor 1 now names `Margin`. The rival
|
||||
rewrote `Margin`, and this test fails, along with
|
||||
`an_edit_whose_formula_was_deleted_meanwhile_refuses_out_loud` and
|
||||
`an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud`. The other nine
|
||||
formula-edit tests pass under it, because none of them moves the list.
|
||||
|
||||
### an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud
|
||||
|
||||
improvise-km71. Another client rewrote `Tax`'s text but kept its target, so the
|
||||
id still resolves. Only the `was` check can see this. Rival: the `was` filter
|
||||
deleted. This test is the only one that fails. That is the case for keeping
|
||||
`was` after improvise-sboz gives formulas a stable id: a concurrent replace
|
||||
keeps the id.
|
||||
|
||||
### a_rename_onto_another_formulas_target_refuses_and_keeps_both
|
||||
|
||||
improvise-km71. Rival: `Model::replace_formula`'s collision check disabled.
|
||||
This test and `replace_formula_refuses_to_displace_another_formula` fail.
|
||||
Without the check, renaming `Tax` to `Margin` leaves two formulas with one
|
||||
identity, and `formula(&id)` then finds the first.
|
||||
|
||||
### a_fresh_session_has_no_history_to_step_back_through
|
||||
|
||||
improvise-lxg0. `project.rs::fresh_view` built a new session's slice by folding
|
||||
@@ -5800,8 +5862,10 @@ client sends `SetCell` (then, separately, `ClearCell`) for `{Month=Q2, _Measure=
|
||||
with a caught-up basis. At `ea119bd9` both were answered `Accepted`, and the
|
||||
`SetCell` stored `Month=Q2, _Measure=Cost = 7`, the non-item coordinate
|
||||
improvise-gw18 exists to prevent. The test asserts `Rejected(MutationRefused)`
|
||||
and an unchanged document for both. Rival: the aggregate refusal disabled. This
|
||||
test and the None-axis test fail. The control is
|
||||
and an unchanged document for both. Rival: the aggregate refusal disabled. At
|
||||
`46e75c41` that failed this test and the None-axis test. Since improvise-b114 a
|
||||
fold is refused first by the group rule, so this rival fails only the None-axis
|
||||
test. Disabling both rules fails this test. The control is
|
||||
`a_wire_write_to_a_stored_cell_beside_a_fold_is_accepted`, so a host that refused
|
||||
every wire write cannot pass.
|
||||
|
||||
@@ -5844,6 +5908,39 @@ that the target projects as `Record`, so a fixture where the first column is
|
||||
harmless cannot pass. The fix looks the cell up by its whole key. The rival is
|
||||
the old lookup by record, which is this test's red commit, `6e021699`.
|
||||
|
||||
### a_wire_write_naming_a_measure_group_is_rejected_in_any_view
|
||||
|
||||
improvise-b114, found in the review of PR #217 at `bc060f67` (the reviewer's r3).
|
||||
`_Measure` is on Column, Revenue and Cost are grouped as `Money`, and `Units` is
|
||||
ungrouped. Every month is funded. A caught-up `SetCell` on
|
||||
`{Month=Feb, _Measure=Money}` was answered `Accepted` and stored
|
||||
`Month=Feb, _Measure=Money = 7`, a non-item coordinate. Its record `{Month=Feb}`
|
||||
is stored, so the stored arm accepted it without asking the projection. The test
|
||||
runs with `Money` collapsed, where the host draws that cell as the fold, and
|
||||
expanded, where the host draws no such cell. It asserts `MutationRefused` and an
|
||||
unchanged document in both, so a rule that asks the host's layout cannot pass
|
||||
the expanded half. Red at `5eaafbae`. The fix is `App::group_refusal`, which asks
|
||||
the document whether any coordinate names a group rather than an item. Rival:
|
||||
the group rule removed. Injected into a full workspace run, it fails this test
|
||||
and nothing else.
|
||||
|
||||
### a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected
|
||||
|
||||
improvise-xur5, found in the delta re-review of PR #217 at `6026768c`. It is the
|
||||
reviewer's `review_217b::probe_fold_hidden_measure`, widened to all three rows
|
||||
of the reviewer's table. Q2 is collapsed and every month is stored, so the
|
||||
record `{Month=Q2}` is unstored and resolves only through the layout, at the
|
||||
fold row. A caught-up write names that record with a measure the host draws
|
||||
nowhere: `Cost` hidden in the host's view, `Bogus`, which is no measure at all,
|
||||
or no measure. At `6026768c` all three were **accepted** and stored `Month=Q2`,
|
||||
a group name, as a coordinate. The sjdb fix finds the position by the whole key,
|
||||
and "not drawn" read as "not refused". Falling back to the by-record lookup for
|
||||
an undrawn key would close this, but the reviewer showed it wrongly refuses
|
||||
Feb × Revenue beside a collapsed `Money` group, so it is not the fix. The
|
||||
improvise-b114 rule is: no coordinate names a group, asked of the document.
|
||||
Rival: the group rule removed. Injected into a full workspace run, it fails this
|
||||
test and the b114 test, and nothing else.
|
||||
|
||||
### a_stored_record_the_hosts_view_aggregates_is_writable
|
||||
|
||||
improvise-sjdb, from the #217 review. South/Revenue is stored, then `Product` is
|
||||
@@ -5930,6 +6027,16 @@ host's `Normal`, and nothing else different. `install_workbook` needs no
|
||||
change of its own, because both of its callers (`LoadModel`,
|
||||
`AcceptWizardModel`) run inside `apply_effects`, which settles after the batch.
|
||||
|
||||
### renaming_a_formula_outside_measure_refuses_and_keeps_the_editor
|
||||
|
||||
improvise-kb1d, found by the pre-merge review of #214 and by ChatGPT Codex (P1).
|
||||
The fixture has a Region-targeted formula at index 1, between two `_Measure`
|
||||
formulas, because every other km71 test used `_Measure`, whose items are
|
||||
derived from the formula list and cannot show a stranded item. Rival: the guard
|
||||
in `Model::replace_formula` disabled. This test fails on the formula list, and
|
||||
`replace_formula_refuses_to_rename_a_target_outside_measure` in core fails too.
|
||||
|
||||
|
||||
### `an_untouched_pivot_edit_of_a_misread_value_writes_nothing`
|
||||
### `an_untouched_records_edit_of_a_misread_value_writes_nothing`
|
||||
|
||||
|
||||
@@ -426,38 +426,69 @@ generator-driven proptests in `improvise-io`. What *those* cannot see is a
|
||||
checked-in document drifting out of the format, because they generate their
|
||||
inputs. This test is aimed at exactly that gap.
|
||||
|
||||
**It is deliberately a directory walk rather than a list of names**: a list is a
|
||||
**It walks the files the repository tracks, not a list of names**: a list is a
|
||||
second place to remember, and the file that gets forgotten is the one nobody
|
||||
added to it. The scope is the directories `.gitignore` un-ignores `*.improv`
|
||||
in. Everything else matching `*.improv` is somebody's working file.
|
||||
added to it. "Shipped" means "tracked", and `shipped_documents` reads it off
|
||||
the index: every `*.improv` and `*.improv.gz` path that `ls-files` lists. It does
|
||||
not include untracked files, so a working `.improv.gz` in someone's tree cannot
|
||||
fail their run. A document not yet added is walked once it is, which is the
|
||||
state CI tests.
|
||||
|
||||
**The directory list is read from `.gitignore`, not written in the test
|
||||
(improvise-qlrh).** The test used to hard-code `["examples", "web"]`, a list of
|
||||
exactly the kind the paragraph above warns against. When improvise-plcx
|
||||
un-ignored `harness/fixtures/*.improv`, nothing added that directory to the
|
||||
walk. Its two fixtures were then loaded only by the Playwright job, where a
|
||||
missing seed degrades to an empty model and surfaces as a missing-label
|
||||
failure, not a load error. `shipped_directories` now takes every
|
||||
`!<dir>/*.improv` line, so un-ignoring a directory is what enlists it.
|
||||
**How the walk got here: two steps, each closing the previous one's holes.**
|
||||
|
||||
The precondition is per directory: each derived directory must yield at least
|
||||
one document. The old whole-walk `>= 2` could not see a directory silently lost.
|
||||
- **improvise-qlrh.** The test hard-coded `["examples", "web"]`. When
|
||||
improvise-plcx un-ignored `harness/fixtures/*.improv`, nothing added that
|
||||
directory. Its two fixtures were then loaded only by the Playwright job,
|
||||
where a missing seed degrades to an empty model and fails as a missing label,
|
||||
not a load error. The fix read `!<dir>/*.improv` lines from the root
|
||||
`.gitignore`, with a per-directory precondition. Red at `705c92d`:
|
||||
`harness/fixtures/tall.improv` replaced with `{"legacy": true}` passed the old
|
||||
test.
|
||||
- **improvise-c0aj.** That `.gitignore` reading was a parser for one shape of
|
||||
one file, so four other ways of shipping a document were missed silently:
|
||||
- a single-file un-ignore (`!web/demo.improv`);
|
||||
- a `.improv.gz` anywhere, since `*.improv` never ignores it and so no
|
||||
un-ignore line names it;
|
||||
- a `!` line in a nested `.gitignore`;
|
||||
- a force-added (`add -f`) document.
|
||||
|
||||
**Red, measured at `705c92d`:** `harness/fixtures/tall.improv` was replaced with
|
||||
`{"legacy": true}`, and the old test still passed. **Rivals, each run against
|
||||
the new test:**
|
||||
Each now has its own test, which builds the shape in a scratch repository
|
||||
and asserts the walk lists the document. **All four were red** against the
|
||||
`.gitignore` walk, each failing at its `walked` assertion with its setup
|
||||
commands succeeding. Walking the index closes all four by construction and
|
||||
deletes the parser, because git has already answered what the parser
|
||||
approximated.
|
||||
|
||||
- **That same broken fixture.** Fails with "…/harness/fixtures/tall.improv does
|
||||
not load".
|
||||
- **A walk that drops every `harness` path.** Fails the per-directory
|
||||
precondition, naming `harness/fixtures`.
|
||||
- **A derivation that matches no `.gitignore` line.** Fails "…must un-ignore at
|
||||
least one *.improv directory".
|
||||
**The one new failure mode, answered: no index.** `ls-files` needs a checkout.
|
||||
In CI (`actions/checkout`) and every worktree it has one. No nix build or
|
||||
packaging step runs this test; the flake is a dev shell only. Where there is no
|
||||
index, the walk must not answer "nothing", because an empty walk passes every
|
||||
per-document assertion vacuously. So it asserts `ls-files` succeeded, and
|
||||
`a_directory_that_is_not_a_checkout_fails_loudly` pins that. The whole-walk
|
||||
precondition "at least one document" stays as the second line of defence.
|
||||
|
||||
**It matches on the full name, not on `Path::extension()`**: `.improv.gz` is the
|
||||
same document gzipped and `extension()` calls it `gz`, so matching on the
|
||||
extension alone would skip it and put back the very hole this test exists to
|
||||
close.
|
||||
**Rivals, each run against the new walk:**
|
||||
|
||||
- **The `.improv.gz` pathspec removed.** Fails `a_gzipped_document_is_walked`,
|
||||
and nothing else.
|
||||
- **A failed `ls-files` answered with an empty list instead of a panic.** Fails
|
||||
`a_directory_that_is_not_a_checkout_fails_loudly`.
|
||||
- **`harness/fixtures/tall.improv` replaced with `{"legacy": true}`.** Fails
|
||||
`every_shipped_document_loads`, naming the file.
|
||||
|
||||
**It matches on the full name, not on `Path::extension()`.** `.improv.gz` is the
|
||||
same document gzipped, and `extension()` calls it `gz`. The pathspec lists both
|
||||
suffixes for the same reason.
|
||||
|
||||
### a_single_file_un_ignore_is_walked
|
||||
|
||||
improvise-c0aj. This test is the first of four shape tests. Its siblings are
|
||||
`a_gzipped_document_is_walked`,
|
||||
`a_document_un_ignored_by_a_nested_gitignore_is_walked` and
|
||||
`a_force_added_document_is_walked`. Each builds one way of shipping a document
|
||||
in a scratch repository and asserts the walk lists it. All four were red
|
||||
against the `.gitignore` walk. The account, including the rivals, is under
|
||||
`every_shipped_document_loads` above.
|
||||
|
||||
---
|
||||
|
||||
@@ -892,3 +923,26 @@ exactly, and first asserts that hint is non-empty and pages.
|
||||
the protocol keymap). The test then fails: the copy still advertises `n`,
|
||||
which the keymap no longer binds. That rebinding case is exactly the one the
|
||||
ticket said the old footer would lie through.
|
||||
|
||||
### a_status_message_gives_way_to_the_hint_on_the_next_key
|
||||
|
||||
**What broke (improvise-cq3d).** Nothing in production ever cleared
|
||||
`status_msg`, and `draw_status` prints it instead of the derived hint. After the
|
||||
first message of a session, the TUI never drew the key hint again, in any mode.
|
||||
|
||||
**The case, in three steps, each guarding one direction.**
|
||||
1. A standing "Saved to x.improv", then `T`: in TileSelect, a mode other than
|
||||
the one the message was written in, the row must hold TileSelect's hint and
|
||||
not the stale message. This is the ticket's assertion.
|
||||
2. `Space` cycles an axis and writes a message: that message must be on the row
|
||||
after its own key. This guards against expiring too eagerly.
|
||||
3. `Esc` back to Normal: the axis message is gone and Normal's hint is drawn.
|
||||
|
||||
It drives keys through `draw.rs::press`, the function `run_tui` calls, rather
|
||||
than `App::handle_key`, because the expiry is the front end's rule.
|
||||
|
||||
**Red, and the rival.**
|
||||
- With `press` delegating straight to `handle_key` (the old behaviour), it fails
|
||||
at step 1: `" TILES Saved to x.improv …"`.
|
||||
- With the expiry moved *after* dispatch, it fails at step 2: "the axis cycle
|
||||
must say what it did", since every message is wiped before it can be drawn.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::formula::FormulaId;
|
||||
use crate::model::category::MEASURE_CATEGORY;
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::protocol::FormulaSlot;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
use crate::view::RecordCol;
|
||||
@@ -1042,6 +1044,12 @@ impl Cmd for CommitAndAdvance {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CommitKind {
|
||||
Formula,
|
||||
/// Edit the formula the editor was opened on. Never parsed: `execute` reads
|
||||
/// it off the mode's slot, the capture made at open (improvise-km71).
|
||||
ReplaceFormula {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
},
|
||||
Category,
|
||||
/// Add an item to this category, resolved from [`AppMode::ItemAdd`] at
|
||||
/// construction so `execute` stays pure over the kind and its buffer.
|
||||
@@ -1065,10 +1073,28 @@ pub fn parse_commit_kind(arg: &str, mode: &AppMode) -> Result<CommitKind, String
|
||||
}
|
||||
|
||||
impl CommitKind {
|
||||
/// A formula commit inside an editor opened on an existing formula edits
|
||||
/// that one. Read in `execute`, never at parse — `context/code/command.md`.
|
||||
fn in_mode(&self, mode: &AppMode) -> CommitKind {
|
||||
match (self, mode) {
|
||||
(
|
||||
Self::Formula,
|
||||
AppMode::FormulaEdit {
|
||||
slot: FormulaSlot::Existing { id, was },
|
||||
..
|
||||
},
|
||||
) => Self::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
},
|
||||
(kind, _) => kind.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The minibuffer this kind consumes.
|
||||
pub fn buffer(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Formula => "formula",
|
||||
Self::Formula | Self::ReplaceFormula { .. } => "formula",
|
||||
Self::Category => "category",
|
||||
Self::Item { .. } => "item",
|
||||
}
|
||||
@@ -1078,7 +1104,7 @@ impl CommitKind {
|
||||
/// minibuffer.
|
||||
fn on_empty(&self) -> AppMode {
|
||||
match self {
|
||||
Self::Formula => AppMode::FormulaPanel,
|
||||
Self::Formula | Self::ReplaceFormula { .. } => AppMode::FormulaPanel,
|
||||
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
|
||||
}
|
||||
}
|
||||
@@ -1087,7 +1113,7 @@ impl CommitKind {
|
||||
/// categories and items are entered in batches, formulas are not.
|
||||
fn on_commit(&self) -> Option<AppMode> {
|
||||
match self {
|
||||
Self::Formula => Some(AppMode::FormulaPanel),
|
||||
Self::Formula | Self::ReplaceFormula { .. } => Some(AppMode::FormulaPanel),
|
||||
Self::Category | Self::Item { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -1095,6 +1121,7 @@ impl CommitKind {
|
||||
fn status(&self, name: &str) -> String {
|
||||
match self {
|
||||
Self::Formula => "Formula added".to_string(),
|
||||
Self::ReplaceFormula { .. } => "Formula updated".to_string(),
|
||||
Self::Category => format!("Added category \"{name}\""),
|
||||
Self::Item { .. } => format!("Added \"{name}\""),
|
||||
}
|
||||
@@ -1108,6 +1135,11 @@ impl CommitKind {
|
||||
raw: name.to_string(),
|
||||
target_category: "_Measure".to_string(),
|
||||
}),
|
||||
Self::ReplaceFormula { id, was } => Box::new(effect::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
raw: name.to_string(),
|
||||
}),
|
||||
Self::Category => Box::new(effect::AddCategory(name.to_string())),
|
||||
Self::Item { category } => Box::new(effect::AddItem {
|
||||
category: category.clone(),
|
||||
@@ -1128,18 +1160,19 @@ impl Cmd for CommitBuffer {
|
||||
"commit-buffer"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let raw = read_buffer(ctx, self.kind.buffer());
|
||||
let kind = self.kind.in_mode(ctx.mode);
|
||||
let raw = read_buffer(ctx, kind.buffer());
|
||||
let name = raw.trim();
|
||||
if name.is_empty() {
|
||||
return vec![effect::change_mode(self.kind.on_empty())];
|
||||
return vec![effect::change_mode(kind.on_empty())];
|
||||
}
|
||||
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||
effect::set_status(self.kind.status(name)),
|
||||
effect::set_status(kind.status(name)),
|
||||
// The add goes last: it is the only party that knows whether it worked,
|
||||
// so the optimistic status must not clobber its error (improvise-6t3).
|
||||
self.kind.add_effect(name),
|
||||
kind.add_effect(name),
|
||||
];
|
||||
effects.extend(self.kind.on_commit().map(effect::change_mode));
|
||||
effects.extend(kind.on_commit().map(effect::change_mode));
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect, Panel, ViewTarget};
|
||||
use crate::view::Axis;
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::core::{Cmd, CmdContext, write_buffer};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -685,6 +685,25 @@ impl Cmd for DeleteFormulaAtCursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the formula editor on the formula under the cursor, seeded with its
|
||||
/// text and naming it from here on — the commit never re-reads the cursor.
|
||||
#[derive(Debug)]
|
||||
pub struct EditFormulaAtCursor;
|
||||
impl Cmd for EditFormulaAtCursor {
|
||||
fn name(&self) -> &'static str {
|
||||
"edit-formula-at-cursor"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
match ctx.model.formulas().get(ctx.formula_cursor) {
|
||||
Some(f) => vec![
|
||||
effect::change_mode(AppMode::formula_replace(f.id(), f.raw.clone())),
|
||||
write_buffer("formula", f.raw.clone()),
|
||||
],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category panel commands ─────────────────────────────────────────────────
|
||||
|
||||
/// Cycle the axis assignment of the category at the cursor.
|
||||
|
||||
@@ -382,6 +382,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
None,
|
||||
);
|
||||
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
|
||||
r.register_nullary(|| Box::new(EditFormulaAtCursor));
|
||||
r.register_nullary(|| Box::new(AddRecordRow));
|
||||
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||
|
||||
@@ -587,7 +587,20 @@ impl App {
|
||||
f.target
|
||||
)
|
||||
});
|
||||
derived.or_else(|| self.aggregate_refusal(cell.record, &key))
|
||||
let refusal = self.group_refusal(&key).or(derived);
|
||||
refusal.or_else(|| self.aggregate_refusal(cell.record, &key))
|
||||
}
|
||||
|
||||
/// A coordinate naming a group rather than an item is no cell in ANY view, so
|
||||
/// this asks the document, never the layout (improvise-b114).
|
||||
fn group_refusal(&self, key: &CellKey) -> Option<String> {
|
||||
let model = &self.model_state.workbook.model;
|
||||
key.0.iter().find_map(|(cat, name)| {
|
||||
let category = model.category(cat)?;
|
||||
let group = !category.items.contains_key(name.as_str())
|
||||
&& !category.items_in_group(name).is_empty();
|
||||
group.then(|| format!("{name} is a group of {cat}, so there is no one cell to write"))
|
||||
})
|
||||
}
|
||||
|
||||
/// A record the store does not hold is known only by its layout position, and
|
||||
@@ -4864,3 +4877,261 @@ mod basis_tiers {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editing a formula from the panel (improvise-km71), driven through
|
||||
/// `handle_key` at a NONZERO cursor over three formulas.
|
||||
#[cfg(test)]
|
||||
mod formula_edit_tests {
|
||||
use super::*;
|
||||
use crate::formula::parse_formula;
|
||||
|
||||
const PROFIT: &str = "Profit = Revenue - Cost";
|
||||
const TAX: &str = "Tax = Revenue * 0.1";
|
||||
const MARGIN: &str = "Margin = Profit / Revenue";
|
||||
|
||||
fn app_with_three_formulas() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for raw in [PROFIT, TAX, MARGIN] {
|
||||
wb.model
|
||||
.add_formula(parse_formula(raw, "_Measure").unwrap());
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
app
|
||||
}
|
||||
|
||||
fn press(app: &mut App, code: KeyCode) {
|
||||
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Clear the minibuffer by keys and type `text`, as a user would.
|
||||
fn retype(app: &mut App, text: &str) {
|
||||
let len = app.view_state.buffers.get("formula").map_or(0, String::len);
|
||||
for _ in 0..len {
|
||||
press(app, KeyCode::Backspace);
|
||||
}
|
||||
for c in text.chars() {
|
||||
press(app, KeyCode::Char(c));
|
||||
}
|
||||
}
|
||||
|
||||
fn raws(app: &App) -> Vec<String> {
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.formulas()
|
||||
.iter()
|
||||
.map(|f| f.raw.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn buffer(app: &App) -> Option<&str> {
|
||||
app.view_state.buffers.get("formula").map(String::as_str)
|
||||
}
|
||||
|
||||
fn open_editor(app: &mut App) {
|
||||
press(app, KeyCode::Enter);
|
||||
assert!(
|
||||
matches!(app.view_state.mode, AppMode::FormulaEdit { .. }),
|
||||
"Enter on a formula must open the editor, got {:?}",
|
||||
app.view_state.mode
|
||||
);
|
||||
}
|
||||
|
||||
fn in_editor(app: &App) -> bool {
|
||||
matches!(app.view_state.mode, AppMode::FormulaEdit { .. })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_opens_the_editor_seeded_with_the_formula_under_the_cursor() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
assert_eq!(buffer(&app), Some(TAX), "cursor 1 is Tax, not the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e_opens_the_same_editor() {
|
||||
let mut app = app_with_three_formulas();
|
||||
press(&mut app, KeyCode::Char('e'));
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some(TAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_an_edit_replaces_the_formula_in_place() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.2", MARGIN]);
|
||||
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
|
||||
}
|
||||
|
||||
/// The rival "add instead of replace" passes the test above, because
|
||||
/// `add_formula` replaces by target. A rename is where it cannot hide.
|
||||
#[test]
|
||||
fn changing_the_target_renames_the_formula_in_its_own_place() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Levy = Revenue * 0.1");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Levy = Revenue * 0.1", MARGIN]);
|
||||
let measures = app.model_state.workbook.model.measure_item_names();
|
||||
assert!(
|
||||
!measures.contains(&"Tax".to_string()),
|
||||
"Tax was a formula target only, so it goes: {measures:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_says_which_formulas_still_name_the_old_target() {
|
||||
let mut app = app_with_three_formulas();
|
||||
app.view_state.formula_cursor = 0;
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Gross = Revenue - Cost");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app)[0], "Gross = Revenue - Cost");
|
||||
let status = &app.view_state.status_msg;
|
||||
assert!(
|
||||
status.contains("Profit") && status.contains("Margin"),
|
||||
"Margin still refers to Profit, and the user must be told: {status:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_onto_another_formulas_target_refuses_and_keeps_both() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Margin = Revenue * 0.1");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert!(app.view_state.status_msg.contains("Margin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parse_error_keeps_the_editor_the_text_and_the_formula() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = = ");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Tax = = "));
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("Formula error"),
|
||||
"{:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// The editor names its formula when it OPENS. Deleting an earlier one
|
||||
/// moves the edited formula to index 0 and puts `Margin` under cursor 1.
|
||||
#[test]
|
||||
fn an_edit_lands_on_its_formula_after_an_earlier_one_is_deleted_meanwhile() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let model = &mut app.model_state.workbook.model;
|
||||
model.remove_formula("Profit", "_Measure");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec!["Tax = Revenue * 0.2", MARGIN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_whose_formula_was_deleted_meanwhile_refuses_out_loud() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let model = &mut app.model_state.workbook.model;
|
||||
model.remove_formula("Tax", "_Measure");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, MARGIN], "nothing re-added");
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Tax = Revenue * 0.2"));
|
||||
assert!(app.last_batch_refused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_whose_formula_was_changed_meanwhile_refuses_out_loud() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = Revenue * 0.2");
|
||||
let theirs = parse_formula("Tax = Revenue * 0.3", "_Measure").unwrap();
|
||||
app.model_state.workbook.model.add_formula(theirs);
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Tax = Revenue * 0.3", MARGIN]);
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert!(app.last_batch_refused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_leaves_the_formula_untouched() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Tax = 0");
|
||||
press(&mut app, KeyCode::Esc);
|
||||
assert_eq!(raws(&app), vec![PROFIT, TAX, MARGIN]);
|
||||
assert_eq!(app.view_state.mode, AppMode::FormulaPanel);
|
||||
}
|
||||
|
||||
/// `n` still adds: the replace slot must not leak into a fresh editor.
|
||||
#[test]
|
||||
fn a_new_formula_after_an_edit_is_added_not_replaced() {
|
||||
let mut app = app_with_three_formulas();
|
||||
open_editor(&mut app);
|
||||
press(&mut app, KeyCode::Esc);
|
||||
press(&mut app, KeyCode::Char('n'));
|
||||
assert_eq!(buffer(&app), Some(""), "a new formula starts empty");
|
||||
retype(&mut app, "Levy = Revenue * 0.3");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![PROFIT, TAX, MARGIN, "Levy = Revenue * 0.3"]
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-kb1d: a Region-targeted formula at index 1. Renaming it would
|
||||
/// strand `Total` as an empty item, so the edit refuses and stays open.
|
||||
#[test]
|
||||
fn renaming_a_formula_outside_measure_refuses_and_keeps_the_editor() {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
for (raw, cat) in [
|
||||
(PROFIT, "_Measure"),
|
||||
("Total = East + West", "Region"),
|
||||
(MARGIN, "_Measure"),
|
||||
] {
|
||||
wb.model.add_formula(parse_formula(raw, cat).unwrap());
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let regions = |app: &App| {
|
||||
let region = app.model_state.workbook.model.category("Region").unwrap();
|
||||
region
|
||||
.ordered_item_names()
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = regions(&app);
|
||||
open_editor(&mut app);
|
||||
retype(&mut app, "Sum = East + West");
|
||||
press(&mut app, KeyCode::Enter);
|
||||
assert_eq!(raws(&app), vec![PROFIT, "Total = East + West", MARGIN]);
|
||||
assert_eq!(regions(&app), before, "no ghost item, nothing appended");
|
||||
assert!(in_editor(&app), "{:?}", app.view_state.mode);
|
||||
assert_eq!(buffer(&app), Some("Sum = East + West"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::collections::BTreeMap;
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::formula::FormulaId;
|
||||
use crate::model::Model;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::{Axis, RecordCol, RecordRow};
|
||||
|
||||
@@ -246,6 +248,61 @@ impl Effect for RemoveFormula {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit the formula `id` names in place. Every refusal stops the batch, so the
|
||||
/// editor stays open over the user's text — `context/code/command.md`.
|
||||
#[derive(Debug)]
|
||||
pub struct ReplaceFormula {
|
||||
pub id: FormulaId,
|
||||
pub was: String,
|
||||
pub raw: String,
|
||||
}
|
||||
impl Effect for ReplaceFormula {
|
||||
fn apply(&self, app: &mut App) {
|
||||
match self.replace(&mut app.model_state.workbook.model) {
|
||||
Ok(Some(status)) => app.view_state.status_msg = status,
|
||||
Ok(None) => {}
|
||||
Err(why) => refuse_and_stop(app, why),
|
||||
}
|
||||
}
|
||||
fn kind(&self) -> EffectKind {
|
||||
EffectKind::Model(ModelChange::CellData)
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplaceFormula {
|
||||
/// The replacement, and what a rename leaves naming the old target.
|
||||
fn replace(&self, model: &mut Model) -> Result<Option<String>, String> {
|
||||
let old = model
|
||||
.formula(&self.id)
|
||||
.filter(|f| f.raw == self.was)
|
||||
.ok_or_else(|| format!("\"{}\" was changed or removed meanwhile", self.was))?;
|
||||
let old_target = old.target.clone();
|
||||
let new = crate::formula::parse_formula(&self.raw, &old.target_category)
|
||||
.map_err(|e| format!("Formula error: {e}"))?;
|
||||
let new_target = new.target.clone();
|
||||
model
|
||||
.replace_formula(&self.id, new)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((new_target != old_target).then(|| renamed(model, &old_target, &new_target)))
|
||||
}
|
||||
}
|
||||
|
||||
fn renamed(model: &Model, old: &str, new: &str) -> String {
|
||||
let still: Vec<&str> = model
|
||||
.formulas()
|
||||
.iter()
|
||||
.filter(|f| f.mentions(old))
|
||||
.map(|f| f.target.as_str())
|
||||
.collect();
|
||||
match still.is_empty() {
|
||||
true => format!("Renamed {old} to {new}"),
|
||||
false => format!(
|
||||
"Renamed {old} to {new}; {old} is still used by {}",
|
||||
still.join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the derived grid layout mid-batch: `apply_effects` rebuilds only once
|
||||
/// after the batch, so an effect that *reads* it needs this first (improvise-ehz).
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -1606,6 +1606,58 @@ mod tests {
|
||||
assert_eq!(model.get_cell(&target), Some(&CellValue::Number(7.0)));
|
||||
}
|
||||
|
||||
/// improvise-b114: a coordinate naming a group is no item, in any view — see
|
||||
/// context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_naming_a_measure_group_is_rejected_in_any_view() {
|
||||
for collapsed in [true, false] {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for month in ["Jan", "Feb"] {
|
||||
wb.add_item("Month", month).unwrap();
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
wb.add_item_in_group("_Measure", measure, "Money").unwrap();
|
||||
let key = month_cell(month, measure);
|
||||
wb.model.set_cell(key, CellValue::Number(10.0));
|
||||
}
|
||||
}
|
||||
wb.add_item("_Measure", "Units").unwrap();
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
if collapsed {
|
||||
wb.active_view_mut()
|
||||
.toggle_group_collapse("_Measure", "Money");
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
let mut host = Host::new(app);
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &month_cell("Feb", "Money"), false);
|
||||
assert!(refused(&answer), "collapsed={collapsed}: {answer:?}");
|
||||
let after = document_text(&host);
|
||||
assert_eq!(after, before, "collapsed={collapsed}: a group was stored");
|
||||
}
|
||||
}
|
||||
|
||||
/// improvise-xur5 (the reviewer's `probe_fold_hidden_measure`): a fold's record
|
||||
/// with a measure the host draws nowhere — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn a_wire_write_to_a_fold_record_with_an_undrawn_measure_is_rejected() {
|
||||
let hidden = month_cell("Q2", "Cost");
|
||||
let bogus = month_cell("Q2", "Bogus");
|
||||
let bare = CellKey::new(vec![("Month".to_string(), "Q2".to_string())]);
|
||||
for key in [hidden, bogus, bare] {
|
||||
let mut host = host_on_a_fold();
|
||||
let view = host.app.model_state.workbook.active_view_mut();
|
||||
view.hide_item("_Measure", "Cost");
|
||||
host.app.rebuild_layout();
|
||||
let before = document_text(&host);
|
||||
let answer = wire_write(&mut host, &key, false);
|
||||
assert!(refused(&answer), "{key:?} accepted: {answer:?}");
|
||||
assert_eq!(document_text(&host), before, "{key:?} stored a group");
|
||||
}
|
||||
}
|
||||
|
||||
/// A STORED record is writable whatever the host's view does to it — see
|
||||
/// context/test-evidence/command.md (improvise-sjdb).
|
||||
#[test]
|
||||
|
||||
@@ -15,9 +15,9 @@ use improvise_io::import::analyzer::{DateComponent, FieldKind};
|
||||
use improvise_io::import::wizard::{ImportWizard, WizardStep};
|
||||
use improvise_protocol::{
|
||||
AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay,
|
||||
CellId, Command, CreateKind, DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache,
|
||||
GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache, TileEntry,
|
||||
ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
CellId, Command, CreateKind, DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup,
|
||||
GridCache, GridExtent, GridUpdate, Projection, RecordCell, Rejection, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewState, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
WizardFieldKind, WizardProposal, WizardStepKind, basis, reduce_view,
|
||||
};
|
||||
|
||||
@@ -375,14 +375,15 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The raw text of every formula, which is all the formula panel draws.
|
||||
pub fn formula_texts(app: &App) -> Vec<String> {
|
||||
/// Every formula's text, which the panel draws, and its identity, which an
|
||||
/// edit names it by (improvise-km71).
|
||||
pub fn formula_entries(app: &App) -> Vec<FormulaEntry> {
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.formulas()
|
||||
.iter()
|
||||
.map(|f| f.raw.clone())
|
||||
.map(FormulaEntry::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -439,10 +440,10 @@ pub fn category_tree_in(app: &App, view: &View) -> CategoryTreeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula panel's cache: the raw text of every formula.
|
||||
/// The formula panel's cache: every formula's text and identity.
|
||||
pub fn formulas_of(app: &App) -> FormulaCache {
|
||||
FormulaCache {
|
||||
formulas: formula_texts(app),
|
||||
formulas: formula_entries(app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1826,8 +1827,13 @@ mod tests {
|
||||
client.fold(&projections);
|
||||
}
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
vec!["Total = Revenue".to_string()],
|
||||
client
|
||||
.formulas
|
||||
.formulas
|
||||
.iter()
|
||||
.map(|f| f.raw.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Total = Revenue"],
|
||||
"the client must be holding the formula before it can lose it"
|
||||
);
|
||||
|
||||
@@ -1841,7 +1847,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
Vec::<String>::new(),
|
||||
Vec::<FormulaEntry>::new(),
|
||||
"the client is still showing a formula the document no longer has"
|
||||
);
|
||||
}
|
||||
@@ -1866,8 +1872,13 @@ mod tests {
|
||||
let mut client = ClientCaches::default();
|
||||
client.fold(&snapshot.seed);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
vec!["Total = Revenue".to_string()],
|
||||
client
|
||||
.formulas
|
||||
.formulas
|
||||
.iter()
|
||||
.map(|f| f.raw.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Total = Revenue"],
|
||||
"the client must be holding the formula before it can lose it"
|
||||
);
|
||||
|
||||
@@ -1890,7 +1901,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
client.formulas.formulas,
|
||||
Vec::<String>::new(),
|
||||
Vec::<FormulaEntry>::new(),
|
||||
"the client is still showing a formula the document no longer has"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec<Box<dyn Effect>> {
|
||||
target: target.clone(),
|
||||
target_category: target_category.clone(),
|
||||
}),
|
||||
ModelCommand::ReplaceFormula { id, was, raw } => Box::new(effect::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
raw: raw.clone(),
|
||||
}),
|
||||
ModelCommand::CleanEmptyRecords => Box::new(effect::CleanEmptyRecords),
|
||||
ModelCommand::SortData => Box::new(effect::SortData),
|
||||
|
||||
@@ -687,6 +692,13 @@ pub(crate) mod tests {
|
||||
target: "Profit".into(),
|
||||
target_category: "_Measure".into(),
|
||||
},
|
||||
ModelCommand::ReplaceFormula {
|
||||
id: crate::formula::parse_formula("Profit = Revenue", "_Measure")
|
||||
.unwrap()
|
||||
.id(),
|
||||
was: "Profit = Revenue".into(),
|
||||
raw: "Profit = Revenue - Cost".into(),
|
||||
},
|
||||
ModelCommand::CleanEmptyRecords,
|
||||
ModelCommand::SortData,
|
||||
ModelCommand::AddCategory("Product".into()),
|
||||
@@ -751,6 +763,7 @@ pub(crate) mod tests {
|
||||
ModelCommand::RemoveItem { .. } => "RemoveItem",
|
||||
ModelCommand::AddFormula { .. } => "AddFormula",
|
||||
ModelCommand::RemoveFormula { .. } => "RemoveFormula",
|
||||
ModelCommand::ReplaceFormula { .. } => "ReplaceFormula",
|
||||
ModelCommand::CleanEmptyRecords => "CleanEmptyRecords",
|
||||
ModelCommand::SortData => "SortData",
|
||||
ModelCommand::AddCategory(_) => "AddCategory",
|
||||
@@ -918,15 +931,47 @@ pub(crate) mod tests {
|
||||
/// to the same mutation lands, `Dirty` included.
|
||||
#[test]
|
||||
fn a_model_command_lands_where_its_keypress_lands() {
|
||||
/// Two categories on different axes: a one-category fixture cannot tell
|
||||
/// a transpose from a cycle (`doctrine(check_the_checker)`).
|
||||
/// Two categories on two axes, plus cells and 2+-entry view maps: why each
|
||||
/// is needed is in `context/test-evidence/command.md` (improvise-djcn).
|
||||
fn pivoted() -> App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
let mut app = app();
|
||||
app.model_state.workbook.add_category("Month").unwrap();
|
||||
app.model_state.workbook.add_item("Month", "Jan").unwrap();
|
||||
let view = app.model_state.workbook.active_view_mut();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
let items = [
|
||||
("Region", ["South", "East", "West"]),
|
||||
("Month", ["Jan", "Feb", "Mar"]),
|
||||
("Channel", ["Web", "Store", "Phone"]),
|
||||
("Year", ["2024", "2025", "2026"]),
|
||||
];
|
||||
for (category, names) in items {
|
||||
wb.add_category(category).ok();
|
||||
names.iter().for_each(|n| drop(wb.add_item(category, *n)));
|
||||
}
|
||||
for (n, (region, month)) in [("North", "Jan"), ("South", "Feb"), ("East", "Jan")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let key = CellKey::new(vec![
|
||||
("Region".into(), region.into()),
|
||||
("Month".into(), month.into()),
|
||||
("Channel".into(), "Web".into()),
|
||||
("Year".into(), "2024".into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(n as f64 + 1.0));
|
||||
}
|
||||
let view = wb.active_view_mut();
|
||||
view.set_axis("Region", Axis::Row);
|
||||
view.set_axis("Month", Axis::Column);
|
||||
view.set_axis("Channel", Axis::Page);
|
||||
view.set_axis("Year", Axis::Page);
|
||||
view.set_page_selection("Channel", "Web");
|
||||
view.set_page_selection("Year", "2024");
|
||||
view.hide_item("Region", "West");
|
||||
view.hide_item("Region", "South");
|
||||
view.hide_item("Month", "Mar");
|
||||
view.toggle_group_collapse("Region", "Coastal");
|
||||
view.toggle_group_collapse("Region", "Inland");
|
||||
view.toggle_group_collapse("Month", "Q1");
|
||||
app.rebuild_layout();
|
||||
app
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ impl RecordEntry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DataStore {
|
||||
/// Primary storage: interned keys, insertion-ordered so records mode can
|
||||
/// show rows in entry order. That order is correctness — core.md §8.
|
||||
@@ -208,6 +208,37 @@ pub struct DataStore {
|
||||
records: HashMap<RecordHash, RecordEntry>,
|
||||
}
|
||||
|
||||
/// Resolved through the symbol table and sorted wherever the storage is a
|
||||
/// `HashMap`, so two equal stores print alike (improvise-djcn).
|
||||
impl std::fmt::Debug for DataStore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let DataStore {
|
||||
cells,
|
||||
symbols,
|
||||
index,
|
||||
records,
|
||||
} = self;
|
||||
let cells: Vec<_> = cells
|
||||
.iter()
|
||||
.map(|(key, value)| (self.to_cell_key(key), value))
|
||||
.collect();
|
||||
let index: std::collections::BTreeMap<_, std::collections::BTreeSet<_>> = index
|
||||
.iter()
|
||||
.map(|((cat, item), keys)| {
|
||||
let pair = (symbols.resolve(*cat), symbols.resolve(*item));
|
||||
(pair, keys.iter().map(|k| self.to_cell_key(k).0).collect())
|
||||
})
|
||||
.collect();
|
||||
let records: std::collections::BTreeMap<_, _> = records.iter().collect();
|
||||
f.debug_struct("DataStore")
|
||||
.field("cells", &cells)
|
||||
.field("symbols", symbols)
|
||||
.field("index", &index)
|
||||
.field("records", &records)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DataStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
|
||||
@@ -5,12 +5,22 @@ use std::collections::HashMap;
|
||||
pub struct Symbol(u64);
|
||||
|
||||
/// Bidirectional string ↔ Symbol mapping.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SymbolTable {
|
||||
to_id: HashMap<String, Symbol>,
|
||||
to_str: Vec<String>,
|
||||
}
|
||||
|
||||
/// The interned strings, sorted: which `Symbol` each got is interning order,
|
||||
/// not content, so two equal stores print alike (improvise-djcn).
|
||||
impl std::fmt::Debug for SymbolTable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let SymbolTable { to_id, to_str: _ } = self;
|
||||
let interned: std::collections::BTreeSet<&String> = to_id.keys().collect();
|
||||
f.debug_set().entries(interned).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolTable {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -5,7 +5,7 @@ use indexmap::{IndexMap, IndexSet};
|
||||
|
||||
use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY};
|
||||
use super::cell::{CellKey, CellValue, DataStore};
|
||||
use crate::formula::{AggFunc, Expr, Filter, Formula};
|
||||
use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId};
|
||||
|
||||
const MAX_CATEGORIES: usize = 12;
|
||||
|
||||
@@ -317,16 +317,25 @@ pub struct Model {
|
||||
/// cache in hash order, which is not a change (improvise-pc3b, gotcha 18).
|
||||
impl std::fmt::Debug for Model {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut aggs: Vec<_> = self.measure_agg.iter().collect();
|
||||
aggs.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let mut cache: Vec<_> = self.formula_cache.iter().collect();
|
||||
// Destructured, so a new field is a compile error here (improvise-djcn).
|
||||
let Model {
|
||||
name,
|
||||
categories,
|
||||
data,
|
||||
formulas,
|
||||
next_category_id,
|
||||
measure_agg,
|
||||
formula_cache,
|
||||
} = self;
|
||||
let aggs: std::collections::BTreeMap<_, _> = measure_agg.iter().collect();
|
||||
let mut cache: Vec<_> = formula_cache.iter().collect();
|
||||
cache.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||
f.debug_struct("Model")
|
||||
.field("name", &self.name)
|
||||
.field("categories", &self.categories)
|
||||
.field("data", &self.data)
|
||||
.field("formulas", &self.formulas)
|
||||
.field("next_category_id", &self.next_category_id)
|
||||
.field("name", name)
|
||||
.field("categories", categories)
|
||||
.field("data", data)
|
||||
.field("formulas", formulas)
|
||||
.field("next_category_id", next_category_id)
|
||||
.field("measure_agg", &aggs)
|
||||
.field("formula_cache", &cache)
|
||||
.finish()
|
||||
@@ -496,6 +505,38 @@ impl Model {
|
||||
&self.formulas
|
||||
}
|
||||
|
||||
pub fn formula(&self, id: &FormulaId) -> Option<&Formula> {
|
||||
self.formulas.iter().find(|f| f.id() == *id)
|
||||
}
|
||||
|
||||
/// Put `formula` where `id` is, keeping its position; refuses rather than
|
||||
/// let a new target displace another formula — `context/code/core.md` §18.
|
||||
pub fn replace_formula(&mut self, id: &FormulaId, formula: Formula) -> Result<()> {
|
||||
let new_id = formula.id();
|
||||
let renames = new_id != *id;
|
||||
// A regular category's target is a real item; renaming it is sboz's (kb1d).
|
||||
if renames && !CategoryKind::of_name(&formula.target_category).is_measure() {
|
||||
return Err(anyhow!(
|
||||
"Renaming a formula outside _Measure is not supported yet; delete and re-add it"
|
||||
));
|
||||
}
|
||||
if renames && self.formula(&new_id).is_some() {
|
||||
return Err(anyhow!("A formula for {} already exists", formula.target));
|
||||
}
|
||||
let pos = self
|
||||
.formulas
|
||||
.iter()
|
||||
.position(|f| f.id() == *id)
|
||||
.ok_or_else(|| anyhow!("That formula no longer exists"))?;
|
||||
if !CategoryKind::of_name(&formula.target_category).is_measure()
|
||||
&& let Some(cat) = self.categories.get_mut(&formula.target_category)
|
||||
{
|
||||
cat.add_item(&formula.target);
|
||||
}
|
||||
self.formulas[pos] = formula;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The formula that owns `key`, if one does. **The one home** of "is this
|
||||
/// cell derived" — every other answer to it is a second derivation.
|
||||
pub fn formula_for(&self, key: &CellKey) -> Option<&Formula> {
|
||||
@@ -2137,6 +2178,88 @@ mod formula_tests {
|
||||
assert_eq!(m.evaluate(&k), None);
|
||||
}
|
||||
|
||||
fn three_formulas() -> Model {
|
||||
let mut m = revenue_cost_model();
|
||||
for raw in [
|
||||
"Profit = Revenue - Cost",
|
||||
"Tax = Revenue * 0.1",
|
||||
"Margin = Profit / Revenue",
|
||||
] {
|
||||
m.add_formula(parse_formula(raw, "_Measure").unwrap());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn targets(m: &Model) -> Vec<&str> {
|
||||
m.formulas.iter().map(|f| f.target.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_renames_in_place_and_evaluates_under_the_new_name() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
m.replace_formula(
|
||||
&id,
|
||||
parse_formula("Levy = Revenue * 0.5", "_Measure").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(targets(&m), vec!["Profit", "Levy", "Margin"]);
|
||||
let levy = coord(&[("_Measure", "Levy"), ("Region", "East")]);
|
||||
assert_eq!(m.evaluate(&levy).and_then(|v| v.as_f64()), Some(500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_refuses_to_displace_another_formula() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
let onto_margin = parse_formula("Margin = Revenue", "_Measure").unwrap();
|
||||
assert!(m.replace_formula(&id, onto_margin).is_err());
|
||||
assert_eq!(targets(&m), vec!["Profit", "Tax", "Margin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_formula_refuses_an_id_that_names_nothing() {
|
||||
let mut m = three_formulas();
|
||||
let id = m.formulas[1].id();
|
||||
m.remove_formula("Tax", "_Measure");
|
||||
let tax = parse_formula("Tax = Revenue", "_Measure").unwrap();
|
||||
assert!(m.replace_formula(&id, tax).is_err());
|
||||
assert_eq!(targets(&m), vec!["Profit", "Margin"]);
|
||||
}
|
||||
|
||||
/// improvise-kb1d: a Region-targeted formula at index 1. A rename would
|
||||
/// leave `Total` as an empty item and append `Sum`, so it is refused.
|
||||
#[test]
|
||||
fn replace_formula_refuses_to_rename_a_target_outside_measure() {
|
||||
let mut m = three_formulas();
|
||||
m.formulas
|
||||
.insert(1, parse_formula("Total = East + West", "Region").unwrap());
|
||||
let shape = |m: &Model| {
|
||||
let region = m.category("Region").unwrap().ordered_item_names();
|
||||
let owned: Vec<String> = region.iter().map(|s| s.to_string()).collect();
|
||||
(
|
||||
targets(m).iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
owned,
|
||||
)
|
||||
};
|
||||
let before = shape(&m);
|
||||
let id = m.formulas[1].id();
|
||||
let sum = parse_formula("Sum = East + West", "Region").unwrap();
|
||||
assert!(m.replace_formula(&id, sum).is_err());
|
||||
assert_eq!(shape(&m), before, "nothing moved, nothing added");
|
||||
let edit = parse_formula("Total = East - West", "Region").unwrap();
|
||||
m.replace_formula(&id, edit).unwrap();
|
||||
assert_eq!(targets(&m), vec!["Profit", "Total", "Tax", "Margin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_mentions_what_its_expression_or_its_where_names() {
|
||||
let margin = parse_formula("Margin = Profit / Revenue", "_Measure").unwrap();
|
||||
assert!(margin.mentions("Profit") && !margin.mentions("Tax"));
|
||||
let scoped = parse_formula("East = SUM(Revenue) WHERE Region = \"East\"", "_Measure");
|
||||
assert!(scoped.unwrap().mentions("Region"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sum_aggregation_across_region() {
|
||||
let mut m = revenue_cost_model();
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator<Item = &'a str>) -> Stri
|
||||
.expect("a name is free below usize::MAX, there being fewer views")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct View {
|
||||
pub name: String,
|
||||
/// Axis assignment for each category
|
||||
@@ -44,6 +44,38 @@ pub struct View {
|
||||
pub prune_empty: bool,
|
||||
}
|
||||
|
||||
/// Sorted wherever the storage is a `HashMap`, so two equal views print alike;
|
||||
/// destructured, so a new field is a compile error here (improvise-djcn).
|
||||
impl std::fmt::Debug for View {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let View {
|
||||
name,
|
||||
category_axes,
|
||||
page_selections,
|
||||
hidden_items,
|
||||
collapsed_groups,
|
||||
number_format,
|
||||
prune_empty,
|
||||
} = self;
|
||||
let sorted = |m: &HashMap<String, HashSet<String>>| -> BTreeMap<String, BTreeSet<String>> {
|
||||
m.iter()
|
||||
.map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
|
||||
.collect()
|
||||
};
|
||||
let pages: BTreeMap<_, _> = page_selections.iter().collect();
|
||||
f.debug_struct("View")
|
||||
.field("name", name)
|
||||
.field("category_axes", category_axes)
|
||||
.field("page_selections", &pages)
|
||||
.field("hidden_items", &sorted(hidden_items))
|
||||
.field("collapsed_groups", &sorted(collapsed_groups))
|
||||
.field("number_format", number_format)
|
||||
.field("prune_empty", prune_empty)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// True for the on-demand scratch views, rebuilt rather than persisted.
|
||||
pub fn is_virtual(&self) -> bool {
|
||||
|
||||
@@ -10,6 +10,8 @@ repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||
anyhow = "1"
|
||||
pest = "2.9.2"
|
||||
pest_derive = "2.9.2"
|
||||
# For FormulaId alone, which crosses the wire (improvise-km71); no AST derive.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pest_meta = "2.9.2"
|
||||
|
||||
@@ -84,4 +84,47 @@ impl Formula {
|
||||
self.format = format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this formula's expression or its `WHERE` names `name`.
|
||||
pub fn mentions(&self, name: &str) -> bool {
|
||||
self.expr.mentions(name) || self.filter.as_ref().is_some_and(|f| f.names(name))
|
||||
}
|
||||
|
||||
/// Which formula this is. The one constructor of a [`FormulaId`].
|
||||
pub fn id(&self) -> FormulaId {
|
||||
FormulaId {
|
||||
target: self.target.clone(),
|
||||
target_category: self.target_category.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which formula an edit means, opaque so its content can change in one place:
|
||||
/// today a target and its category — `context/code/core.md` §18.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FormulaId {
|
||||
target: String,
|
||||
target_category: String,
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// Whether this expression names `name` anywhere, as a reference or as
|
||||
/// either half of a coordinate filter.
|
||||
pub fn mentions(&self, name: &str) -> bool {
|
||||
match self {
|
||||
Expr::Number(_) => false,
|
||||
Expr::Ref(r) => r == name,
|
||||
Expr::BinOp(_, l, r) => l.mentions(name) || r.mentions(name),
|
||||
Expr::UnaryMinus(e) => e.mentions(name),
|
||||
Expr::Agg(_, e, f) => e.mentions(name) || f.as_ref().is_some_and(|f| f.names(name)),
|
||||
Expr::If(c, t, e) => c.mentions(name) || t.mentions(name) || e.mentions(name),
|
||||
Expr::Is(f) => f.names(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn names(&self, name: &str) -> bool {
|
||||
self.category == name || self.item == name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ pub mod ast;
|
||||
pub mod parser;
|
||||
pub mod quote;
|
||||
|
||||
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
||||
pub use ast::{AggFunc, BinOp, Expr, Filter, Formula, FormulaId};
|
||||
pub use parser::parse_formula;
|
||||
pub use quote::{is_bare_name, quote_name};
|
||||
|
||||
@@ -94,15 +94,16 @@ impl BasisCache {
|
||||
}
|
||||
|
||||
impl BasisCache {
|
||||
/// A basis for records whose counters were read EARLIER, such as when an
|
||||
/// editor opened (improvise-0j0f). The document tiers are current.
|
||||
/// A basis for what was read EARLIER, such as when an editor opened: the
|
||||
/// document and its records then, the layout now (improvise-0j0f, improvise-vndi).
|
||||
pub fn claim_as_read(
|
||||
&self,
|
||||
document_generation: u64,
|
||||
read: impl IntoIterator<Item = (RecordHash, u64)>,
|
||||
) -> Option<Basis> {
|
||||
let current = self.current.as_ref()?;
|
||||
Some(Basis {
|
||||
document_generation: current.document_generation,
|
||||
document_generation,
|
||||
layout_version: current.layout_version,
|
||||
records: read.into_iter().collect(),
|
||||
})
|
||||
|
||||
@@ -101,6 +101,7 @@ pub(crate) fn advertised(mode: &AppMode) -> Vec<(&'static str, Option<Vec<String
|
||||
],
|
||||
AppMode::FormulaPanel => vec![
|
||||
("enter-mode", arg("formula-edit"), "new"),
|
||||
("edit-formula-at-cursor", None, "edit"),
|
||||
("delete-formula-at-cursor", None, "delete"),
|
||||
("move-panel-cursor", None, "nav"),
|
||||
("enter-mode", arg("normal"), "back"),
|
||||
@@ -720,6 +721,12 @@ fn panels(keys: KeysRow) -> Vec<HelpLine> {
|
||||
Some(&["formula-edit"]),
|
||||
"New formula",
|
||||
),
|
||||
keys(
|
||||
&ModeKey::FormulaPanel,
|
||||
"edit-formula-at-cursor",
|
||||
None,
|
||||
"Edit selected formula",
|
||||
),
|
||||
keys(
|
||||
&ModeKey::FormulaPanel,
|
||||
"delete-formula-at-cursor",
|
||||
|
||||
@@ -766,6 +766,8 @@ impl Keymaps {
|
||||
"enter-mode",
|
||||
vec!["formula-edit".into()],
|
||||
)
|
||||
.bind(KeyCode::Enter, "edit-formula-at-cursor")
|
||||
.bind(KeyCode::Char('e'), "edit-formula-at-cursor")
|
||||
.delete("delete-formula-at-cursor")
|
||||
.finish(),
|
||||
),
|
||||
|
||||
@@ -30,10 +30,10 @@ pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey};
|
||||
pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view};
|
||||
pub use render::{
|
||||
AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind,
|
||||
DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate,
|
||||
MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry,
|
||||
ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, WizardFieldKind,
|
||||
WizardProposal, WizardStepKind, header_level_widths, opening_tile,
|
||||
DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent,
|
||||
GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache,
|
||||
TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField,
|
||||
WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, opening_tile,
|
||||
};
|
||||
pub use resolve::{
|
||||
Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed,
|
||||
@@ -49,6 +49,9 @@ pub use improvise_core::view::layout::PageAxis;
|
||||
// without depending on `improvise-core` directly.
|
||||
pub use improvise_core::model::cell::CellValue;
|
||||
|
||||
// On the wire in `ModelCommand::ReplaceFormula` and in `AppMode::FormulaEdit`.
|
||||
pub use improvise_core::formula::FormulaId;
|
||||
|
||||
/// Number of pages in the Help screen. Lives here with the `help_page` state
|
||||
/// it bounds, so help-page effects stay terminal-independent.
|
||||
pub const HELP_PAGE_COUNT: usize = 5;
|
||||
@@ -140,6 +143,7 @@ pub enum AppMode {
|
||||
},
|
||||
FormulaEdit {
|
||||
minibuf: MinibufferConfig,
|
||||
slot: FormulaSlot,
|
||||
},
|
||||
FormulaPanel,
|
||||
CategoryPanel,
|
||||
@@ -177,6 +181,18 @@ pub enum AppMode {
|
||||
},
|
||||
}
|
||||
|
||||
/// What a formula editor commits to, captured when it OPENS and never
|
||||
/// recomputed at commit — `context/code/protocol.md` §1 (improvise-km71).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FormulaSlot {
|
||||
New,
|
||||
/// Replace the formula `id` names, provided it still reads `was`.
|
||||
Existing {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `Normal` and `RecordsNormal` are one mode over two layouts, so the mode an
|
||||
/// end holds must answer for the grid it holds — `context/code/protocol.md`.
|
||||
pub fn settled_mode(mode: &AppMode, records: bool) -> AppMode {
|
||||
@@ -334,12 +350,22 @@ impl AppMode {
|
||||
}
|
||||
|
||||
pub fn formula_edit() -> Self {
|
||||
Self::formula_editor("formula: ", FormulaSlot::New)
|
||||
}
|
||||
|
||||
/// The editor opened on an existing formula, which it names from here on.
|
||||
pub fn formula_replace(id: FormulaId, was: String) -> Self {
|
||||
Self::formula_editor("edit formula: ", FormulaSlot::Existing { id, was })
|
||||
}
|
||||
|
||||
fn formula_editor(prompt: &str, slot: FormulaSlot) -> Self {
|
||||
Self::FormulaEdit {
|
||||
minibuf: MinibufferConfig {
|
||||
buffer_key: "formula".into(),
|
||||
prompt: "formula: ".into(),
|
||||
prompt: prompt.into(),
|
||||
color: MinibufColor::Cyan,
|
||||
},
|
||||
slot,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use improvise_core::view::Axis;
|
||||
use crate::basis::Basis;
|
||||
use crate::render::{Projection, RecordCell};
|
||||
use crate::resolve::OpenedEditor;
|
||||
use crate::{AppMode, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
||||
use crate::{AppMode, FormulaId, HELP_PAGE_COUNT, KeyCode, KeyModifiers, Panel, ViewState};
|
||||
use improvise_core::model::record::RecordHash;
|
||||
|
||||
/// One message on the wire, in either direction.
|
||||
@@ -79,7 +79,7 @@ pub enum Command {
|
||||
Project(Projection),
|
||||
}
|
||||
|
||||
/// A mutation of the shared document: 24 variants, derived not invented (§2). A
|
||||
/// A mutation of the shared document: 25 variants, derived not invented (§2). A
|
||||
/// view-configuration variant **names its view** (improvise-lltt).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ModelCommand {
|
||||
@@ -111,6 +111,13 @@ pub enum ModelCommand {
|
||||
target: String,
|
||||
target_category: String,
|
||||
},
|
||||
/// Edit the formula `id` names in place, if it still reads `was` — an edit
|
||||
/// of that formula, not a remove-then-add (improvise-km71).
|
||||
ReplaceFormula {
|
||||
id: FormulaId,
|
||||
was: String,
|
||||
raw: String,
|
||||
},
|
||||
CleanEmptyRecords,
|
||||
SortData,
|
||||
|
||||
@@ -180,6 +187,7 @@ pub fn records_named(cmd: &ModelCommand) -> Vec<RecordHash> {
|
||||
| ModelCommand::RemoveItem { .. }
|
||||
| ModelCommand::AddFormula { .. }
|
||||
| ModelCommand::RemoveFormula { .. }
|
||||
| ModelCommand::ReplaceFormula { .. }
|
||||
| ModelCommand::CleanEmptyRecords
|
||||
| ModelCommand::SortData
|
||||
| ModelCommand::AddCategory(_)
|
||||
@@ -593,6 +601,9 @@ mod tests {
|
||||
fn surrogate(_: &RecordHash) -> usize {
|
||||
1
|
||||
}
|
||||
fn formula(_: &FormulaId) -> usize {
|
||||
1
|
||||
}
|
||||
fn record(RecordCell { record, measure }: &RecordCell) -> usize {
|
||||
surrogate(record) + optional_name(measure)
|
||||
}
|
||||
@@ -627,6 +638,7 @@ mod tests {
|
||||
target,
|
||||
target_category,
|
||||
} => name(target) + name(target_category),
|
||||
ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw),
|
||||
ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0,
|
||||
ModelCommand::AddCategory(category)
|
||||
| ModelCommand::RemoveCategory(category)
|
||||
|
||||
@@ -9,7 +9,8 @@ use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||
use improvise_core::view::Axis;
|
||||
use improvise_core::view::layout::PageAxis;
|
||||
|
||||
use crate::ViewState;
|
||||
use crate::{FormulaId, ViewState};
|
||||
use improvise_core::formula::Formula;
|
||||
|
||||
/// A widget's local copy of what it draws. **Total and deterministic**: an
|
||||
/// update naming something absent is dropped, never a panic.
|
||||
@@ -728,17 +729,42 @@ impl WidgetCache for CategoryTreeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula panel's copy of the formula list (improvise-pca). One
|
||||
/// mutation: add and delete are the only effects, and both send whole.
|
||||
/// The formula panel's copy of the formula list (improvise-pca). Every
|
||||
/// formula effect sends it whole.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormulaCache {
|
||||
pub formulas: Vec<String>,
|
||||
pub formulas: Vec<FormulaEntry>,
|
||||
}
|
||||
|
||||
/// One formula as a client holds it: its text, and the identity an edit names
|
||||
/// it by, which the text alone cannot give a client (improvise-km71).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FormulaEntry {
|
||||
pub id: FormulaId,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl From<&Formula> for FormulaEntry {
|
||||
fn from(f: &Formula) -> Self {
|
||||
Self {
|
||||
id: f.id(),
|
||||
raw: f.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `_Measure` formula's entry, identified exactly as the host identifies it.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl From<&str> for FormulaEntry {
|
||||
fn from(raw: &str) -> Self {
|
||||
Self::from(&improvise_core::formula::parse_formula(raw, "_Measure").expect(raw))
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetCache for FormulaCache {
|
||||
type Update = Vec<String>;
|
||||
type Update = Vec<FormulaEntry>;
|
||||
|
||||
fn apply(&mut self, update: &Vec<String>) {
|
||||
fn apply(&mut self, update: &Vec<FormulaEntry>) {
|
||||
self.formulas = update.clone();
|
||||
}
|
||||
}
|
||||
@@ -882,8 +908,8 @@ pub struct WizardCache {
|
||||
pub enum Projection {
|
||||
Grid(GridUpdate),
|
||||
CategoryTree(CategoryTreeUpdate),
|
||||
/// The raw text of every formula.
|
||||
Formulas(Vec<String>),
|
||||
/// Every formula: its text and its identity.
|
||||
Formulas(Vec<FormulaEntry>),
|
||||
ViewList(Vec<ViewSummary>),
|
||||
TileBar(Vec<TileEntry>),
|
||||
/// Name, dirty marker and where the host keeps it (improvise-z754).
|
||||
@@ -1490,10 +1516,10 @@ mod tests {
|
||||
#[test]
|
||||
fn the_list_caches_hold_the_last_update() {
|
||||
let formulas = FormulaCache::folded(&[
|
||||
vec!["Profit = Revenue - Cost".to_string()],
|
||||
vec!["Profit = Revenue - Cost".into()],
|
||||
vec![
|
||||
"Profit = Revenue - Cost".to_string(),
|
||||
"Tax = Revenue * 0.1".to_string(),
|
||||
"Profit = Revenue - Cost".into(),
|
||||
"Tax = Revenue * 0.1".into(),
|
||||
],
|
||||
]);
|
||||
assert_eq!(formulas.formulas.len(), 2);
|
||||
@@ -1851,7 +1877,7 @@ mod tests {
|
||||
|
||||
assert_eq!(booted, (grid, formulas));
|
||||
assert_eq!(booted.0.cell(0, 0).unwrap().text, "5");
|
||||
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1"]);
|
||||
assert_eq!(booted.1.formulas, vec!["Tax = Revenue * 0.1".into()]);
|
||||
}
|
||||
|
||||
/// Two subscribers, each folding only what is addressed to it — the
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::frame::ClientFrame;
|
||||
use crate::keymap::{Binding, Keymaps, ModeKey};
|
||||
use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree};
|
||||
use crate::{
|
||||
AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent,
|
||||
ModelCommand, Panel, RecordCell, ViewState, records_named,
|
||||
AppMode, CellId, CellValue, Command, CreateKind, FormulaId, FormulaSlot, GridExtent, HelpPage,
|
||||
KeyCode, KeyEvent, ModelCommand, Panel, RecordCell, ViewState, records_named,
|
||||
};
|
||||
|
||||
/// The virtual category a new measure is filed under, spelled as
|
||||
@@ -187,8 +187,8 @@ fn click_view_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// The formula panel binds **no** `Enter`, so the rule yields navigation and
|
||||
/// nothing else. That is the rule applying, not an omission — clients.md.
|
||||
/// Focus the panel, move its cursor, then open the editor on the row — what
|
||||
/// `F` then `Enter` do. `edit-formula-at-cursor` is the activation (km71).
|
||||
fn click_formula_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
state.formulas.formulas.get(index)?;
|
||||
let mut commands = focus(state, Panel::Formula);
|
||||
@@ -196,9 +196,26 @@ fn click_formula_row(state: &ClientState, index: usize) -> Option<Vec<Command>>
|
||||
panel: Panel::Formula,
|
||||
cursor: index,
|
||||
});
|
||||
// Off `index`, never the cursor: `SetPanelCursor` is not folded yet.
|
||||
commands.extend(edit_formula_at(state, index));
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
/// `EditFormulaAtCursor::execute` at an arbitrary index: open the editor on
|
||||
/// that formula, naming it, and seed the buffer with its text.
|
||||
fn edit_formula_at(state: &ClientState, index: usize) -> Vec<Command> {
|
||||
match state.formulas.formulas.get(index) {
|
||||
Some(entry) => vec![
|
||||
Command::ChangeMode(AppMode::formula_replace(
|
||||
entry.id.clone(),
|
||||
entry.raw.clone(),
|
||||
)),
|
||||
write_buffer("formula", entry.raw.clone()),
|
||||
],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus the panel, move its cursor, then activate — the three things the
|
||||
/// keyboard needs three keystrokes for, and `filter-to-item` is the activation.
|
||||
fn click_category_row(state: &ClientState, index: usize) -> Option<Vec<Command>> {
|
||||
@@ -456,6 +473,9 @@ fn composed(
|
||||
"Move cursor to a category header to change axis".to_string(),
|
||||
)],
|
||||
}),
|
||||
// ── the formula panel ────────────────────────────────────────
|
||||
"edit-formula-at-cursor" => Some(edit_formula_at(state, state.view.formula_cursor)),
|
||||
|
||||
"open-item-add-at-cursor" => Some(match cat_at_cursor(state) {
|
||||
Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))],
|
||||
None => vec![Command::SetStatus(
|
||||
@@ -1333,10 +1353,11 @@ fn parse_axis(s: &str) -> Option<Axis> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mirror of `commit.rs`'s `CommitKind`, resolved against the mode exactly
|
||||
/// as `parse_commit_kind` does — only `Item` needs the mode.
|
||||
/// A mirror of `commit.rs`'s `CommitKind`, resolved against the mode as
|
||||
/// `parse_commit_kind` then `CommitKind::in_mode` do; there is no registry here.
|
||||
enum CommitKind {
|
||||
Formula,
|
||||
ReplaceFormula { id: FormulaId, was: String },
|
||||
Category,
|
||||
Item { category: String },
|
||||
}
|
||||
@@ -1344,6 +1365,16 @@ enum CommitKind {
|
||||
impl CommitKind {
|
||||
fn of(arg: &str, mode: &AppMode) -> Option<Self> {
|
||||
match (arg, mode) {
|
||||
(
|
||||
"formula",
|
||||
AppMode::FormulaEdit {
|
||||
slot: FormulaSlot::Existing { id, was },
|
||||
..
|
||||
},
|
||||
) => Some(Self::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
}),
|
||||
("formula", _) => Some(Self::Formula),
|
||||
("category", _) => Some(Self::Category),
|
||||
("item", AppMode::ItemAdd { category, .. }) => Some(Self::Item {
|
||||
@@ -1355,7 +1386,7 @@ impl CommitKind {
|
||||
|
||||
fn buffer(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Formula => "formula",
|
||||
Self::Formula | Self::ReplaceFormula { .. } => "formula",
|
||||
Self::Category => "category",
|
||||
Self::Item { .. } => "item",
|
||||
}
|
||||
@@ -1363,14 +1394,14 @@ impl CommitKind {
|
||||
|
||||
fn on_empty(&self) -> AppMode {
|
||||
match self {
|
||||
Self::Formula => AppMode::FormulaPanel,
|
||||
Self::Formula | Self::ReplaceFormula { .. } => AppMode::FormulaPanel,
|
||||
Self::Category | Self::Item { .. } => AppMode::CategoryPanel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_commit(&self) -> Option<AppMode> {
|
||||
match self {
|
||||
Self::Formula => Some(AppMode::FormulaPanel),
|
||||
Self::Formula | Self::ReplaceFormula { .. } => Some(AppMode::FormulaPanel),
|
||||
Self::Category | Self::Item { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -1378,6 +1409,7 @@ impl CommitKind {
|
||||
fn status(&self, name: &str) -> String {
|
||||
match self {
|
||||
Self::Formula => "Formula added".to_string(),
|
||||
Self::ReplaceFormula { .. } => "Formula updated".to_string(),
|
||||
Self::Category => format!("Added category \"{name}\""),
|
||||
Self::Item { .. } => format!("Added \"{name}\""),
|
||||
}
|
||||
@@ -1389,6 +1421,11 @@ impl CommitKind {
|
||||
raw: name.to_string(),
|
||||
target_category: "_Measure".to_string(),
|
||||
},
|
||||
Self::ReplaceFormula { id, was } => ModelCommand::ReplaceFormula {
|
||||
id: id.clone(),
|
||||
was: was.clone(),
|
||||
raw: name.to_string(),
|
||||
},
|
||||
Self::Category => ModelCommand::AddCategory(name.to_string()),
|
||||
Self::Item { category } => ModelCommand::AddItem {
|
||||
category: category.clone(),
|
||||
@@ -1944,11 +1981,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The formula panel binds no `Enter`, so the rule yields navigation and
|
||||
/// nothing else. A mode change here would be an invented activation.
|
||||
/// The formula panel's `Enter` edits (improvise-km71), so a click opens the
|
||||
/// editor on the row CLICKED — `Margin`, where the cursor is not.
|
||||
#[test]
|
||||
fn a_formula_row_click_navigates_and_activates_nothing() {
|
||||
fn a_formula_row_click_navigates_and_opens_the_editor_on_that_row() {
|
||||
let state = with_panels();
|
||||
let margin = state.formulas.formulas[1].clone();
|
||||
let commands = click(&state, Click::FormulaRow(1));
|
||||
assert_eq!(
|
||||
commands,
|
||||
@@ -1962,28 +2000,30 @@ mod tests {
|
||||
panel: Panel::Formula,
|
||||
cursor: 1
|
||||
},
|
||||
Command::ChangeMode(AppMode::formula_replace(margin.id, margin.raw.clone())),
|
||||
Command::SetBuffer {
|
||||
name: "formula".into(),
|
||||
value: margin.raw,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The asymmetry is a fact about the keymap, so it is asserted against
|
||||
/// the keymap — not against a reading of it. See `context/code/clients.md`.
|
||||
/// A click mirrors `Enter`, so the tie is asserted against what `Enter`
|
||||
/// resolves to, not against a reading of it. See `context/code/clients.md`.
|
||||
#[test]
|
||||
fn the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does() {
|
||||
let keymaps = Keymaps::shared();
|
||||
fn a_formula_row_click_ends_with_what_enter_does_on_that_row() {
|
||||
let mut state = with_panels();
|
||||
state.view.mode = AppMode::FormulaPanel;
|
||||
state.view.formula_cursor = 1;
|
||||
let KeyOutcome::Local(enter) = resolve(&state, ev(KeyCode::Enter)) else {
|
||||
panic!("Enter in the formula panel resolves here");
|
||||
};
|
||||
assert!(!enter.is_empty(), "Enter on a formula must do something");
|
||||
let clicked = click(&with_panels(), Click::FormulaRow(1));
|
||||
assert!(
|
||||
keymaps
|
||||
.lookup(&ModeKey::FormulaPanel, KeyCode::Enter, KeyModifiers::NONE)
|
||||
.is_none(),
|
||||
"if FormulaPanel gains an Enter binding, click_formula_row must \
|
||||
activate too — the click mirrors Enter, and this is the only \
|
||||
thing tying the two together"
|
||||
);
|
||||
assert!(
|
||||
keymaps
|
||||
.lookup(&ModeKey::ViewPanel, KeyCode::Enter, KeyModifiers::NONE)
|
||||
.is_some(),
|
||||
"and the view panel's click activates because its Enter does"
|
||||
clicked.ends_with(&enter),
|
||||
"the click's activation must be Enter's: {clicked:?} vs {enter:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2195,7 +2235,7 @@ mod tests {
|
||||
"Net = Profit",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.map(crate::FormulaEntry::from)
|
||||
.collect();
|
||||
state.views.views = ["Default", "Second", "Third"]
|
||||
.into_iter()
|
||||
|
||||
@@ -109,6 +109,24 @@ impl Client {
|
||||
.map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// What the editor opened on `target` shows: the seed the target captured,
|
||||
/// never the page's paint, which can be stale (improvise-ugj4).
|
||||
#[wasm_bindgen]
|
||||
pub fn edit_seed(&self, target: &str) -> Result<String, JsError> {
|
||||
let target: EditTarget =
|
||||
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(target.opened.seed)
|
||||
}
|
||||
|
||||
/// `target` after the user's first edit of its `<input>`, still opaque to
|
||||
/// the page (improvise-jibp).
|
||||
#[wasm_bindgen]
|
||||
pub fn edited_target(&self, target: &str) -> Result<String, JsError> {
|
||||
let target: EditTarget =
|
||||
serde_json::from_str(target).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&target.edited()).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The frame for "the user typed `text` into the cell `target` names". By
|
||||
/// identity, never position: rows renumber while an editor is open.
|
||||
#[wasm_bindgen]
|
||||
@@ -134,8 +152,8 @@ impl Client {
|
||||
self.clicked(Click::ViewRow(index))
|
||||
}
|
||||
|
||||
/// The user clicked row `index` of the formula panel. Navigation only —
|
||||
/// that panel binds no `Enter` (improvise-iioi).
|
||||
/// The user clicked row `index` of the formula panel: focus it, move there,
|
||||
/// then open the editor on that formula, as `Enter` does (improvise-km71).
|
||||
#[wasm_bindgen]
|
||||
pub fn click_formula(&mut self, index: usize) -> Result<Option<String>, JsError> {
|
||||
self.clicked(Click::FormulaRow(index))
|
||||
@@ -267,6 +285,62 @@ mod tests {
|
||||
assert_eq!(client.typed_cell_frame(0, 0, "42"), None);
|
||||
}
|
||||
|
||||
/// BUG (improvise-ugj4, improvise-jibp) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn the_page_opens_its_editor_on_what_the_target_captured() {
|
||||
let id = improvise_protocol::RecordCell {
|
||||
record: surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"),
|
||||
measure: Some("Revenue".into()),
|
||||
};
|
||||
let at = |text: &str, stored: &str| {
|
||||
let mut cell = improvise_protocol::CellDisplay::number(text);
|
||||
cell.edit_text = Some(stored.into());
|
||||
cell.id = Some(CellId::Record(id.clone()));
|
||||
Projection::Grid(GridUpdate::Cells(vec![((0, 0), cell)]))
|
||||
};
|
||||
let mut client = init();
|
||||
client.bootstrap(Snapshot {
|
||||
view: ViewState::default(),
|
||||
seed: vec![
|
||||
Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat(["North"]))),
|
||||
Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat(["Revenue"]))),
|
||||
at("1,200.50", "1200.5"),
|
||||
Projection::Basis(improvise_protocol::basis::published(
|
||||
0,
|
||||
0,
|
||||
[(id.record, 0)].into_iter().collect(),
|
||||
)),
|
||||
],
|
||||
});
|
||||
let first = client.edit_target(0, 0).unwrap().expect("a record cell");
|
||||
assert_eq!(
|
||||
client.edit_seed(&first).unwrap(),
|
||||
"1200.5",
|
||||
"not the drawn text"
|
||||
);
|
||||
client.fold_frame(ServerFrame::Commands(vec![Command::Project(at(
|
||||
"777", "777",
|
||||
))]));
|
||||
let later = client.edit_target(0, 0).unwrap().expect("a record cell");
|
||||
assert_eq!(
|
||||
client.edit_seed(&later).unwrap(),
|
||||
"777",
|
||||
"not the cache at open"
|
||||
);
|
||||
assert_eq!(
|
||||
client.edit_seed(&first).unwrap(),
|
||||
"1200.5",
|
||||
"a target is one capture"
|
||||
);
|
||||
|
||||
assert!(client.typed_cell(&later, "777").unwrap().is_none());
|
||||
let edited = client.edited_target(&later).unwrap();
|
||||
assert!(
|
||||
client.typed_cell(&edited, "777").unwrap().is_some(),
|
||||
"an edited editor typed back to its seed composed nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The end-to-end shape a JS harness drives: bootstrap, key, message.
|
||||
#[test]
|
||||
fn a_client_can_be_driven_entirely_through_json() {
|
||||
|
||||
@@ -133,9 +133,6 @@ pub struct RenderCell {
|
||||
/// May this client compose a write for it? `CellId::Record` alone.
|
||||
pub editable: bool,
|
||||
pub selected: bool,
|
||||
/// What an editor should start with: the stored value, not the formatted
|
||||
/// one. Seeding from `text` turns `1200` into the string `"1,200"`.
|
||||
pub edit_text: String,
|
||||
}
|
||||
|
||||
impl RenderCell {
|
||||
@@ -155,7 +152,6 @@ impl RenderCell {
|
||||
| None => false,
|
||||
},
|
||||
selected,
|
||||
edit_text: cell.edit_text.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,8 +373,8 @@ fn formula_rows(state: &ClientState) -> Option<Vec<RenderFormulaRow>> {
|
||||
.formulas
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, text)| RenderFormulaRow {
|
||||
text: text.clone(),
|
||||
.map(|(i, entry)| RenderFormulaRow {
|
||||
text: entry.raw.clone(),
|
||||
selected: i == state.view.formula_cursor,
|
||||
})
|
||||
.collect()
|
||||
@@ -1026,10 +1022,6 @@ mod tests {
|
||||
"a row says which of the two it is; the page does not guess"
|
||||
);
|
||||
assert_eq!(value["rows"][0]["labels"][0], "North");
|
||||
assert_eq!(
|
||||
value["rows"][0]["cells"][0]["edit_text"], "1200",
|
||||
"an editor seeds from the stored value, never from the drawn one"
|
||||
);
|
||||
}
|
||||
|
||||
fn page_axis(cat: &str, selected: &str, items: Option<Vec<&str>>) -> PageAxis {
|
||||
|
||||
@@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
|
||||
/// A struct so a field such as the seed can join it — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EditTarget {
|
||||
/// The document the editor opened on; a swap under it refuses the commit (improvise-vndi).
|
||||
pub document_generation: u64,
|
||||
pub cell: RecordCell,
|
||||
/// The record's write counter as this client had it at open.
|
||||
pub version: u64,
|
||||
@@ -19,6 +21,20 @@ pub struct EditTarget {
|
||||
pub opened: OpenedEditor,
|
||||
}
|
||||
|
||||
impl EditTarget {
|
||||
/// The same target after the user's first edit: a commit of its seed is then
|
||||
/// a write (improvise-jibp). The page's `<input>` is not a buffer `resolve` sees.
|
||||
pub fn edited(self) -> Self {
|
||||
Self {
|
||||
opened: OpenedEditor {
|
||||
touched: true,
|
||||
..self.opened
|
||||
},
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a refused batch says to the person who composed it. Worded here rather
|
||||
/// than on `Rejection`, and why the wording matters: `context/code/clients.md`.
|
||||
fn rejection_message(rejection: &Rejection) -> String {
|
||||
@@ -103,9 +119,12 @@ impl Client {
|
||||
/// version of it the user is looking at. `None` before any basis arrives.
|
||||
pub fn edit_target_at(&self, row: usize, col: usize) -> Option<EditTarget> {
|
||||
let cell = self.record_cell_at(row, col)?;
|
||||
let version = self.state().basis.current()?.record(cell.record)?;
|
||||
let basis = self.state().basis.current()?;
|
||||
let version = basis.record(cell.record)?;
|
||||
let document_generation = basis.document_generation;
|
||||
let seed = self.state().grid.cell(row, col)?.edit_text.clone();
|
||||
Some(EditTarget {
|
||||
document_generation,
|
||||
cell,
|
||||
version,
|
||||
opened: OpenedEditor::on(seed.unwrap_or_default()),
|
||||
@@ -118,10 +137,10 @@ impl Client {
|
||||
if untouched(Some(&target.opened), text) {
|
||||
return None;
|
||||
}
|
||||
let basis = self
|
||||
.state()
|
||||
.basis
|
||||
.claim_as_read([(target.cell.record, target.version)])?;
|
||||
let basis = self.state().basis.claim_as_read(
|
||||
target.document_generation,
|
||||
[(target.cell.record, target.version)],
|
||||
)?;
|
||||
let cmds = vec![Command::Model {
|
||||
basis,
|
||||
cmd: typed_cell_command(&improvise_protocol::CellId::Record(target.cell), text)?,
|
||||
@@ -304,6 +323,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// BUG (improvise-jibp) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_mouse_target_marked_edited_writes_its_own_seed() {
|
||||
let mut client = client_showing(Some(CellId::Record(record("Revenue"))));
|
||||
let target = client.edit_target_at(0, 0).expect("a record cell");
|
||||
assert_eq!(
|
||||
client.typed_target_frame(target.clone(), "1200"),
|
||||
None,
|
||||
"control: an untouched editor committing its seed writes nothing"
|
||||
);
|
||||
let edited = target.edited();
|
||||
assert_eq!(edited.opened.seed, "1200", "marking must not move the seed");
|
||||
assert!(
|
||||
client.typed_target_frame(edited, "1200").is_some(),
|
||||
"an edited editor typed back to its seed was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// BUG (improvise-ixxx) — see `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn a_mouse_commit_is_judged_against_the_seed_it_opened_with() {
|
||||
|
||||
@@ -817,6 +817,70 @@ fn an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open() {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-vndi: an editor opened before another client's `LoadModel` must not
|
||||
/// write into the loaded document. Evidence: test-evidence/clients.md.
|
||||
#[test]
|
||||
fn an_edit_opened_before_a_document_swap_is_refused_in_the_new_document() {
|
||||
use improvise_command::ui::reduce::caught_up;
|
||||
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
|
||||
|
||||
let north = CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("_Measure".into(), "Revenue".into()),
|
||||
]);
|
||||
// The incoming document keeps every name, view included (gotcha 39's
|
||||
// fixture warning), and differs only in the one value the edit targets.
|
||||
let path = std::path::PathBuf::from("other.improv");
|
||||
let mut host = host();
|
||||
host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new());
|
||||
let mut other = host.app().model_state.workbook.clone();
|
||||
other
|
||||
.model
|
||||
.set_cell(north.clone(), CellValue::Number(5555.0));
|
||||
improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap();
|
||||
|
||||
let mut editor = Client::new();
|
||||
let (a, snapshot) = host.connect(10, 10);
|
||||
deliver(&mut editor, &[snapshot]);
|
||||
let (b, _) = host.connect(10, 10);
|
||||
|
||||
// A opens an editor on North/Revenue, which shows 1200.
|
||||
let target = editor
|
||||
.edit_target(0, 0)
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell has an edit target");
|
||||
|
||||
// B loads the other document; A folds its rebase Snapshot, editor still open.
|
||||
let generation = host.app().document_generation();
|
||||
let load = caught_up(host.app(), ModelCommand::LoadModel(path));
|
||||
let relayed = host.handle(b, ClientFrame::command(load));
|
||||
assert_eq!(
|
||||
host.app().document_generation(),
|
||||
generation + 1,
|
||||
"setup: the load must really replace the document"
|
||||
);
|
||||
let for_a: Vec<Addressed> = relayed.into_iter().filter(|f| f.to == a).collect();
|
||||
deliver(&mut editor, &for_a);
|
||||
|
||||
let commit = editor
|
||||
.typed_cell(&target, "42")
|
||||
.expect("serialization must not fail")
|
||||
.expect("a stored cell composes a write");
|
||||
let answer = send(&mut host, a, &commit);
|
||||
|
||||
assert!(
|
||||
answer
|
||||
.iter()
|
||||
.any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::DocumentReplaced))),
|
||||
"the edit was composed against the outgoing document: {answer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
host.app().model_state.workbook.model.get_cell(&north),
|
||||
Some(&CellValue::Number(5555.0)),
|
||||
"the loaded document's value was overwritten by an edit its user never saw"
|
||||
);
|
||||
}
|
||||
|
||||
// ── numbers across the serialized wire (improvise-6oy4) ─────────────────
|
||||
|
||||
/// A value serde_json's default f64 parser reads back one ULP low. Evidence file.
|
||||
|
||||
@@ -2267,3 +2267,96 @@ fn every_projected_edit_text_commits_back_to_its_value() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FORMULAS: [&str; 3] = [
|
||||
"Profit = Revenue * 2",
|
||||
"Tax = Revenue * 0.1",
|
||||
"Margin = Profit / Revenue",
|
||||
];
|
||||
|
||||
fn add_formulas(app: &mut App) {
|
||||
for raw in FORMULAS {
|
||||
let f = improvise_command::formula::parse_formula(raw, "_Measure").unwrap();
|
||||
app.model_state.workbook.model.add_formula(f);
|
||||
}
|
||||
app.rebuild_layout();
|
||||
}
|
||||
|
||||
fn raws(app: &App) -> Vec<String> {
|
||||
let formulas = app.model_state.workbook.model.formulas();
|
||||
formulas.iter().map(|f| f.raw.clone()).collect()
|
||||
}
|
||||
|
||||
/// The formula panel's edit (improvise-km71), opened at a NONZERO cursor over
|
||||
/// three formulas, typed into and committed, with both ends agreeing throughout.
|
||||
#[test]
|
||||
fn editing_a_formula_from_the_panel_agrees_with_the_host() {
|
||||
let mut app = furnished_host();
|
||||
add_formulas(&mut app);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let mut twin = twin_of(&app);
|
||||
add_formulas(&mut twin);
|
||||
let mut client = furnished_client(&app, (2, 1));
|
||||
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
assert_eq!(
|
||||
client.view.buffers.get("formula"),
|
||||
app.view_state.buffers.get("formula"),
|
||||
"the two ends seeded the editor differently"
|
||||
);
|
||||
assert_eq!(
|
||||
app.view_state.buffers.get("formula").map(String::as_str),
|
||||
Some(FORMULAS[1]),
|
||||
"Enter must seed the formula under cursor 1"
|
||||
);
|
||||
for key in ["Backspace", "2", "Enter"] {
|
||||
both_ends(&mut app, &mut twin, &mut client, key);
|
||||
}
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![FORMULAS[0], "Tax = Revenue * 0.2", FORMULAS[2]],
|
||||
"the commit must replace the formula it opened on, in place"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rename through the browser: the one input that separates "replace" from
|
||||
/// "add", since `add_formula` upserts by target. The status diverges on purpose.
|
||||
#[test]
|
||||
fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() {
|
||||
let mut app = furnished_host();
|
||||
add_formulas(&mut app);
|
||||
app.view_state.formula_panel_open = true;
|
||||
app.view_state.mode = AppMode::FormulaPanel;
|
||||
app.view_state.formula_cursor = 1;
|
||||
let mut twin = twin_of(&app);
|
||||
add_formulas(&mut twin);
|
||||
let mut client = furnished_client(&app, (2, 1));
|
||||
client.formulas = improvise_command::ui::project::formulas_of(&app);
|
||||
both_ends(&mut app, &mut twin, &mut client, "Enter");
|
||||
for view in [&mut app.view_state, &mut twin.view_state, &mut client.view] {
|
||||
view.buffers
|
||||
.insert("formula".into(), "Levy = Revenue * 0.1".into());
|
||||
}
|
||||
|
||||
let event = BrowserKey::new("Enter").to_key_event().unwrap();
|
||||
let KeyOutcome::Local(cmds) = resolve(&client, event) else {
|
||||
panic!("Enter in the formula editor resolves here");
|
||||
};
|
||||
for cmd in &cmds {
|
||||
client.fold(cmd);
|
||||
}
|
||||
apply_on(&mut twin, &cmds);
|
||||
reduce_key(&mut app, event);
|
||||
|
||||
assert_eq!(document_text(&twin), document_text(&app));
|
||||
assert_eq!(
|
||||
raws(&app),
|
||||
vec![FORMULAS[0], "Levy = Revenue * 0.1", FORMULAS[2]]
|
||||
);
|
||||
assert_eq!(client.view.mode, app.view_state.mode);
|
||||
assert_eq!(client.view.status_msg, "Formula updated");
|
||||
assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy");
|
||||
}
|
||||
|
||||
Generated
+1
@@ -196,6 +196,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"pest",
|
||||
"pest_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -9,6 +9,19 @@ import { test, expect } from "@playwright/test";
|
||||
// the same with the handshake window).
|
||||
import { NOTICE_KEY } from "../../web/shell.js";
|
||||
|
||||
/** Every string the page posts to its worker, kept on `window.__posted`. */
|
||||
function recordPosts() {
|
||||
window.__posted = [];
|
||||
const post = Worker.prototype.postMessage;
|
||||
Worker.prototype.postMessage = function (message, ...rest) {
|
||||
window.__posted.push(String(message));
|
||||
return post.call(this, message, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
const setCells = (page) =>
|
||||
page.evaluate(() => window.__posted.filter((m) => m.includes("SetCell")).length);
|
||||
|
||||
async function openApp(page) {
|
||||
await page.goto("/app/index.html");
|
||||
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -251,15 +264,16 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
await expect(page.locator("#views li.active")).toContainText("Season");
|
||||
await expect(page.locator("#views li.selected")).toContainText("Season");
|
||||
|
||||
// A formula row: navigation only, because FormulaPanel binds no Enter and
|
||||
// a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md.
|
||||
// `#grid input` toHaveCount(0) belongs here and is deliberately absent:
|
||||
// it cannot fail, measured by injection (improvise-qf57, clients.md).
|
||||
// A formula row: FormulaPanel's Enter edits the formula (improvise-km71)
|
||||
// and a click mirrors Enter, so the click opens the editor on that row.
|
||||
// `#grid input` toHaveCount(0) is deliberately absent (improvise-qf57).
|
||||
await page.keyboard.press("Shift+F");
|
||||
await expect(page.locator("#formulas li").first()).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const formula = await page.locator("#formulas li").first().innerText();
|
||||
await page.locator("#formulas li").first().click();
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULAS");
|
||||
await expect(page.locator("#mode")).toHaveText("FORMULA");
|
||||
await expect(page.locator("#minibuffer")).toContainText(formula.trim());
|
||||
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -321,6 +335,76 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
|
||||
await expect.poll(writes).toBe(1);
|
||||
});
|
||||
|
||||
// improvise-jibp. The page reports its first edit, so retyping the seed over
|
||||
// the selection is a write, not an untouched editor.
|
||||
test("a mouse editor typed back to its own seed still writes", async ({ page }) => {
|
||||
await page.addInitScript(recordPosts);
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
await cells.nth(4).click();
|
||||
const input = cells.nth(4).locator("input");
|
||||
await expect(input).toHaveCount(1);
|
||||
const seed = await input.inputValue();
|
||||
expect(seed, "the fixture cell has no seed to retype").not.toBe("");
|
||||
await page.keyboard.type(seed);
|
||||
await expect(input).toHaveValue(seed);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => setCells(page)).toBe(1);
|
||||
});
|
||||
|
||||
// improvise-ugj4. A frame that lands while an editor is open changes the
|
||||
// cache but not the paint; the next editor must open on what it captured.
|
||||
test("an editor opened after an unpainted write shows and commits what it captured", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(recordPosts);
|
||||
await page.addInitScript(() => {
|
||||
window.__frames = [];
|
||||
const add = Worker.prototype.addEventListener;
|
||||
Worker.prototype.addEventListener = function (type, listener, ...rest) {
|
||||
if (type !== "message") return add.call(this, type, listener, ...rest);
|
||||
window.__deliver = (data) => listener.call(this, new MessageEvent("message", { data }));
|
||||
const recorded = (event) => {
|
||||
if (typeof event.data === "string") window.__frames.push(event.data);
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return add.call(this, type, recorded, ...rest);
|
||||
};
|
||||
});
|
||||
await openApp(page);
|
||||
const cells = page.locator("#grid tbody td");
|
||||
const target = cells.nth(4);
|
||||
const where = await target.evaluate((td) => [Number(td.dataset.row), Number(td.dataset.col)]);
|
||||
|
||||
await cells.nth(0).click();
|
||||
await expect(cells.nth(0).locator("input")).toHaveCount(1);
|
||||
// Another client's write to cell 4, delivered while cell 0's editor is open.
|
||||
await page.evaluate(([row, col]) => {
|
||||
let latest = null;
|
||||
const walk = (node) => {
|
||||
if (Array.isArray(node)) node.forEach(walk);
|
||||
else if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "Cells" && Array.isArray(value)) {
|
||||
for (const [[r, c], cell] of value) if (r === row && c === col) latest = cell;
|
||||
} else walk(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__frames.forEach((f) => walk(JSON.parse(f)));
|
||||
const moved = { ...latest, text: "777", edit_text: "777", value: { Number: 777 } };
|
||||
window.__deliver(JSON.stringify({ Commands: [{ Project: { Grid: { Cells: [[[row, col], moved]] } } }] }));
|
||||
}, where);
|
||||
|
||||
await target.click();
|
||||
const input = target.locator("input");
|
||||
await expect(input).toHaveCount(1);
|
||||
await expect(input, "the editor opened on the stale paint").toHaveValue("777");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(300);
|
||||
expect(await setCells(page), "an untouched editor wrote the stale text").toBe(0);
|
||||
});
|
||||
|
||||
// improvise-b8ot moved these five surfaces onto one handler per stable root.
|
||||
// The three tests below are the properties a naive delegation loses, each of
|
||||
// which is silent: the page still paints, and only the pointer stops working.
|
||||
|
||||
+50
-2
@@ -79,7 +79,7 @@ pub fn run_tui(
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
app.handle_key(convert_key_event(key))?;
|
||||
press(&mut app, convert_key_event(key))?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
app.term_width = w;
|
||||
@@ -99,6 +99,13 @@ pub fn run_tui(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One keypress in the TUI. A message lasts until the next key, so it is drawn
|
||||
/// once and then gives way to the hint (improvise-cq3d, `context/code/tui.md`).
|
||||
fn press(app: &mut App, key: key::KeyEvent) -> Result<()> {
|
||||
app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]);
|
||||
app.handle_key(key)
|
||||
}
|
||||
|
||||
// ── Key event conversion (TUI boundary) ──────────────────────────────────────
|
||||
|
||||
// The only place crossterm's key types and `command::key` may both appear.
|
||||
@@ -566,7 +573,8 @@ fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod bottom_bar_tests {
|
||||
use super::{draw_bottom_bar, minibuffer_line};
|
||||
use super::{draw_bottom_bar, minibuffer_line, press};
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::ui::app::{App, AppMode};
|
||||
use crate::workbook::Workbook;
|
||||
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
|
||||
@@ -587,6 +595,46 @@ mod bottom_bar_tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A message lasts until the next key, then the derived hint is back
|
||||
/// (improvise-cq3d). Why each step: `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn a_status_message_gives_way_to_the_hint_on_the_next_key() {
|
||||
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
|
||||
let mut app = App::new(Workbook::new("T"), None);
|
||||
app.view_state.status_msg = "Saved to x.improv".to_string();
|
||||
|
||||
press(&mut app, key(KeyCode::Char('T'))).unwrap();
|
||||
assert!(matches!(app.view_state.mode, AppMode::TileSelect));
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains("Saved to"),
|
||||
"a stale message outlived a key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint in TileSelect: {row:?}"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Char(' '))).unwrap();
|
||||
let said = app.view_state.status_msg.clone();
|
||||
assert!(!said.is_empty(), "the axis cycle must say what it did");
|
||||
assert!(
|
||||
bottom_row(&app, 240).contains(&said),
|
||||
"a message was wiped before it was drawn"
|
||||
);
|
||||
|
||||
press(&mut app, key(KeyCode::Esc)).unwrap();
|
||||
let row = bottom_row(&app, 240);
|
||||
assert!(
|
||||
!row.contains(&said),
|
||||
"the axis message outlived the next key: {row:?}"
|
||||
);
|
||||
assert!(
|
||||
row.contains(&app.hint_text()),
|
||||
"no hint back in Normal: {row:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wizard overlay draws correct per-step hints; the status bar stacked a
|
||||
/// second static table under it (improvise-rr8n) — `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
|
||||
+14
-4
@@ -30,7 +30,7 @@ impl PanelContent for FormulaContent<'_> {
|
||||
}
|
||||
|
||||
fn title(&self) -> &str {
|
||||
" Formulas [n]ew [d]elete "
|
||||
" Formulas [n]ew [e]dit [d]elete "
|
||||
}
|
||||
|
||||
fn item_count(&self) -> usize {
|
||||
@@ -42,7 +42,7 @@ impl PanelContent for FormulaContent<'_> {
|
||||
}
|
||||
|
||||
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
|
||||
let raw = &self.formulas.formulas[index];
|
||||
let raw = &self.formulas.formulas[index].raw;
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
@@ -79,6 +79,8 @@ impl PanelContent for FormulaContent<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::formula::parse_formula;
|
||||
use crate::protocol::FormulaEntry;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::panel::Panel;
|
||||
use crate::workbook::Workbook;
|
||||
@@ -101,7 +103,10 @@ mod tests {
|
||||
/// improvise-8l0c — `context/test-evidence/tui.md`.
|
||||
fn render_formulas(raws: &[&str], width: u16) -> Buffer {
|
||||
let cache = FormulaCache {
|
||||
formulas: raws.iter().map(|s| (*s).to_string()).collect(),
|
||||
formulas: raws
|
||||
.iter()
|
||||
.map(|s| FormulaEntry::from(&parse_formula(s, "_Measure").unwrap()))
|
||||
.collect(),
|
||||
};
|
||||
let area = Rect::new(0, 0, width, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
@@ -151,7 +156,12 @@ mod tests {
|
||||
.map(|f| f.raw.clone())
|
||||
.collect();
|
||||
assert_eq!(was.len(), 2, "fixture must carry formulas");
|
||||
assert_eq!(was, project::formulas_of(&app).formulas);
|
||||
let cached: Vec<String> = project::formulas_of(&app)
|
||||
.formulas
|
||||
.into_iter()
|
||||
.map(|f| f.raw)
|
||||
.collect();
|
||||
assert_eq!(was, cached);
|
||||
}
|
||||
|
||||
/// Pins the drawn rows, so a change to how the cache is read shows up.
|
||||
|
||||
+120
-43
@@ -1,30 +1,28 @@
|
||||
//! Every `.improv` document this repository ships must load (improvise-odbc).
|
||||
//! Why a DIRECTORY WALK and not a list: `context/test-evidence/tui.md`.
|
||||
//! "Shipped" is "tracked", read off git's index: `context/test-evidence/tui.md`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The directories `.gitignore` un-ignores `*.improv` in: the definition of
|
||||
/// "shipped", read from the one place that states it (improvise-qlrh).
|
||||
fn shipped_directories(root: &Path) -> Vec<PathBuf> {
|
||||
let gitignore = std::fs::read_to_string(root.join(".gitignore")).expect("a .gitignore");
|
||||
gitignore
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix('!'))
|
||||
.filter_map(|pattern| pattern.strip_suffix("/*.improv"))
|
||||
.map(|dir| root.join(dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn documents_in(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut found: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.expect("a shipped-document directory")
|
||||
.map(|entry| entry.expect("a readable directory entry").path())
|
||||
// `extension()` of `x.improv.gz` is `gz`, so matching on the extension
|
||||
// alone would skip it and put back the hole this test closes.
|
||||
.filter(|p| {
|
||||
p.to_str()
|
||||
.is_some_and(|s| s.ends_with(".improv") || s.ends_with(".improv.gz"))
|
||||
})
|
||||
/// Every tracked `.improv` and `.improv.gz` under `root`. Outside a checkout this
|
||||
/// panics rather than answering empty (improvise-c0aj).
|
||||
fn shipped_documents(root: &Path) -> Vec<PathBuf> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(["ls-files", "-z", "--", "*.improv", "*.improv.gz"])
|
||||
.output()
|
||||
.expect("git runs: this test reads the index, so it needs a checkout");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git ls-files failed in {}: {}",
|
||||
root.display(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let mut found: Vec<PathBuf> = String::from_utf8(out.stdout)
|
||||
.expect("UTF-8 paths")
|
||||
.split('\0')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| root.join(p))
|
||||
.collect();
|
||||
found.sort();
|
||||
found
|
||||
@@ -33,30 +31,109 @@ fn documents_in(dir: &Path) -> Vec<PathBuf> {
|
||||
#[test]
|
||||
fn every_shipped_document_loads() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dirs = shipped_directories(root);
|
||||
let documents = shipped_documents(root);
|
||||
assert!(
|
||||
!dirs.is_empty(),
|
||||
"precondition: .gitignore must un-ignore at least one *.improv directory"
|
||||
!documents.is_empty(),
|
||||
"precondition: the index must list at least one shipped document"
|
||||
);
|
||||
|
||||
for dir in dirs {
|
||||
let documents = documents_in(&dir);
|
||||
// Per directory, so a walk that silently loses one cannot pass (qlrh).
|
||||
for path in documents {
|
||||
let workbook = improvise::persistence::load(&path)
|
||||
.unwrap_or_else(|e| panic!("{} does not load: {e}", path.display()));
|
||||
assert!(
|
||||
!documents.is_empty(),
|
||||
"precondition: {} is un-ignored for *.improv but the walk found none there",
|
||||
dir.display()
|
||||
workbook.views.contains_key(&workbook.active_view),
|
||||
"{} loads but its initial view {:?} is not one of {:?}",
|
||||
path.display(),
|
||||
workbook.active_view,
|
||||
workbook.views.keys().collect::<Vec<_>>()
|
||||
);
|
||||
for path in documents {
|
||||
let workbook = improvise::persistence::load(&path)
|
||||
.unwrap_or_else(|e| panic!("{} does not load: {e}", path.display()));
|
||||
assert!(
|
||||
workbook.views.contains_key(&workbook.active_view),
|
||||
"{} loads but its initial view {:?} is not one of {:?}",
|
||||
path.display(),
|
||||
workbook.active_view,
|
||||
workbook.views.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scratch repository, so each way of shipping a document is set up for real.
|
||||
fn scratch_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
run_git(dir.path(), &["init", "-q"]);
|
||||
dir
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) {
|
||||
let status = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("git runs");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().expect("a parent")).expect("dirs");
|
||||
std::fs::write(path, text).expect("writes");
|
||||
}
|
||||
|
||||
fn walked(root: &Path, rel: &str) -> bool {
|
||||
shipped_documents(root).contains(&root.join(rel))
|
||||
}
|
||||
|
||||
/// improvise-c0aj: one test per way of shipping a document that reading the
|
||||
/// root `.gitignore` missed. Evidence: `context/test-evidence/tui.md`.
|
||||
#[test]
|
||||
fn a_single_file_un_ignore_is_walked() {
|
||||
let repo = scratch_repo();
|
||||
let root = repo.path();
|
||||
write(root, ".gitignore", "*.improv\n!web/demo.improv\n");
|
||||
write(root, "web/demo.improv", "x");
|
||||
run_git(root, &["add", ".gitignore", "web/demo.improv"]);
|
||||
assert!(walked(root, "web/demo.improv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gzipped_document_is_walked() {
|
||||
let repo = scratch_repo();
|
||||
let root = repo.path();
|
||||
write(root, ".gitignore", "*.improv\n");
|
||||
write(root, "docs/model.improv.gz", "x");
|
||||
run_git(root, &["add", ".gitignore", "docs/model.improv.gz"]);
|
||||
assert!(walked(root, "docs/model.improv.gz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_un_ignored_by_a_nested_gitignore_is_walked() {
|
||||
let repo = scratch_repo();
|
||||
let root = repo.path();
|
||||
write(root, ".gitignore", "*.improv\n");
|
||||
write(root, "harness/.gitignore", "!keep.improv\n");
|
||||
write(root, "harness/keep.improv", "x");
|
||||
run_git(
|
||||
root,
|
||||
&[
|
||||
"add",
|
||||
".gitignore",
|
||||
"harness/.gitignore",
|
||||
"harness/keep.improv",
|
||||
],
|
||||
);
|
||||
assert!(walked(root, "harness/keep.improv"));
|
||||
}
|
||||
|
||||
/// An empty walk would pass every per-document assertion vacuously, so a
|
||||
/// directory git cannot read must stop the test instead.
|
||||
#[test]
|
||||
#[should_panic(expected = "git ls-files failed")]
|
||||
fn a_directory_that_is_not_a_checkout_fails_loudly() {
|
||||
let not_a_repo = tempfile::tempdir().expect("a temp dir");
|
||||
shipped_documents(not_a_repo.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_force_added_document_is_walked() {
|
||||
let repo = scratch_repo();
|
||||
let root = repo.path();
|
||||
write(root, ".gitignore", "*.improv\n");
|
||||
write(root, "secret/forced.improv", "x");
|
||||
run_git(root, &["add", ".gitignore"]);
|
||||
run_git(root, &["add", "-f", "secret/forced.improv"]);
|
||||
assert!(walked(root, "secret/forced.improv"));
|
||||
}
|
||||
|
||||
+9
-8
@@ -546,20 +546,19 @@ function gridTable(model) {
|
||||
|
||||
/** Open an inline editor. Committing sends a frame; the value that comes
|
||||
* back is the server's, never the string typed here. */
|
||||
function edit(td, row, col, seed) {
|
||||
function edit(td, row, col) {
|
||||
if (editing) return;
|
||||
// WHICH cell, captured now: rows renumber while the editor is open, so the
|
||||
// position is not an address by commit time (improvise-2wu3).
|
||||
const target = client.edit_target(row, col);
|
||||
let target = client.edit_target(row, col);
|
||||
if (!target) return;
|
||||
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn one:
|
||||
// `td.textContent` has been through the number format.
|
||||
const original = seed;
|
||||
// What was on the screen, kept so closing can put it back: `close` no longer
|
||||
// always repaints, so restoring `seed` would leave `1200` where `1,200` was.
|
||||
// always repaints, so restoring the seed would leave `1200` where `1,200` was.
|
||||
const drawn = td.textContent;
|
||||
const input = document.createElement("input");
|
||||
input.value = original;
|
||||
// The seed the target captured, never the paint, which may be stale while
|
||||
// another editor is open: one capture shown and compared (improvise-ugj4).
|
||||
input.value = client.edit_seed(target);
|
||||
td.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
@@ -590,6 +589,8 @@ function edit(td, row, col, seed) {
|
||||
|
||||
// These two stay DIRECT while the five surfaces above are delegated: one
|
||||
// transient node, one at a time, and `blur` does not bubble (improvise-b8ot).
|
||||
// The first edit marks the target, so retyping the seed is still a write.
|
||||
input.addEventListener("input", () => (target = client.edited_target(target)), { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") dismiss(true);
|
||||
else if (e.key === "Escape") dismiss(false);
|
||||
@@ -745,7 +746,7 @@ el.grid.addEventListener("mousedown", (event) => {
|
||||
if (frame) transport.send(frame);
|
||||
// Every cell moves the selection; an editable one also opens its editor.
|
||||
// NOT repainted in that branch: a repaint would take the <input> away.
|
||||
if (action.editable) edit(td, action.row, action.col, action.editText);
|
||||
if (action.editable) edit(td, action.row, action.col);
|
||||
else repaint();
|
||||
});
|
||||
|
||||
|
||||
+2
-3
@@ -222,7 +222,6 @@ export function gridPlan(model) {
|
||||
row: dataRow,
|
||||
col,
|
||||
text: typing ? `${model.editing}▏` : cell.text,
|
||||
editText: cell.edit_text,
|
||||
editable: cell.editable,
|
||||
classes: {
|
||||
numeric: cell.numeric && !typing,
|
||||
@@ -260,7 +259,7 @@ export function gridPlan(model) {
|
||||
/** What a data cell carries on its node, so one delegated handler can read
|
||||
* back the answers `gridPlan` already made (improvise-b8ot, clients.md). */
|
||||
export function cellAttrs(cell) {
|
||||
return { row: String(cell.row), col: String(cell.col), edit: cell.editText };
|
||||
return { row: String(cell.row), col: String(cell.col) };
|
||||
}
|
||||
|
||||
/** …and the inverse: what a delegated mousedown MEANS. `null` where the event
|
||||
@@ -271,7 +270,7 @@ export function cellAction(dataset, editable) {
|
||||
// A node that carries no coordinates is not a cell: every header and the
|
||||
// corner reach here, and none of them means anything.
|
||||
if (row === null || col === null) return null;
|
||||
return { row, col, editable, editText: dataset.edit ?? "" };
|
||||
return { row, col, editable };
|
||||
}
|
||||
|
||||
/** The same, for the surfaces whose rows are named by one index: the panels,
|
||||
|
||||
@@ -80,9 +80,13 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
"click_page_item",
|
||||
"click_tile",
|
||||
"click_view",
|
||||
// improvise-ugj4: the editor shows the seed its target captured, not the paint.
|
||||
"edit_seed",
|
||||
// improvise-2wu3/0j0f: what an editor edits, captured when it opens, so
|
||||
// its commit names that record at that version and not a position.
|
||||
"edit_target",
|
||||
// improvise-jibp: the first edit of the <input> marks the target touched.
|
||||
"edited_target",
|
||||
"on_frame",
|
||||
"on_key_event",
|
||||
"render_model",
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
const cell = (text, extra = {}) => ({
|
||||
text,
|
||||
edit_text: text,
|
||||
numeric: true,
|
||||
error: false,
|
||||
selected: false,
|
||||
@@ -40,7 +39,7 @@ function grouped() {
|
||||
{
|
||||
kind: "data",
|
||||
labels: ["Cold Brew"],
|
||||
cells: [cell("3", { editable: false }), cell("4", { edit_text: "4000" })],
|
||||
cells: [cell("3", { editable: false }), cell("4")],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -50,7 +49,7 @@ test("a cell's attributes carry the plan's answers back to the handler", () => {
|
||||
const plan = gridPlan(grouped());
|
||||
const last = plan.body.at(-1).cells[1];
|
||||
const action = cellAction(cellAttrs(last), last.editable);
|
||||
assert.deepEqual(action, { row: 1, col: 1, editable: true, editText: "4000" });
|
||||
assert.deepEqual(action, { row: 1, col: 1, editable: true });
|
||||
});
|
||||
|
||||
// improvise-b8ot: the DATA row index is what a click sends, and `gridPlan`
|
||||
|
||||
@@ -8,7 +8,6 @@ import { gridPlan } from "../shell.js";
|
||||
|
||||
const cell = (text, extra = {}) => ({
|
||||
text,
|
||||
edit_text: text,
|
||||
numeric: true,
|
||||
error: false,
|
||||
selected: false,
|
||||
@@ -102,14 +101,6 @@ test("an empty edit buffer still means an editor is open", () => {
|
||||
assert.equal(plan.body[1].cells[0].text, "▏");
|
||||
});
|
||||
|
||||
test("the editor is seeded from the stored value, not the drawn one", () => {
|
||||
const model = grouped();
|
||||
model.rows[1].cells[0] = cell("20,000", { edit_text: "20000" });
|
||||
const plan = gridPlan(model);
|
||||
assert.equal(plan.body[1].cells[0].text, "20,000");
|
||||
assert.equal(plan.body[1].cells[0].editText, "20000");
|
||||
});
|
||||
|
||||
test("an empty totals strip is no strip at all, not a row of blanks", () => {
|
||||
// Gotcha 33: an EMPTY strip means this grid has no totals — records mode,
|
||||
// or an empty grid — so there is nothing to draw and nothing to pad.
|
||||
|
||||
Reference in New Issue
Block a user