improvise-6qet: CSV export test, formatting, and the reasoning in context/

A collapsed group is written to CSV as its own total row, because an export
writes what the grid draws (improvise-iot6). core.md §14 carries the argument;
the evidence entries record each rival and where it failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 21:06:41 -07:00
parent bd2ed2fc1f
commit 1a359b4ae6
9 changed files with 388 additions and 58 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`
@@ -4778,40 +4787,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.
@@ -81,7 +81,10 @@ mod tests {
view.is_group_collapsed("Month", "Q2"), view.is_group_collapsed("Month", "Q2"),
"the effect was returned but the view did not move" "the effect was returned but the view did not move"
); );
assert!(!view.is_group_collapsed("Month", "Q1"), "the neighbour was left alone"); assert!(
!view.is_group_collapsed("Month", "Q1"),
"the neighbour was left alone"
);
} }
/// Evidence: `context/test-evidence/command.md`. /// Evidence: `context/test-evidence/command.md`.
@@ -92,7 +95,11 @@ 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();
app.cursor_mut().selected = (2, 0); app.cursor_mut().selected = (2, 0);
assert_eq!(app.layout.row_label(2), "Apr", "the cursor starts inside Q2"); assert_eq!(
app.layout.row_label(2),
"Apr",
"the cursor starts inside Q2"
);
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
@@ -102,8 +109,15 @@ mod tests {
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
assert!(dbg.contains(r#"group: "Q2""#), "z again names Q2, got: {dbg}"); assert!(
assert_eq!(row_labels(&app), ["Jan", "Feb", "Apr", "May"], "z z is a no-op"); dbg.contains(r#"group: "Q2""#),
"z again names Q2, got: {dbg}"
);
assert_eq!(
row_labels(&app),
["Jan", "Feb", "Apr", "May"],
"z z is a no-op"
);
assert_eq!(app.cursor().selected, (2, 0), "back on Q2's first item"); assert_eq!(app.cursor().selected, (2, 0), "back on Q2's first item");
} }
@@ -144,17 +158,35 @@ mod tests {
wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().set_axis("Type", Axis::Column);
let mut app = App::new(wb, None); let mut app = App::new(wb, None);
app.rebuild_layout(); app.rebuild_layout();
assert_eq!(app.layout.row_cats, ["Region", "Month"], "Month is the INNER level"); assert_eq!(
app.layout.row_cats,
["Region", "Month"],
"Month is the INNER level"
);
app.cursor_mut().selected = (6, 0); app.cursor_mut().selected = (6, 0);
assert_eq!(app.layout.row_tuple(6), Some(&["South".into(), "Apr".into()][..])); assert_eq!(
app.layout.row_tuple(6),
Some(&["South".into(), "Apr".into()][..])
);
toggle_group_at_cursor(&mut app); toggle_group_at_cursor(&mut app);
assert_eq!( assert_eq!(
row_labels(&app), row_labels(&app),
["North/Jan", "North/Feb", "North/Q2", "South/Jan", "South/Feb", "South/Q2"] [
"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"
); );
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`.
@@ -173,7 +205,10 @@ mod tests {
let dbg = toggle_group_at_cursor(&mut app); let dbg = toggle_group_at_cursor(&mut app);
assert!(dbg.contains(r#"group: "Q2""#), "the Q2 row names Q2, got: {dbg}"); assert!(
dbg.contains(r#"group: "Q2""#),
"the Q2 row names Q2, got: {dbg}"
);
assert_eq!(row_labels(&app), ["Q1", "Apr", "May"]); assert_eq!(row_labels(&app), ["Q1", "Apr", "May"]);
assert_eq!(app.cursor().selected, (1, 0), "on Q2's first item"); assert_eq!(app.cursor().selected, (1, 0), "on Q2's first item");
} }
@@ -409,7 +444,11 @@ impl Cmd for ToggleGroupAtCursor {
category: cat, category: cat,
group, group,
})]; })];
effects.extend(landing.map(|at| self.place_cursor(ctx, at)).unwrap_or_default()); effects.extend(
landing
.map(|at| self.place_cursor(ctx, at))
.unwrap_or_default(),
);
effects effects
} }
} }
+8 -2
View File
@@ -1440,7 +1440,10 @@ mod tests {
vec!["North", "[Hidden]", "Hidden", "[Late]", "East"], vec!["North", "[Hidden]", "Hidden", "[Late]", "East"],
"both headers are drawn, the collapsed one above its folded row" "both headers are drawn, the collapsed one above its folded row"
); );
let hidden = grid.row_frame.group_of(1).expect("the fold is under Hidden"); let hidden = grid
.row_frame
.group_of(1)
.expect("the fold is under Hidden");
assert_eq!(hidden.name, "Hidden"); assert_eq!(hidden.name, "Hidden");
assert!(hidden.collapsed, "and it arrives collapsed"); assert!(hidden.collapsed, "and it arrives collapsed");
let late = grid.row_frame.group_of(2).expect("East is under Late"); let late = grid.row_frame.group_of(2).expect("East is under Late");
@@ -4328,7 +4331,10 @@ mod cell_identity {
"a fold has no record under it and must not be offered as settable" "a fold has no record under it and must not be offered as settable"
); );
let grid = grid_of(&app, WHOLE); let grid = grid_of(&app, WHOLE);
assert_eq!(grid.cell(1, 0).expect("the fold's cell").text.trim(), "7.00"); 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 {
+6 -1
View File
@@ -566,7 +566,12 @@ impl Model {
/// A group's cell: stored values fold ONCE over every record the group covers, /// 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. /// 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> { fn evaluate_group(
&self,
key: &CellKey,
members: &[CellKey],
agg: Aggregation,
) -> Option<CellValue> {
let values: Vec<CellValue> = match self.formula_for(key) { let values: Vec<CellValue> = match self.formula_for(key) {
Some(_) => members Some(_) => members
.iter() .iter()
+30 -8
View File
@@ -1365,10 +1365,19 @@ mod tests {
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());
let labels: Vec<String> = (0..layout.row_count()).map(|r| layout.row_label(r)).collect(); let labels: Vec<String> = (0..layout.row_count())
assert_eq!(labels, ["Q1", "Apr", "May"], "Q1 folds into one row; Q2 stays open"); .map(|r| layout.row_label(r))
.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(0, 0), "the Q1 row is a fold");
assert!(!layout.folds_a_group(1, 0), "Apr, under the OPEN group, is not"); assert!(
!layout.folds_a_group(1, 0),
"Apr, under the OPEN group, is not"
);
assert_eq!( assert_eq!(
layout.row_group_for(0), layout.row_group_for(0),
Some(("Month".into(), "Q1".into())), Some(("Month".into(), "Q1".into())),
@@ -1388,14 +1397,21 @@ mod tests {
wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Food").unwrap();
wb.active_view_mut().toggle_group_collapse("Month", "G1"); wb.active_view_mut().toggle_group_collapse("Month", "G1");
let layout = GridLayout::new(&wb.model, wb.active_view()); let layout = GridLayout::new(&wb.model, wb.active_view());
let labels: Vec<String> = (0..layout.row_count()).map(|r| layout.row_label(r)).collect(); 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"); assert_eq!(labels, ["G1", "B"], "one G1 row, not one per run");
let g1_headers = layout let g1_headers = layout
.row_items .row_items
.iter() .iter()
.filter(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "G1")) .filter(
|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "G1"),
)
.count(); .count();
assert_eq!(g1_headers, 1, "a second G1 header would open a run with no row"); assert_eq!(
g1_headers, 1,
"a second G1 header would open a run with no row"
);
} }
/// Evidence: context/test-evidence/core.md. /// Evidence: context/test-evidence/core.md.
@@ -1412,8 +1428,14 @@ mod tests {
wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Region", Axis::Row);
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());
assert_eq!(layout.row_tuple(0), Some(&["Q1".to_string(), "North".to_string()][..])); assert_eq!(
assert!(layout.folds_a_group(0, 0), "Q1/North has no record under it either"); 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"); assert!(!layout.folds_a_group(1, 0), "Apr/North does");
} }
@@ -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();