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:
@@ -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
|
||||
key. Evaluated bare, outside a `SUM`, it degrades exactly as `Formula::filter`
|
||||
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)
|
||||
|
||||
`GridLayout::page_coords` answers two questions that gotcha 27 says must not be
|
||||
|
||||
@@ -1257,9 +1257,11 @@ substring.
|
||||
|
||||
### 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
|
||||
the only row that says those rows exist. A window that reaches the last row
|
||||
therefore carries it.
|
||||
A collapsed group at the bottom of the grid folds into one row (improvise-6qet,
|
||||
`context/code/core.md` §14), and the header comes above it. A window that reaches
|
||||
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
|
||||
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.
|
||||
|
||||
**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
|
||||
follows it immediately) because that is the only way to tell "the nearest header
|
||||
comes" from "every header above comes". With one group above, deleting the
|
||||
window (`Hidden`, whose collapsed group is now one row, and `Late`) because that is
|
||||
the only way to tell "the nearest header comes" from "every header above comes".
|
||||
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
|
||||
crossed), and correctness alone cannot see it: `group_of` still finds the nearest
|
||||
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
|
||||
a header and no items, and `Late`'s header follows it immediately.
|
||||
Two group headers between `North` and `East`: `Hidden`, collapsed, and `Late`, open.
|
||||
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
|
||||
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`
|
||||
returns no effects at all.
|
||||
|
||||
So the cursor-addressed toggle is not an involution, and a collapsed group cannot
|
||||
be reopened from the grid — only by `:toggle-group`, which names the group. That
|
||||
behaviour is unchanged by this work and is filed separately as improvise-6qet; the
|
||||
three tests below pin what the command *does*, which is what the old test claimed
|
||||
to and could not.
|
||||
So the cursor-addressed toggle was not an involution, and a collapsed group could
|
||||
not be reopened from the grid. **improvise-6qet changed that**, and the tests below
|
||||
are its guards; the two that pinned the old behaviour
|
||||
(`a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one`,
|
||||
`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
|
||||
|
||||
Asserts the effect names `category: "Month", group: "Q1"` — a category and a group
|
||||
by name, which no assertion in the tile/grid suite had ever done — and that the
|
||||
view's collapsed set actually moved afterwards. The fixture is asserted expanded
|
||||
first, so the post-condition is not supplied by the default.
|
||||
Asserts the effect names `category: "Month", group: "Q2"` — a category and a group
|
||||
by name — and that the view's collapsed set actually moved, and that the neighbour
|
||||
`Q1` did not. The cursor sits on `Apr` (row 2): at row 0 the neighbour and the
|
||||
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
|
||||
law passed (both sides empty, as they already were); this test fails on the
|
||||
missing effect, and the two below fail with it.
|
||||
Faults injected: `execute` returning `vec![]` after resolving the lookup, and
|
||||
`row_group_for(0)` in place of the cursor row (improvise-6eqi). Both fail 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
|
||||
layout the first one changed.
|
||||
improvise-6qet, driven through the real path (`App::cmd_context` → `execute` →
|
||||
`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
|
||||
`ToggleGroup { category: "Month", group: "Q1" }` — i.e. one that ignores the
|
||||
applied state, which is exactly what "does not apply effects between calls" hides.
|
||||
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.
|
||||
Faults injected: **the swap** (cursor placement emitted before `ToggleGroup`) fails
|
||||
it `left: (0, 0) right: (2, 0)` — the toggle's observed reframe resets the cursor
|
||||
after the placement. `row_group_for(0)` fails it too.
|
||||
|
||||
### 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)
|
||||
|
||||
`Dirty::of(ModelChange::CellData)` reports `formulas: true`. That was written as
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
than one overall; this is one warning per **landed** row rather than per
|
||||
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"),
|
||||
"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`.
|
||||
@@ -92,7 +95,11 @@ mod tests {
|
||||
let mut app = App::new(two_groups_on_the_row_axis(), None);
|
||||
app.rebuild_layout();
|
||||
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);
|
||||
|
||||
@@ -102,8 +109,15 @@ mod tests {
|
||||
|
||||
let dbg = toggle_group_at_cursor(&mut app);
|
||||
|
||||
assert!(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!(
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -144,17 +158,35 @@ mod tests {
|
||||
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");
|
||||
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()][..]));
|
||||
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"]
|
||||
[
|
||||
"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`.
|
||||
@@ -173,7 +205,10 @@ mod tests {
|
||||
|
||||
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!(app.cursor().selected, (1, 0), "on Q2's first item");
|
||||
}
|
||||
@@ -409,7 +444,11 @@ impl Cmd for ToggleGroupAtCursor {
|
||||
category: cat,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1440,7 +1440,10 @@ mod tests {
|
||||
vec!["North", "[Hidden]", "Hidden", "[Late]", "East"],
|
||||
"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!(hidden.collapsed, "and it arrives collapsed");
|
||||
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"
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -566,7 +566,12 @@ impl Model {
|
||||
|
||||
/// 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> {
|
||||
fn evaluate_group(
|
||||
&self,
|
||||
key: &CellKey,
|
||||
members: &[CellKey],
|
||||
agg: Aggregation,
|
||||
) -> Option<CellValue> {
|
||||
let values: Vec<CellValue> = match self.formula_for(key) {
|
||||
Some(_) => members
|
||||
.iter()
|
||||
|
||||
@@ -1365,10 +1365,19 @@ mod tests {
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
wb.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||||
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, ["Q1", "Apr", "May"], "Q1 folds into one row; Q2 stays open");
|
||||
let labels: Vec<String> = (0..layout.row_count())
|
||||
.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(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!(
|
||||
layout.row_group_for(0),
|
||||
Some(("Month".into(), "Q1".into())),
|
||||
@@ -1388,14 +1397,21 @@ mod tests {
|
||||
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();
|
||||
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
|
||||
.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();
|
||||
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.
|
||||
@@ -1412,8 +1428,14 @@ mod tests {
|
||||
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_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");
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,35 @@ mod tests {
|
||||
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]
|
||||
fn export_csv_unknown_view_returns_error() {
|
||||
let m = two_cat_model();
|
||||
|
||||
Reference in New Issue
Block a user