Merge pull request #176 from fiddlerwoaroof/fix/6qet-collapse-row
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

improvise-6qet: a collapsed group is one aggregate row, and z's cursor follows it
This commit is contained in:
Ed Langley
2026-09-22 21:36:41 -07:00
committed by GitHub
14 changed files with 942 additions and 152 deletions
+102
View File
@@ -832,6 +832,108 @@ that item. Under route-then-aggregate that arm is reached only where a record
genuinely lacks the coordinate, because the fold hands the expression a record genuinely lacks the coordinate, because the fold hands the expression a record
key. Evaluated bare, outside a `SUM`, it degrades exactly as `Formula::filter` key. Evaluated bare, outside a `SUM`, it degrades exactly as `Formula::filter`
does — which is why a fitted tree is always emitted wrapped. does — which is why a fitted tree is always emitted wrapped.
## 14. A collapsed group is one row, and its cell folds the group (improvise-6qet)
**The ruling.** "When collapsing a group, I'd generally expect the cursor to move
to whichever place the group collapsed into" (user, 2026-09-23). The shape chosen
from a presented fork: **a collapsed group contributes one data row, whose cell is
an aggregate over the group.** Before this, `expand_category` emitted the group's
`GroupHeader` and then `continue`d past every item, so a collapsed group had no
data row at all. `row_group_for` is indexed by data row, so nothing on the grid
could name the group once it was collapsed: `z` pressed twice collapsed the
*neighbour*, and a collapsed group could be reopened only by `:toggle-group`.
The fix is to emit **one** `DataItem` where the `continue` was, and every
cursor-addressed question then answers itself by arithmetic. With
`Q1={Jan,Feb}, Q2={Apr,May}` and the cursor on `Apr` (row 2), collapsing Q2 gives
rows `[Jan, Feb, Q2]`, so row 2 *is* Q2 and a second `z` names Q2 again. `z z` is
an involution without anything being taught about involution, and neither
`ViewCursor` nor `row_group_for` changed.
### The row
- **Its tuple is the group name** (`Month=Q1`). `AxisEntry::DataItem` became a
struct variant `{ tuple, collapsed }` rather than growing a second variant,
because a new variant would be silently missed by `matches!(e, DataItem(_))`
sites; changing the shape made the compiler list all six external matches.
- **`collapsed` cannot be derived from the tuple**: a group and an item may
legally share a name. A parallel `Vec<bool>` was rejected for the reason gotcha
1 gives `prune_axis_items` a predicate — pruning shifts indices.
- **The flag is inherited** down `cross_product`, so a category *nested under* a
collapsed group (`Q1/North`) is also a fold: no record carries `Month=Q1`
whichever inner coordinate sits beside it.
- **A group's items need not be contiguous** (`A[G1], B[G2], C[G1]` is legal), so
`expand_category` keeps a `folded` list: a collapsed non-contiguous group folds
**once**, and its header is not re-emitted on the repeat — that header would
open a run with no row in it.
### The cell
`Model::evaluate_aggregated` expands, at its head, any coordinate that names a
group rather than an item into one key per member (a cross product where several
coordinates do). Doing it in the **model** is what makes `display_text`, prune,
column totals, `grid_of`, search, yank and CSV export all follow with no change.
Three decisions:
- **Membership is read off each item's own `group` field, never off
`Category::groups`.** `add_item_in_group` registers no `Group`; that list is
filled only by `.improv` `>` lines. Asking it answers "not a group" for every
document built in code, and the symptom is the fold's row pruned as empty.
- **An item with the same name as the group wins.** `items.contains_key`
short-circuits the expansion, so an existing document with an item `Q1` reads
exactly as before.
- **Stored values fold once over every record the group covers; a derived value
folds its members' results.** Folding the members' *aggregates* is right for
`Sum` and wrong for `Avg`/`Count` under `Axis::None` — `avg(avg(2,4), 9) = 6`
where the group's average is `5`. A formula target has no stored records to
pool, so its members' evaluated results are folded with the measure's
aggregation; for a ratio formula that is the usual pivot-table limitation, not
a new one. Hidden items are included in the fold, as on `Axis::None`: hiding
decides what is drawn, not what an aggregate covers.
### Settability and drill
The row is not a record, so `project.rs::cell_id` answers
`CellId::Aggregate` when `GridLayout::folds_a_group(row, col)` — the one home of
that question — whatever is on `Axis::None`. Without it, a document with nothing
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.
### The cursor, and why the order is the fix
`ToggleGroup` is `Model(LayoutGeometry)`, and `apply_effects` resets the cursor
when it observes the frame move *inside* the loop (gotcha 24). So
`ToggleGroupAtCursor` emits `ToggleGroup` **first** and the cursor placement
**after**; the swap is the rival, and it lands every toggle at `(0, 0)`. The
placement also restores the scroll offsets the reset zeroed.
Where the cursor lands is asked of the layout the toggle **will** produce
(`GridLayout::row_run_start(cat, group, prefix)` on a view with the toggle
applied), not of the current one. The same answer serves collapse and expand —
the group's first row — and it matters when the group is at an inner level:
collapsing `Month` under `Region` shrinks every earlier `Region`'s run too, so
`South/Apr` at row 6 lands on `South/Q2` at row 5, not row 6. That is the only
test the "ask the current layout" rival fails.
### What this does not do
- **Part 2 of the ruling** (a toggle carrying the group it acted on, so a later
command in the same batch can be invalidated) is not added: nothing binds `z`
as a `Binding::Sequence`, and if something did, both toggles would resolve
against one pre-batch context and name the same group, so the pair is a no-op —
which is what `z z` means. A mechanism with no reachable case is not built.
- **CSV export writes the fold as a row**, because an export writes what the grid
draws (improvise-iot6); a collapsed group is its own total row there too.
- Under `prune_empty` an **empty** collapsed group now disappears header and all,
where improvise-lhi4 kept its header. It is a narrowing, and deliberate: the
header was kept because it was the only way back to the rows, and the fold row
is now that way back — an empty fold has nothing to show.
- The browser still cannot press `z` (`toggle-group-under-cursor` resolves
`Upstream`), so the cursor half takes effect in the TUI only.
## `PageAxis` — what constrains, and what the user may turn (improvise-p1ym) ## `PageAxis` — what constrains, and what the user may turn (improvise-p1ym)
`GridLayout::page_coords` answers two questions that gotcha 27 says must not be `GridLayout::page_coords` answers two questions that gotcha 27 says must not be
+86 -37
View File
@@ -1257,9 +1257,11 @@ substring.
### a_window_reaching_the_last_row_carries_the_trailing_group_header ### a_window_reaching_the_last_row_carries_the_trailing_group_header
A collapsed group at the bottom of the grid has no items to hang off, and is still A collapsed group at the bottom of the grid folds into one row (improvise-6qet,
the only row that says those rows exist. A window that reaches the last row `context/code/core.md` §14), and the header comes above it. A window that reaches
therefore carries it. the last row therefore carries both: the frame is `[North, South, [Late], Late]`.
Before 6qet a collapsed group had no row, and this test asserted the header was the
*last* entry — the premise 6qet changed, so the length moved from 2 to 3.
It must be *seen*; it is not yet actionable in a browser. `web/app.js` registers no It must be *seen*; it is not yet actionable in a browser. `web/app.js` registers no
listener on a group `<th>`, and `toggle-group-under-cursor` is still in listener on a group `<th>`, and `toggle-group-under-cursor` is still in
@@ -1289,23 +1291,30 @@ say which group a column or row belongs to — while `body_from` declines to pai
header that sits above the window. header that sits above the window.
**And only the nearest one comes.** The fixture puts **two** headers above the **And only the nearest one comes.** The fixture puts **two** headers above the
window (`Hidden` is collapsed, so it emits a header and no items, and `Late`'s window (`Hidden`, whose collapsed group is now one row, and `Late`) because that is
follows it immediately) because that is the only way to tell "the nearest header the only way to tell "the nearest header comes" from "every header above comes".
comes" from "every header above comes". With one group above, deleting the Since improvise-6qet the fold is itself a row, so the window opens at row **2**
(`East`), past both `Hidden`'s header and its folded row; at row 1 the window would
start on the fold and bring `Hidden`'s header with it. With one group above, deleting the
compaction leaves this green — it is a *cost* property, O(the groups a deep scroll compaction leaves this green — it is a *cost* property, O(the groups a deep scroll
crossed), and correctness alone cannot see it: `group_of` still finds the nearest crossed), and correctness alone cannot see it: `group_of` still finds the nearest
and `body_from` still starts at the first item either way. and `body_from` still starts at the first item either way.
#### Helper — `app_with_consecutive_headers` #### Helper — `app_with_a_collapsed_group_between_rows`
Not a test — the fixture for that test and for `consecutive_headers_between_two_rows_all_come`. Not a test — the fixture for that test and for `every_header_between_two_in_window_rows_comes`.
Two consecutive group headers between two rows: `Hidden` is collapsed, so it emits Two group headers between `North` and `East`: `Hidden`, collapsed, and `Late`, open.
a header and no items, and `Late`'s header follows it immediately. It was `app_with_consecutive_headers` until improvise-6qet: a collapsed group now
contributes one row, so a collapse no longer produces two *adjacent* headers and
the old name described a shape the layout cannot emit.
### consecutive_headers_between_two_rows_all_come ### every_header_between_two_in_window_rows_comes
Headers between two in-window rows all come, not just the nearest. Headers between two in-window rows all come, not just the nearest — the frame is
`[North, [Hidden], Hidden, [Late], East]`, each header above the row it opens.
Renamed from `consecutive_headers_between_two_rows_all_come` for the reason the
fixture was.
Only the nearest wins `group_of`, so a compaction that kept one would still answer Only the nearest wins `group_of`, so a compaction that kept one would still answer
every question about *membership* correctly — and would silently lose the `Hidden` every question about *membership* correctly — and would silently lose the `Hidden`
@@ -4803,40 +4812,80 @@ cursor at data row 0:
3. The row axis is now empty, so `row_group_for(0)` resolves nothing and `z` 3. The row axis is now empty, so `row_group_for(0)` resolves nothing and `z`
returns no effects at all. returns no effects at all.
So the cursor-addressed toggle is not an involution, and a collapsed group cannot So the cursor-addressed toggle was not an involution, and a collapsed group could
be reopened from the grid — only by `:toggle-group`, which names the group. That not be reopened from the grid. **improvise-6qet changed that**, and the tests below
behaviour is unchanged by this work and is filed separately as improvise-6qet; the are its guards; the two that pinned the old behaviour
three tests below pin what the command *does*, which is what the old test claimed (`a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one`,
to and could not. `a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address`) were replaced
rather than kept, since the behaviour they pinned was the bug. The old law stays
deleted; a real determinism law over two independently built contexts is
improvise-hzjo.
improvise-6eqi found the survivors of ub4o all seated the cursor at data row 0, so a
toggle that ignored the cursor (`row_group_for(0)`) passed the whole workspace. Every
toggle test below starts off row 0; that rival was injected and fails five of them.
### toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it ### toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it
Asserts the effect names `category: "Month", group: "Q1"` — a category and a group Asserts the effect names `category: "Month", group: "Q2"` — a category and a group
by name, which no assertion in the tile/grid suite had ever done — and that the by name — and that the view's collapsed set actually moved, and that the neighbour
view's collapsed set actually moved afterwards. The fixture is asserted expanded `Q1` did not. The cursor sits on `Apr` (row 2): at row 0 the neighbour and the
first, so the post-condition is not supplied by the default. subject are the same answer. Fixture asserted expanded first, so the post-condition
is not supplied by the default.
Fault injected: `execute` returning `vec![]` after resolving the lookup. The old Faults injected: `execute` returning `vec![]` after resolving the lookup, and
law passed (both sides empty, as they already were); this test fails on the `row_group_for(0)` in place of the cursor row (improvise-6eqi). Both fail it.
missing effect, and the two below fail with it.
### a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one ### toggling_a_group_collapses_that_group_and_the_cursor_follows_it
The test the old one could not be: it requires the second `execute` to see a improvise-6qet, driven through the real path (`App::cmd_context` → `execute` →
layout the first one changed. `apply_effects`). Cursor at row 2 (`Apr`); `z` gives rows `[Jan, Feb, Q2]` with the
cursor on `Q2` at row 2, and `z` again gives `[Jan, Feb, Apr, May]` with the cursor
back on row 2 — the involution the old law claimed.
Fault injected: a stateless `execute` that always returns Faults injected: **the swap** (cursor placement emitted before `ToggleGroup`) fails
`ToggleGroup { category: "Month", group: "Q1" }` — i.e. one that ignores the it `left: (0, 0) right: (2, 0)` — the toggle's observed reframe resets the cursor
applied state, which is exactly what "does not apply effects between calls" hides. after the placement. `row_group_for(0)` fails it too.
Run against it, the old law **passes** (`first == second` by construction) and this
test fails, as does the one below. That rival is one-directional: it can only ever
name `Q1`, so a test that merely asserts "some group is named" is satisfied by it.
### a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address ### a_collapse_from_the_tail_of_a_run_lands_on_the_runs_first_row
Cursor on `May` (row 3). Collapsing Q2 lands on row 2, the fold. The rival "leave
the cursor where it was" is right for a cursor already on the run's first row and
wrong here (row 3 is past the end of `[Jan, Feb, Q2]`); injected, it fails this and
the next test only.
### collapsing_an_inner_group_lands_where_every_earlier_run_shrank
`Region × Month` on Row, `Month` the inner level. Cursor on `South/Apr` (row 6).
Collapsing Q2 shrinks `North`'s run as well, so `South/Q2` is at row **5**. The only
test that separates "ask the layout the toggle will produce" from "ask the current
layout": that rival returns 6 (`left: (6, 0) right: (5, 0)`).
### a_collapsed_group_can_be_re_expanded_from_the_grid
Both groups collapsed (`[Q1, Q2]`), cursor on the `Q2` row: `z` names Q2 and
reopens it, landing on `Apr` at row 1. Before 6qet a collapsed group had no row and
this was impossible from the grid. The swap fails it at `(0, 0)`.
### edit_or_drill_declines_a_collapsed_groups_row
A fold is neither editable nor drillable: no record carries `Month=Q2`. The same
context at `Feb` (row 1) still opens the editor, so the decline is about the row,
not the fixture. Rival: the `folds_a_group` guard removed — the command opens an
editor on the fold, and the test fails.
## `ui/project.rs` — a collapsed group's row is an aggregate (improvise-6qet)
### ui::project::cell_identity::a_collapsed_groups_row_is_projected_as_an_aggregate
Nothing regular is on `Axis::None`, so the none-axis arm of `cell_id` cannot supply
`Aggregate` — and `North` in the same grid is asserted `Record` to prove it. The
`Coastal` fold must be `Aggregate`, and its text `7.00` (East 3 + West 4). Rival:
`folds_a_group` dropped from `cell_id` — the fold is projected `Record`, which
`render.rs::editable` would offer for a write to the non-item `Region=Coastal`. It
also fails when group membership is read off `Category::groups` instead of each
item's `group`, because a document built with `add_item_in_group` registers none.
Pins step 3 — `row_count() == 0` is asserted before the call, so the empty effect
vector is the command declining to name a group rather than the fixture never
having had one. Both rivals above fail it.
## `ui/project.rs` — a formula lost with its target ITEM (improvise-en4s) ## `ui/project.rs` — a formula lost with its target ITEM (improvise-en4s)
`Dirty::of(ModelChange::CellData)` reports `formulas: true`. That was written as `Dirty::of(ModelChange::CellData)` reports `formulas: true`. That was written as
+68
View File
@@ -1293,3 +1293,71 @@ at different assertions — the first at the East count, the second at the total
The fixture is deliberately asymmetric: one matching cell under the scoped key The fixture is deliberately asymmetric: one matching cell under the scoped key
and three under the unscoped one, so no single constant can satisfy both. and three under the unscoped one, so no single constant can satisfy both.
## `view/layout.rs`, `model/types.rs` — a collapsed group is one aggregate row (improvise-6qet)
Reasoning in `context/code/core.md` §14. The bug is one statement: `expand_category`
`continue`d past every item of a collapsed group, so the group had no data row and
the cursor-addressed `z` could never name it. Restoring that `continue` against the
fixed tree fails fifteen tests across `improvise-core`, `improvise-io` and
`improvise-command` — every test below that touches the layout.
### a_collapsed_group_contributes_one_aggregate_row
The inversion of `collapsed_group_has_header_but_no_data_items`, which asserted the
premise 6qet changed and was rewritten rather than deleted. **Two** groups, one
collapsed and one not, so `folds_a_group` is asserted `true` for the fold and
`false` for `Apr` under the open group — a predicate that answered `true` for
everything under a header would pass a one-group fixture. Also asserts the fold
sits under its own header (`row_group_for(0) == Q1`), which is what lets `z` name
it.
### a_non_contiguous_collapsed_group_folds_once
`A[G1], B[G2], C[G1]`: a group's items need not be contiguous. Rival: dedup only
against the *previous* group (right for the contiguous case, which every other
fixture is) — `G1` then folds twice, under two headers, and only this test fails.
### a_category_nested_under_a_collapsed_group_is_folded_too
`Month × Region` on Row, `Q1` collapsed: `Q1/North` is a fold too, because no
record carries `Month=Q1` whatever sits beside it. Rival: the `collapsed` flag not
inherited down `cross_product` — only this test fails, and without it the TUI would
offer `Q1/North` for editing.
### model::types::model_tests::a_collapsed_groups_cell_aggregates_its_items
`Jan=3, Feb=4` under `Q1`, `Apr=100` under `Q2`: the fold is `7`. That value
separates the real fold from absent (`None`), from pass-through of the first member
(`3`), from a count (`2`) and from a fold that leaks another group (`107`).
Also the witness for the trap the first implementation of this ticket fell into:
reading membership off `Category::groups` rather than each item's `group` field.
`add_item_in_group` registers no `Group`, so that rival answers "not a group" for
this fixture and the value is absent — this test, the next, and
`a_collapsed_groups_header_survives_pruning` fail (the fold's row prunes as empty).
### model::types::model_tests::a_groups_stored_values_fold_once_not_per_member
Under `Axis::None` (`Store`), `Avg` and `Count` over `Jan/A=2, Jan/B=4, Feb/A=9`.
The group's average is `5` and its count `3`; folding the members' aggregates gives
`avg(3, 9) = 6` and `count(2, 1) = 2`. Rival: always fold members' results (the
formula-target branch taken for stored values) — this test fails. `Sum` cannot see
the difference, which is why the fixture sets `measure_agg`.
### model::types::model_tests::an_item_named_like_a_group_is_read_as_the_item
An item literally named `Q1` beside the group `Q1`: the item wins and reads `50`, so
an existing document with such an item is unchanged. Rival: drop the
`items.contains_key` short-circuit — the key expands to `Jan + Feb = 7` and this
test fails.
#### `a_collapsed_groups_header_survives_pruning`, re-aimed
Re-aimed by improvise-6qet (the entry above, under improvise-lhi4, still holds for
why the fixture carries an unfunded `Mar`). The expected sequence now includes the
fold, `DataItem { ["Q1"], collapsed: true }`, and `row_group_for(0)` is `Q1` — the
fold is the row `z` names the group from. Note the narrowing this implies: an
**empty** collapsed group now has an empty fold row, which `prune_empty` drops, and
its header goes with it. lhi4 kept that header because it was the only way back to
the rows; the fold is now that way back.
+10
View File
@@ -1424,3 +1424,13 @@ This is the second time in this ticket that the interesting question was *which
rows the count is over*. The first was one warning per derived column rather rows the count is over*. The first was one warning per derived column rather
than one overall; this is one warning per **landed** row rather than per than one overall; this is one warning per **landed** row rather than per
attempted one. attempted one.
### export_writes_a_collapsed_group_as_its_own_total_row
improvise-6qet (`context/code/core.md` §14). An export writes what the grid draws
(improvise-iot6), and the grid now draws a collapsed group as one row whose cell
folds the group, so the CSV body is exactly `["Q1,7.00"]` (Jan 3 + Feb 4). Decided
rather than defaulted: the alternative — expanding the group in the export — would
reintroduce the iot6 divergence between what the user sees and what they export.
On the pre-6qet tree the body is empty (the group had no row, and pruning left
nothing), so the test fails there.
+175 -27
View File
@@ -1,9 +1,10 @@
use crate::model::cell::CellValue; use crate::model::cell::CellValue;
use crate::ui::app::AppMode; use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect, ViewTarget}; use crate::ui::effect::{self, Effect, ViewTarget};
use crate::view::AxisEntry; use crate::view::{AxisEntry, GridLayout};
use super::core::{Cmd, CmdContext}; use super::core::{Cmd, CmdContext};
use super::navigation::viewport_effects;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -33,10 +34,12 @@ mod tests {
wb wb
} }
/// The real path: the App's own context, the command, then `apply_effects`,
/// so a placement the toggle's reframe throws away is seen.
fn toggle_group_at_cursor(app: &mut crate::ui::app::App) -> String { fn toggle_group_at_cursor(app: &mut crate::ui::app::App) -> String {
use crate::command::key::{KeyCode, KeyModifiers};
let effects = { let effects = {
let reg = make_registry(); let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE);
let ctx = make_ctx(&app.model_state.workbook, &app.layout, &reg);
ToggleGroupAtCursor { is_row: true }.execute(&ctx) ToggleGroupAtCursor { is_row: true }.execute(&ctx)
}; };
let dbg = effects_debug(&effects); let dbg = effects_debug(&effects);
@@ -44,6 +47,12 @@ mod tests {
dbg dbg
} }
fn row_labels(app: &crate::ui::app::App) -> Vec<String> {
(0..app.layout.row_count())
.map(|r| app.layout.row_label(r))
.collect()
}
/// Evidence: `context/test-evidence/command.md`. /// Evidence: `context/test-evidence/command.md`.
#[test] #[test]
fn toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it() { fn toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it() {
@@ -51,68 +60,157 @@ mod tests {
let mut app = App::new(two_groups_on_the_row_axis(), None); let mut app = App::new(two_groups_on_the_row_axis(), None);
app.rebuild_layout(); app.rebuild_layout();
// Off row 0, or a toggle that ignores the cursor names Q1 and passes.
app.cursor_mut().selected = (2, 0);
assert!( assert!(
!app.model_state !app.model_state
.workbook .workbook
.active_view() .active_view()
.is_group_collapsed("Month", "Q1"), .is_group_collapsed("Month", "Q2"),
"the fixture starts expanded, or the assertion below proves nothing" "the fixture starts expanded, or the assertion below proves nothing"
); );
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
assert!( assert!(
dbg.contains(r#"ToggleGroup { view: Active, category: "Month", group: "Q1" }"#), dbg.contains(r#"ToggleGroup { view: Active, category: "Month", group: "Q2" }"#),
"the cursor sits on Jan, which is in Q1, got: {dbg}" "the cursor sits on Apr, which is in Q2, got: {dbg}"
);
let view = app.model_state.workbook.active_view();
assert!(
view.is_group_collapsed("Month", "Q2"),
"the effect was returned but the view did not move"
); );
assert!( assert!(
app.model_state !view.is_group_collapsed("Month", "Q1"),
.workbook "the neighbour was left alone"
.active_view()
.is_group_collapsed("Month", "Q1"),
"the effect was returned but the view did not move"
); );
} }
/// Evidence: `context/test-evidence/command.md`. /// Evidence: `context/test-evidence/command.md`.
#[test] #[test]
fn a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one() { fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
use crate::ui::app::App; use crate::ui::app::App;
let mut app = App::new(two_groups_on_the_row_axis(), None); let mut app = App::new(two_groups_on_the_row_axis(), None);
app.rebuild_layout(); app.rebuild_layout();
toggle_group_at_cursor(&mut app); app.cursor_mut().selected = (2, 0);
assert_eq!(
app.layout.row_label(2),
"Apr",
"the cursor starts inside Q2"
);
let dbg = toggle_group_at_cursor(&mut app);
assert!(dbg.contains(r#"group: "Q2""#), "Apr is in Q2, got: {dbg}");
assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]);
assert_eq!(app.cursor().selected, (2, 0), "the cursor is on the Q2 row");
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
assert!( assert!(
dbg.contains(r#"group: "Q2""#), dbg.contains(r#"group: "Q2""#),
"collapsing Q1 removes its rows, so data row 0 is now Apr in Q2, got: {dbg}" "z again names Q2, got: {dbg}"
); );
let view = app.model_state.workbook.active_view(); assert_eq!(
assert!( row_labels(&app),
view.is_group_collapsed("Month", "Q1") && view.is_group_collapsed("Month", "Q2"), ["Jan", "Feb", "Apr", "May"],
"pressing twice collapses both groups; it is not an involution" "z z is a no-op"
);
assert_eq!(app.cursor().selected, (2, 0), "back on Q2's first item");
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn a_collapse_from_the_tail_of_a_run_lands_on_the_runs_first_row() {
use crate::ui::app::App;
let mut app = App::new(two_groups_on_the_row_axis(), None);
app.rebuild_layout();
app.cursor_mut().selected = (3, 0);
assert_eq!(app.layout.row_label(3), "May");
toggle_group_at_cursor(&mut app);
assert_eq!(row_labels(&app), ["Jan", "Feb", "Q2"]);
assert_eq!(app.cursor().selected, (2, 0), "May folded into the Q2 row");
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn collapsing_an_inner_group_lands_where_every_earlier_run_shrank() {
use crate::ui::app::App;
use crate::view::Axis;
let mut wb = crate::workbook::Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").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("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("Month", Axis::Row);
wb.active_view_mut().set_axis("Type", Axis::Column);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(
app.layout.row_cats,
["Region", "Month"],
"Month is the INNER level"
);
app.cursor_mut().selected = (6, 0);
assert_eq!(
app.layout.row_tuple(6),
Some(&["South".into(), "Apr".into()][..])
);
toggle_group_at_cursor(&mut app);
assert_eq!(
row_labels(&app),
[
"North/Jan",
"North/Feb",
"North/Q2",
"South/Jan",
"South/Feb",
"South/Q2"
]
);
assert_eq!(
app.cursor().selected,
(5, 0),
"South's Q2, not the old index 6"
); );
} }
/// Evidence: `context/test-evidence/command.md`. /// Evidence: `context/test-evidence/command.md`.
#[test] #[test]
fn a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address() { fn a_collapsed_group_can_be_re_expanded_from_the_grid() {
use crate::ui::app::App; use crate::ui::app::App;
let mut app = App::new(two_groups_on_the_row_axis(), None); let mut app = App::new(two_groups_on_the_row_axis(), None);
app.rebuild_layout(); app.rebuild_layout();
app.cursor_mut().selected = (2, 0);
toggle_group_at_cursor(&mut app); toggle_group_at_cursor(&mut app);
app.cursor_mut().selected = (1, 0);
toggle_group_at_cursor(&mut app); toggle_group_at_cursor(&mut app);
assert_eq!(app.layout.row_count(), 0); assert_eq!(row_labels(&app), ["Q1", "Q2"], "both groups collapsed");
app.cursor_mut().selected = (1, 0);
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
assert_eq!( assert!(
dbg, "[]", dbg.contains(r#"group: "Q2""#),
"there is no row under the cursor to name a group" "the Q2 row names Q2, got: {dbg}"
); );
assert_eq!(row_labels(&app), ["Q1", "Apr", "May"]);
assert_eq!(app.cursor().selected, (1, 0), "on Q2's first item");
} }
#[test] #[test]
@@ -338,11 +436,61 @@ impl Cmd for ToggleGroupAtCursor {
let Some((cat, group)) = lookup else { let Some((cat, group)) = lookup else {
return vec![]; return vec![];
}; };
vec![Box::new(effect::ToggleGroup { let landing = self.landing(ctx, &cat, &group);
// The toggle reframes and so resets the cursor; the placement must come
// AFTER it or it is thrown away (gotcha 24, core.md §14).
let mut effects: Vec<Box<dyn Effect>> = vec![Box::new(effect::ToggleGroup {
view: ViewTarget::Active, view: ViewTarget::Active,
category: cat, category: cat,
group, group,
})] })];
effects.extend(
landing
.map(|at| self.place_cursor(ctx, at))
.unwrap_or_default(),
);
effects
}
}
impl ToggleGroupAtCursor {
/// Where the toggled group sits once toggled, asked of the layout the toggle
/// will produce — collapsing an inner level shifts every earlier run (§14).
fn landing(&self, ctx: &CmdContext, cat: &str, group: &str) -> Option<usize> {
let (cats, tuple) = match self.is_row {
true => (&ctx.layout.row_cats, ctx.layout.row_tuple(ctx.selected.0)?),
false => (&ctx.layout.col_cats, ctx.layout.col_tuple(ctx.selected.1)?),
};
let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?;
let mut view = ctx.view.clone();
view.toggle_group_collapse(cat, group);
let after = GridLayout::with_frozen_records(ctx.model, &view, None);
match self.is_row {
true => after.row_run_start(cat, group, prefix),
false => after.col_run_start(cat, group, prefix),
}
}
/// The cursor on `at`, the scroll restored where the toggle zeroed it and
/// moved only as far as `at` needs.
fn place_cursor(&self, ctx: &CmdContext, at: usize) -> Vec<Box<dyn Effect>> {
let (row, col) = match self.is_row {
true => (at, ctx.selected.1),
false => (ctx.selected.0, at),
};
let mut effects: Vec<Box<dyn Effect>> = vec![
Box::new(effect::SetRowOffset(ctx.row_offset)),
Box::new(effect::SetColOffset(ctx.col_offset)),
];
effects.extend(viewport_effects(
row,
col,
ctx.row_offset,
ctx.col_offset,
ctx.visible_rows,
ctx.visible_cols,
));
effects
} }
} }
@@ -363,8 +511,8 @@ impl Cmd for HideSelectedRowItem {
.row_items .row_items
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
if let AxisEntry::DataItem(v) = e { if let AxisEntry::DataItem { tuple, .. } = e {
Some(v) Some(tuple)
} else { } else {
None None
} }
@@ -188,6 +188,39 @@ mod tests {
); );
} }
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn edit_or_drill_declines_a_collapsed_groups_row() {
let mut wb = crate::workbook::Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").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("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q2");
let layout = make_layout(&wb);
let reg = make_registry();
let mut ctx = make_ctx(&wb, &layout, &reg);
let edit = EditOrDrill {
edit_mode: AppMode::editing(),
};
ctx.selected = (1, 0);
assert!(
effects_debug(&edit.execute(&ctx)).contains("Editing"),
"Feb, beside the fold, still edits"
);
ctx.selected = (2, 0);
let dbg = effects_debug(&edit.execute(&ctx));
assert_eq!(layout.row_label(2), "Q2");
assert!(
dbg.contains("Collapsed group") && !dbg.contains("Editing") && !dbg.contains("Drill"),
"the fold names no record to edit or drill into, got: {dbg}"
);
}
/// improvise-ir7l: the cell under the cursor is a formula target, so there is /// improvise-ir7l: the cell under the cursor is a formula target, so there is
/// nothing to type into. `context/test-evidence/command.md`. /// nothing to type into. `context/test-evidence/command.md`.
#[test] #[test]
@@ -320,6 +353,12 @@ impl Cmd for EditOrDrill {
"edit-or-drill" "edit-or-drill"
} }
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> { fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
// Neither edit nor drill: no record carries a group's name (core.md §14).
if ctx.layout.folds_a_group(ctx.selected.0, ctx.selected.1) {
return vec![effect::set_status(
"Collapsed group — press z to expand it, then edit an item",
)];
}
// Only **regular** categories on None are true aggregation: virtuals like // Only **regular** categories on None are true aggregation: virtuals like
// `_Index`/`_Dim` are always None in pivot mode and imply nothing. // `_Index`/`_Dim` are always None in pivot mode and imply nothing.
let regular_none = ctx.none_cats().iter().any(|c| { let regular_none = ctx.none_cats().iter().any(|c| {
+2 -1
View File
@@ -101,9 +101,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 {
group_name.hash(&mut h); group_name.hash(&mut h);
view.is_group_collapsed(cat_name, group_name).hash(&mut h); view.is_group_collapsed(cat_name, group_name).hash(&mut h);
} }
crate::view::layout::AxisEntry::DataItem(tuple) => { crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => {
1u8.hash(&mut h); 1u8.hash(&mut h);
tuple.hash(&mut h); tuple.hash(&mut h);
collapsed.hash(&mut h);
} }
// Drawn as nothing, but it ends a group's run (improvise-gd83), // Drawn as nothing, but it ends a group's run (improvise-gd83),
// so where it sits is what a row's group attribution depends on. // so where it sits is what a row's group attribution depends on.
+75 -38
View File
@@ -205,7 +205,7 @@ fn axis_frame(
) -> AxisFrame { ) -> AxisFrame {
let total = items let total = items
.iter() .iter()
.filter(|e| matches!(e, AxisEntry::DataItem(_))) .filter(|e| matches!(e, AxisEntry::DataItem { .. }))
.count(); .count();
let mut data_idx = 0usize; let mut data_idx = 0usize;
let mut entries: Vec<FrameEntry> = Vec::new(); let mut entries: Vec<FrameEntry> = Vec::new();
@@ -230,7 +230,7 @@ fn axis_frame(
// Queued like a header, never clearing one: a COLLAPSED group has // Queued like a header, never clearing one: a COLLAPSED group has
// no item to carry it and reaches the frame only through `pending`. // no item to carry it and reaches the frame only through `pending`.
AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd), AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd),
AxisEntry::DataItem(tuple) => { AxisEntry::DataItem { tuple, .. } => {
let idx = data_idx; let idx = data_idx;
data_idx += 1; data_idx += 1;
if window.contains(&idx) { if window.contains(&idx) {
@@ -332,11 +332,14 @@ fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option<Cel
})); }));
} }
let key = layout.cell_key(row, col)?; let key = layout.cell_key(row, col)?;
let aggregated = layout.none_cats.iter().any(|cat| { // A folded group is an aggregate whatever is on None: no record carries
model // its coordinate, so a `SetCell` there would write a non-item (§14).
.category(cat) let aggregated = layout.folds_a_group(row, col)
.is_some_and(|category| category.kind.is_regular()) || layout.none_cats.iter().any(|cat| {
}); model
.category(cat)
.is_some_and(|category| category.kind.is_regular())
});
// A derived cell is asked about before a stored one: a formula target is a // A derived cell is asked about before a stored one: a formula target is a
// real coordinate, so the record branch would otherwise claim it (ir7l). // real coordinate, so the record branch would otherwise claim it (ir7l).
let derived = model.formula_for(&key).map(|f| CellId::Derived { let derived = model.formula_for(&key).map(|f| CellId::Derived {
@@ -1318,8 +1321,8 @@ mod tests {
.collect() .collect()
} }
/// A collapsed group at the bottom has no items to hang off and is still the /// A collapsed group at the bottom folds into one row, and the last window
/// only row saying those rows exist, so the last window carries it. /// carries its header above that row. Evidence: context/test-evidence/command.md.
#[test] #[test]
fn a_window_reaching_the_last_row_carries_the_trailing_group_header() { fn a_window_reaching_the_last_row_carries_the_trailing_group_header() {
let app = app_with_a_trailing_group(true); let app = app_with_a_trailing_group(true);
@@ -1334,13 +1337,13 @@ mod tests {
); );
assert_eq!( assert_eq!(
grid.row_frame.len(), grid.row_frame.len(),
2, 3,
"East is inside the collapsed group" "East folds into the collapsed group's one row"
); );
assert_eq!( assert_eq!(
frame_shape(&grid.row_frame), frame_shape(&grid.row_frame),
vec!["North", "South", "[Late]"], vec!["North", "South", "[Late]", "Late"],
"the header is the last entry, and it is what the user expands" "the header, then the row the group collapsed into"
); );
} }
@@ -1369,11 +1372,11 @@ mod tests {
/// header comes. Two headers above, or the compaction is invisible. /// header comes. Two headers above, or the compaction is invisible.
#[test] #[test]
fn a_window_opening_inside_a_group_names_only_the_nearest() { fn a_window_opening_inside_a_group_names_only_the_nearest() {
let app = app_with_consecutive_headers(); let app = app_with_a_collapsed_group_between_rows();
let grid = grid_of( let grid = grid_of(
&app, &app,
Viewport { Viewport {
row_offset: 1, row_offset: 2,
col_offset: 0, col_offset: 0,
rows: 20, rows: 20,
cols: 20, cols: 20,
@@ -1395,9 +1398,9 @@ mod tests {
); );
} }
/// Two consecutive group headers between two rows: `Hidden` is collapsed, so it /// Two group headers between `North` and `East`: `Hidden`, collapsed into one
/// emits a header and no items, and `Late`'s follows immediately. /// row, and `Late`, open.
fn app_with_consecutive_headers() -> App { fn app_with_a_collapsed_group_between_rows() -> App {
let mut wb = Workbook::new("Consecutive"); let mut wb = Workbook::new("Consecutive");
wb.add_category("Region").unwrap(); wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "North").unwrap();
@@ -1420,11 +1423,11 @@ mod tests {
app app
} }
/// Headers between two in-window rows all come, not just the nearest: the far /// Headers between two in-window rows all come, each above the row it opens.
/// one is the only row saying those items exist. Above the window, the opposite. /// Evidence: context/test-evidence/command.md.
#[test] #[test]
fn consecutive_headers_between_two_rows_all_come() { fn every_header_between_two_in_window_rows_comes() {
let app = app_with_consecutive_headers(); let app = app_with_a_collapsed_group_between_rows();
let grid = grid_of( let grid = grid_of(
&app, &app,
Viewport { Viewport {
@@ -1436,25 +1439,17 @@ mod tests {
); );
assert_eq!( assert_eq!(
frame_shape(&grid.row_frame), frame_shape(&grid.row_frame),
vec!["North", "[Hidden]", "[Late]", "East"], vec!["North", "[Hidden]", "Hidden", "[Late]", "East"],
"both headers sit between the two rows and both are drawn" "both headers are drawn, the collapsed one above its folded row"
); );
let group = grid.row_frame.group_of(1).expect("East is under Late");
assert_eq!(group.name, "Late", "the NEAREST header wins membership");
let hidden = grid let hidden = grid
.row_frame .row_frame
.entries .group_of(1)
.iter() .expect("the fold is under Hidden");
.find_map(|e| match e { assert_eq!(hidden.name, "Hidden");
FrameEntry::Group(g) if g.name == "Hidden" => Some(g), assert!(hidden.collapsed, "and it arrives collapsed");
_ => None, let late = grid.row_frame.group_of(2).expect("East is under Late");
}) assert_eq!(late.name, "Late", "the NEAREST header wins membership");
.expect("kept");
assert!(
hidden.collapsed,
"and it arrives collapsed, which is what makes it the row that \
says those rows still exist"
);
} }
fn app_with_data() -> App { fn app_with_data() -> App {
@@ -4302,6 +4297,48 @@ mod cell_identity {
.unwrap_or_else(|| panic!("cell ({row}, {col}) was projected with no identity")) .unwrap_or_else(|| panic!("cell ({row}, {col}) was projected with no identity"))
} }
/// Nothing regular on None, so only the fold can make the row an aggregate.
/// Evidence: context/test-evidence/command.md.
#[test]
fn a_collapsed_groups_row_is_projected_as_an_aggregate() {
let mut wb = Workbook::new("Folded");
wb.add_category("Region").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
wb.add_item_in_group("Region", "West", "Coastal").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
for (region, value) in [("North", 1.0), ("East", 3.0), ("West", 4.0)] {
wb.model.set_cell(
CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]),
CellValue::Number(value),
);
}
wb.active_view_mut()
.toggle_group_collapse("Region", "Coastal");
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.row_label(1), "Coastal");
assert!(
matches!(id_at(&app, 0, 0), CellId::Record(_)),
"North is stored, so the none-axis arm is not what answers below"
);
assert!(
matches!(id_at(&app, 1, 0), CellId::Aggregate(_)),
"a fold has no record under it and must not be offered as settable"
);
let grid = grid_of(&app, WHOLE);
assert_eq!(
grid.cell(1, 0).expect("the fold's cell").text.trim(),
"7.00"
);
}
fn record_of_id(id: CellId) -> RecordCell { fn record_of_id(id: CellId) -> RecordCell {
match id { match id {
CellId::Record(cell) => cell, CellId::Record(cell) => cell,
+2 -2
View File
@@ -123,7 +123,7 @@ fn cell_value(
fn header_level_seed(layout: &GridLayout, n: usize) -> Vec<u16> { fn header_level_seed(layout: &GridLayout, n: usize) -> Vec<u16> {
let mut widths = vec![0u16; n]; let mut widths = vec![0u16; n];
let items = layout.col_items.iter().filter_map(|e| match e { let items = layout.col_items.iter().filter_map(|e| match e {
AxisEntry::DataItem(v) => Some(v), AxisEntry::DataItem { tuple, .. } => Some(tuple),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None,
}); });
for (ci, levels) in items.enumerate().take(n) { for (ci, levels) in items.enumerate().take(n) {
@@ -152,7 +152,7 @@ pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan)
/// keypress-path caller that borrows tuples — see `context/code/command.md`. /// keypress-path caller that borrows tuples — see `context/code/command.md`.
pub fn compute_row_header_width(layout: &GridLayout) -> u16 { pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
let items = layout.row_items.iter().filter_map(|e| match e { let items = layout.row_items.iter().filter_map(|e| match e {
AxisEntry::DataItem(v) => Some(v), AxisEntry::DataItem { tuple, .. } => Some(tuple),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None,
}); });
header_level_widths(items, layout.row_cats.len().max(1)) header_level_widths(items, layout.row_cats.len().max(1))
@@ -199,6 +199,16 @@ impl Category {
pub fn ordered_item_names(&self) -> Vec<&str> { pub fn ordered_item_names(&self) -> Vec<&str> {
self.items.keys().map(|s| s.as_str()).collect() self.items.keys().map(|s| s.as_str()).collect()
} }
/// The items whose own `group` names `group`, in insertion order. Read off the
/// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14).
pub fn items_in_group(&self, group: &str) -> Vec<&str> {
self.items
.values()
.filter(|i| i.group.as_deref() == Some(group))
.map(|i| i.name.as_str())
.collect()
}
} }
#[cfg(test)] #[cfg(test)]
+127
View File
@@ -523,6 +523,10 @@ impl Model {
} }
pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> { pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
let members = self.group_expansion(key);
if !members.is_empty() {
return self.evaluate_group(key, &members, agg);
}
if agg == Aggregation::Exact { if agg == Aggregation::Exact {
return self.evaluate(key); return self.evaluate(key);
} }
@@ -536,6 +540,55 @@ impl Model {
aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key)) aggregate_cell_values(&self.data.matching_values(&key.0), self.agg_for(key))
} }
/// One key per member where `key` names a collapsed GROUP rather than an item —
/// the cross product over every such coordinate. Empty when none does (§14).
fn group_expansion(&self, key: &CellKey) -> Vec<CellKey> {
let mut expanded = vec![key.clone()];
for (cat_name, name) in &key.0 {
let members = self
.category(cat_name)
.filter(|cat| !cat.items.contains_key(name.as_str()))
.map(|cat| cat.items_in_group(name))
.unwrap_or_default();
if members.is_empty() {
continue;
}
expanded = expanded
.iter()
.flat_map(|k| members.iter().map(|m| k.clone().with(cat_name, *m)))
.collect();
}
match expanded.as_slice() {
[only] if only == key => Vec::new(),
_ => expanded,
}
}
/// A group's cell: stored values fold ONCE over every record the group covers,
/// a derived one folds its members' results — why the two differ: core.md §14.
fn evaluate_group(
&self,
key: &CellKey,
members: &[CellKey],
agg: Aggregation,
) -> Option<CellValue> {
let values: Vec<CellValue> = match self.formula_for(key) {
Some(_) => members
.iter()
.filter_map(|k| self.evaluate_aggregated(k, agg))
.collect(),
None => members
.iter()
.flat_map(|k| match agg {
Aggregation::Exact => self.data.get(k).into_iter().collect(),
Aggregation::OverHidden => self.data.matching_values(&k.0),
})
.cloned()
.collect(),
};
aggregate_cell_values(&values.iter().collect::<Vec<_>>(), self.agg_for(key))
}
/// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell. /// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell.
pub fn evaluate_aggregated_f64(&self, key: &CellKey, agg: Aggregation) -> f64 { pub fn evaluate_aggregated_f64(&self, key: &CellKey, agg: Aggregation) -> f64 {
self.evaluate_aggregated(key, agg) self.evaluate_aggregated(key, agg)
@@ -1403,6 +1456,80 @@ mod model_tests {
let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats)); let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats));
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20 assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
} }
/// `Month` with `Q1 = {Jan, Feb}` built through `add_item_in_group`, which
/// registers no `Group` — the shape a document built in code has.
fn quarter_model(cells: &[(&str, &str, f64)]) -> Model {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Store").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2")] {
m.category_mut("Month")
.unwrap()
.add_item_in_group(month, quarter);
}
for store in ["A", "B"] {
m.category_mut("Store").unwrap().add_item(store);
}
m.category_mut("_Measure").unwrap().add_item("Revenue");
for (month, store, value) in cells {
m.set_cell(
coord(&[("Month", month), ("Store", store), ("_Measure", "Revenue")]),
CellValue::Number(*value),
);
}
m
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn a_collapsed_groups_cell_aggregates_its_items() {
let m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0), ("Apr", "A", 100.0)]);
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]);
assert_eq!(
m.evaluate_aggregated(&key, Aggregation::Exact),
Some(CellValue::Number(7.0)),
"Jan + Feb; not absent, not Jan alone, not a count, not Q2's Apr"
);
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn a_groups_stored_values_fold_once_not_per_member() {
use crate::formula::AggFunc;
let mut m = quarter_model(&[("Jan", "A", 2.0), ("Jan", "B", 4.0), ("Feb", "A", 9.0)]);
let key = coord(&[("Month", "Q1"), ("_Measure", "Revenue")]);
let over_store = Aggregation::of(&["Store".to_string()]);
m.measure_agg.insert("Revenue".to_string(), AggFunc::Avg);
assert_eq!(
m.evaluate_aggregated(&key, over_store),
Some(CellValue::Number(5.0)),
"avg(2, 4, 9); folding the members' averages gives avg(3, 9) = 6"
);
m.measure_agg.insert("Revenue".to_string(), AggFunc::Count);
assert_eq!(
m.evaluate_aggregated(&key, over_store),
Some(CellValue::Number(3.0)),
"three records; counting the members' counts gives 2"
);
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn an_item_named_like_a_group_is_read_as_the_item() {
let mut m = quarter_model(&[("Jan", "A", 3.0), ("Feb", "A", 4.0)]);
m.category_mut("Month").unwrap().add_item("Q1");
m.set_cell(
coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]),
CellValue::Number(50.0),
);
let key = coord(&[("Month", "Q1"), ("Store", "A"), ("_Measure", "Revenue")]);
assert_eq!(
m.evaluate_aggregated(&key, Aggregation::Exact),
Some(CellValue::Number(50.0)),
"the stored item, not Jan + Feb"
);
}
} }
#[cfg(test)] #[cfg(test)]
+206 -39
View File
@@ -187,7 +187,7 @@ pub enum RecordCol {
} }
/// One entry on a grid axis. A `GroupHeader` is always visible so its label /// One entry on a grid axis. A `GroupHeader` is always visible so its label
/// and collapse state show; a `DataItem` is absent when its group collapses. /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum AxisEntry { pub enum AxisEntry {
GroupHeader { GroupHeader {
@@ -197,18 +197,73 @@ pub enum AxisEntry {
/// The run opened by the last `GroupHeader` ends here (improvise-gd83). /// The run opened by the last `GroupHeader` ends here (improvise-gd83).
/// Nothing is drawn for it; it exists so membership is not inherited. /// Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd, GroupEnd,
DataItem(Vec<String>), /// `collapsed` is not derivable from `tuple`: a group and an item may share a
/// name. Set for a folded group and for everything expanded beneath one.
DataItem { tuple: Vec<String>, collapsed: bool },
}
impl AxisEntry {
/// An ordinary, uncollapsed data item.
pub fn item(tuple: Vec<String>) -> Self {
AxisEntry::DataItem {
tuple,
collapsed: false,
}
}
} }
/// The data-item tuples on an axis, skipping group headers. Every public /// The data-item tuples on an axis, skipping group headers. Every public
/// `row`/`col` argument indexes this sequence, not the visual entry list. /// `row`/`col` argument indexes this sequence, not the visual entry list.
fn data_items(items: &[AxisEntry]) -> impl Iterator<Item = &Vec<String>> { fn data_items(items: &[AxisEntry]) -> impl Iterator<Item = &Vec<String>> {
items.iter().filter_map(|e| match e { items.iter().filter_map(|e| match e {
AxisEntry::DataItem(v) => Some(v), AxisEntry::DataItem { tuple, .. } => Some(tuple),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None,
}) })
} }
/// Whether the data item at `idx` folds a collapsed group.
fn folds(items: &[AxisEntry], idx: usize) -> bool {
items
.iter()
.filter_map(|e| match e {
AxisEntry::DataItem { collapsed, .. } => Some(*collapsed),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None,
})
.nth(idx)
.unwrap_or(false)
}
/// The data index of the first item in the run `cat`/`group` opens under
/// `prefix` — where a toggle's cursor lands, collapsing or expanding (§14).
fn run_start(
cats: &[String],
items: &[AxisEntry],
cat: &str,
group: &str,
prefix: &[String],
) -> Option<usize> {
let level = cats.iter().position(|c| c == cat)?;
let mut data_idx = 0usize;
let mut armed = false;
for entry in items {
match entry {
AxisEntry::GroupHeader {
cat_name,
group_name,
} => armed |= cat_name == cat && group_name == group,
AxisEntry::GroupEnd => {}
AxisEntry::DataItem { tuple, .. } => {
if armed && tuple.get(..level) == Some(prefix) {
return Some(data_idx);
}
armed = false;
data_idx += 1;
}
}
}
None
}
/// The display label of the data item at `idx`, or empty when out of bounds. /// The display label of the data item at `idx`, or empty when out of bounds.
fn axis_label(items: &[AxisEntry], idx: usize) -> String { fn axis_label(items: &[AxisEntry], idx: usize) -> String {
data_items(items) data_items(items)
@@ -222,7 +277,7 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
items items
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, e)| matches!(e, AxisEntry::DataItem(_))) .filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. }))
.map(|(vi, _)| vi) .map(|(vi, _)| vi)
.nth(data_idx) .nth(data_idx)
} }
@@ -267,7 +322,7 @@ fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
run_open = false; run_open = false;
} }
} }
AxisEntry::DataItem(_) => { AxisEntry::DataItem { .. } => {
run_had_item = true; run_had_item = true;
if keep(data_idx) { if keep(data_idx) {
if let Some(h) = pending_header.take() { if let Some(h) = pending_header.take() {
@@ -344,7 +399,7 @@ impl GridLayout {
&& let Some(records) = frozen_records && let Some(records) = frozen_records
{ {
let row_items: Vec<AxisEntry> = (0..records.len()) let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::item(vec![i.to_string()]))
.collect(); .collect();
layout.row_items = row_items; layout.row_items = row_items;
layout.records = Some(records); layout.records = Some(records);
@@ -427,13 +482,13 @@ impl GridLayout {
let records = Self::group_records(model, &page_constraints(&page_coords)); let records = Self::group_records(model, &page_constraints(&page_coords));
let row_items: Vec<AxisEntry> = (0..records.len()) let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()])) .map(|i| AxisEntry::item(vec![i.to_string()]))
.collect(); .collect();
let record_cols = Self::records_columns(model); let record_cols = Self::records_columns(model);
let col_items: Vec<AxisEntry> = record_cols let col_items: Vec<AxisEntry> = record_cols
.iter() .iter()
.map(|c| AxisEntry::DataItem(vec![Self::record_col_label(c)])) .map(|c| AxisEntry::item(vec![Self::record_col_label(c)]))
.collect(); .collect();
Self { Self {
@@ -687,11 +742,38 @@ impl GridLayout {
group_name, group_name,
} => Some(Some((cat_name.clone(), group_name.clone()))), } => Some(Some((cat_name.clone(), group_name.clone()))),
AxisEntry::GroupEnd => Some(None), AxisEntry::GroupEnd => Some(None),
AxisEntry::DataItem(_) => None, AxisEntry::DataItem { .. } => None,
}) })
.flatten() .flatten()
} }
/// 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)
}
/// The first data row of `cat`/`group`'s run under `prefix` — where a toggle
/// of that group puts the cursor, asked of the layout AFTER the toggle (§14).
pub fn row_run_start(&self, cat: &str, group: &str, prefix: &[String]) -> Option<usize> {
run_start(&self.row_cats, &self.row_items, cat, group, prefix)
}
/// [`Self::row_run_start`] for a column group.
pub fn col_run_start(&self, cat: &str, group: &str, prefix: &[String]) -> Option<usize> {
run_start(&self.col_cats, &self.col_items, cat, group, prefix)
}
/// The coordinate tuple of the Nth data row.
pub fn row_tuple(&self, row: usize) -> Option<&[String]> {
data_items(&self.row_items).nth(row).map(Vec::as_slice)
}
/// The coordinate tuple of the Nth data column.
pub fn col_tuple(&self, col: usize) -> Option<&[String]> {
data_items(&self.col_items).nth(col).map(Vec::as_slice)
}
/// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding /// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding
/// the Nth data column. /// the Nth data column.
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> { pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
@@ -705,25 +787,28 @@ impl GridLayout {
group_name, group_name,
} => Some(Some((cat_name.clone(), group_name.clone()))), } => Some(Some((cat_name.clone(), group_name.clone()))),
AxisEntry::GroupEnd => Some(None), AxisEntry::GroupEnd => Some(None),
AxisEntry::DataItem(_) => None, AxisEntry::DataItem { .. } => None,
}) })
.flatten() .flatten()
} }
} }
/// Expand one category into `AxisEntry` values under a coordinate prefix: a /// Expand one category into `AxisEntry` values under a coordinate prefix: a
/// `GroupHeader` per boundary, then the visible, non-collapsed items. /// `GroupHeader` per boundary, then each visible item or collapsed group (§14).
fn expand_category( fn expand_category(
model: &Model, model: &Model,
view: &View, view: &View,
cat_name: &str, cat_name: &str,
prefix: Vec<String>, prefix: Vec<String>,
prefix_collapsed: bool,
) -> Vec<AxisEntry> { ) -> Vec<AxisEntry> {
let Some(cat) = model.category(cat_name) else { let Some(cat) = model.category(cat_name) else {
return vec![]; return vec![];
}; };
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_group: Option<&str> = None; let mut last_group: Option<&str> = None;
// A group's items need not be contiguous, so a collapsed one folds ONCE.
let mut folded: Vec<&str> = Vec::new();
// effective_item_names, so _Measure picks up formula targets. // effective_item_names, so _Measure picks up formula targets.
let effective_names = model.effective_item_names(cat_name); let effective_names = model.effective_item_names(cat_name);
@@ -735,6 +820,10 @@ fn expand_category(
.items .items
.get(item_name.as_str()) .get(item_name.as_str())
.and_then(|i| i.group.as_deref()); .and_then(|i| i.group.as_deref());
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g));
if collapsed.is_some_and(|g| folded.contains(&g)) {
continue;
}
// A header on entering a group; a `GroupEnd` only on leaving one for // A header on entering a group; a `GroupEnd` only on leaving one for
// NO group — another group's header already ends the run. // NO group — another group's header already ends the run.
@@ -751,14 +840,18 @@ fn expand_category(
last_group = item_group; last_group = item_group;
} }
// Skip the data item if its group is collapsed. let mut tuple = prefix.clone();
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) { match collapsed {
continue; Some(g) => {
folded.push(g);
tuple.push(g.to_string());
}
None => tuple.push(item_name.to_string()),
} }
result.push(AxisEntry::DataItem {
let mut row = prefix.clone(); tuple,
row.push(item_name.to_string()); collapsed: prefix_collapsed || collapsed.is_some(),
result.push(AxisEntry::DataItem(row)); });
} }
// The expansion is the end of the run too: the next prefix's items follow // The expansion is the end of the run too: the next prefix's items follow
// immediately and must not inherit it (improvise-gd83). // immediately and must not inherit it (improvise-gd83).
@@ -772,14 +865,16 @@ fn expand_category(
/// `vec![DataItem(vec![])]` when `cats` is empty. /// `vec![DataItem(vec![])]` when `cats` is empty.
fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> { fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry> {
if cats.is_empty() { if cats.is_empty() {
return vec![AxisEntry::DataItem(vec![])]; return vec![AxisEntry::item(vec![])];
} }
let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])]; let mut result: Vec<AxisEntry> = vec![AxisEntry::item(vec![])];
for cat_name in cats { for cat_name in cats {
result = result result = result
.into_iter() .into_iter()
.flat_map(|entry| match entry { .flat_map(|entry| match entry {
AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix), AxisEntry::DataItem { tuple, collapsed } => {
expand_category(model, view, cat_name, tuple, collapsed)
}
marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd) => vec![marker], marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd) => vec![marker],
}) })
.collect(); .collect();
@@ -832,13 +927,13 @@ mod tests {
#[test] #[test]
fn prune_axis_items_indexes_data_positions_not_visual_ones() { fn prune_axis_items_indexes_data_positions_not_visual_ones() {
let mut items = vec![ let mut items = vec![
AxisEntry::DataItem(vec!["A".into()]), AxisEntry::item(vec!["A".into()]),
AxisEntry::GroupHeader { AxisEntry::GroupHeader {
cat_name: "C".into(), cat_name: "C".into(),
group_name: "G".into(), group_name: "G".into(),
}, },
AxisEntry::DataItem(vec!["B".into()]), AxisEntry::item(vec!["B".into()]),
AxisEntry::DataItem(vec!["C".into()]), AxisEntry::item(vec!["C".into()]),
]; ];
// Data index 1 is "B": the header does not consume an index. If the // Data index 1 is "B": the header does not consume an index. If the
// predicate saw visual indices, "A" would be the one dropped. // predicate saw visual indices, "A" would be the one dropped.
@@ -861,8 +956,8 @@ mod tests {
cat_name: "C".into(), cat_name: "C".into(),
group_name: "G".into(), group_name: "G".into(),
}, },
AxisEntry::DataItem(vec!["A".into()]), AxisEntry::item(vec!["A".into()]),
AxisEntry::DataItem(vec!["B".into()]), AxisEntry::item(vec!["B".into()]),
]; ];
prune_axis_items(&mut items, |_| false); prune_axis_items(&mut items, |_| false);
assert!(items.is_empty()); assert!(items.is_empty());
@@ -1256,29 +1351,92 @@ mod tests {
); );
} }
/// The inversion of `collapsed_group_has_header_but_no_data_items`: a
/// collapsed group is ONE row. Evidence: context/test-evidence/core.md.
#[test] #[test]
fn collapsed_group_has_header_but_no_data_items() { fn a_collapsed_group_contributes_one_aggregate_row() {
let mut wb = Workbook::new("T"); let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap(); wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap(); wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item_in_group("Month", "May", "Q2").unwrap();
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "Q1"); wb.active_view_mut().toggle_group_collapse("Month", "Q1");
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact let labels: Vec<String> = (0..layout.row_count())
assert_eq!(layout.row_count(), 1); // only Apr .map(|r| layout.row_label(r))
let q1_header = layout .collect();
assert_eq!(
labels,
["Q1", "Apr", "May"],
"Q1 folds into one row; Q2 stays open"
);
assert!(layout.folds_a_group(0, 0), "the Q1 row is a fold");
assert!(
!layout.folds_a_group(1, 0),
"Apr, under the OPEN group, is not"
);
assert_eq!(
layout.row_group_for(0),
Some(("Month".into(), "Q1".into())),
"the fold sits under its own header, so z can name it"
);
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn a_non_contiguous_collapsed_group_folds_once() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "A", "G1").unwrap();
wb.add_item_in_group("Month", "B", "G2").unwrap();
wb.add_item_in_group("Month", "C", "G1").unwrap();
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "G1");
let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count())
.map(|r| layout.row_label(r))
.collect();
assert_eq!(labels, ["G1", "B"], "one G1 row, not one per run");
let g1_headers = layout
.row_items .row_items
.iter() .iter()
.find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1")); .filter(
assert!(q1_header.is_some()); |e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "G1"),
let jan = layout )
.row_items .count();
.iter() assert_eq!(
.find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string()))); g1_headers, 1,
assert!(jan.is_none()); "a second G1 header would open a run with no row"
);
}
/// Evidence: context/test-evidence/core.md.
#[test]
fn a_category_nested_under_a_collapsed_group_is_folded_too() {
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Region").unwrap();
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Apr", "Q2").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
let layout = GridLayout::new(&wb.model, wb.active_view());
assert_eq!(
layout.row_tuple(0),
Some(&["Q1".to_string(), "North".to_string()][..])
);
assert!(
layout.folds_a_group(0, 0),
"Q1/North has no record under it either"
);
assert!(!layout.folds_a_group(1, 0), "Apr/North does");
} }
/// improvise-gd83: two arms — an ungrouped item AFTER a grouped run, and /// improvise-gd83: two arms — an ungrouped item AFTER a grouped run, and
@@ -1513,13 +1671,22 @@ mod tests {
cat_name: "Month".into(), cat_name: "Month".into(),
group_name: "Q1".into() group_name: "Q1".into()
}, },
AxisEntry::DataItem {
tuple: vec!["Q1".into()],
collapsed: true
},
AxisEntry::GroupEnd, AxisEntry::GroupEnd,
AxisEntry::DataItem(vec!["Sep".into()]), AxisEntry::item(vec!["Sep".into()]),
], ],
"Q1 is collapsed, not empty — its header is the only way back, and Mar pruned" "Q1 folds to one funded row under its header, and Mar pruned"
); );
assert_eq!( assert_eq!(
layout.row_group_for(0), layout.row_group_for(0),
Some(("Month".into(), "Q1".into())),
"the fold is where z names the collapsed group from"
);
assert_eq!(
layout.row_group_for(1),
None, None,
"Sep must not inherit the collapsed run" "Sep must not inherit the collapsed run"
); );
@@ -204,6 +204,35 @@ mod tests {
assert_eq!(header, ["Jan"], "CSV wrote a column the grid does not draw"); assert_eq!(header, ["Jan"], "CSV wrote a column the grid does not draw");
} }
/// An export writes what the grid draws (improvise-iot6), so a collapsed group
/// is one total row. Evidence: context/test-evidence/formula-and-io.md.
#[test]
fn export_writes_a_collapsed_group_as_its_own_total_row() {
use crate::view::Axis;
let mut wb = Workbook::new("T");
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item_in_group("Month", "Jan", "Q1").unwrap();
wb.add_item_in_group("Month", "Feb", "Q1").unwrap();
wb.add_item("Type", "Food").unwrap();
for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] {
wb.model.set_cell(
coord(&[("Month", month), ("Type", "Food")]),
CellValue::Number(value),
);
}
{
let v = wb.active_view_mut();
v.set_axis("Month", Axis::Row);
v.set_axis("Type", Axis::Column);
v.prune_empty = true;
v.toggle_group_collapse("Month", "Q1");
}
let csv = String::from_utf8(super::render_csv(&wb, "Default").unwrap()).unwrap();
let rows: Vec<&str> = csv.lines().skip(1).collect();
assert_eq!(rows, ["Q1,7.00"], "one folded row, summing Jan and Feb");
}
#[test] #[test]
fn export_csv_unknown_view_returns_error() { fn export_csv_unknown_view_returns_error() {
let m = two_cat_model(); let m = two_cat_model();
+11 -8
View File
@@ -146,18 +146,21 @@ test.describe("a grouped, multi-level document", () => {
expect(await groupRows.locator("td").count()).toBe(0); expect(await groupRows.locator("td").count()).toBe(0);
await expect(groupRows.first().locator("th")).toHaveCount(1); await expect(groupRows.first().locator("th")).toHaveCount(1);
// The collapse is the document's, and it must have REMOVED rows: Upland's // The collapse is the document's: Upland's two regions are gone and the
// two regions are absent while Inland's two are here. Without this, a // group is ONE row in their place (improvise-6qet, core.md §14).
// page that drew the indicator and ignored the frame would pass.
const labels = await page.locator("#grid tbody tr th[scope=row]").allTextContents(); const labels = await page.locator("#grid tbody tr th[scope=row]").allTextContents();
expect(labels).toContain("East"); expect(labels).toContain("East");
expect(labels).toContain("West"); expect(labels).toContain("West");
expect(labels).toContain("Upland");
expect(labels).not.toContain("Ridge"); expect(labels).not.toContain("Ridge");
expect(labels).not.toContain("Valley"); expect(labels).not.toContain("Valley");
// 4 regions visible x 2 stores. Stated as a count as well, because a row // (4 regions + the Upland fold) x 2 stores. A count too, because a row
// drawn with empty labels would satisfy the four assertions above. // drawn with empty labels would satisfy the assertions above.
await expect(page.locator("#grid tbody tr").filter({ hasNot: page.locator("th.group") })).toHaveCount( const dataRows = page.locator("#grid tbody tr").filter({ hasNot: page.locator("th.group") });
8, await expect(dataRows).toHaveCount(10);
); // The fold is an aggregate: no record carries Region=Upland, so nothing
// in its rows may open an editor, where a stored row beside it does.
await expect(dataRows.first().locator("td.editable")).not.toHaveCount(0);
await expect(dataRows.last().locator("td.editable")).toHaveCount(0);
}); });
}); });