Merge pull request #197 from fiddlerwoaroof/collapse-fu/gw18
improvise-gw18/yk2s/lx7w/54g1: the cursor's cell has three doors
This commit is contained in:
+71
-3
@@ -3340,9 +3340,77 @@ so `paste Region=West _Measure=Revenue Product=Gadget` from a script still
|
||||
writes where the current view happens to aggregate `Product` away: a key that
|
||||
names every coordinate is a record whatever the view shows. For the cursor's
|
||||
key the two questions agree, because a cursor key never carries a None-axis
|
||||
category. The **collapsed-group fold** half of improvise-l4mp is not covered:
|
||||
its key carries the group's name as an ordinary coordinate, so no question about
|
||||
the key alone can see it — it needs `GridLayout::folds_a_group`.
|
||||
category. The **collapsed-group fold** half of improvise-l4mp cannot be seen by
|
||||
any question about the key alone, because the key carries the group's name as an
|
||||
ordinary coordinate. It is answered at the cursor instead, by the next section.
|
||||
**The first version of that section wrote a false sentence here**: that the
|
||||
checks in `paste_into` were reached "only by a key a script spells out". The
|
||||
cursor path handed `PasteCell` the resolved record key, and `paste_into` ran
|
||||
`aggregates` over it **again**. That is a second derivation beside `cell_id`, and
|
||||
it reads a record lacking a later category (every record, once `+cat` adds one)
|
||||
as an aggregate, so `p` refused where gh/main had pasted and the browser still
|
||||
wrote (improvise-q9eg). The distinction is now a type: `PasteAt::Resolved`
|
||||
carries a key the door answered and is written as it stands, and
|
||||
`PasteAt::Spelled` carries a key a script typed, which only its shape can vet.
|
||||
Nothing is asked twice.
|
||||
|
||||
## The cursor's cell has three doors (improvise-gw18, lx7w, 54g1, yk2s)
|
||||
|
||||
**The lesson of improvise-gw18 is that guarding one command did not close the
|
||||
path.** PR #176 taught `EditOrDrill` to refuse a collapsed group's fold, and `>`
|
||||
still drilled into it. The drill was empty, and `o` inside it wrote
|
||||
`Month=Q1, _Measure=Revenue`, a coordinate that is not an item, into `## Data`.
|
||||
The same shape turned up three more times, each in a command nobody had
|
||||
listed. `p` wrote onto the fold (the fold half of improvise-l4mp). `x` in records
|
||||
mode cleared the synthetic `(_Index, _Dim)` key, a silent no-op, while the
|
||||
browser cleared the real record (improvise-lx7w). `p` onto a formula's cell
|
||||
stored a value under the formula that survived in `## Data`, while the browser
|
||||
declined (improvise-54g1). Each fix was one more guard in one more command. So
|
||||
the answer is the **accessor**, not the commands: a command gets the cursor's
|
||||
cell through a door that matches what it does with it.
|
||||
|
||||
Every command that takes the cursor's cell, and its door:
|
||||
|
||||
| command | door | on a fold | on a formula's cell | in records mode |
|
||||
|---|---|---|---|---|
|
||||
| `yank` | `cell_key` (reads) | yanks the fold's value | yanks the result | `YankCell` resolves the synthetic key |
|
||||
| `paste`, `clear-cell` | `value_cell_key` | refused | refused | resolved to the real record; a coordinate or creating column and a drill are refused |
|
||||
| `commit-cell-edit`, `commit-and-advance-right` | `committable_cell_key` | refused | refused | the synthetic key, which `commit_cell_value` resolves itself (gotcha 19) |
|
||||
| `drill-into-cell` | `unfolded_cell_key` | refused | drills | unchanged |
|
||||
| `edit-or-drill` | its own check, `folds_a_group` then `owning_formula` | refused | refused | unchanged |
|
||||
| `hide-selected-row-item` (`H`) | `GridLayout::row_folds_a_group` | refused | n/a (hides a row item) | n/a |
|
||||
| `add-record-row` (`o`) | none, it takes no cell | n/a | n/a | closed by the drill door: no drill of a fold exists to add a row in |
|
||||
|
||||
**`value_cell_key` asks the browser's own question.** It calls
|
||||
`project::cell_id`, the function that fills `CellDisplay::id` for the browser
|
||||
(ADR 002), and writes only where the answer is `CellId::Record`. The browser's
|
||||
`record_cell_at_cursor` is that same match on the projected id. The two ends
|
||||
therefore agree because they read one answer, not because two derivations
|
||||
happen to match. `tests/lockstep.rs` holds them together for all three tickets.
|
||||
`cell_id` took `&App` only to reach the model, so it now takes `&Model`. That is
|
||||
what let `CmdContext` ask it.
|
||||
|
||||
**Commit has a door of its own**, and not `value_cell_key`, because commit is
|
||||
the one writer that must see the synthetic key: renaming a coordinate column,
|
||||
creating a column and staging a drill edit all resolve the records position
|
||||
inside `commit_cell_value`. Its door refuses the two arms `i` refuses, an
|
||||
aggregate (a fold included) and a formula's result. It needed one because
|
||||
`Enter` and `Tab` advance the editor, and `EnterEditAtCursor` re-opens it at the
|
||||
new cursor with no guard. `i` declines to edit `Profit`, yet `Tab` from `Cost`
|
||||
landed the editor on it, and the next commit stored a value under the formula.
|
||||
That is a fourth door nobody had listed. **The entry half is still open**
|
||||
(improvise-enz6): the editor still opens on a fold or a formula's cell. Only the
|
||||
write is refused, out loud, at commit. Closing the entry belongs in `ui/effect.rs`.
|
||||
|
||||
**Drill keeps the narrowest door** because drilling into an aggregate is its
|
||||
purpose. Only a fold is refused, since no record carries a group's name. The
|
||||
ticket's alternative, expanding the fold into its members' records, would be the
|
||||
more useful drill and is not built.
|
||||
|
||||
**A text-path key is not covered.** `paste Month=Q1 _Measure=Revenue` typed as
|
||||
coordinates still reaches `PasteCell` with a group name. The doors are about the
|
||||
**cursor**, and a key a user spells out is a claim about the document that no
|
||||
layout checked. The same is true of `set-cell`.
|
||||
|
||||
## A command is a function of its context — the determinism law (improvise-hzjo)
|
||||
|
||||
|
||||
@@ -900,7 +900,11 @@ regular on None projected the fold as `CellId::Record`, offered it for editing,
|
||||
and a `SetCell` would have written the coordinate `Month=Q1`, which is not an
|
||||
item. `render.rs::editable` is untouched (ADR 002: the variant decides).
|
||||
`EditOrDrill` asks the same predicate and declines out loud: drilling would filter
|
||||
on `Month=Q1`, which no record carries, and show an empty list.
|
||||
on `Month=Q1`, which no record carries, and show an empty list. Guarding that one
|
||||
command left `>`, `p`, `H` and an `Enter` advanced onto the fold all still writing
|
||||
or drilling there (improvise-gw18, yk2s). Every command that takes the cursor's
|
||||
cell now goes through a door that asks this predicate, or asks `cell_id`, which
|
||||
does: `context/code/command.md`, "The cursor's cell has three doors".
|
||||
|
||||
### The cursor, and why the order is the fix
|
||||
|
||||
|
||||
@@ -4558,6 +4558,27 @@ registers to agree; a TUI-only blank filter (`Text("")` to `None`) fails it on
|
||||
`+`, which is why that filter was removed rather than kept. The evidence entries
|
||||
for the host-side tests are in `command.md`.
|
||||
|
||||
### x_in_records_mode_clears_the_record_the_browser_clears
|
||||
|
||||
improvise-lx7w, both ends. The browser composes `ClearCell` of the projected
|
||||
`CellId::Record`. Before the fix the host's `x` cleared the synthetic key and
|
||||
the documents diverged in `both_ends`. South carries **two** measures. With one,
|
||||
clearing it removes South's whole records row, the host resets its cursor on
|
||||
the reframe, and `both_ends` reports a cursor divergence that belongs to the
|
||||
fixture and not to `x`. The first version of the test did exactly that. The test
|
||||
also asserts that South/Revenue is gone and North is untouched, because two ends
|
||||
that both do nothing also agree.
|
||||
|
||||
### p_onto_a_formula_owned_cell_declines_on_both_ends
|
||||
|
||||
improvise-54g1, both ends, on `yank_host`'s `Profit` column (row 1, column 3).
|
||||
The client must not compose a `Command::Model`, and the host's document must not
|
||||
move and must say why. The control, Cost at column 2, must compose a write, so
|
||||
a client that never writes cannot pass. The fold case,
|
||||
`a_write_onto_a_fold_declines_on_both_ends`, runs the same helper for `p` and `x`
|
||||
at row 2. Rival for all three: the host's paste/clear door restored to
|
||||
`ctx.cell_key()`, which was `gh/main`'s code. All three fail.
|
||||
|
||||
### the worker is not called up until its first frame has arrived
|
||||
|
||||
improvise-i0v9. The worker transport wrote `in-page worker · <doc>` when it
|
||||
@@ -4750,6 +4771,17 @@ disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the
|
||||
cell B cleared. Under that rival, the 0j0f test above failed as well. Both
|
||||
files were restored.
|
||||
|
||||
### p_onto_a_sparse_record_writes_what_the_browser_writes
|
||||
|
||||
improvise-q9eg, both ends, on a **sparse** record: `Season` is added after
|
||||
entering records mode, so no record carries it. `yy` on North/Revenue, then `p`
|
||||
on South/Revenue (row 1), through `both_ends`, which compares the host's
|
||||
document with the one the client's composed commands build. The host refused
|
||||
while the browser composed `SetCell`, so the documents differed. The final
|
||||
assertion that South holds 1200 is the half that keeps the test from passing
|
||||
when both ends decline. Rival: `PasteAt::Resolved` back through `paste_into`.
|
||||
This test fails.
|
||||
|
||||
### a_locally_resolved_page_scroll_lands_where_the_host_would_put_it
|
||||
|
||||
**What was missing (improvise-5xbq).** `tests/lockstep.rs` compared only `j`
|
||||
|
||||
@@ -5269,6 +5269,97 @@ the first message does not mention drilling. At `0ed2e41` it failed with the
|
||||
old text. The rival is restoring that text. Real drill paste, which means
|
||||
staging a typed `CellValue`, is still open as improvise-48jd.
|
||||
|
||||
The drilled paste now declines at the cursor door (`value_cell_key`) before
|
||||
`PasteCell` runs, so the status carries the registry's `paste:` prefix and a
|
||||
wording shared with `clear-cell`. The assertion was re-aimed from equality with
|
||||
`PASTE_INSIDE_DRILL` to `ends_with(INSIDE_A_DRILL_REFUSAL)` (improvise-gw18's
|
||||
doors). The advice half is unchanged.
|
||||
|
||||
### drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate
|
||||
|
||||
improvise-gw18. On `gh/main` at `800348a`, `>` on the Q2 fold (row 2, column 1)
|
||||
opened a `_Drill` with no rows, and `o` in it stored `Month=Q2` in `## Data`. The
|
||||
test presses both keys through `App::handle_key`, then saves and reloads, and
|
||||
asserts that no coordinate names a non-item (`non_item_coordinates`, which asks
|
||||
the category, not the spelling). It failed on the first assertion: the drill
|
||||
opened. Rival: the registry's cursor fallback on the reading accessor
|
||||
(`cell_key`) in place of `unfolded_cell_key`. This test and
|
||||
`no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate` fail. The
|
||||
control, `drilling_into_the_row_above_a_fold_still_opens_a_drill`, holds the
|
||||
refusal to the fold alone.
|
||||
|
||||
### no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate
|
||||
|
||||
improvise-gw18's enumeration, made executable. Every key bound in Normal mode,
|
||||
prefix sub-keys included, is pressed on a fresh app with the cursor on a fold
|
||||
and a value yanked, and no stored coordinate may name a non-item. The sweep is
|
||||
**enumerated** rather than hand-picked, which is how it found `p` (the fold half
|
||||
of improvise-l4mp) without being told. The first run failed on
|
||||
`[(Char('p'), NONE)] on a fold stored [("Month", "Q2")]`. It asserts that the
|
||||
enumeration reaches `p`, so an empty sweep cannot pass. What it cannot see is a
|
||||
write two keys deep (`>` then `o`). That is why the drill test above exists. A
|
||||
two-key sweep was **estimated**, not measured, at several seconds and not built.
|
||||
|
||||
Widened after PR #197's review, which showed that `H` without its fold check
|
||||
passed the sweep: a ghost `hidden:` entry is not a stored coordinate. Every
|
||||
stroke must now also leave the `## Data` section and the view's hidden set
|
||||
exactly as they were. With the `H` guard disabled, this test fails as well as
|
||||
the yk2s one. What no sweep can see is `clear-cell` back on the reading
|
||||
accessor, because clearing a key nothing is stored at changes nothing. The
|
||||
lx7w tests and the fold lockstep case catch that.
|
||||
|
||||
### hiding_a_folds_row_is_refused_and_persists_no_hidden_group
|
||||
|
||||
improvise-yk2s. `H` on the fold emitted `HideItem { Month, "Q2" }`. `View`
|
||||
accepts an unknown name, so a ghost `hidden:` line was saved, with no visible
|
||||
change and no message. The test asserts no hidden entry, a message, and no
|
||||
`hidden:` line in the saved text. The control hides `Feb` one row up. Rival: the
|
||||
`row_folds_a_group` guard disabled (`if false && …`). Only this test fails. The
|
||||
guard asks the row half of `folds_a_group`, so an inner-level fold
|
||||
(`North/Q1`) is refused too, although `North` is a real item. That is a
|
||||
deliberate over-refusal, taken to keep one predicate.
|
||||
|
||||
### enter_advancing_onto_a_fold_commits_nothing_there
|
||||
|
||||
The write half of improvise-enz6, closed by the commit door. `i` on Feb, then
|
||||
`Enter`, which commits and advances onto the Q2 fold and re-opens the editor
|
||||
there. That entry is still enz6's open half. Then `7` and `Enter`. At the
|
||||
previous commit door (`cell_key`) the second commit stored `Month=Q2`. Rival:
|
||||
`commit_at_cursor` on `cell_key` again. This test and
|
||||
`tab_advancing_onto_a_formula_cell_commits_nothing_under_it` fail.
|
||||
|
||||
### x_in_records_mode_clears_the_real_record_under_the_cursor
|
||||
|
||||
improvise-lx7w. In records mode `x` built `ClearCell` from the synthetic
|
||||
`(_Index, _Dim)` key, which names nothing stored, so it was a silent no-op. The
|
||||
cursor is on West × Cost (row 1, column 3). The test asserts that West/Cost is
|
||||
gone, that East/Cost is untouched (so a clear of the wrong row cannot pass), and
|
||||
that nothing synthetic reaches the reloaded file. Rival: `clear-cell`'s cursor
|
||||
fallback on the old door. This test and the coordinate-column one fail.
|
||||
|
||||
### x_on_a_records_coordinate_column_changes_nothing_and_says_so
|
||||
|
||||
improvise-lx7w's decline arm, the answer #184 gave paste: a coordinate column
|
||||
stores no value, so `x` there changes nothing and says so. Before the fix it
|
||||
changed nothing *silently*, and it fails on the message.
|
||||
|
||||
### p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula
|
||||
|
||||
improvise-54g1. `p` on West × Profit (`Profit = Revenue - Cost`, row 1,
|
||||
column 2) stored `_Measure=Profit` under the formula. The recompute hid it, but
|
||||
it survived in `## Data`. The test asserts that the saved text has no Profit
|
||||
line, that the reload has no such cell, and that the status names the formula.
|
||||
The control, one column left, must write, so a door that refused everything
|
||||
cannot pass. Rival: the old door. It fails on the saved text.
|
||||
|
||||
### tab_advancing_onto_a_formula_cell_commits_nothing_under_it
|
||||
|
||||
A door the tickets did not list, found by working the enumeration. `i` declines
|
||||
to edit a formula's cell, but `Tab` from West/Cost commits and advances onto
|
||||
Profit, `EnterEditAtCursor` re-opens the editor there, and `Enter` stored
|
||||
`7` under the formula. Found by this test failing at `90b93d4`, before
|
||||
`committable_cell_key` existed. The rival is the one above.
|
||||
|
||||
### ui::host::tests::every_failing_model_command_refuses_its_whole_frame
|
||||
|
||||
improvise-9fpl, found by PR #181's review. Eight of bok4's refusal sites had no
|
||||
@@ -5621,6 +5712,20 @@ cannot see the feature removed from `improvise-command` alone, because
|
||||
`improvise-io`, a normal dependency, enables it in the same build. That case is
|
||||
held by `every_crate_that_can_parse_json_enables_float_roundtrip` (`tooling.md`).
|
||||
|
||||
### p_onto_a_sparse_record_writes_it
|
||||
|
||||
improvise-q9eg, a regression introduced by PR #197 and found in its review. In
|
||||
records mode a category is added (as `+cat` does), so every existing record
|
||||
lacks it and it sits on `Axis::None`. `yy` on East/Cost, then `p` on West/Cost
|
||||
(row 1): gh/main pasted 700, and #197 refused with the aggregate message.
|
||||
`value_cell_key` handed `PasteCell` the real record key, and `paste_into` then
|
||||
re-ran `aggregates(ctx, key)`. That key-shape check is a second derivation
|
||||
beside `cell_id`, and it reads a record missing a None-axis category as an
|
||||
aggregate. Every records fixture before this one was fully populated, which is
|
||||
why nothing saw it. The fix is `PasteAt::{Resolved, Spelled}`: a resolved key is
|
||||
written as it stands. Rival: routing `Resolved` back through `paste_into`. This
|
||||
test and `p_onto_a_sparse_record_writes_what_the_browser_writes` fail.
|
||||
|
||||
## `ui/effect.rs` — `RecordRewrite`'s untested branches (improvise-phg1)
|
||||
|
||||
### renaming_a_drill_row_held_by_a_lone_measureless_cell_moves_that_cell
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::ui::effect::{self, Effect, ViewTarget};
|
||||
use crate::view::synthetic_record_info;
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
use super::core::{AGGREGATE_REFUSAL, Cmd, CmdContext, INSIDE_A_DRILL_REFUSAL, NO_VALUE_REFUSAL};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -66,7 +66,9 @@ mod tests {
|
||||
("Type".into(), "Clothing".into()),
|
||||
("Month".into(), "Feb".into()),
|
||||
]);
|
||||
let cmd = PasteCell { key: key.clone() };
|
||||
let cmd = PasteCell {
|
||||
at: PasteAt::Spelled(key.clone()),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert_eq!(effects.len(), 1);
|
||||
let mut app = crate::ui::app::App::new(two_cat_model(), None);
|
||||
@@ -88,7 +90,9 @@ mod tests {
|
||||
("Type".into(), "Food".into()),
|
||||
("Month".into(), "Jan".into()),
|
||||
]);
|
||||
let cmd = PasteCell { key };
|
||||
let cmd = PasteCell {
|
||||
at: PasteAt::Spelled(key),
|
||||
};
|
||||
let effects = cmd.execute(&ctx);
|
||||
assert!(effects.is_empty());
|
||||
}
|
||||
@@ -332,9 +336,9 @@ mod tests {
|
||||
app.cursor_mut().selected = (0, col_of(&app, "Revenue"));
|
||||
press(&mut app, 'p');
|
||||
let in_drill = app.view_state.status_msg.clone();
|
||||
assert_eq!(
|
||||
in_drill, PASTE_INSIDE_DRILL,
|
||||
"setup: the drilled paste declines"
|
||||
assert!(
|
||||
in_drill.ends_with(INSIDE_A_DRILL_REFUSAL),
|
||||
"setup: the drilled paste declines, got {in_drill:?}"
|
||||
);
|
||||
assert!(
|
||||
!advice.to_lowercase().contains("drill"),
|
||||
@@ -353,7 +357,10 @@ mod tests {
|
||||
ctx.yanked = &yanked;
|
||||
assert!(!ctx.none_cats().is_empty(), "setup: the view aggregates");
|
||||
let key = record("West", "Revenue").with("Product", "Gadget");
|
||||
let effects = PasteCell { key: key.clone() }.execute(&ctx);
|
||||
let effects = PasteCell {
|
||||
at: PasteAt::Spelled(key.clone()),
|
||||
}
|
||||
.execute(&ctx);
|
||||
let mut app = aggregated_app(crate::view::Axis::None);
|
||||
app.apply_effects(effects);
|
||||
assert_eq!(
|
||||
@@ -389,6 +396,163 @@ mod tests {
|
||||
let dbg = effects_debug(&effects);
|
||||
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
||||
}
|
||||
|
||||
/// improvise-q9eg: a record that lacks a later category is still a record —
|
||||
/// see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn p_onto_a_sparse_record_writes_it() {
|
||||
let mut app = records_app();
|
||||
app.model_state.workbook.add_category("Season").unwrap();
|
||||
app.rebuild_layout();
|
||||
assert!(
|
||||
app.layout.none_cats.iter().any(|c| c == "Season"),
|
||||
"setup: the new category is aggregated away, so every record lacks it"
|
||||
);
|
||||
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
|
||||
press(&mut app, 'y');
|
||||
press(&mut app, 'y');
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
|
||||
press(&mut app, 'p');
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&record("West", "Cost")),
|
||||
Some(&CellValue::Number(700.0)),
|
||||
"status: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-lx7w — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn x_in_records_mode_clears_the_real_record_under_the_cursor() {
|
||||
let mut app = records_app();
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
|
||||
press(&mut app, 'x');
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("West", "Cost")),
|
||||
None,
|
||||
"West/Cost"
|
||||
);
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("East", "Cost")),
|
||||
Some(&CellValue::Number(700.0)),
|
||||
"the neighbouring record is untouched"
|
||||
);
|
||||
let (text, back) = saved_and_reloaded(wb);
|
||||
assert!(!text.contains("West, _Measure=Cost"), "saved:\n{text}");
|
||||
assert_eq!(back.model.get_cell(&record("West", "Cost")), None);
|
||||
assert_eq!(synthetic_cells(&back), Vec::<CellKey>::new());
|
||||
}
|
||||
|
||||
/// improvise-lx7w: a coordinate column stores no value, so `x` declines there.
|
||||
#[test]
|
||||
fn x_on_a_records_coordinate_column_changes_nothing_and_says_so() {
|
||||
let mut app = records_app();
|
||||
let before = crate::persistence::format_md(&app.model_state.workbook);
|
||||
app.cursor_mut().selected = (1, col_of(&app, "Region"));
|
||||
press(&mut app, 'x');
|
||||
assert_eq!(
|
||||
crate::persistence::format_md(&app.model_state.workbook),
|
||||
before
|
||||
);
|
||||
assert!(
|
||||
app.view_state.status_msg.ends_with(NO_VALUE_REFUSAL),
|
||||
"declined out loud, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// Two regions by two raw measures and `Profit = Revenue - Cost`, the formula
|
||||
/// in the third column, so the cursor on it is off row 0 and column 0.
|
||||
fn formula_app() -> App {
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Region").unwrap();
|
||||
wb.add_item("Region", "East").unwrap();
|
||||
wb.add_item("Region", "West").unwrap();
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
wb.model.add_formula(
|
||||
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
|
||||
);
|
||||
wb.active_view_mut()
|
||||
.set_axis("Region", crate::view::Axis::Row);
|
||||
wb.active_view_mut()
|
||||
.set_axis("_Measure", crate::view::Axis::Column);
|
||||
for (region, measure, n) in [
|
||||
("East", "Revenue", 10.0),
|
||||
("East", "Cost", 4.0),
|
||||
("West", "Revenue", 9.0),
|
||||
("West", "Cost", 2.0),
|
||||
] {
|
||||
wb.model
|
||||
.set_cell(record(region, measure), CellValue::Number(n));
|
||||
}
|
||||
let mut app = App::new(wb, None);
|
||||
app.view_state.yanked = Some(CellValue::Number(99.0));
|
||||
app
|
||||
}
|
||||
|
||||
/// improvise-54g1 — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula() {
|
||||
let mut app = formula_app();
|
||||
app.cursor_mut().selected = (1, 2);
|
||||
assert_eq!(app.layout.col_label(2), "Profit", "setup: on the formula");
|
||||
press(&mut app, 'p');
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("_Measure=Profit"),
|
||||
"saved under the formula:\n{text}"
|
||||
);
|
||||
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
|
||||
assert!(
|
||||
app.view_state
|
||||
.status_msg
|
||||
.contains("Profit is computed by a formula"),
|
||||
"declined out loud, got {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
|
||||
// The control: one column left is a stored cell, and p writes it.
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'p');
|
||||
let wb = &app.model_state.workbook;
|
||||
assert_eq!(
|
||||
wb.model.get_cell(&record("West", "Cost")),
|
||||
Some(&CellValue::Number(99.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// The second door onto a formula cell: Tab advances the editor onto it, where
|
||||
/// `i` would have declined. See context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn tab_advancing_onto_a_formula_cell_commits_nothing_under_it() {
|
||||
let mut app = formula_app();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'i');
|
||||
assert!(app.view_state.mode.is_editing(), "setup: editing West/Cost");
|
||||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.cursor().selected,
|
||||
(1, 2),
|
||||
"setup: Tab moved onto Profit"
|
||||
);
|
||||
for c in "7".chars() {
|
||||
press(&mut app, c);
|
||||
}
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("_Measure=Profit"),
|
||||
"saved under the formula:\n{text}"
|
||||
);
|
||||
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cell operations ──────────────────────────────────────────────────────────
|
||||
@@ -432,20 +596,31 @@ impl Cmd for YankCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a paste lands: a key the cursor's door already resolved, or one a
|
||||
/// script spelled out, which only the key's own shape can vet (improvise-q9eg).
|
||||
#[derive(Debug)]
|
||||
pub enum PasteAt {
|
||||
Resolved(CellKey),
|
||||
Spelled(CellKey),
|
||||
}
|
||||
|
||||
/// Paste the yanked value into a cell.
|
||||
#[derive(Debug)]
|
||||
pub struct PasteCell {
|
||||
pub key: crate::model::cell::CellKey,
|
||||
pub at: PasteAt,
|
||||
}
|
||||
impl Cmd for PasteCell {
|
||||
fn name(&self) -> &'static str {
|
||||
"paste"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
match ctx.yanked.clone() {
|
||||
Some(value) => vec![paste_into(ctx, &self.key, value)],
|
||||
None => vec![],
|
||||
}
|
||||
let Some(value) = ctx.yanked.clone() else {
|
||||
return vec![];
|
||||
};
|
||||
vec![match &self.at {
|
||||
PasteAt::Resolved(key) => Box::new(effect::SetCell(key.clone(), value)),
|
||||
PasteAt::Spelled(key) => paste_into(ctx, key, value),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +629,7 @@ impl Cmd for PasteCell {
|
||||
fn paste_into(ctx: &CmdContext, key: &CellKey, value: CellValue) -> Box<dyn Effect> {
|
||||
match synthetic_record_info(key) {
|
||||
Some((row, col)) => paste_into_record(ctx, row, col, value),
|
||||
None if aggregates(ctx, key) => effect::set_status(PASTE_ONTO_AGGREGATE),
|
||||
None if aggregates(ctx, key) => effect::set_status(AGGREGATE_REFUSAL),
|
||||
None => Box::new(effect::SetCell(key.clone(), value)),
|
||||
}
|
||||
}
|
||||
@@ -466,9 +641,9 @@ fn paste_into_record(
|
||||
value: CellValue,
|
||||
) -> Box<dyn Effect> {
|
||||
match (ctx.layout.record_cell_key(row, col), ctx.has_drill_state) {
|
||||
(Some(_), true) => effect::set_status(PASTE_INSIDE_DRILL),
|
||||
(Some(_), true) => effect::set_status(INSIDE_A_DRILL_REFUSAL),
|
||||
(Some(real), false) => Box::new(effect::SetCell(real, value)),
|
||||
(None, _) => effect::set_status(PASTE_ONTO_NO_VALUE),
|
||||
(None, _) => effect::set_status(NO_VALUE_REFUSAL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,10 +654,6 @@ fn aggregates(ctx: &CmdContext, key: &CellKey) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
const PASTE_ONTO_AGGREGATE: &str = "Cannot paste onto an aggregate — it combines several records, so there is no one cell to write";
|
||||
const PASTE_ONTO_NO_VALUE: &str = "Paste lands only on a measure column";
|
||||
const PASTE_INSIDE_DRILL: &str = "Paste is not staged inside a drill — edit the cell instead";
|
||||
|
||||
// ── View commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -11,6 +11,21 @@ use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{Effect, Panel};
|
||||
use crate::view::{Axis, GridLayout, View};
|
||||
use crate::workbook::Workbook;
|
||||
use improvise_protocol::CellId;
|
||||
|
||||
/// What a command says when the cursor sits on a collapsed group's row (§14).
|
||||
pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first";
|
||||
const NO_CELL: &str = "no cell at cursor";
|
||||
pub const AGGREGATE_REFUSAL: &str =
|
||||
"Cannot write to an aggregate — it combines several records, so there is no one cell to write";
|
||||
pub const NO_VALUE_REFUSAL: &str = "Only a measure column holds a value";
|
||||
pub const INSIDE_A_DRILL_REFUSAL: &str =
|
||||
"A value is not staged inside a drill — edit the cell instead";
|
||||
|
||||
/// What a command says when the cell it would write is a formula's result.
|
||||
pub fn derived_refusal(target: &str) -> String {
|
||||
format!("{target} is computed by a formula — edit the formula, not the cell")
|
||||
}
|
||||
|
||||
/// Read-only context available to commands for decision-making: a `&Model`, the
|
||||
/// active `&View`, and the `&Workbook` for the rare command that enumerates views.
|
||||
@@ -64,10 +79,75 @@ impl<'a> CmdContext<'a> {
|
||||
self.layout.is_records_mode()
|
||||
}
|
||||
|
||||
/// The cell under the cursor, for a command that only READS it. The writers'
|
||||
/// doors: `context/code/command.md`, "The cursor's cell has three doors".
|
||||
pub fn cell_key(&self) -> Option<CellKey> {
|
||||
self.layout.cell_key(self.selected.0, self.selected.1)
|
||||
}
|
||||
|
||||
/// The cursor's key for a command that resolves it itself (commit, drill),
|
||||
/// refusing a fold: its key names a group, which no record carries (§14).
|
||||
pub fn unfolded_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
||||
true => Err(FOLD_REFUSAL.to_string()),
|
||||
false => self.cell_key().ok_or_else(|| NO_CELL.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor's key for a commit, which resolves a records position itself
|
||||
/// (gotcha 19). An aggregate or a formula's result is refused, as `i` refuses it.
|
||||
pub fn committable_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.cursor_cell_id() {
|
||||
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
|
||||
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
|
||||
Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => {
|
||||
self.cell_key().ok_or_else(|| NO_CELL.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored cell a VALUE written at the cursor lands on, answered by the
|
||||
/// `CellId` the browser is given: `Record` is the one settable arm (ADR 002).
|
||||
pub fn value_cell_key(&self) -> Result<CellKey, String> {
|
||||
match self.cursor_cell_id() {
|
||||
Some(CellId::Record(_)) => self.stored_cell_key(),
|
||||
Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()),
|
||||
Some(CellId::Derived { target }) => Err(derived_refusal(&target)),
|
||||
Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()),
|
||||
None => Err(match self.cell_key() {
|
||||
Some(_) => NO_VALUE_REFUSAL,
|
||||
None => NO_CELL,
|
||||
}
|
||||
.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cursor's cell is, asked exactly as the projection asks it.
|
||||
fn cursor_cell_id(&self) -> Option<CellId> {
|
||||
crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1)
|
||||
}
|
||||
|
||||
/// An aggregate's refusal, naming the fold where the aggregate is one (§14).
|
||||
fn aggregate_refusal(&self) -> String {
|
||||
match self.layout.folds_a_group(self.selected.0, self.selected.1) {
|
||||
true => FOLD_REFUSAL,
|
||||
false => AGGREGATE_REFUSAL,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A `Record`'s real key: a records position resolves through its row, and a
|
||||
/// drill's records are staged, never written directly (gotcha 1, 19).
|
||||
fn stored_cell_key(&self) -> Result<CellKey, String> {
|
||||
let (row, col) = self.selected;
|
||||
let stored = match (self.is_records_mode(), self.has_drill_state) {
|
||||
(true, true) => return Err(INSIDE_A_DRILL_REFUSAL.to_string()),
|
||||
(true, false) => self.layout.record_cell_key(row, col),
|
||||
(false, _) => self.cell_key(),
|
||||
};
|
||||
stored.ok_or_else(|| NO_CELL.to_string())
|
||||
}
|
||||
|
||||
/// The formula that owns the cell under the cursor, if one does. In records
|
||||
/// mode the synthetic key names no measure, so the record's key is resolved.
|
||||
pub fn owning_formula(&self) -> Option<&'a crate::formula::Formula> {
|
||||
|
||||
@@ -11,6 +11,96 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::command::cmd::test_helpers::*;
|
||||
|
||||
fn press(app: &mut crate::ui::app::App, c: char) {
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// improvise-gw18 — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn drilling_into_a_fold_is_refused_and_o_after_it_writes_no_group_coordinate() {
|
||||
let mut app = app_on_a_fold();
|
||||
let origin = app.view_state.active_view.clone();
|
||||
press(&mut app, '>');
|
||||
assert_eq!(app.view_state.active_view, origin, "no drill was opened");
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("ollapsed group"),
|
||||
"the refusal is out loud, got: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
press(&mut app, 'o');
|
||||
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
let data = text.split("## Data").nth(1).expect("a data section");
|
||||
assert!(
|
||||
!data.contains("Q2"),
|
||||
"a group coordinate was saved:\n{text}"
|
||||
);
|
||||
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
|
||||
}
|
||||
|
||||
/// The commit door onto a fold: Enter advances the editor onto it, and the next
|
||||
/// commit is what must refuse. See context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn enter_advancing_onto_a_fold_commits_nothing_there() {
|
||||
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
let mut app = app_on_a_fold();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'i');
|
||||
app.handle_key(enter).unwrap();
|
||||
assert_eq!(
|
||||
app.cursor().selected,
|
||||
(2, 1),
|
||||
"setup: Enter moved onto the fold"
|
||||
);
|
||||
press(&mut app, '7');
|
||||
app.handle_key(enter).unwrap();
|
||||
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
|
||||
let data = text.split("## Data").nth(1).expect("a data section");
|
||||
assert!(
|
||||
!data.contains("Q2"),
|
||||
"a group coordinate was saved:\n{text}"
|
||||
);
|
||||
assert_eq!(non_item_coordinates(&back), Vec::<(String, String)>::new());
|
||||
}
|
||||
|
||||
/// The control for the refusal above: the same key one row up drills.
|
||||
#[test]
|
||||
fn drilling_into_the_row_above_a_fold_still_opens_a_drill() {
|
||||
let mut app = app_on_a_fold();
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
let origin = app.view_state.active_view.clone();
|
||||
press(&mut app, '>');
|
||||
assert_ne!(app.view_state.active_view, origin, "Feb × Cost drills");
|
||||
}
|
||||
|
||||
/// improvise-yk2s — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn hiding_a_folds_row_is_refused_and_persists_no_hidden_group() {
|
||||
let mut app = app_on_a_fold();
|
||||
press(&mut app, 'H');
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(!view.is_hidden("Month", "Q2"), "no ghost hidden entry");
|
||||
assert!(
|
||||
app.view_state.status_msg.contains("ollapsed group"),
|
||||
"the refusal is out loud, got: {:?}",
|
||||
app.view_state.status_msg
|
||||
);
|
||||
let (text, _) = saved_and_reloaded(&app.model_state.workbook);
|
||||
assert!(
|
||||
!text.contains("hidden:"),
|
||||
"a hidden entry was saved:\n{text}"
|
||||
);
|
||||
|
||||
// The control: the row above is an item, and H hides it.
|
||||
app.cursor_mut().selected = (1, 1);
|
||||
press(&mut app, 'H');
|
||||
let view = app.model_state.workbook.active_view();
|
||||
assert!(view.is_hidden("Month", "Feb"), "H on Feb hides Feb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
||||
let m = two_cat_model();
|
||||
@@ -505,6 +595,10 @@ impl Cmd for HideSelectedRowItem {
|
||||
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
|
||||
return vec![];
|
||||
};
|
||||
// A fold's row names a group, not an item, so there is nothing to hide (§14).
|
||||
if ctx.layout.row_folds_a_group(ctx.selected.0) {
|
||||
return vec![effect::refuse(super::core::FOLD_REFUSAL)];
|
||||
}
|
||||
let sel_row = ctx.selected.0;
|
||||
let Some(items) = ctx
|
||||
.layout
|
||||
|
||||
@@ -143,4 +143,62 @@ pub(super) mod test_helpers {
|
||||
pub fn make_registry() -> CmdRegistry {
|
||||
default_registry()
|
||||
}
|
||||
|
||||
/// Two quarters of months on Row, two measures on Column, every cell funded,
|
||||
/// Q2 collapsed: rows `[Jan, Feb, Q2]`, and the cursor on the fold at `(2, 1)`.
|
||||
pub fn app_on_a_fold() -> crate::ui::app::App {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::view::Axis;
|
||||
let mut wb = Workbook::new("T");
|
||||
wb.add_category("Month").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
let key = CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
("_Measure".into(), measure.into()),
|
||||
]);
|
||||
wb.model.set_cell(key, CellValue::Number(10.0 + i as f64));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = crate::ui::app::App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
app.cursor_mut().selected = (2, 1);
|
||||
assert!(
|
||||
app.layout.folds_a_group(2, 1),
|
||||
"setup: the cursor is on the fold"
|
||||
);
|
||||
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is Q2's row");
|
||||
app
|
||||
}
|
||||
|
||||
/// Every stored coordinate naming no item of its category — what a write to a
|
||||
/// fold leaves behind (core.md §14).
|
||||
pub fn non_item_coordinates(wb: &Workbook) -> Vec<(String, String)> {
|
||||
let model = &wb.model;
|
||||
model
|
||||
.data
|
||||
.iter_cells()
|
||||
.flat_map(|(key, _)| key.0.clone())
|
||||
.filter(|(cat, item)| {
|
||||
!model
|
||||
.category(cat)
|
||||
.is_some_and(|c| c.items.contains_key(item.as_str()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The document saved and read back: what reaches the file, not the store.
|
||||
pub fn saved_and_reloaded(wb: &Workbook) -> (String, Workbook) {
|
||||
let text = crate::persistence::format_md(wb);
|
||||
let back = crate::persistence::parse_md(&text).expect("the saved text reloads");
|
||||
(text, back)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,9 +380,8 @@ impl Cmd for EditOrDrill {
|
||||
// The same question `cell_id` asks, off the same predicate. In records mode
|
||||
// the synthetic key names no measure, so the record's key is resolved.
|
||||
if let Some(formula) = ctx.owning_formula() {
|
||||
return vec![effect::refuse(format!(
|
||||
"{} is computed by a formula — edit the formula, not the cell",
|
||||
formula.target
|
||||
return vec![effect::refuse(super::core::derived_refusal(
|
||||
&formula.target,
|
||||
))];
|
||||
}
|
||||
vec![
|
||||
|
||||
@@ -22,9 +22,20 @@ use super::text_buffer::*;
|
||||
use super::tile::*;
|
||||
use super::wizard::*;
|
||||
|
||||
/// The cell under the cursor: what a coordinate command falls back to when given none.
|
||||
/// The cell under the cursor for `drill-into-cell`, which opens aggregates but
|
||||
/// not a fold. Which command takes which door: `context/code/command.md`.
|
||||
fn cursor_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.cell_key().clone().ok_or("no cell at cursor".into())
|
||||
ctx.unfolded_cell_key()
|
||||
}
|
||||
|
||||
/// The stored cell a value lands on, for `clear-cell` and `paste`.
|
||||
fn cursor_value_key(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.value_cell_key()
|
||||
}
|
||||
|
||||
/// [`cursor_key`] for the one command that only READS the cell: `yank`.
|
||||
fn cursor_key_to_read(ctx: &CmdContext) -> Result<CellKey, String> {
|
||||
ctx.cell_key().ok_or("no cell at cursor".into())
|
||||
}
|
||||
|
||||
impl AtCursor for Move {
|
||||
@@ -57,7 +68,7 @@ fn commit_at_cursor(
|
||||
Ok(CommitAndAdvance {
|
||||
edit_mode: parse_mode_name(mode)?,
|
||||
value: read_buffer(ctx, "edit"),
|
||||
key: cursor_key(ctx)?,
|
||||
key: ctx.committable_cell_key()?,
|
||||
advance,
|
||||
cursor: CursorState::from_ctx(ctx),
|
||||
})
|
||||
@@ -89,7 +100,7 @@ pub fn default_registry() -> CmdRegistry {
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Ok(ClearCellCommand {
|
||||
key: cursor_key(ctx)?,
|
||||
key: cursor_value_key(ctx)?,
|
||||
})
|
||||
}),
|
||||
);
|
||||
@@ -237,25 +248,25 @@ pub fn default_registry() -> CmdRegistry {
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Ok(YankCell {
|
||||
key: cursor_key(ctx)?,
|
||||
key: cursor_key_to_read(ctx)?,
|
||||
})
|
||||
}),
|
||||
);
|
||||
r.register(
|
||||
&PasteCell {
|
||||
key: CellKey::new(vec![]),
|
||||
at: PasteAt::Spelled(CellKey::new(vec![])),
|
||||
},
|
||||
|args| {
|
||||
if args.is_empty() {
|
||||
return Err("paste requires at least one Cat/Item coordinate".into());
|
||||
}
|
||||
Ok(PasteCell {
|
||||
key: parse_cell_key_from_args(args),
|
||||
at: PasteAt::Spelled(parse_cell_key_from_args(args)),
|
||||
})
|
||||
},
|
||||
Some(|_, ctx| {
|
||||
Ok(PasteCell {
|
||||
key: cursor_key(ctx)?,
|
||||
at: PasteAt::Resolved(cursor_value_key(ctx)?),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -477,6 +477,69 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// improvise-gw18: every Normal key, prefixes walked, pressed on a fold with a
|
||||
/// value yanked — see context/test-evidence/command.md.
|
||||
#[test]
|
||||
fn no_normal_mode_key_pressed_on_a_fold_stores_a_group_coordinate() {
|
||||
use improvise_protocol::key::KeyEvent;
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let normal = ks
|
||||
.maps()
|
||||
.get(&ModeKey::Normal)
|
||||
.expect("a Normal keymap")
|
||||
.clone();
|
||||
let mods = [
|
||||
KeyModifiers::NONE,
|
||||
KeyModifiers::SHIFT,
|
||||
KeyModifiers::CONTROL,
|
||||
KeyModifiers::ALT,
|
||||
];
|
||||
let mut strokes: Vec<Vec<(KeyCode, KeyModifiers)>> = Vec::new();
|
||||
for key in probe_keys() {
|
||||
for m in mods {
|
||||
match normal.lookup(key, m) {
|
||||
Some(Binding::Prefix(sub)) => strokes.extend(
|
||||
probe_keys()
|
||||
.into_iter()
|
||||
.flat_map(|k2| mods.map(|m2| (k2, m2)))
|
||||
.filter(|&(k2, m2)| sub.lookup(k2, m2).is_some())
|
||||
.map(|second| vec![(key, m), second]),
|
||||
),
|
||||
Some(_) => strokes.push(vec![(key, m)]),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
strokes
|
||||
.iter()
|
||||
.any(|s| s == &[(KeyCode::Char('p'), KeyModifiers::NONE)]),
|
||||
"the sweep reaches paste"
|
||||
);
|
||||
// No key on a fold may touch the stored data or the hidden set at all.
|
||||
let touched = |app: &crate::ui::app::App| {
|
||||
let wb = &app.model_state.workbook;
|
||||
let text = crate::persistence::format_md(wb);
|
||||
let data = text.split("## Data").nth(1).unwrap_or_default().to_string();
|
||||
(data, wb.active_view().hidden_items.clone())
|
||||
};
|
||||
let before = touched(&test_helpers::app_on_a_fold());
|
||||
for stroke in &strokes {
|
||||
let mut app = test_helpers::app_on_a_fold();
|
||||
app.view_state.yanked = Some(crate::model::cell::CellValue::Number(5.0));
|
||||
for &(key, m) in stroke {
|
||||
let _ = app.handle_key(KeyEvent::new(key, m));
|
||||
}
|
||||
let stray = test_helpers::non_item_coordinates(&app.model_state.workbook);
|
||||
assert!(stray.is_empty(), "{stroke:?} on a fold stored {stray:?}");
|
||||
assert_eq!(
|
||||
touched(&app),
|
||||
before,
|
||||
"{stroke:?} on a fold changed the document"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A representative `AppMode` for each keymap, so a test can put a
|
||||
/// `CmdContext` in the mode whose bindings it is walking.
|
||||
fn app_mode_for(mode: &ModeKey) -> AppMode {
|
||||
|
||||
@@ -280,7 +280,7 @@ fn cell_display(
|
||||
text,
|
||||
numeric: matches!(value, Some(CellValue::Number(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
id: cell_id(app, layout, row, col),
|
||||
id: cell_id(&app.model_state.workbook.model, layout, row, col),
|
||||
value: value.clone(),
|
||||
};
|
||||
}
|
||||
@@ -289,7 +289,7 @@ fn cell_display(
|
||||
text: plan.at(key.as_ref()).value(value.as_ref()),
|
||||
numeric: matches!(value, Some(CellValue::Number(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
id: cell_id(app, layout, row, col),
|
||||
id: cell_id(&app.model_state.workbook.model, layout, row, col),
|
||||
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
|
||||
// beats a client un-formatting `text` (gotcha 47).
|
||||
edit_text: key.map(|key| app.display_value_in(layout, &key)),
|
||||
@@ -299,8 +299,12 @@ fn cell_display(
|
||||
|
||||
/// What the cell at `(row, col)` *is*, as the surrogate the client echoes back. The
|
||||
/// fork is `EditOrDrill`'s, asked the same way — see `context/code/command.md`.
|
||||
fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<CellId> {
|
||||
let model = &app.model_state.workbook.model;
|
||||
pub(crate) fn cell_id(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
row: usize,
|
||||
col: usize,
|
||||
) -> Option<CellId> {
|
||||
if layout.is_records_mode() {
|
||||
// A measure can be BOTH an explicit `_Measure` item and a formula target,
|
||||
// so the document is asked about the record's real key (improvise-ir7l).
|
||||
|
||||
@@ -736,7 +736,12 @@ impl GridLayout {
|
||||
/// Whether the cell at `(row, col)` folds a collapsed group: an aggregate
|
||||
/// with no record under it. The one home of that answer (§14).
|
||||
pub fn folds_a_group(&self, row: usize, col: usize) -> bool {
|
||||
folds(&self.row_items, row) || folds(&self.col_items, col)
|
||||
self.row_folds_a_group(row) || folds(&self.col_items, col)
|
||||
}
|
||||
|
||||
/// The row half of [`Self::folds_a_group`], for a command about the row alone.
|
||||
pub fn row_folds_a_group(&self, row: usize) -> bool {
|
||||
folds(&self.row_items, row)
|
||||
}
|
||||
|
||||
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
|
||||
|
||||
@@ -1895,6 +1895,185 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() {
|
||||
);
|
||||
}
|
||||
|
||||
// The cursor's cell as a write target (improvise-lx7w, improvise-54g1, improvise-gw18).
|
||||
|
||||
/// improvise-lx7w: `x` on a records measure clears the real record on both ends.
|
||||
/// Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn x_in_records_mode_clears_the_record_the_browser_clears() {
|
||||
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
||||
let two_records = || {
|
||||
let mut app = furnished_host();
|
||||
let wb = &mut app.model_state.workbook;
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
// Two measures, so clearing one leaves South's row where the cursor is.
|
||||
for (measure, n) in [("Revenue", 900.0), ("Cost", 40.0)] {
|
||||
let key = north_revenue()
|
||||
.with("Region", "South")
|
||||
.with("_Measure", measure);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(n));
|
||||
}
|
||||
app.rebuild_layout();
|
||||
reduce_key(&mut app, r);
|
||||
app
|
||||
};
|
||||
let (mut app, mut twin) = (two_records(), two_records());
|
||||
let revenue = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.col_label(c) == "Revenue")
|
||||
.expect("a Revenue column");
|
||||
assert_eq!(
|
||||
app.layout.record_row(1).unwrap().coords.get("Region"),
|
||||
Some("South"),
|
||||
"setup: row 1 is South"
|
||||
);
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (1, revenue);
|
||||
}
|
||||
let mut client = records_client(&app);
|
||||
client.view.cursor_mut().selected = (1, revenue);
|
||||
|
||||
both_ends(&mut app, &mut twin, &mut client, "x");
|
||||
let model = &app.model_state.workbook.model;
|
||||
assert_eq!(
|
||||
model.get_cell(&north_revenue().with("Region", "South")),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
model.get_cell(&north_revenue()).is_some(),
|
||||
"North is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-q9eg: `yy` then `p` onto a record that lacks a later category,
|
||||
/// on both ends. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn p_onto_a_sparse_record_writes_what_the_browser_writes() {
|
||||
let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE);
|
||||
let sparse = || {
|
||||
let mut app = furnished_host();
|
||||
let south = north_revenue().with("Region", "South");
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.set_cell(south, improvise_protocol::CellValue::Number(900.0));
|
||||
app.rebuild_layout();
|
||||
reduce_key(&mut app, r);
|
||||
// As `+cat` does: every existing record now lacks `Season`.
|
||||
app.model_state.workbook.add_category("Season").unwrap();
|
||||
app.rebuild_layout();
|
||||
app
|
||||
};
|
||||
let (mut app, mut twin) = (sparse(), sparse());
|
||||
assert!(
|
||||
app.layout.none_cats.iter().any(|c| c == "Season"),
|
||||
"setup: the new category is aggregated away"
|
||||
);
|
||||
let revenue = (0..app.layout.col_count())
|
||||
.find(|&c| app.layout.col_label(c) == "Revenue")
|
||||
.expect("a Revenue column");
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (0, revenue);
|
||||
}
|
||||
let mut client = records_client(&app);
|
||||
client.view.cursor_mut().selected = (0, revenue);
|
||||
both_ends(&mut app, &mut twin, &mut client, "y");
|
||||
both_ends(&mut app, &mut twin, &mut client, "y");
|
||||
|
||||
for host in [&mut app, &mut twin] {
|
||||
host.cursor_mut().selected = (1, revenue);
|
||||
}
|
||||
client.view.cursor_mut().selected = (1, revenue);
|
||||
both_ends(&mut app, &mut twin, &mut client, "p");
|
||||
assert_eq!(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.get_cell(&north_revenue().with("Region", "South")),
|
||||
Some(&improvise_protocol::CellValue::Number(1200.0)),
|
||||
"the paste must land, or both ends agreeing is both declining"
|
||||
);
|
||||
}
|
||||
|
||||
/// `key` at `at` on a host and on a client seeded from it: neither writes.
|
||||
fn declines_on_both_ends(mut app: App, at: (usize, usize), key: char) {
|
||||
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE);
|
||||
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
||||
app.cursor_mut().selected = at;
|
||||
let client = client_ready_to_edit(&app, (6, 6));
|
||||
assert!(
|
||||
!composes_a_write(&client, event),
|
||||
"the browser composed a write"
|
||||
);
|
||||
let before = document_text(&app);
|
||||
reduce_key(&mut app, event);
|
||||
assert_eq!(
|
||||
document_text(&app),
|
||||
before,
|
||||
"the TUI wrote where the browser declines"
|
||||
);
|
||||
assert!(
|
||||
!app.view_state.status_msg.is_empty(),
|
||||
"the TUI declined silently"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-54g1: `p` on a formula-owned cell. The control is the stored cell
|
||||
/// beside it. Evidence: `context/test-evidence/clients.md`.
|
||||
#[test]
|
||||
fn p_onto_a_formula_owned_cell_declines_on_both_ends() {
|
||||
let app = yank_host();
|
||||
assert_eq!(
|
||||
app.layout.col_label(3),
|
||||
"Profit",
|
||||
"setup: column 3 is the formula"
|
||||
);
|
||||
declines_on_both_ends(app, (1, 3), 'p');
|
||||
|
||||
let mut app = yank_host();
|
||||
app.view_state.yanked = Some(improvise_protocol::CellValue::Number(99.0));
|
||||
app.cursor_mut().selected = (1, 2);
|
||||
let client = client_ready_to_edit(&app, (6, 6));
|
||||
let p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
|
||||
assert!(
|
||||
composes_a_write(&client, p),
|
||||
"the control: Cost is settable"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-gw18/l4mp: `p` and `x` on a collapsed group's fold, off the origin.
|
||||
#[test]
|
||||
fn a_write_onto_a_fold_declines_on_both_ends() {
|
||||
let fold_host = || {
|
||||
let mut wb = Workbook::new("Test");
|
||||
wb.add_category("Month").unwrap();
|
||||
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
}
|
||||
wb.add_item("_Measure", "Revenue").unwrap();
|
||||
wb.add_item("_Measure", "Cost").unwrap();
|
||||
for month in ["Jan", "Feb", "Apr", "May"] {
|
||||
for measure in ["Revenue", "Cost"] {
|
||||
let key = improvise_command::model::cell::CellKey::new(vec![
|
||||
("Month".to_string(), month.to_string()),
|
||||
("_Measure".to_string(), measure.to_string()),
|
||||
]);
|
||||
wb.model
|
||||
.set_cell(key, improvise_protocol::CellValue::Number(3.0));
|
||||
}
|
||||
}
|
||||
wb.active_view_mut().set_axis("Month", Axis::Row);
|
||||
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
|
||||
let mut app = App::new(wb, None);
|
||||
app.rebuild_layout();
|
||||
assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2");
|
||||
app
|
||||
};
|
||||
declines_on_both_ends(fold_host(), (2, 1), 'p');
|
||||
declines_on_both_ends(fold_host(), (2, 1), 'x');
|
||||
}
|
||||
|
||||
/// Two regions by two measures, `value` at (South, Revenue): row 1, column 1,
|
||||
/// so "the cell under the cursor" is not "the first cell".
|
||||
fn precise_host(value: improvise_protocol::CellValue) -> App {
|
||||
|
||||
Reference in New Issue
Block a user