diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index a61a8272..bfb77330 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -84,9 +84,6 @@ fn chain_app(regions: usize, products: usize) -> App { v.set_axis("Region", Axis::Row); v.set_axis("Product", Axis::Row); v.set_axis("_Measure", Axis::Column); - for g in 0..regions.div_ceil(10) { - v.toggle_group_collapse("Region", &format!("G{g}")); - } } let mut n = 0.0f64; for r in 0..regions { @@ -127,9 +124,6 @@ fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize) v.set_axis("Day", Axis::Row); v.set_axis("Product", Axis::Row); v.set_axis("_Measure", Axis::Column); - for g in 0..days.div_ceil(per_group) { - v.toggle_group_collapse("Day", &format!("G{g}")); - } } let mut n = 0.0f64; for d in 0..days { diff --git a/context/code/core.md b/context/code/core.md index ed64269e..b4d667c1 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -258,9 +258,11 @@ user drilled in to see all the raw data. On an axis with two grouped categories the runs **nest**: `North` in `Upper` holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a -marker names its level. `GroupHeader { level, .. }` opens a run at `level` and -closes every deeper one; `GroupEnd { level }` closes `level` and every deeper -one. `expand_category` reads the level off its prefix, which holds one item per +marker names its level. `GroupHeader { level, depth, .. }` opens a run at +`(level, depth)` and closes every deeper one; `GroupEnd { level, depth }` closes +that run and every deeper one. "Deeper" is the ordering of the pair: the level +first, then the depth inside one category's hierarchy (§19, improvise-felo.6). +`expand_category` reads the level off its prefix, which holds one item per outer category, so it is derived rather than passed. A row's group, for `row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open at it. That is the user's ruling on the ticket: membership is per level, and @@ -1556,3 +1558,128 @@ editor stays open over the user's text (gotcha 21). `was` is not redundant with a stable id: a concurrent *replace* keeps the id and changes the text, which only `was` can see. + +## 19. Group hierarchies: parents nest runs, and every group starts closed (improvise-felo.6) + +`Group::parent` was parsed (`> Q1[2024]`), written, round-tripped and hashed, +and nothing read it: `Q1` was never drawn inside `2024`. It is now the +hierarchy. + +### The user's rulings + +- "I think hierarchies should start closed unless we somehow opt into a + specific one opened to a certain depth (view config maybe?)" — and the + reason: "in most cases, the closed by default start is preferable because it + makes the grid more dense." +- "Everything opens closed, including old files." There is no version gate: a + file written before this change opens with its groups closed, and its + `collapsed:` lines simply agree with the default. +- "I think we'll need a depth for expanded, something like expanded(2): ...". +- "expanded+collapsed in the same group is an error". + +### The model + +- **A group's chain** is the group and its ancestors, root first + (`Category::group_chain`). An item's chain is its group's + (`Category::item_chain`); an ungrouped item has none. A group's depth is its + number of ancestors. +- **A group covers every leaf beneath it.** `items_in_group` reads each item's + chain, so `2024` covers the months of `Q1-2024` and `Q2-2024`. That is the + whole change to evaluation: `evaluate_covering` already expanded a group + coordinate through `items_in_group` (§14), so a year's fold is the fold over + its months with no second path. +- **An item may sit directly in a parent group, beside its child groups.** + Chosen because nothing is simpler: the chain handles it (`Jun[H1]` after a + run of `Q2` inside `H1` ends the `Q2` run and stays in `H1`), and refusing it + would be a rule with nothing to protect. A date import never produces one. +- **A cycle is refused on load, naming its members** (`Refusal::HierarchyCycle`, + `persistence/parse.rs`). A cycle built in code cannot hang anything either: + `group_chain` stops where it would repeat. +- **There is no such thing as a dangling parent in this format.** A root group + is declared *only* by being named as a parent: the writer emits `>` lines + only for groups that have a parent, and nothing else can name `2024`. So + "a parent that names no group" and "a root" are the same line, and refusing + one refuses the other. + +### Which groups are open + +State lives on the `View`, in two places that cannot disagree about one group: + +- `expand_depth: cat → N` — `expanded(N): Cat`, the category's top N levels + open. +- `group_folds: cat → group → GroupFold` — ONE map, so a group has at most one + fold. `GroupFold::Collapsed` is `collapsed: Cat/Group`; + `GroupFold::Expanded(N)` is `expanded(N): Cat/Group`, the group open and + N-1 levels beneath it. Two sets would have made "both" representable, which + is the state the user ruled an error + (`doctrine(an_invariant_is_made_unrepresentable_not_validated)`). + +`View::is_group_collapsed(cat, chain)` answers, in order: + +1. the group's own fold, if it has one — so a `collapsed:` group inside an + `expanded(N)` region stays collapsed, and an `expanded:` group below the + category's depth opens; +2. open, if an ancestor's `Expanded(n)` reaches it (the ancestor `k` levels up + reaches it when `k < n`), or the category's depth does (`depth < N`); +3. closed. + +**`z` writes one fold, or none.** `toggle_group_collapse` flips the answer and +records the flip as `Collapsed` or `Expanded(1)` — unless the flip lands on +what the group would inherit from rule 2, in which case it removes the group's +fold. So `z z` leaves the view as it was, and no sequence of toggles writes a +file that names one group twice +(`no_sequence_of_toggles_writes_a_conflicting_file`). Closing a group that +carried `Expanded(3)` drops the 3: reopening it with `z` opens one level. + +### The layout + +`expand_category` walks each drawn item's chain. The **outermost** collapsed +group in the chain folds everything beneath it: the row's tuple is that group's +name, it folds once however non-contiguous its members are (§14), and the +headers above it are drawn. The run markers move from item to item exactly as +`remark` moves them after pruning — past the shared prefix of the two chains, +end the deeper runs innermost-out, then open what differs — so an unpruned axis +survives `prune_axis_items` marker for marker, and the enumeration in +`group_membership` (now with a `NESTED` fixture: a non-contiguous quarter, an +item directly in a half-year, an outer grouped category) asserts it. + +A header carries its own `collapsed`, computed once in `expand_category`, so +the frame projection and the frame hash read it rather than asking the view +again with a chain they would have to rebuild. + +### Persistence + +`expanded(N): Cat`, `expanded(N): Cat/Group`, bare `expanded:` for N = 1, and +the existing `collapsed: Cat/Group` (`improv.pest`, `expanded_line`). N is at +least 1 by the grammar (`'1'..'9' ~ ASCII_DIGIT*`), so `expanded(0)` is a parse +error rather than a value to explain. Two further decisions: + +- **The writer keeps what the view holds.** An old file's `collapsed:` lines + load as `Collapsed` folds and are written back verbatim, though they agree + with the default. They are the user's explicit closes, and they survive a + later `expanded(N): Cat` that would otherwise open those groups; dropping + them on save would change what that later line means. A `z` round trip + removes such a line, because `z` normalises (above). +- **A category depth is never written bare.** `axis_line` is tried before + `expanded_line`, so a category literally named `expanded` keeps its + `expanded: row`. The price is that `expanded: row` meaning "category `row`, + depth 1" would read as that axis line, so the writer always spells the + category form `expanded(N): Cat`. The group form has a `/` and is never + ambiguous. + +A view naming one group with two different folds, or one category with two +depths, is refused on load, naming the view and the group +(`Refusal::FoldConflict`). The same line twice is not a conflict. + +The grammar-walking generator emits cycles and conflicts with ordinary +probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a +`Refusal` — a typed error, so it cannot accept a parse failure by accident. + +### Known limits + +- The TUI draws nested headers exactly as it draws any other: one line per + header, not indented by depth, and the column band shows each column's + innermost group only. Both are pre-existing presentation choices; the frame + carries `depth` for the day a front end wants to indent. +- Two `>` lines giving one group two different parents: the last wins, as it + did before this change. That makes the hierarchy a function of line order. diff --git a/context/code/formula-and-io.md b/context/code/formula-and-io.md index 7dd1c322..3cda7532 100644 --- a/context/code/formula-and-io.md +++ b/context/code/formula-and-io.md @@ -218,6 +218,32 @@ times. The probe was removed rather than kept: its rate depends on proptest's internal RNG, so as a permanent assertion it would fail on a dependency bump for a reason unrelated to the format. +### Folds are view lines: `collapsed:` and `expanded(N):` (improvise-felo.6) + +Groups start collapsed, so a view persists what it opens. `expanded(N): Cat` +opens a category's top N levels, `expanded(N): Cat/Group` opens that group and +N-1 levels beneath it, a bare `expanded:` is N = 1, and `collapsed: Cat/Group` +keeps its old meaning. The semantics, the precedence and the refusals are +`context/code/core.md` §19; what is particular to the format: + +- **No version bump.** The line is additive, and the user ruled that old files + open closed like new ones, so `v2025-04-09` still reads every file it did. +- **N >= 1 is grammar, not validation**: `depth_number = @{ '1'..'9' ~ + ASCII_DIGIT* }`. Spelled with a `Range` and `ASCII_DIGIT` rather than + `ASCII_NONZERO_DIGIT`, because the generator knows the first two and would + emit nothing for the third — `expanded(): ` — a file its own grammar refuses. +- **`axis_line` comes before `expanded_line`** in `view_entry`, so a category + named `expanded` keeps its axis line, and the writer never spells a category + depth bare (`expanded: row` would be that axis line). +- **Two refusals the grammar cannot express** — a cycle among `>` lines and one + group given two folds — are a typed `Refusal`, which `generated_file_parses` + accepts and nothing else does. The generator produces both at ordinary rates. +- **The generator reaches the new rule**, asserted by + `the_generator_writes_expanded_lines_with_and_without_a_depth` over 512 + deterministic files. Unlike the kind-line probe above, it asserts that each + spelling occurs at all, not a rate, so a proptest bump fails it only if the + walk stopped reaching the rule. + ### The tree walkers are infallible by construction Every `build_*` in `parser.rs` operates on a `Pair` pest has already diff --git a/context/code/protocol.md b/context/code/protocol.md index 81151eb8..044ddb9c 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -1474,6 +1474,22 @@ sequence. The header's level also gives a client the group's category, the dead field improvise-7xyx removed: `level` is read by `groups()`, and the category name is not carried twice. +**Within one category runs nest by `depth` (improvise-felo.6).** A group's +parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME +level, so both markers also carry `depth`, the group's number of ancestors. +A run's key is `(level, depth)`, compared in that order: a header at a key +closes every run with a greater key, and so does a `GroupEnd`. With `Date` +alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after +`2024` ends in nothing. Depth rides beside `level` rather than being folded +into one flat index, because a flat index would need every category's tree +height to be known before the first marker, and `level` would stop naming +`cats[level]`. Both front ends draw a nested header exactly as they draw any +other: one inline row per `Group` entry, the innermost group in the column +band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and +`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and +`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the +windowed frame against the layout. + `group_runs` is unchanged. It coalesces whatever `groups()` answers, by header identity (improvise-h0iv), so the band still paints one group per position, now the innermost. One band per level would be a separate feature. @@ -1554,14 +1570,19 @@ runs. Reversing it is one comparison, and the test is renamed ## §3b. A frame's levels are checked as it arrives -A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index -`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack -with `open.resize(g.level, None)`. So a level arriving over the wire decides how -much it allocates. A frame whose level is `4294967295` would ask for a +A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index +`cats`. `AxisFrame::groups` used to keep one open run per level and size that +stack with `open.resize(g.level, None)`, so a level arriving over the wire +decided how much it allocated. A frame whose level is `4294967295` would ask for a multi-gigabyte vector, and the client would be taken down instead of the frame being declined. ChatGPT Codex found this on #250, the PR that gave levels to the wire (improvise-e265). +Since improvise-felo.6 `groups()` keeps only the headers it has received, +ordered by `(level, depth)`, so no number on the wire sizes anything and the +new `depth` field needs no bound. The level check stays for its other reason: +`cats[level]` names the group's category, which a browser `z` reads. + The bound is enforced at deserialization, by `#[serde(try_from = "UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A level that is not below `cats.len()` fails to parse, with an error that names the diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 0444831b..08ce94cb 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -7214,3 +7214,16 @@ cleared the cell cannot pass. `the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable` wrote with `view: "Default"` against `web/demo.improv`, whose view is `Plan`. It passed only because a stored record ignored the view name. It now names `Plan`. + +### the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy + +improvise-felo.6. The windowed frame carries the runs open above the window; +those are now keyed by `(level, depth)`. Rival: `axis_frame` closing carried +runs by level alone. Only this test fails; the existing agreement test has one +group per level, where the two keyings coincide. + +### z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again + +`z` through a closed Year > Quarter > Month hierarchy, cursor at row 1. Rival: +`toggle_group_collapse` never removing a fold; the final assertion (no fold left +behind once the quarter is closed again) fails. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index fb4b2e6e..d7d99df7 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1865,5 +1865,56 @@ were run against the whole `improvise-core` suite: **What it cannot see is the allocation itself.** A visitor that clones the stack at every row returns the same answers, so no assertion here can tell the two apart without a counting allocator. The copy-free property is held by the -shape: the visitor receives a borrowed `&[Option]`, and no owned stack +shape: the visitor receives a borrowed slice of open runs (`&[OpenRun]` since +improvise-felo.6), and no owned stack leaves the walk. It is not held by a test. + +### a_parent_group_covers_every_leaf_beneath_its_child_groups + +improvise-felo.6. `items_in_group` read only the items whose own `group` named +the group, so a year covered nothing: its months sit in quarters. Rival run +against `improvise-core`: the direct-only filter restored. Four tests fail — +this one, `a_hierarchy_opens_closed_as_one_fold_per_root` (the 2024 fold reads +empty), `collapsing_a_parent_folds_its_children_whatever_their_state` and +`hidden_items_leave_every_fold_above_them`. The fixture adds `2024-03` last, so +the year and its first quarter are non-contiguous in insertion order. + +### a_year_quarter_month_hierarchy_nests_its_runs + +improvise-felo.6: the whole marker sequence of a Year > Quarter > Month axis +opened to depth 2, compared exactly. Rival: every header inside a category +emitted at depth 0 (a flat hierarchy). This test fails, and so do +`pruning_keeps_every_surviving_rows_runs_at_every_level` (the `NESTED` fixture), +`the_nested_fixture_draws_runs_inside_one_category` and +`pruning_drops_an_empty_inner_group_and_keeps_its_parent`. + +### a_hierarchy_opens_closed_as_one_fold_per_root + +The user's ruling on improvise-felo.6: groups start closed. Two rivals fail it. +The old open default (`inherits_collapsed` always false) fails twelve tests +across `improvise-core` and `improvise-io`, this one among them. The INNERMOST +collapsed group folding instead of the outermost (`.rev()` on the fold search) +fails this one, `hidden_items_leave_every_fold_above_them` and +`the_group_under_the_cursor_is_the_innermost_at_every_depth`: under it a closed +year shows its quarters as rows. + +### a_groups_depth_opens_it_and_n_minus_one_levels_beneath + +`expanded(2): Time/2024` opens 2024 and its quarters, not its months. Rival: an +ancestor reaching `k <= n` levels instead of `k < n`. Only this test fails, which +is why it exists: the boundary is the whole semantics of N. + +### a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it + +`z` writes `Collapsed` or `Expanded(1)`, or removes the fold where the flip +lands on what the group inherits. Rival: never remove. This test and +`command::cmd::grid::tests::z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again` +fail; every layout test passes, since a redundant fold draws the same grid. What +the rival breaks is the file (a line per toggle, forever) and `z z` being the +identity on the view. + +### pruning_drops_an_empty_inner_group_and_keeps_its_parent + +`prune_empty` at depth: an empty quarter goes, header and all, and its year's +header stays for the sibling quarter that has data. Rival: `prune_axis_items` +keying open runs by level alone. This test and the `NESTED` enumeration fail. diff --git a/context/test-evidence/formula-and-io.md b/context/test-evidence/formula-and-io.md index 887302e6..be6ebb0e 100644 --- a/context/test-evidence/formula-and-io.md +++ b/context/test-evidence/formula-and-io.md @@ -1644,6 +1644,48 @@ the reviewer's rival, **date-derived names dropped from the taken names**, which fails it. That fault would have let `add_category` reuse the existing category, and every cell key would then name `when_Year` twice. +### a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group + +The user's ruling on improvise-felo.6. Three spellings of one group given two +folds, each refused as `Refusal::FoldConflict` naming the view and the group, +plus a category given two depths. Rival: a second fold for a group silently +wins. Only this test fails. Its control is +`a_collapsed_group_inside_an_expanded_region_loads`: a DIFFERENT group +collapsed inside an expanded region, and the same line twice, both load. + +### a_cycle_among_group_parents_is_refused_naming_it + +`> Q1-2025[|2025|]` and `> |2025|[Q1-2025]`. Rival: the cycle check disabled; +only this test fails. The fixture's first version spelled `2024-01` bare, which +the grammar refuses (a bare name starts with a letter), so every test on it +passed or failed for the parse error, `a_malformed_depth_is_a_parse_error` +vacuously. It now carries a control that parses. + +### an_axis_line_for_a_category_named_expanded_is_still_an_axis_line + +`axis_line` is tried before `expanded_line`, so the writer must never spell a +category depth bare. Rival: the writer writing `expanded: Cat` for depth 1. This +test fails: the category `row` at depth 1 reads back as an axis line for a +category named `expanded`. + +### an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines + +"Everything opens closed, including old files." Every group of the file opens +closed, the one its `collapsed:` line names and the three it does not, and the +line is written back. Rival: the old open default; this test fails with the +other eleven in that rival's list. + +### every_fold_the_api_can_build_reads_back_from_the_file_it_writes + +ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and +`View::set_group_fold` are public, so `Expanded(0)` could be built, written as +`expanded(0): …` and then refused by the grammar that wrote it. Confirmed by +reading: the writer formatted any `usize`, and `depth_number` starts at `1`. +The fix makes the state unrepresentable (`Expanded(NonZeroUsize)`), so the +regression guard is the type, and the old failing input no longer compiles. +This test covers every fold the API CAN build (`Collapsed` and depths 1 to 3, +beside a category depth), each saved and read back. + ### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342 diff --git a/context/test-evidence/protocol.md b/context/test-evidence/protocol.md index 8b1ead78..b5583639 100644 --- a/context/test-evidence/protocol.md +++ b/context/test-evidence/protocol.md @@ -1259,3 +1259,11 @@ the Enter commit. - **Rival** (the commit naming `state.view.active_view`, the view at `Enter`), run across the whole workspace: fails **only** this test, with `["Later"]` against `["Other"]`. + +### a_quarters_end_leaves_its_year_open_on_the_same_level + +improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct` +sits in `2024` after `Q1`'s `GroupEnd { level: 0, depth: 1 }`, and `Undated` +in nothing after `2024` ends. Rival: `groups()` closing by level alone, as it did +before depth existed. Only this test fails in `improvise-protocol`: every other +frame fixture puts one group per level. diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 69cf6582..2e80651c 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -1429,3 +1429,18 @@ to the list's header row, which no item can cover. This test fills three rows with a three-line item and a second item after it. It asserts the first item is drawn whole and the header reads `1–1 of 2`. The rival **the header claims every item shown** fails it. + +### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows + +improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer +category, with a year open two levels, a quarter closed and a year folded. The +precondition pins the header sequence, so the agreement cannot pass on a frame +that drew no nested header. What makes the two ends agree is that neither +derives membership: both walk the same `FrameEntry` list. Rival: the browser's +`render.rs` skipping a `Group` entry whose `depth > 0`. This test fails on the +row count (the TUI draws the quarter line, the browser does not); the column +test does not, since the band shows only each column's innermost group, which +the rival leaves alone. Group names are short because the TUI +truncates a band label to its column's width, which the browser does not: the +first version named its years `Y2024` and the column test failed on `▶ Y2…` +against `▶ Y2025`, a truncation, not a disagreement. diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 03506c3b..67abeb9e 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -177,6 +177,7 @@ mod tests { wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "May", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb } @@ -213,7 +214,7 @@ mod tests { !app.model_state .workbook .active_view() - .is_group_collapsed("Month", "Q2"), + .is_group_collapsed("Month", &["Q2"]), "the fixture starts expanded, or the assertion below proves nothing" ); @@ -225,11 +226,11 @@ mod tests { ); let view = app.model_state.workbook.active_view(); assert!( - view.is_group_collapsed("Month", "Q2"), + view.is_group_collapsed("Month", &["Q2"]), "the effect was returned but the view did not move" ); assert!( - !view.is_group_collapsed("Month", "Q1"), + !view.is_group_collapsed("Month", &["Q1"]), "the neighbour was left alone" ); } @@ -262,9 +263,9 @@ mod tests { ); app.apply_effects(effects); let view = app.model_state.workbook.active_view(); - assert!(view.is_group_collapsed("Month", "Q2")); + assert!(view.is_group_collapsed("Month", &["Q2"])); assert!( - !view.is_group_collapsed("Month", "Q1"), + !view.is_group_collapsed("Month", &["Q1"]), "the neighbour stays" ); } @@ -286,6 +287,7 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Tools").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Column); @@ -328,6 +330,66 @@ mod tests { ); } + /// improvise-felo.6: `z` walks DOWN a closed hierarchy — year, then quarter — + /// and back up, the cursor on the group it acted on each time. + #[test] + fn z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again() { + use crate::ui::app::App; + let mut wb = crate::workbook::Workbook::new("T"); + wb.add_category("Date").unwrap(); + wb.add_category("Type").unwrap(); + for (month, quarter) in [ + ("Jan", "Q1"), + ("Feb", "Q1"), + ("Apr", "Q2"), + ("Jan25", "Q1-25"), + ] { + wb.add_item_in_group("Date", month, quarter).unwrap(); + } + let date = wb.model.category_mut("Date").unwrap(); + for (quarter, year) in [("Q1", "Y2024"), ("Q2", "Y2024"), ("Q1-25", "Y2025")] { + date.add_group(crate::model::category::Group::new(quarter).with_parent(year)); + } + wb.add_item("Type", "Food").unwrap(); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert_eq!(row_labels(&app), ["Y2024", "Y2025"], "closed by default"); + + app.cursor_mut().selected = (1, 0); + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Q1-25"], + "Y2025 opens to its quarter" + ); + assert_eq!(app.cursor().selected, (1, 0)); + + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Jan25"], + "then the quarter to its month" + ); + + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Q1-25"], + "z on the month closes its quarter" + ); + app.cursor_mut().selected = (0, 0); + toggle_group_at_cursor(&mut app); + assert_eq!(row_labels(&app), ["Q1", "Q2", "Q1-25"]); + assert_eq!( + app.model_state + .workbook + .active_view() + .group_fold("Date", "Q1-25"), + None, + "closing back to the default leaves no fold behind" + ); + } + /// Evidence: `context/test-evidence/command.md`. #[test] fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { @@ -393,6 +455,7 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row); @@ -704,7 +767,9 @@ impl ToggleGroupAtCursor { }; let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?; let mut view = ctx.view.clone(); - view.toggle_group_collapse(cat, group); + let chain = ctx.model.group_chain(cat, group); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); + view.toggle_group_collapse(cat, &chain); let after = GridLayout::with_frozen_records(ctx.model, &view, None); match self.is_row { true => after.row_run_start(cat, group, prefix), diff --git a/crates/improvise-command/src/command/cmd/mod.rs b/crates/improvise-command/src/command/cmd/mod.rs index cf82f2ff..fc4523c7 100644 --- a/crates/improvise-command/src/command/cmd/mod.rs +++ b/crates/improvise-command/src/command/cmd/mod.rs @@ -155,6 +155,7 @@ pub(super) mod test_helpers { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() { @@ -168,7 +169,7 @@ pub(super) mod test_helpers { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = crate::ui::app::App::new(wb, None); app.rebuild_layout(); app.cursor_mut().selected = (2, 1); diff --git a/crates/improvise-command/src/command/cmd/mode.rs b/crates/improvise-command/src/command/cmd/mode.rs index b72d49eb..dfdf28a2 100644 --- a/crates/improvise-command/src/command/cmd/mode.rs +++ b/crates/improvise-command/src/command/cmd/mode.rs @@ -197,8 +197,9 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + 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, ®); diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 1b3f84c0..649f9e15 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -50,17 +50,17 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 { .filter(|item| !hidden.is_some_and(|h| h.contains(item))) .collect(); drawn.hash(&mut h); - // Off the drawn items, not `Category::groups`: `add_item_in_group` does not - // populate that, and a group with nothing drawn under it is not shape. + // Off the drawn items' chains, not `Category::groups`: a group with nothing + // drawn under it is not shape, and a fold at any depth is. let mut collapsed: Vec<&str> = model .category(cat) .into_iter() - .flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item))) - .filter_map(|item| item.group.as_deref()) - .filter(|group| { - view.collapsed_groups - .get(cat) - .is_some_and(|c| c.contains(*group)) + .flat_map(|c| drawn.iter().map(move |item| c.item_chain(item))) + .flat_map(|chain| { + (0..chain.len()) + .filter(|&d| view.is_group_collapsed(cat, &chain[..=d])) + .map(|d| chain[d]) + .collect::>() }) .collect(); collapsed.sort_unstable(); @@ -77,15 +77,14 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 { fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 { // `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a // pruned row is exactly the kind of reframing this observation exists for. - frame_of( - &crate::view::layout::GridLayout::with_frozen_records(model, view, None), - view, - ) + frame_of(&crate::view::layout::GridLayout::with_frozen_records( + model, view, None, + )) } /// The same hash off a layout already built. The batch's opening basis is free /// this way — `context/code/command.md` (improvise-mcq4). -fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { +fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); layout.row_cats.hash(&mut h); @@ -95,14 +94,17 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { for entry in entries { match entry { crate::view::layout::AxisEntry::GroupHeader { + depth, cat_name, group_name, + collapsed, .. } => { 0u8.hash(&mut h); + depth.hash(&mut h); cat_name.hash(&mut h); group_name.hash(&mut h); - view.is_group_collapsed(cat_name, group_name).hash(&mut h); + collapsed.hash(&mut h); } crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => { 1u8.hash(&mut h); @@ -111,9 +113,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { } // 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. - crate::view::layout::AxisEntry::GroupEnd { level } => { + crate::view::layout::AxisEntry::GroupEnd { level, depth } => { 2u8.hash(&mut h); level.hash(&mut h); + depth.hash(&mut h); } } } @@ -204,7 +207,16 @@ pub(crate) fn document_config(workbook: &Workbook) -> u64 { axis.hash(&mut h); } sorted_pairs(&view.hidden_items).hash(&mut h); - sorted_pairs(&view.collapsed_groups).hash(&mut h); + let mut depths: Vec<_> = view.expand_depth.iter().collect(); + depths.sort_unstable(); + depths.hash(&mut h); + let mut folds: Vec<_> = view + .group_folds + .iter() + .flat_map(|(cat, groups)| groups.iter().map(move |(g, fold)| (cat, g, fold))) + .collect(); + folds.sort_unstable(); + folds.hash(&mut h); let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect(); pages.sort_unstable(); pages.hash(&mut h); @@ -862,7 +874,7 @@ impl App { fn opening_view_frame(&self) -> u64 { let (_, view) = self.resolve_view(self.view_state.active_view.as_str()); match self.drill_state.is_none() { - true => frame_of(&self.layout, view), + true => frame_of(&self.layout), // A drill froze the record list, so the held layout is not what // `view_frame` would build and the two must not be compared. false => view_frame(&self.model_state.workbook.model, view), @@ -3430,8 +3442,9 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); app diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 84173f4f..3a53d393 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -550,8 +550,14 @@ pub struct ToggleGroup { } impl Effect for ToggleGroup { fn apply(&self, app: &mut App) { + let chain = app + .model_state + .workbook + .model + .group_chain(&self.category, &self.group); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); on_view(app, &self.view, |v| { - v.toggle_group_collapse(&self.category, &self.group); + v.toggle_group_collapse(&self.category, &chain); }); } fn kind(&self) -> EffectKind { @@ -3876,10 +3882,11 @@ mod tests { } .apply(&mut app); assert!( - app.model_state + !app.model_state .workbook .active_view() - .is_group_collapsed("Type", "MyGroup") + .is_group_collapsed("Type", &["MyGroup"]), + "a group starts collapsed, so the first toggle opens it" ); ToggleGroup { view: ViewTarget::Active, @@ -3888,10 +3895,10 @@ mod tests { } .apply(&mut app); assert!( - !app.model_state + app.model_state .workbook .active_view() - .is_group_collapsed("Type", "MyGroup") + .is_group_collapsed("Type", &["MyGroup"]) ); } diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 2b4280ec..5e0fd69b 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1547,11 +1547,12 @@ mod tests { wb.model.set_cell(month_cell(month, measure), value); } } + wb.active_view_mut().set_expand_depth("Month", 1); let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure"); wb.model.add_formula(profit.unwrap()); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert!( @@ -1733,13 +1734,14 @@ mod tests { for measure in ["Revenue", "Cost"] { wb.add_item_in_group("_Measure", measure, "Money").unwrap(); } + wb.active_view_mut().set_expand_depth("_Measure", 1); wb.add_item("_Measure", "Units").unwrap(); let jan = month_cell("Jan", "Revenue"); wb.model.set_cell(jan, CellValue::Number(10.0)); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut() - .toggle_group_collapse("_Measure", "Money"); + .toggle_group_collapse("_Measure", &["Money"]); let mut app = App::new(wb, None); app.rebuild_layout(); let target = month_cell("Feb", "Units"); @@ -1779,12 +1781,13 @@ mod tests { wb.model.set_cell(key, CellValue::Number(10.0)); } } + wb.active_view_mut().set_expand_depth("_Measure", 1); wb.add_item("_Measure", "Units").unwrap(); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); if collapsed { wb.active_view_mut() - .toggle_group_collapse("_Measure", "Money"); + .toggle_group_collapse("_Measure", &["Money"]); } let mut app = App::new(wb, None); app.rebuild_layout(); @@ -1808,6 +1811,7 @@ mod tests { for measure in ["Revenue", "Cost"] { wb.add_item_in_group("_Measure", measure, "Money").unwrap(); } + wb.active_view_mut().set_expand_depth("_Measure", 1); let measures = wb.model.category_mut("_Measure").unwrap(); measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals")); wb.model diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 75982e61..e434bfb5 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -147,8 +147,8 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache { let body = grid_body(model, layout, &plan, rows.clone(), cols.clone()); GridCache { - row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()), - col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()), + row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()), + col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()), col_widths: cols .clone() .map(|c| body.col_widths.get(c).copied().unwrap_or(0)) @@ -212,12 +212,7 @@ pub(crate) fn rederived_for( /// One axis's frame, windowed to the data indices `window` names. Three edge rules, /// all about group headers — see `context/code/command.md`. -fn axis_frame( - cats: &[String], - items: &[AxisEntry], - view: &View, - window: std::ops::Range, -) -> AxisFrame { +fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range) -> AxisFrame { let total = items .iter() .filter(|e| matches!(e, AxisEntry::DataItem { .. })) @@ -225,40 +220,48 @@ fn axis_frame( let mut data_idx = 0usize; let mut entries: Vec = Vec::new(); let mut pending: Vec = Vec::new(); - // Before the window opens, only the runs still open matter, one per level: - // O(levels), however many groups a deep scroll passed (improvise-e265). - let mut carried: Vec> = Vec::new(); + // Before the window opens, only the runs still open matter, keyed `(level, + // depth)`: O(depth), however many groups a deep scroll passed (e265, felo.6). + let mut carried: Vec<((usize, usize), FrameEntry)> = Vec::new(); + let close = |carried: &mut Vec<((usize, usize), FrameEntry)>, key: (usize, usize)| { + carried.truncate(carried.partition_point(|(k, _)| *k < key)) + }; for entry in items { match entry { AxisEntry::GroupHeader { level, - cat_name, + depth, group_name, + collapsed, + .. } => { let group = FrameEntry::Group(FrameGroup { level: *level, + depth: *depth, name: group_name.clone(), - collapsed: view.is_group_collapsed(cat_name, group_name), + collapsed: *collapsed, }); match entries.is_empty() { true => { - carried.truncate(*level); - carried.resize(*level, None); - carried.push(Some(group)); + close(&mut carried, (*level, *depth)); + carried.push(((*level, *depth), group)); } false => pending.push(group), } } - AxisEntry::GroupEnd { level } => match entries.is_empty() { - true => carried.truncate(*level), - false => pending.push(FrameEntry::GroupEnd { level: *level }), + AxisEntry::GroupEnd { level, depth } => match entries.is_empty() { + true => close(&mut carried, (*level, *depth)), + false => pending.push(FrameEntry::GroupEnd { + level: *level, + depth: *depth, + }), }, AxisEntry::DataItem { tuple, collapsed } => { let idx = data_idx; data_idx += 1; if window.contains(&idx) { if entries.is_empty() { - entries.extend(carried.drain(..).flatten()); + entries.extend(carried.drain(..).map(|(_, group)| group)); } entries.append(&mut pending); entries.push(FrameEntry::Item { @@ -271,7 +274,7 @@ fn axis_frame( } if window.end >= total { if entries.is_empty() { - entries.extend(carried.drain(..).flatten()); + entries.extend(carried.drain(..).map(|(_, group)| group)); } entries.append(&mut pending); } @@ -1458,6 +1461,7 @@ mod tests { wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "South").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -1469,7 +1473,8 @@ mod tests { wb.model.set_cell(key, CellValue::Number(1.0)); } if collapsed { - wb.active_view_mut().toggle_group_collapse("Region", "Late"); + wb.active_view_mut() + .toggle_group_collapse("Region", &["Late"]); } let mut app = App::new(wb, None); app.rebuild_layout(); @@ -1573,6 +1578,7 @@ mod tests { wb.add_item("Region", "North").unwrap(); wb.add_item_in_group("Region", "Inner", "Hidden").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -1584,7 +1590,7 @@ mod tests { wb.model.set_cell(key, CellValue::Number(1.0)); } wb.active_view_mut() - .toggle_group_collapse("Region", "Hidden"); + .toggle_group_collapse("Region", &["Hidden"]); let mut app = App::new(wb, None); app.rebuild_layout(); app @@ -1626,9 +1632,11 @@ mod tests { wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); wb.add_item_in_group("Region", "North", "Upper").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("Region", "South").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Month", "Mar").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); @@ -1674,6 +1682,52 @@ mod tests { ); } + /// improvise-felo.6: the same agreement for runs nested INSIDE one category, + /// at every window — the carried stack is keyed by depth as well as level. + #[test] + fn the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy() { + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("Date").unwrap(); + wb.add_item_in_group("Region", "North", "Upper").unwrap(); + wb.add_item("Region", "South").unwrap(); + for (month, group) in [("Jan", "Q1"), ("Feb", "Q1"), ("Jun", "H1"), ("Jul", "Q3")] { + wb.add_item_in_group("Date", month, group).unwrap(); + } + wb.add_item("Date", "Undated").unwrap(); + let date = wb.model.category_mut("Date").unwrap(); + date.add_group(crate::model::category::Group::new("Q1").with_parent("H1")); + date.add_group(crate::model::category::Group::new("Q3").with_parent("H2")); + wb.add_item("_Measure", "Revenue").unwrap(); + let view = wb.active_view_mut(); + view.set_axis("Region", Axis::Row); + view.set_axis("Date", Axis::Row); + view.set_axis("_Measure", Axis::Column); + view.set_expand_depth("Region", 1); + view.set_expand_depth("Date", 2); + let mut app = App::new(wb, None); + app.rebuild_layout(); + let rows = app.layout.row_count(); + assert_eq!(rows, 10, "two regions by five dates"); + for offset in 0..rows { + let frame = grid_of(&app, rows_from(offset)).row_frame; + let from_frame: Vec> = frame + .groups() + .into_iter() + .map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone()))) + .collect(); + let from_layout: Vec> = (offset..rows) + .map(|r| app.layout.row_group_for(r)) + .collect(); + assert_eq!(from_frame, from_layout, "window from row {offset}"); + } + assert_eq!( + app.layout.row_group_for(2), + Some(("Date".into(), "H1".into())), + "precondition: North/Jun sits in H1 once Q1 ends, not in Q1 nor Upper" + ); + } + /// A window opening inside `North/Q1` carries the open run at EVERY level, or /// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md. #[test] @@ -1693,7 +1747,7 @@ mod tests { fn z_on_a_row_past_an_inner_run_folds_the_outer_group() { use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); - let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, group); + let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, &[group]); let mut app = app_with_nested_groups(); app.cursor_mut().selected = (0, 0); @@ -2895,6 +2949,7 @@ mod tests { .unwrap(); wb.add_item_in_group("Product", "Gadget", "Hardware") .unwrap(); + wb.active_view_mut().set_expand_depth("Product", 1); wb.add_item("Product", "Service").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); @@ -4658,6 +4713,7 @@ mod cell_identity { 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.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -4671,7 +4727,7 @@ mod cell_identity { ); } wb.active_view_mut() - .toggle_group_collapse("Region", "Coastal"); + .toggle_group_collapse("Region", &["Coastal"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert_eq!(app.layout.row_label(1), "Coastal"); @@ -5642,9 +5698,11 @@ mod cell_identity { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); for month in ["Jan", "Feb", "Mar"] { wb.add_item_in_group("Month", month, "Q1").unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)] { for (measure, n) in [("Revenue", revenue), ("Cost", cost)] { @@ -5663,7 +5721,7 @@ mod cell_identity { wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().hide_item("Month", "Mar"); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let app = App::new(wb, None); let grid = grid_of(&app, WHOLE); @@ -6334,13 +6392,14 @@ mod column_totals { wb.model.set_cell(at, CellValue::Number(v)); } } + wb.active_view_mut().set_expand_depth("Month", 1); let max = crate::formula::AggFunc::Max; wb.model.measure_agg.insert("Revenue".into(), max); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("Region", Axis::None); if collapsed { - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); } wb }; diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index f1b41d66..605f22f8 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -1052,9 +1052,9 @@ pub(crate) mod tests { view.hide_item("Region", "West"); view.hide_item("Region", "South"); view.hide_item("Month", "Mar"); - view.toggle_group_collapse("Region", "Coastal"); - view.toggle_group_collapse("Region", "Inland"); - view.toggle_group_collapse("Month", "Q1"); + view.toggle_group_collapse("Region", &["Coastal"]); + view.toggle_group_collapse("Region", &["Inland"]); + view.toggle_group_collapse("Month", &["Q1"]); app.rebuild_layout(); app } diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 32905e8e..e71519ba 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -214,6 +214,7 @@ mod tests { wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] { let key = CellKey::new(vec![ @@ -231,7 +232,7 @@ mod tests { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); wb } diff --git a/crates/improvise-core/src/model/category.rs b/crates/improvise-core/src/model/category.rs index 31d881c6..f737975f 100644 --- a/crates/improvise-core/src/model/category.rs +++ b/crates/improvise-core/src/model/category.rs @@ -199,15 +199,61 @@ impl Category { 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). + /// The items `group` covers — its own and every child group's, through any + /// depth — in insertion order. Read off the items, not `groups` (core.md §14). pub fn items_in_group(&self, group: &str) -> Vec<&str> { self.items .values() - .filter(|i| i.group.as_deref() == Some(group)) + .filter(|i| self.item_chain(&i.name).contains(&group)) .map(|i| i.name.as_str()) .collect() } + + /// The parent a `>` line gave `group`; `None` for a root. + pub fn group_parent(&self, group: &str) -> Option<&str> { + self.groups + .iter() + .find(|g| g.name == group) + .and_then(|g| g.parent.as_deref()) + } + + /// `group` and its ancestors, ROOT FIRST. A cycle built in code stops the walk + /// where it would repeat; a file cannot carry one (`hierarchy_cycle`). + pub fn group_chain<'a>(&'a self, group: &'a str) -> Vec<&'a str> { + let mut chain = vec![group]; + while let Some(parent) = chain.last().and_then(|g| self.group_parent(g)) { + if chain.contains(&parent) { + break; + } + chain.push(parent); + } + chain.reverse(); + chain + } + + /// The groups an item sits in, root first; empty for an ungrouped item. + pub fn item_chain(&self, item: &str) -> Vec<&str> { + self.items + .get(item) + .and_then(|i| i.group.as_deref()) + .map(|g| self.group_chain(g)) + .unwrap_or_default() + } + + /// A cycle among the declared parents, its members in walk order: the one + /// shape a hierarchy can take that is not a forest (core.md §19). + pub fn hierarchy_cycle(&self) -> Option> { + self.groups.iter().find_map(|start| { + let mut path: Vec<&str> = vec![start.name.as_str()]; + while let Some(parent) = path.last().and_then(|g| self.group_parent(g)) { + if let Some(at) = path.iter().position(|g| *g == parent) { + return Some(path[at..].to_vec()); + } + path.push(parent); + } + None + }) + } } #[cfg(test)] @@ -297,6 +343,66 @@ mod tests { assert_eq!(c.groups.len(), 1); } + /// Year > Quarter > Month, with `2024-03` added LAST so Q1-2024 and 2024 are + /// non-contiguous in insertion order. + fn dates() -> Category { + let mut c = Category::new(0, "Date"); + c.add_item_in_group("2024-01", "Q1-2024"); + c.add_item_in_group("2024-04", "Q2-2024"); + c.add_item_in_group("2025-01", "Q1-2025"); + c.add_item("Undated"); + c.add_item_in_group("2024-03", "Q1-2024"); + c.add_group(Group::new("Q1-2024").with_parent("2024")); + c.add_group(Group::new("Q2-2024").with_parent("2024")); + c.add_group(Group::new("Q1-2025").with_parent("2025")); + c + } + + #[test] + fn a_parent_group_covers_every_leaf_beneath_its_child_groups() { + let c = dates(); + assert_eq!( + c.items_in_group("2024"), + vec!["2024-01", "2024-04", "2024-03"] + ); + assert_eq!(c.items_in_group("Q1-2024"), vec!["2024-01", "2024-03"]); + assert_eq!(c.items_in_group("2025"), vec!["2025-01"]); + assert!(c.items_in_group("Undated").is_empty()); + } + + #[test] + fn an_items_chain_runs_root_first() { + let c = dates(); + assert_eq!(c.item_chain("2024-03"), vec!["2024", "Q1-2024"]); + assert!(c.item_chain("Undated").is_empty()); + assert_eq!(c.group_chain("2024"), vec!["2024"]); + assert_eq!(c.group_chain("Q2-2024"), vec!["2024", "Q2-2024"]); + } + + #[test] + fn a_cycle_among_parents_is_found_and_named() { + let mut c = dates(); + assert_eq!(c.hierarchy_cycle(), None); + c.add_group(Group::new("2024").with_parent("Q2-2024")); + let cycle = c.hierarchy_cycle().expect("2024 -> Q2-2024 -> 2024"); + assert!( + cycle.contains(&"2024") && cycle.contains(&"Q2-2024"), + "{cycle:?}" + ); + assert!(!cycle.contains(&"Q1-2025"), "{cycle:?}"); + assert!( + c.group_chain("Q1-2024").len() <= 3, + "a walk through a cycle stops" + ); + } + + #[test] + fn a_self_parent_is_a_cycle_of_one() { + let mut c = cat(); + c.add_group(Group::new("G").with_parent("G")); + assert_eq!(c.hierarchy_cycle(), Some(vec!["G"])); + } + #[test] fn item_index_reflects_insertion_order() { let mut c = cat(); diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index 73a0b00f..4068121f 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -525,6 +525,15 @@ impl Model { self.categories.get(name) } + /// `group` and its ancestors in `cat`, root first and owned: the chain a + /// `View` folds by (core.md §19). Just `[group]` where `cat` is unknown. + pub fn group_chain(&self, cat: &str, group: &str) -> Vec { + match self.category(cat) { + Some(c) => c.group_chain(group).into_iter().map(String::from).collect(), + None => vec![group.to_string()], + } + } + pub fn set_cell(&mut self, key: CellKey, value: CellValue) { self.data.set(key, value); } diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index b139d4db..7978c56d 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -205,16 +205,19 @@ pub enum RecordCol { /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14). #[derive(Debug, Clone, PartialEq)] pub enum AxisEntry { - /// Opens a run at `level` (the category's place on the axis) and closes - /// every run inside it. Membership is per level: core.md §14, improvise-e265. + /// Opens a run at `(level, depth)` — the category's place on the axis, then the + /// group's depth in its hierarchy — and closes every run inside it (§6, §19). GroupHeader { level: usize, + depth: usize, cat_name: String, group_name: String, + /// The view's answer for this group, so no reader re-derives it. + collapsed: bool, }, - /// Ends the run at `level` and every run inside it (improvise-gd83, e265). - /// Nothing is drawn for it; it exists so membership is not inherited. - GroupEnd { level: usize }, + /// Ends the run at `(level, depth)` and every run inside it (improvise-gd83, + /// e265). Nothing is drawn for it; it exists so membership is not inherited. + GroupEnd { level: usize, depth: usize }, /// `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, collapsed: bool }, @@ -301,28 +304,38 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option { .nth(data_idx) } -/// Each open run, by level, as `(position of its header, the header)`. A header -/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper. -type OpenRuns = Vec>; +/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than". +/// Across categories the level decides; within one, the depth (§19). +pub type RunKey = (usize, usize); -fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) { - runs.truncate(level); - runs.resize(level, None); - runs.extend(run.map(Some)); +/// One open run: its key and the position of the header that opened it. +pub type OpenRun = (RunKey, usize); + +/// Each open run, innermost last, with its header. A header at K replaces K +/// and closes every deeper run; a `GroupEnd` at K closes K and deeper. +type OpenRuns = Vec<(RunKey, usize, AxisEntry)>; + +/// Close every run at or inside `key`, then open `run` there if one is given. +fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) { + let outside = runs.partition_point(|(k, _, _)| *k < key); + runs.truncate(outside); + runs.extend(run.map(|(at, header)| (key, at, header))); } /// Drop the data items `keep` rejects, then re-mark the survivors so each keeps /// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265. fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { let mut kept: Vec = Vec::new(); - // What the input has open at each level, and what `kept` has open so far. + // What the input has open, and what `kept` has open so far. let mut input: OpenRuns = Vec::new(); - let mut output: Vec> = Vec::new(); + let mut output: Vec = Vec::new(); let mut data_idx = 0usize; for (at, entry) in items.drain(..).enumerate() { match entry { - AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))), - AxisEntry::GroupEnd { level } => open_at(&mut input, level, None), + AxisEntry::GroupHeader { level, depth, .. } => { + open_at(&mut input, (level, depth), Some((at, entry))) + } + AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None), AxisEntry::DataItem { .. } => { if keep(data_idx) { remark(&input, &mut output, &mut kept); @@ -335,48 +348,58 @@ fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { *items = kept; } -/// Emit the markers that make `kept`'s open runs equal the input's, outermost -/// first: a header where a different run is open, a `GroupEnd` where none is. -fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec) { - for level in 0..input.len().max(output.len()) { - let want = input.get(level).cloned().flatten(); - let have = output.get(level).copied().flatten(); - if want.as_ref().map(|(at, _)| *at) == have { - continue; - } - // Deeper runs end first, innermost out, as `expand_category` ends them. - for deeper in (level + 1..output.len()).rev() { - if output[deeper].is_some() { - kept.push(AxisEntry::GroupEnd { level: deeper }); - } - } - output.truncate(level); - output.resize(level, None); - match want { - Some((at, header)) => { - kept.push(header); - output.push(Some(at)); - } - None => kept.push(AxisEntry::GroupEnd { level }), - } +fn group_end((level, depth): RunKey) -> AxisEntry { + AxisEntry::GroupEnd { level, depth } +} + +/// Emit the markers that make `kept`'s open runs equal the input's: past the +/// runs they share, end the deeper ones innermost out, then open what differs. +fn remark(input: &OpenRuns, output: &mut Vec, kept: &mut Vec) { + let shared = input + .iter() + .zip(output.iter()) + .take_while(|((_, want, _), (_, have))| want == have) + .count(); + let want = input.get(shared); + let have = output.get(shared).copied(); + let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)] + .into_iter() + .flatten() + .min() + else { + return; + }; + // Deeper runs end first, innermost out, as `expand_category` ends them. + for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) { + kept.push(group_end(*key)); + } + output.truncate(shared); + let replaces = want.is_some_and(|w| w.0 == slot); + if !replaces && have.is_some_and(|h| h.0 == slot) { + kept.push(group_end(slot)); + } + for (key, at, header) in &input[shared..] { + kept.push(header.clone()); + output.push((*key, *at)); } } -/// The one reading of the markers: each data item's open runs, by level, as -/// header positions, lent to `visit` — one stack, never copied (0r01). +/// The one reading of the markers: each data item's open runs, innermost last, +/// lent to `visit` — one stack, never copied (0r01). fn walk_open_runs( items: &[AxisEntry], - mut visit: impl FnMut(&[Option]) -> ControlFlow, + mut visit: impl FnMut(&[OpenRun]) -> ControlFlow, ) -> Option { - let mut open: Vec> = Vec::new(); + let mut open: Vec = Vec::new(); for (at, entry) in items.iter().enumerate() { match entry { - AxisEntry::GroupHeader { level, .. } => { - open.truncate(*level); - open.resize(*level, None); - open.push(Some(at)); + AxisEntry::GroupHeader { level, depth, .. } => { + open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))); + open.push(((*level, *depth), at)); + } + AxisEntry::GroupEnd { level, depth } => { + open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))) } - AxisEntry::GroupEnd { level } => open.truncate(*level), AxisEntry::DataItem { .. } => { if let ControlFlow::Break(found) = visit(&open) { return Some(found); @@ -392,7 +415,7 @@ fn walk_open_runs( fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { let mut data_idx = 0usize; let at = walk_open_runs(items, |open| match data_idx == idx { - true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)), + true => ControlFlow::Break(open.last().map(|(_, at)| *at)), false => { data_idx += 1; ControlFlow::Continue(()) @@ -872,9 +895,13 @@ fn expand_category( return vec![]; }; // The prefix holds one item per outer category, so its length is this level. - let level = prefix.len(); - let mut result = Vec::new(); - let mut last_group: Option<&str> = None; + let mut runs = CategoryRuns { + level: prefix.len(), + cat_name, + view, + open: Vec::new(), + out: Vec::new(), + }; // A group's items need not be contiguous, so a collapsed one folds ONCE. let mut folded: Vec<&str> = Vec::new(); @@ -884,50 +911,79 @@ fn expand_category( if view.is_hidden(cat_name, item_name) { continue; } - let item_group = cat - .items - .get(item_name.as_str()) - .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)) { + let chain = cat.item_chain(item_name); + // The OUTERMOST collapsed group folds every leaf beneath it (§19). + let fold_depth = + (0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth])); + let fold = fold_depth.map(|depth| chain[depth]); + if fold.is_some_and(|g| folded.contains(&g)) { continue; } - - // A header on entering a group; a `GroupEnd` only on leaving one for - // NO group — another group's header already ends the run. - if item_group != last_group { - if last_group.is_some() && item_group.is_none() { - result.push(AxisEntry::GroupEnd { level }); - } - if let Some(g) = item_group { - result.push(AxisEntry::GroupHeader { - level, - cat_name: cat_name.to_string(), - group_name: g.to_string(), - }); - } - last_group = item_group; - } + runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]); let mut tuple = prefix.clone(); - match collapsed { + match fold { Some(g) => { folded.push(g); tuple.push(g.to_string()); } None => tuple.push(item_name.to_string()), } - result.push(AxisEntry::DataItem { + runs.out.push(AxisEntry::DataItem { tuple, - collapsed: prefix_collapsed || collapsed.is_some(), + collapsed: prefix_collapsed || fold.is_some(), }); } // The expansion is the end of the run too: the next prefix's items follow // immediately and must not inherit it (improvise-gd83). - if last_group.is_some() { - result.push(AxisEntry::GroupEnd { level }); + runs.enter(&[]); + runs.out +} + +/// One category's group runs as `expand_category` emits them: the chain of groups +/// open, root first, and the markers that move it from item to item. +struct CategoryRuns<'a> { + level: usize, + cat_name: &'a str, + view: &'a View, + open: Vec<&'a str>, + out: Vec, +} + +impl<'a> CategoryRuns<'a> { + /// Move the open chain to `chain` in `remark`'s own convention, so an unpruned + /// axis survives pruning marker for marker (core.md §6). + fn enter(&mut self, chain: &[&'a str]) { + let shared = self + .open + .iter() + .zip(chain) + .take_while(|(open, want)| open == want) + .count(); + let level = self.level; + for depth in (shared + 1..self.open.len()).rev() { + self.out.push(AxisEntry::GroupEnd { level, depth }); + } + if chain.len() == shared && self.open.len() > shared { + self.out.push(AxisEntry::GroupEnd { + level, + depth: shared, + }); + } + self.open.truncate(shared); + for (depth, group) in chain.iter().enumerate().skip(shared) { + self.out.push(AxisEntry::GroupHeader { + level, + depth, + cat_name: self.cat_name.to_string(), + group_name: group.to_string(), + collapsed: self + .view + .is_group_collapsed(self.cat_name, &chain[..=depth]), + }); + self.open.push(group); + } } - result } /// Cartesian product of visible items across `cats`, headers always emitted. @@ -1026,8 +1082,10 @@ mod tests { AxisEntry::item(vec!["A".into()]), AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "C".into(), group_name: "G".into(), + collapsed: false, }, AxisEntry::item(vec!["B".into()]), AxisEntry::item(vec!["C".into()]), @@ -1051,8 +1109,10 @@ mod tests { let mut items = vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "C".into(), group_name: "G".into(), + collapsed: false, }, AxisEntry::item(vec!["A".into()]), AxisEntry::item(vec!["B".into()]), @@ -1418,6 +1478,7 @@ mod tests { wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count @@ -1430,6 +1491,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); let headers: Vec<_> = layout @@ -1457,8 +1519,9 @@ mod tests { wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "May", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); 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 labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) @@ -1489,8 +1552,9 @@ mod tests { 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.active_view_mut().set_expand_depth("Month", 1); 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 labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) @@ -1518,10 +1582,11 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); 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"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_tuple(0), @@ -1543,6 +1608,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Month", "Sep").unwrap(); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); @@ -1572,6 +1638,7 @@ mod tests { wb.add_item_in_group("Recipient", "Alice", "Family") .unwrap(); wb.add_item_in_group("Recipient", "Bob", "Family").unwrap(); + wb.active_view_mut().set_expand_depth("Recipient", 1); wb.active_view_mut().set_axis("Recipient", Axis::Row); let layout = GridLayout::new(&wb.model, wb.active_view()); @@ -1612,6 +1679,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.model.set_cell( coord(&[("Month", "Apr"), ("Type", "Food")]), @@ -1630,6 +1698,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] @@ -1646,6 +1715,7 @@ mod tests { wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_col_to_visual(0), Some(1)); @@ -1660,6 +1730,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( @@ -1691,6 +1762,7 @@ mod tests { wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.col_group_for(0), @@ -1725,6 +1797,7 @@ mod tests { None => wb.add_item("Month", *name).unwrap(), }; } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for m in funded { wb.model.set_cell( @@ -1755,7 +1828,7 @@ mod tests { &[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)], &["Jan", "Sep"], ); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); // The whole sequence, not `any(is Q1)`: that cannot see a header // pushed in the wrong place, nor a stray `GroupEnd`. @@ -1764,14 +1837,16 @@ mod tests { vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Month".into(), - group_name: "Q1".into() + group_name: "Q1".into(), + collapsed: true, }, AxisEntry::DataItem { tuple: vec!["Q1".into()], collapsed: true }, - AxisEntry::GroupEnd { level: 0 }, + AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Sep".into()]), ], "Q1 folds to one funded row under its header, and Mar pruned" @@ -1814,6 +1889,8 @@ mod tests { wb.add_item_in_group("Region", "South", "Lower").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for month in ["Jan", "Feb"] { wb.model.set_cell( @@ -1895,6 +1972,7 @@ mod tests { wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] { @@ -1937,7 +2015,7 @@ mod tests { .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let unpruned = GridLayout::new(&wb.model, wb.active_view()); let kept_by_fresh_evaluation: Vec = (0..unpruned.row_count()) @@ -1976,7 +2054,7 @@ mod tests { .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let layout = GridLayout::new(&wb.model, wb.active_view()); let q1 = (0..layout.row_count()) @@ -2055,7 +2133,7 @@ mod tests { #[test] fn a_grid_evaluates_under_its_own_covers_not_another_grids() { let mut wb = margin_book(&["R = Revenue / Cost"]); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let all = GridLayout::new(&wb.model, wb.active_view()); let mut no_feb_view = wb.active_view().clone(); no_feb_view.hide_item("Month", "Feb"); @@ -2093,7 +2171,7 @@ mod tests { let mut folded = margin_book(formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold"); let mut none = margin_book(formulas); @@ -2120,7 +2198,7 @@ mod tests { let mut folded = margin_book(&formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", measure); let mut none = margin_book(&formulas); none.active_view_mut().set_axis("Month", Axis::None); @@ -2133,7 +2211,7 @@ mod tests { let mut folded = margin_book(&formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); assert_eq!( grid_value(&mut folded, "Q1", "Flag"), Some(0.0), @@ -2146,7 +2224,7 @@ mod tests { #[test] fn a_folds_aggregate_formula_pools_its_members_records() { let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0)); assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0)); } @@ -2159,7 +2237,7 @@ mod tests { let mut folded = margin_book(&formula); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Jans"); let mut none = margin_book(&formula); @@ -2198,12 +2276,13 @@ mod tests { CellValue::Number(n), ); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().hide_item("Month", "Feb"); assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn"); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(column_total(&wb), 103.0, "z moved the total"); assert_eq!( grid_value(&mut wb, "Q1", "Food"), @@ -2290,7 +2369,7 @@ mod group_membership { use std::ops::ControlFlow; /// Every data item's open runs, collected through the one reader. - fn open_runs(items: &[AxisEntry]) -> Vec>> { + fn open_runs(items: &[AxisEntry]) -> Vec> { let mut all = Vec::new(); walk_open_runs(items, |open| { all.push(open.to_vec()); @@ -2300,7 +2379,9 @@ mod group_membership { } type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); - type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]); + type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)], &'a [Parent<'a>]); + /// `(category, group, parent)`: a `>` line. + type Parent<'a> = (&'a str, &'a str, &'a str); const TWO: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), @@ -2354,19 +2435,80 @@ mod group_membership { ( TWO, &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], + &[], ), ( THREE, &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], + &[], ), - (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]), - (HOLE, &[("Region", "Upper"), ("Month", "Q1")]), + (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]), + (HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]), ( ADJACENT, &[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")], + &[], + ), + ( + NESTED, + &[ + ("Region", "Upper"), + ("Month", "H1"), + ("Month", "Q1"), + ("Month", "H2"), + ], + NESTED_PARENTS, ), ]; + /// improvise-felo.6: a hierarchy INSIDE one category, under an outer group. + /// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2. + const NESTED: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ( + "Month", + &[ + ("Jan", Some("Q1")), + ("Apr", Some("Q2")), + ("Feb", Some("Q1")), + ("Jun", Some("H1")), + ("Jul", Some("Q3")), + ], + ), + ]; + + const NESTED_PARENTS: &[Parent] = &[ + ("Month", "Q1", "H1"), + ("Month", "Q2", "H1"), + ("Month", "Q3", "H2"), + ]; + + /// [`book`] with `parents` declared and every group OPEN before `collapsed` + /// folds its own, so a subset of groups is exactly what is collapsed. + fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook { + let mut wb = book(levels, &[]); + for (cat, group, parent) in parents { + let c = wb.model.category_mut(cat).unwrap(); + c.add_group(crate::model::category::Group::new(*group).with_parent(*parent)); + } + for (cat, _) in levels { + wb.active_view_mut().set_expand_depth(cat, 8); + } + for (cat, group) in collapsed { + let chain: Vec = wb + .model + .category(cat) + .unwrap() + .group_chain(group) + .into_iter() + .map(String::from) + .collect(); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); + wb.active_view_mut().toggle_group_collapse(cat, &chain); + } + wb + } + /// `levels` on Row, one column, the groups `collapsed` names folded. No cell /// is set: the unpruned layout has every row, and pruning is driven directly. fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook { @@ -2380,12 +2522,13 @@ mod group_membership { }; } wb.active_view_mut().set_axis(cat, Axis::Row); + wb.active_view_mut().set_expand_depth(cat, 1); } wb.add_category("Col").unwrap(); wb.add_item("Col", "X").unwrap(); wb.active_view_mut().set_axis("Col", Axis::Column); for (cat, group) in collapsed { - wb.active_view_mut().toggle_group_collapse(cat, group); + wb.active_view_mut().toggle_group_collapse(cat, &[group]); } wb } @@ -2406,13 +2549,13 @@ mod group_membership { /// non-empty set of kept rows. The layout is the cost, so it is built once. fn cases() -> Vec { let mut out = Vec::new(); - for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() { + for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() { for folds in 0..(1u32 << groups.len()) { let collapsed: Vec<(&str, &str)> = (0..groups.len()) .filter(|g| folds & (1 << g) != 0) .map(|g| groups[g]) .collect(); - let full = layout(&book(levels, &collapsed)).row_items; + let full = layout(&nested_book(levels, parents, &collapsed)).row_items; let rows = data_items(&full).count(); for mask in 1..(1u32 << rows) { let mut pruned = full.clone(); @@ -2441,8 +2584,8 @@ mod group_membership { } } - fn at(runs: &[Option], level: usize) -> Option { - runs.get(level).copied().flatten() + fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option { + runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at) } #[test] @@ -2463,24 +2606,25 @@ mod group_membership { if kept.len() == full_runs.len() { assert_eq!(pruned, full, "{name}: nothing pruned"); } - let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0); + let levels: std::collections::BTreeSet = + full_runs.iter().flatten().map(|(k, _)| *k).collect(); for (r, &u) in kept.iter().enumerate() { - for level in 0..levels { + for &level in &levels { assert_eq!( named(pruned, at(&pruned_runs[r], level)), named(full, at(&full_runs[u], level)), - "{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}" + "{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}" ); if r == 0 { continue; } - let same = |runs: &[Vec>], a: usize, b: usize| { + let same = |runs: &[Vec], a: usize, b: usize| { at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level) }; assert_eq!( same(&pruned_runs, r - 1, r), same(&full_runs, kept[r - 1], u), - "{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}" + "{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}" ); } } @@ -2538,14 +2682,18 @@ mod group_membership { let items = vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Region".into(), group_name: "Upper".into(), + collapsed: false, }, AxisEntry::item(vec!["North".into()]), AxisEntry::GroupHeader { level: usize::MAX, + depth: 0, cat_name: "Nowhere".into(), group_name: "Unreachable".into(), + collapsed: false, }, AxisEntry::item(vec!["South".into()]), ]; @@ -2561,14 +2709,16 @@ mod group_membership { fn a_late_lookup_answers_for_its_own_row() { let header = |group: &str| AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Month".into(), group_name: group.into(), + collapsed: false, }; let items = vec![ header("Q1"), AxisEntry::item(vec!["Jan".into()]), AxisEntry::item(vec!["Feb".into()]), - AxisEntry::GroupEnd { level: 0 }, + AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Mar".into()]), header("Q2"), AxisEntry::item(vec!["Apr".into()]), @@ -2601,4 +2751,222 @@ mod group_membership { ); assert_eq!(l.row_group_for(at(["South", "Mar"])), None); } + + /// Precondition for the enumeration above: NESTED must really produce runs + /// below depth 0, or it enumerates nothing new. + #[test] + fn the_nested_fixture_draws_runs_inside_one_category() { + let wb = nested_book(NESTED, NESTED_PARENTS, &[]); + let deep = layout(&wb) + .row_items + .iter() + .filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. })) + .count(); + assert!( + deep >= 4, + "Q1, Q2, Q1 again and Q3 under each region: {deep}" + ); + } +} + +/// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month. +#[cfg(test)] +mod hierarchy { + use super::{AxisEntry, GridLayout}; + use crate::model::category::Group; + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + use crate::workbook::Workbook; + + /// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are + /// non-contiguous. Values are powers of ten, so every fold is its own sum. + fn dates() -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Date").unwrap(); + for (month, quarter) in [ + ("2024-01", Some("Q1-2024")), + ("2024-04", Some("Q2-2024")), + ("2025-01", Some("Q1-2025")), + ("Undated", None), + ("2024-02", Some("Q1-2024")), + ] { + match quarter { + Some(q) => wb.add_item_in_group("Date", month, q).unwrap(), + None => wb.add_item("Date", month).unwrap(), + }; + } + let cat = wb.model.category_mut("Date").unwrap(); + for (quarter, year) in [ + ("Q1-2024", "2024"), + ("Q2-2024", "2024"), + ("Q1-2025", "2025"), + ] { + cat.add_group(Group::new(quarter).with_parent(year)); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Date", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + for (month, v) in [ + ("2024-01", 1.0), + ("2024-02", 10.0), + ("2024-04", 100.0), + ("2025-01", 1000.0), + ("Undated", 5.0), + ] { + wb.model.set_cell(key(month), CellValue::Number(v)); + } + wb + } + + fn key(month: &str) -> CellKey { + CellKey::new(vec![ + ("Date".into(), month.into()), + ("_Measure".into(), "Revenue".into()), + ]) + } + + fn layout(wb: &Workbook) -> GridLayout { + GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) + } + + fn rows(l: &GridLayout) -> Vec { + (0..l.row_count()).map(|r| l.row_label(r)).collect() + } + + fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option { + let r = rows(l).iter().position(|x| x == label)?; + l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64() + } + + fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry { + AxisEntry::GroupHeader { + level: 0, + depth, + cat_name: "Date".into(), + group_name: group.into(), + collapsed, + } + } + + fn end(depth: usize) -> AxisEntry { + AxisEntry::GroupEnd { level: 0, depth } + } + + fn item(name: &str) -> AxisEntry { + AxisEntry::item(vec![name.into()]) + } + + /// Opened to depth 2, the whole sequence: runs nest by depth, a new year's + /// header replaces the old, and a non-contiguous quarter reopens both. + #[test] + fn a_year_quarter_month_hierarchy_nests_its_runs() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 2); + assert_eq!( + layout(&wb).row_items, + vec![ + header(0, "2024", false), + header(1, "Q1-2024", false), + item("2024-01"), + header(1, "Q2-2024", false), + item("2024-04"), + end(1), + header(0, "2025", false), + header(1, "Q1-2025", false), + item("2025-01"), + end(1), + end(0), + item("Undated"), + header(0, "2024", false), + header(1, "Q1-2024", false), + item("2024-02"), + ] + ); + } + + /// The user's ruling: a hierarchy starts closed, one row per root. + #[test] + fn a_hierarchy_opens_closed_as_one_fold_per_root() { + let wb = dates(); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]); + assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024"); + assert_eq!(value(&wb, &l, "2025"), Some(1000.0)); + assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2)); + } + + /// Open the years only: each quarter folds its months, ONCE, even when its + /// months are not contiguous. + #[test] + fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 1); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]); + assert_eq!( + value(&wb, &l, "Q1-2024"), + Some(11.0), + "Jan and the late Feb" + ); + assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0)); + } + + /// Collapsing an outer group folds everything under it, whatever its + /// children's own state. + #[test] + fn collapsing_a_parent_folds_its_children_whatever_their_state() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 2); + wb.active_view_mut() + .toggle_group_collapse("Date", &["2024"]); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]); + assert_eq!(value(&wb, &l, "2024"), Some(111.0)); + } + + /// `z` names the innermost open group: a month's quarter, a quarter fold's + /// own quarter, a year fold's year — at a nonzero row each time. + #[test] + fn the_group_under_the_cursor_is_the_innermost_at_every_depth() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 1); + wb.active_view_mut() + .toggle_group_collapse("Date", &["2024", "Q2-2024"]); + let l = layout(&wb); + let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap(); + let g = |name: &str| Some(("Date".to_string(), name.to_string())); + assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024")); + assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025")); + assert_eq!(l.row_group_for(at("Undated")), None); + wb.active_view_mut().set_expand_depth("Date", 0); + let l = layout(&wb); + assert_eq!(l.row_group_for(1), g("2025")); + } + + /// A hidden month leaves every fold above it; a quarter whose every month + /// is hidden has no row and no header. + #[test] + fn hidden_items_leave_every_fold_above_them() { + let mut wb = dates(); + wb.active_view_mut().hide_item("Date", "2024-04"); + let l = layout(&wb); + assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden"); + wb.active_view_mut().set_expand_depth("Date", 2); + let l = layout(&wb); + let q2 = |e: &AxisEntry| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024"); + assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items); + } + + /// `prune_empty` at depth: an empty quarter goes, header and all, and the + /// year above it stays for the quarter that has data. + #[test] + fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() { + let mut wb = dates(); + wb.model.clear_cell(&key("2024-04")); + wb.active_view_mut().set_expand_depth("Date", 1); + wb.active_view_mut().prune_empty = true; + let l = layout(&wb); + assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]); + assert_eq!(l.row_items[0], header(0, "2024", false)); + } } diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index fe12c4aa..c0d8892b 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -9,5 +9,5 @@ pub use layout::{ records_steps, synthetic_record_info, }; pub use types::{ - DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name, + DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name, }; diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index e9b91961..448ff94c 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use std::collections::{HashMap, HashSet}; +use std::num::NonZeroUsize; use super::axis::Axis; use crate::model::category::CategoryKind; @@ -27,6 +28,16 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator) -> Stri .expect("a name is free below usize::MAX, there being fewer views") } +/// One group's own fold, overriding what it inherits (core.md §19). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum GroupFold { + /// `collapsed: Cat/Group` — closed, whatever reaches it from above. + Collapsed, + /// `expanded(N): Cat/Group` — open, with N-1 levels beneath it. Non-zero by + /// type, since `expanded(0)` is no spelling the grammar reads back. + Expanded(NonZeroUsize), +} + #[derive(Clone)] pub struct View { pub name: String, @@ -36,8 +47,11 @@ pub struct View { pub page_selections: HashMap, /// Hidden items per category pub hidden_items: HashMap>, - /// Collapsed groups per category - pub collapsed_groups: HashMap>, + /// How deep each category's hierarchy opens: groups shallower than this start + /// open, the rest closed. Absent is 0 — everything closed (core.md §19). + pub expand_depth: HashMap, + /// Per-group answers that override the depth default, in either direction. + pub group_folds: HashMap>, /// Number format string (e.g. ",.0f" for comma-separated integer) pub number_format: String, /// When true, empty rows/columns are pruned from the display. @@ -54,7 +68,8 @@ impl std::fmt::Debug for View { category_axes, page_selections, hidden_items, - collapsed_groups, + expand_depth, + group_folds, number_format, prune_empty, } = self; @@ -64,12 +79,18 @@ impl std::fmt::Debug for View { .collect() }; let pages: BTreeMap<_, _> = page_selections.iter().collect(); + let depths: BTreeMap<_, _> = expand_depth.iter().collect(); + let folds: BTreeMap<_, BTreeMap<_, _>> = group_folds + .iter() + .map(|(k, v)| (k, v.iter().collect())) + .collect(); f.debug_struct("View") .field("name", name) .field("category_axes", category_axes) .field("page_selections", &pages) .field("hidden_items", &sorted(hidden_items)) - .field("collapsed_groups", &sorted(collapsed_groups)) + .field("expand_depth", &depths) + .field("group_folds", &folds) .field("number_format", number_format) .field("prune_empty", prune_empty) .finish() @@ -88,7 +109,8 @@ impl View { category_axes: IndexMap::new(), page_selections: HashMap::new(), hidden_items: HashMap::new(), - collapsed_groups: HashMap::new(), + expand_depth: HashMap::new(), + group_folds: HashMap::new(), // Decimals by default: a ratio is the case a format cannot be // guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex). number_format: ",.2".to_string(), @@ -135,7 +157,8 @@ impl View { self.category_axes.shift_remove(cat_name); self.page_selections.remove(cat_name); self.hidden_items.remove(cat_name); - self.collapsed_groups.remove(cat_name); + self.expand_depth.remove(cat_name); + self.group_folds.remove(cat_name); } /// Forget an item this view names. A hidden or paged-to item that outlives @@ -222,23 +245,78 @@ impl View { self.page_selections.get(cat_name).map(|s| s.as_str()) } - pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { - let set = self - .collapsed_groups - .entry(cat_name.to_string()) - .or_default(); - if set.contains(group_name) { - set.remove(group_name); - } else { - set.insert(group_name.to_string()); + /// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all + /// closed — if unset. + pub fn expand_depth(&self, cat_name: &str) -> usize { + self.expand_depth.get(cat_name).copied().unwrap_or(0) + } + + /// Open `cat_name`'s top `depth` levels; 0 forgets the setting. + pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) { + match depth { + 0 => self.expand_depth.remove(cat_name), + _ => self.expand_depth.insert(cat_name.to_string(), depth), + }; + } + + /// Whether the group ending `chain` (root first, from `Category::group_chain`) + /// is collapsed: its own fold wins, else what it inherits (core.md §19). + pub fn is_group_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool { + match chain.last().and_then(|g| self.group_fold(cat_name, g)) { + Some(GroupFold::Collapsed) => true, + Some(GroupFold::Expanded(_)) => false, + None => self.inherits_collapsed(cat_name, chain), } } - pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { - self.collapsed_groups - .get(cat_name) - .map(|s| s.contains(group_name)) - .unwrap_or(false) + /// The answer without the group's own fold: open when the category's depth or + /// an ancestor's `expanded(N)` reaches it, closed otherwise. + fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool { + let depth = chain.len().saturating_sub(1); + let by_ancestor = chain[..depth].iter().enumerate().any(|(at, ancestor)| { + matches!(self.group_fold(cat_name, ancestor), + Some(GroupFold::Expanded(n)) if depth - at < n.get()) + }); + !by_ancestor && depth >= self.expand_depth(cat_name) + } + + /// The fold this view holds for one group, if any. + pub fn group_fold(&self, cat_name: &str, group_name: &str) -> Option { + self.group_folds.get(cat_name)?.get(group_name).copied() + } + + /// Record a fold as given — what a file's `collapsed:`/`expanded:` says. + pub fn set_group_fold(&mut self, cat_name: &str, group_name: &str, fold: GroupFold) { + self.group_folds + .entry(cat_name.to_string()) + .or_default() + .insert(group_name.to_string(), fold); + } + + /// Flip the group ending `chain`: `z`. It writes `Collapsed` or `Expanded(1)` + /// — or nothing, where the flip lands on what the group inherits (§19). + pub fn toggle_group_collapse(&mut self, cat_name: &str, chain: &[&str]) { + let Some(group) = chain.last() else { + return; + }; + let collapse = !self.is_group_collapsed(cat_name, chain); + match collapse == self.inherits_collapsed(cat_name, chain) { + true => { + if let Some(folds) = self.group_folds.get_mut(cat_name) { + folds.remove(*group); + if folds.is_empty() { + self.group_folds.remove(cat_name); + } + } + } + false => { + let fold = match collapse { + true => GroupFold::Collapsed, + false => GroupFold::Expanded(NonZeroUsize::MIN), + }; + self.set_group_fold(cat_name, group, fold); + } + } } pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { @@ -292,9 +370,10 @@ impl View { #[cfg(test)] mod tests { - use super::{View, unused_view_name}; + use super::{GroupFold, View, unused_view_name}; use crate::model::category::CategoryKind; use crate::view::Axis; + use std::num::NonZeroUsize; /// improvise-vw47: the count is the starting guess, never the answer. /// What a taken name costs: `context/test-evidence/core.md`. @@ -563,28 +642,87 @@ mod tests { assert_eq!(v.page_selection("Region"), None); } + /// The user's ruling on improvise-felo.6: every group starts closed. #[test] - fn toggle_group_collapse_toggles_twice() { + fn a_group_starts_collapsed_at_every_depth() { + let v = View::new("Test"); + assert!(v.is_group_collapsed("Time", &["2024"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"])); + } + + /// `expanded(1): Time` opens the roots and nothing below them. + #[test] + fn a_category_depth_opens_its_top_levels() { let mut v = View::new("Test"); - assert!(!v.is_group_collapsed("Time", "Q1")); - v.toggle_group_collapse("Time", "Q1"); - assert!(v.is_group_collapsed("Time", "Q1")); - v.toggle_group_collapse("Time", "Q1"); - assert!(!v.is_group_collapsed("Time", "Q1")); + v.set_expand_depth("Time", 1); + assert!(!v.is_group_collapsed("Time", &["2024"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"])); + assert!(v.is_group_collapsed("Other", &["G"]), "per category"); + v.set_expand_depth("Time", 0); + assert!(v.expand_depth.is_empty(), "0 is the absence, not a setting"); + } + + /// `expanded(2): Time/2024` opens 2024 and one level beneath it, no deeper, + /// and nothing of the sibling year. + #[test] + fn a_groups_depth_opens_it_and_n_minus_one_levels_beneath() { + let mut v = View::new("Test"); + v.set_group_fold( + "Time", + "2024", + GroupFold::Expanded(NonZeroUsize::new(2).unwrap()), + ); + assert!(!v.is_group_collapsed("Time", &["2024"])); + assert!(!v.is_group_collapsed("Time", &["2024", "Q1"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1", "Jan"])); + assert!(v.is_group_collapsed("Time", &["2025", "Q1"])); + } + + /// A group's own `collapsed:` wins over an `expanded(N)` that reaches it. + #[test] + fn a_collapsed_group_inside_an_expanded_region_stays_collapsed() { + let mut v = View::new("Test"); + v.set_expand_depth("Time", 3); + v.set_group_fold("Time", "Q1", GroupFold::Collapsed); + assert!(v.is_group_collapsed("Time", &["2024", "Q1"])); + assert!(!v.is_group_collapsed("Time", &["2024", "Q2"])); + } + + /// `z` writes `Expanded(1)` or `Collapsed`, in BOTH directions, and + /// toggling back leaves no fold behind. + #[test] + fn a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it() { + let mut v = View::new("Test"); + v.set_expand_depth("Time", 1); + v.toggle_group_collapse("Time", &["2024", "Q1-2024"]); + assert_eq!( + v.group_fold("Time", "Q1-2024"), + Some(GroupFold::Expanded(NonZeroUsize::MIN)) + ); + v.toggle_group_collapse("Time", &["2024"]); + assert_eq!(v.group_fold("Time", "2024"), Some(GroupFold::Collapsed)); + assert!( + !v.is_group_collapsed("Time", &["2025"]), + "a sibling is untouched" + ); + v.toggle_group_collapse("Time", &["2024", "Q1-2024"]); + v.toggle_group_collapse("Time", &["2024"]); + assert!(v.group_folds.is_empty(), "{:?}", v.group_folds); } #[test] fn is_group_collapsed_isolated_across_categories() { let mut v = View::new("Test"); - v.toggle_group_collapse("Cat1", "G1"); - assert!(!v.is_group_collapsed("Cat2", "G1")); + v.toggle_group_collapse("Cat1", &["G1"]); + assert!(!v.is_group_collapsed("Cat1", &["G1"])); + assert!(v.is_group_collapsed("Cat2", &["G1"])); } #[test] fn is_group_collapsed_isolated_across_groups() { let mut v = View::new("Test"); - v.toggle_group_collapse("Cat1", "G1"); - assert!(!v.is_group_collapsed("Cat1", "G2")); + v.toggle_group_collapse("Cat1", &["G1"]); + assert!(v.is_group_collapsed("Cat1", &["G2"])); } /// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed` @@ -593,12 +731,13 @@ mod tests { fn on_category_removed_forgets_a_collapsed_group() { let mut v = View::new("V"); v.on_category_added("Month", CategoryKind::of_name("Month")); - v.toggle_group_collapse("Month", "Q1"); + v.set_expand_depth("Month", 1); + v.toggle_group_collapse("Month", &["Q1"]); v.on_category_removed("Month"); v.on_category_added("Month", CategoryKind::of_name("Month")); assert!( - !v.is_group_collapsed("Month", "Q1"), - "a re-added category came back with its group collapsed" + v.group_folds.is_empty() && v.expand_depth.is_empty(), + "a re-added category came back with its old fold state" ); } @@ -808,10 +947,10 @@ mod prop_tests { group in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); - let initial = v.is_group_collapsed(&cat, &group); - v.toggle_group_collapse(&cat, &group); - v.toggle_group_collapse(&cat, &group); - prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial); + let initial = v.is_group_collapsed(&cat, &[group.as_str()]); + v.toggle_group_collapse(&cat, &[group.as_str()]); + v.toggle_group_collapse(&cat, &[group.as_str()]); + prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial); } } } diff --git a/crates/improvise-io/src/persistence/export.rs b/crates/improvise-io/src/persistence/export.rs index 30a50c13..be4d1557 100644 --- a/crates/improvise-io/src/persistence/export.rs +++ b/crates/improvise-io/src/persistence/export.rs @@ -214,6 +214,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] { wb.model.set_cell( @@ -226,7 +227,7 @@ mod tests { v.set_axis("Month", Axis::Row); v.set_axis("Type", Axis::Column); v.prune_empty = true; - v.toggle_group_collapse("Month", "Q1"); + 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(); diff --git a/crates/improvise-io/src/persistence/format.rs b/crates/improvise-io/src/persistence/format.rs index 11854147..39046be4 100644 --- a/crates/improvise-io/src/persistence/format.rs +++ b/crates/improvise-io/src/persistence/format.rs @@ -2,7 +2,7 @@ use crate::model::category::CategoryKind; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, GroupFold}; use crate::workbook::Workbook; // The one home of the pipe-quoting rule (improvise-i2m5); a name written here @@ -119,19 +119,37 @@ pub fn format_md(workbook: &Workbook) -> String { if view.prune_empty { w!(out, "prune: true"); } - for (prefix, map) in [ - ("hidden", &view.hidden_items), - ("collapsed", &view.collapsed_groups), - ] { - let mut pairs: Vec<_> = map - .iter() - .flat_map(|(cat, items)| { - items.iter().map(move |item| (cat.as_str(), item.as_str())) - }) - .collect(); - pairs.sort(); - for (cat, item) in pairs { - w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item)); + let mut hidden: Vec<_> = view + .hidden_items + .iter() + .flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str()))) + .collect(); + hidden.sort(); + for (cat, item) in hidden { + w!(out, "hidden: {}/{}", quote_name(cat), quote_name(item)); + } + let mut depths: Vec<_> = view.expand_depth.iter().collect(); + depths.sort(); + // Never bare: `expanded: row` would read back as an axis line. + for (cat, depth) in depths { + w!(out, "expanded({depth}): {}", quote_name(cat)); + } + let mut folds: Vec<_> = view + .group_folds + .iter() + .flat_map(|(cat, groups)| { + groups + .iter() + .map(move |(g, fold)| (cat.as_str(), g.as_str(), *fold)) + }) + .collect(); + folds.sort(); + for (cat, group, fold) in folds { + let target = format!("{}/{}", quote_name(cat), quote_name(group)); + match fold { + GroupFold::Collapsed => w!(out, "collapsed: {target}"), + GroupFold::Expanded(n) if n.get() == 1 => w!(out, "expanded: {target}"), + GroupFold::Expanded(n) => w!(out, "expanded({n}): {target}"), } } } @@ -370,7 +388,8 @@ mod tests { #[test] fn format_md_collapsed_group() { let mut m = two_cat_model(); - m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); + m.active_view_mut() + .set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed); let text = format_md(&m); assert!(text.contains("collapsed: Type/MyGroup")); } diff --git a/crates/improvise-io/src/persistence/improv.pest b/crates/improvise-io/src/persistence/improv.pest index b4b60135..fe32d253 100644 --- a/crates/improvise-io/src/persistence/improv.pest +++ b/crates/improvise-io/src/persistence/improv.pest @@ -120,7 +120,9 @@ view_section = { view_entry* } -view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line } +// `axis_line` is tried before `expanded_line`, so a category named `expanded` +// keeps its `expanded: row`; the writer never spells a category depth bare. +view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line | expanded_line } axis_line = { name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines @@ -135,6 +137,12 @@ prune_line = { "prune: " ~ bool_kw ~ NEWLINE ~ blank_lines } bool_kw = @{ "true" | "false" } hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } +// Groups start collapsed. `expanded(N): Cat` opens the category's top N levels; +// `expanded(N): Cat/Group` opens the group and N-1 levels beneath it. A bare +// `expanded:` is N = 1, and N is at least 1. context/code/formula-and-io.md. +expanded_line = { "expanded" ~ fold_depth? ~ ": " ~ name ~ ("/" ~ name)? ~ NEWLINE ~ blank_lines } +fold_depth = { "(" ~ depth_number ~ ")" } +depth_number = @{ '1'..'9' ~ ASCII_DIGIT* } // ── Names ──────────────────────────────────────────────────────────────────── // diff --git a/crates/improvise-io/src/persistence/parse.rs b/crates/improvise-io/src/persistence/parse.rs index a28a76d0..e2661cf7 100644 --- a/crates/improvise-io/src/persistence/parse.rs +++ b/crates/improvise-io/src/persistence/parse.rs @@ -9,10 +9,10 @@ use pest_derive::Parser; use crate::formula::{AggFunc, parse_formula}; use crate::model::category::{CategoryKind, Group}; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, GroupFold}; use crate::workbook::Workbook; -use crate::formula::quote::unescape_pipe_body; +use crate::formula::quote::{quote_name, unescape_pipe_body}; #[derive(Parser)] #[grammar = "persistence/improv.pest"] @@ -40,9 +40,74 @@ struct PView { format: String, prune_empty: bool, hidden: Vec<(String, String)>, - collapsed: Vec<(String, String)>, + folds: Vec, } +/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category +/// form, which only `expanded` has. +struct PFold { + category: String, + group: Option, + fold: GroupFold, +} + +/// A file the grammar accepts and a model cannot hold (improvise-felo.6). +/// Typed, so the generator's property can tell it from a parse failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// `> A[B]` and `> B[A]`: a hierarchy must be a forest. + HierarchyCycle { + category: String, + groups: Vec, + }, + /// One view gives one group (or one category's depth) two different folds. + FoldConflict { + view: String, + category: String, + group: Option, + }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::HierarchyCycle { category, groups } => write!( + f, + "category {}: the group parents form a cycle through {}", + quote_name(category), + groups + .iter() + .map(|g| quote_name(g)) + .collect::>() + .join(" > ") + ), + Refusal::FoldConflict { + view, + category, + group: Some(group), + } => write!( + f, + "view {}: group {}/{} is both expanded and collapsed, or expanded to two depths", + quote_name(view), + quote_name(category), + quote_name(group) + ), + Refusal::FoldConflict { + view, + category, + group: None, + } => write!( + f, + "view {}: category {} is expanded to two depths", + quote_name(view), + quote_name(category) + ), + } + } +} + +impl std::error::Error for Refusal {} + /// Everything pass 1 collects, before any of it is resolved against the model. #[derive(Default)] struct ParsedFile { @@ -332,7 +397,31 @@ fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> { Ok(()) } -/// `## View: Name` plus its axis, format, hidden and collapsed lines. +/// `expanded(N): Cat` or `expanded(N): Cat/Group`; a bare `expanded:` is N = 1. +fn walk_expanded_line(entry: Pair<'_, Rule>) -> Result { + let mut depth = std::num::NonZeroUsize::MIN; + let mut names = Vec::new(); + for part in entry.into_inner() { + match part.as_rule() { + Rule::fold_depth => { + let digits = first_str(part)?; + depth = digits + .parse() + .with_context(|| format!("expanded({digits}): depth out of range"))?; + } + _ => names.push(extract_name(part)?), + } + } + let mut names = names.into_iter(); + let category = names.next().context("expanded_line: no category")?; + Ok(PFold { + category, + group: names.next(), + fold: GroupFold::Expanded(depth), + }) +} + +/// `## View: Name` plus its axis, format, hidden, collapsed and expanded lines. fn walk_view_section(pair: Pair<'_, Rule>) -> Result { let mut inner = pair.into_inner(); let mut pv = PView { @@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result { format: String::new(), prune_empty: false, hidden: Vec::new(), - collapsed: Vec::new(), + folds: Vec::new(), }; for entry in inner { match entry.as_rule() { @@ -350,7 +439,15 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result { Rule::format_line => pv.format = first_str(entry)?, Rule::prune_line => pv.prune_empty = first_str(entry)? == "true", Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?), - Rule::collapsed_line => pv.collapsed.push(extract_name_pair(entry)?), + Rule::collapsed_line => { + let (category, group) = extract_name_pair(entry)?; + pv.folds.push(PFold { + category, + group: Some(group), + fold: GroupFold::Collapsed, + }); + } + Rule::expanded_line => pv.folds.push(walk_expanded_line(entry)?), other => bail!("unhandled rule in view_section: {other:?}"), } } @@ -411,6 +508,13 @@ fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> { None => cat.add_group(Group::new(group_name).with_parent(parent)), } } + if let Some(cycle) = cat.hierarchy_cycle() { + return Err(Refusal::HierarchyCycle { + category: pc.name.clone(), + groups: cycle.into_iter().map(String::from).collect(), + } + .into()); + } Ok(()) } @@ -436,8 +540,29 @@ fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> { for (cat, item) in &pv.hidden { view.hide_item(cat, item); } - for (cat, grp) in &pv.collapsed { - view.toggle_group_collapse(cat, grp); + for PFold { + category, + group, + fold, + } in &pv.folds + { + let conflict = || Refusal::FoldConflict { + view: pv.name.clone(), + category: category.clone(), + group: group.clone(), + }; + match (group, fold) { + (Some(g), _) => match view.group_fold(category, g) { + Some(held) if held != *fold => return Err(conflict().into()), + _ => view.set_group_fold(category, g, *fold), + }, + (None, GroupFold::Expanded(n)) => match view.expand_depth(category) { + 0 => view.set_expand_depth(category, n.get()), + held if held != n.get() => return Err(conflict().into()), + _ => {} + }, + (None, GroupFold::Collapsed) => bail!("a category-wide collapse has no spelling"), + } } Ok(()) } @@ -534,6 +659,7 @@ mod grammar_coverage { Rule::axis_line | Rule::axis_kind => "walk_axis_line", Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section", Rule::hidden_line | Rule::collapsed_line => "walk_view_section", + Rule::expanded_line | Rule::fold_depth | Rule::depth_number => "walk_expanded_line", Rule::view_entry => "silent: hoisted into view_section", Rule::name => "silent: hoisted into its parent", Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name", @@ -1059,12 +1185,14 @@ mod parser_edge_cases { fn collapsed_group_with_slash_in_name() { let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2"); + m.active_view_mut() + .set_group_fold("Type", "Q1/Q2", crate::view::GroupFold::Collapsed); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert!( - loaded.active_view().is_group_collapsed("Type", "Q1/Q2"), + loaded.active_view().group_fold("Type", "Q1/Q2") + == Some(crate::view::GroupFold::Collapsed), "Collapsed group with '/' in name was corrupted.\n{text}" ); } @@ -1604,6 +1732,24 @@ mod generator { }) } + /// improvise-felo.6: the round-trip properties see `expanded(N):` lines only + /// if the walk emits them, depths included. + #[test] + fn the_generator_writes_expanded_lines_with_and_without_a_depth() { + use proptest::strategy::ValueTree; + use proptest::test_runner::TestRunner; + + let mut runner = TestRunner::deterministic(); + let strategy = improv_file(); + let files: Vec = (0..512) + .map(|_| strategy.new_tree(&mut runner).unwrap().current()) + .collect(); + let lines = || files.iter().flat_map(|f| f.lines()); + assert!(lines().any(|l| l.starts_with("expanded(")), "a depth"); + assert!(lines().any(|l| l.starts_with("expanded: ")), "a bare line"); + assert!(lines().any(|l| l.starts_with("collapsed: ")), "a collapse"); + } + /// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`. #[test] fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() { @@ -1625,7 +1771,7 @@ mod generator { #[cfg(test)] mod grammar_prop_tests { - use super::{generator, parse_md}; + use super::{Refusal, generator, parse_md}; use crate::persistence::format_md; use proptest::prelude::*; @@ -1633,11 +1779,13 @@ mod grammar_prop_tests { proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// parse(generate()) — every generated file parses without error. + /// parse(generate()) — every generated file parses, or is refused for a + /// named reason the grammar cannot express (a `Refusal`, improvise-felo.6). #[test] fn generated_file_parses(file in generator::improv_file()) { let result = parse_md(&file); - prop_assert!(result.is_ok(), + let refused = result.as_ref().err().is_some_and(|e| e.downcast_ref::().is_some()); + prop_assert!(result.is_ok() || refused, "Generated file failed to parse:\n{}\nError: {}", file, result.unwrap_err()); } diff --git a/crates/improvise-io/src/persistence/roundtrip_tests.rs b/crates/improvise-io/src/persistence/roundtrip_tests.rs index 0f10003b..4c2c7c69 100644 --- a/crates/improvise-io/src/persistence/roundtrip_tests.rs +++ b/crates/improvise-io/src/persistence/roundtrip_tests.rs @@ -267,10 +267,198 @@ fn parse_md_round_trips_text_cell_values() { #[test] fn parse_md_round_trips_collapsed_group() { let mut m = two_cat_model(); - m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); + m.active_view_mut() + .set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup")); + assert!( + loaded.active_view().group_fold("Type", "MyGroup") + == Some(crate::view::GroupFold::Collapsed) + ); +} + +// ── Group hierarchies and folds (improvise-felo.6) ───────────────────── + +/// Year > Quarter > Month as a file spells it: `> Quarter[Year]` lines. +const DATES: &str = "v2025-04-09\n# T\nInitial View: Default\n\n## View: Default\nDate: row\n\ + _Measure: column\n@FOLDS@\n## Category: Date\n\ + - |2024-01|[Q1-2024]\n- |2024-04|[Q2-2024]\n- |2025-01|[Q1-2025]\n\ + > Q1-2024[|2024|]\n> Q2-2024[|2024|]\n> Q1-2025[|2025|]\n\n\ + ## Category: _Measure\n- Revenue\n"; + +fn dates_with(folds: &str) -> String { + DATES.replace("@FOLDS@", folds) +} + +/// Every spelling of a fold, together in one view, survives save and load — +/// and the writer's own text is stable. +#[test] +fn expanded_and_collapsed_lines_round_trip_together() { + use crate::view::GroupFold; + let text = dates_with( + "expanded(2): Date\ncollapsed: Date/Q1-2024\nexpanded: Date/Q2-2024\n\ + expanded(3): Date/|2025|", + ); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + assert_eq!(v.expand_depth("Date"), 2); + assert_eq!(v.group_fold("Date", "Q1-2024"), Some(GroupFold::Collapsed)); + assert_eq!( + v.group_fold("Date", "Q2-2024"), + Some(GroupFold::Expanded(std::num::NonZeroUsize::MIN)) + ); + assert_eq!( + v.group_fold("Date", "2025"), + Some(GroupFold::Expanded(std::num::NonZeroUsize::new(3).unwrap())) + ); + let written = format_md(&loaded); + for line in [ + "expanded(2): Date", + "collapsed: Date/Q1-2024", + "expanded: Date/Q2-2024", + "expanded(3): Date/|2025|", + ] { + assert!(written.lines().any(|l| l == line), "{line} in:\n{written}"); + } + assert_eq!(format_md(&parse_md(&written).unwrap()), written); +} + +/// A file from before improvise-felo.6 carries only `collapsed:` lines. Every +/// group opens closed, the ones it names AND the ones it does not. +#[test] +fn an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines() { + let text = dates_with("collapsed: Date/Q1-2024"); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + let cat = loaded.model.category("Date").unwrap(); + for group in ["2024", "Q1-2024", "Q2-2024", "2025"] { + assert!( + v.is_group_collapsed("Date", &cat.group_chain(group)), + "{group} opens closed" + ); + } + assert!( + format_md(&loaded) + .lines() + .any(|l| l == "collapsed: Date/Q1-2024"), + "the writer keeps the line the file had, though it agrees with the default" + ); +} + +/// The user's ruling: expanded and collapsed on ONE group is an error, named. +#[test] +fn a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group() { + use super::parse::Refusal; + for folds in [ + "collapsed: Date/Q1-2024\nexpanded: Date/Q1-2024", + "expanded(2): Date/Q1-2024\ncollapsed: Date/Q1-2024", + "expanded: Date/Q1-2024\nexpanded(2): Date/Q1-2024", + ] { + let err = parse_md(&dates_with(folds)).unwrap_err(); + assert_eq!( + err.downcast_ref::(), + Some(&Refusal::FoldConflict { + view: "Default".into(), + category: "Date".into(), + group: Some("Q1-2024".into()), + }), + "{folds}: {err}" + ); + assert!(err.to_string().contains("Default"), "{err}"); + assert!(err.to_string().contains("Date/Q1-2024"), "{err}"); + } + let err = parse_md(&dates_with("expanded(2): Date\nexpanded(3): Date")).unwrap_err(); + assert!(err.downcast_ref::().is_some(), "{err}"); +} + +/// Not the error: a DIFFERENT group collapsed inside an expanded region, and +/// the same line twice. +#[test] +fn a_collapsed_group_inside_an_expanded_region_loads() { + let text = dates_with( + "expanded(2): Date\nexpanded(2): Date/|2024|\ncollapsed: Date/Q1-2024\n\ + collapsed: Date/Q1-2024", + ); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + assert!(v.is_group_collapsed("Date", &["2024", "Q1-2024"])); + assert!(!v.is_group_collapsed("Date", &["2024", "Q2-2024"])); +} + +/// A cycle among `>` lines is refused on load, naming its members. +#[test] +fn a_cycle_among_group_parents_is_refused_naming_it() { + use super::parse::Refusal; + let text = DATES + .replace("@FOLDS@", "") + .replace("> Q1-2025[|2025|]", "> Q1-2025[|2025|]\n> |2025|[Q1-2025]"); + let err = parse_md(&text).unwrap_err(); + match err.downcast_ref::() { + Some(Refusal::HierarchyCycle { category, groups }) => { + assert_eq!(category, "Date"); + let mut groups = groups.clone(); + groups.sort(); + assert_eq!(groups, vec!["2025", "Q1-2025"]); + } + other => panic!("not a cycle refusal: {other:?} ({err})"), + } +} + +/// `expanded(0)` opens nothing and is not a spelling; `(x)` is not a depth. +#[test] +fn a_malformed_depth_is_a_parse_error() { + assert!( + parse_md(&dates_with("expanded(10): Date")).is_ok(), + "the control" + ); + for bad in ["expanded(0): Date", "expanded(x): Date", "expanded(): Date"] { + assert!(parse_md(&dates_with(bad)).is_err(), "{bad}"); + } +} + +/// A category named `expanded` keeps its axis line, and a category whose NAME is +/// an axis keyword keeps its depth: the writer never spells a depth bare. +#[test] +fn an_axis_line_for_a_category_named_expanded_is_still_an_axis_line() { + let mut wb = Workbook::new("T"); + wb.add_category("expanded").unwrap(); + wb.add_category("row").unwrap(); + wb.active_view_mut().set_axis("expanded", Axis::Column); + wb.active_view_mut().set_expand_depth("row", 1); + let loaded = parse_md(&format_md(&wb)).unwrap(); + assert_eq!(loaded.active_view().axis_of("expanded"), Axis::Column); + assert_eq!(loaded.active_view().expand_depth("row"), 1); +} + +/// `z` writes through ONE map, so no sequence of toggles can save a file that +/// names a group both ways. Every toggle order over three levels, twice over. +#[test] +fn no_sequence_of_toggles_writes_a_conflicting_file() { + let base = parse_md(&dates_with("expanded(1): Date")).unwrap(); + let chains: [&[&str]; 4] = [ + &["2024"], + &["2024", "Q1-2024"], + &["2024", "Q2-2024"], + &["2025", "Q1-2025"], + ]; + for order in 0..(4usize.pow(4)) { + let mut wb = base.clone(); + let mut code = order; + for _ in 0..4 { + let chain = chains[code % 4]; + code /= 4; + wb.active_view_mut().toggle_group_collapse("Date", chain); + let text = format_md(&wb); + let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + for chain in chains { + assert_eq!( + back.active_view().is_group_collapsed("Date", chain), + wb.active_view().is_group_collapsed("Date", chain), + "{chain:?} after order {order}:\n{text}" + ); + } + } + } } // ── parse_md: number format roundtrip ─────────────────────────────── @@ -336,7 +524,8 @@ fn full_roundtrip_preserves_all_features() { m.active_view_mut().set_axis("Month", Axis::Page); m.active_view_mut().set_page_selection("Month", "Jan"); m.active_view_mut().hide_item("Type", "Gas"); - m.active_view_mut().toggle_group_collapse("Type", "G1"); + m.active_view_mut() + .set_group_fold("Type", "G1", crate::view::GroupFold::Collapsed); m.active_view_mut().number_format = ",.0".to_string(); let text = format_md(&m); @@ -361,7 +550,7 @@ fn full_roundtrip_preserves_all_features() { assert_eq!(v.axis_of("Month"), Axis::Page); assert_eq!(v.page_selection("Month"), Some("Jan")); assert!(v.is_hidden("Type", "Gas")); - assert!(v.is_group_collapsed("Type", "G1")); + assert!(v.group_fold("Type", "G1") == Some(crate::view::GroupFold::Collapsed)); assert_eq!(v.number_format, ",.0"); } @@ -1237,3 +1426,28 @@ fn a_loaded_label_category_is_not_on_a_pivot_axis() { ); } } + +/// ChatGPT Codex on #317: a fold built through the public API must survive its +/// own save. `Expanded(0)` is now unconstructible; every depth that is survives. +#[test] +fn every_fold_the_api_can_build_reads_back_from_the_file_it_writes() { + use crate::view::GroupFold; + use std::num::NonZeroUsize; + let base = parse_md(&dates_with("")).unwrap(); + for n in 1..=3 { + let depth = NonZeroUsize::new(n).unwrap(); + for fold in [GroupFold::Collapsed, GroupFold::Expanded(depth)] { + let mut wb = base.clone(); + wb.active_view_mut().set_group_fold("Date", "2024", fold); + wb.active_view_mut().set_expand_depth("Date", n); + let text = format_md(&wb); + let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert_eq!( + back.active_view().group_fold("Date", "2024"), + Some(fold), + "{text}" + ); + assert_eq!(back.active_view().expand_depth("Date"), n, "{text}"); + } + } +} diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 8c8ba510..c8621397 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -214,6 +214,9 @@ pub struct FrameGroup { /// Which header level opened it — an index into [`AxisFrame::cats`]. Runs /// nest, so membership is per level (improvise-e265). pub level: usize, + /// The group's depth in its category's hierarchy, 0 for a root. Runs nest + /// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6). + pub depth: usize, pub name: String, pub collapsed: bool, } @@ -223,10 +226,11 @@ pub struct FrameGroup { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FrameEntry { Group(FrameGroup), - /// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd` - /// on the wire (improvise-gd83, e265). Nothing is drawn for it. + /// The run at `(level, depth)`, and every run inside it, ends here — + /// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn. GroupEnd { level: usize, + depth: usize, }, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. @@ -279,7 +283,7 @@ impl TryFrom for AxisFrame { .iter() .filter_map(|e| match e { FrameEntry::Group(g) => Some(g.level), - FrameEntry::GroupEnd { level } => Some(*level), + FrameEntry::GroupEnd { level, .. } => Some(*level), FrameEntry::Item { .. } => None, }) .find(|level| *level >= raw.cats.len()); @@ -418,20 +422,22 @@ impl AxisFrame { .collect() } - /// The innermost group each data item sits in, in order — one pass. A - /// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265). + /// The innermost group each data item sits in, in order — one pass. A group + /// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a). pub fn groups(&self) -> Vec> { - let mut open: Vec> = Vec::new(); + let mut open: Vec<&FrameGroup> = Vec::new(); + let close = |open: &mut Vec<&FrameGroup>, key: (usize, usize)| { + open.truncate(open.partition_point(|g| (g.level, g.depth) < key)) + }; let mut out = Vec::new(); for entry in &self.entries { match entry { FrameEntry::Group(g) => { - open.truncate(g.level); - open.resize(g.level, None); - open.push(Some(g)); + close(&mut open, (g.level, g.depth)); + open.push(g); } - FrameEntry::GroupEnd { level } => open.truncate(*level), - FrameEntry::Item { .. } => out.push(open.iter().rev().find_map(|g| *g)), + FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)), + FrameEntry::Item { .. } => out.push(open.last().copied()), } } out @@ -1349,6 +1355,7 @@ mod tests { FrameEntry::item(vec!["A".into(), "y".into()]), FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Late".into(), collapsed: true, }), @@ -1362,6 +1369,7 @@ mod tests { fn repeated_group_frame() -> AxisFrame { let q1 = || FrameGroup { level: 1, + depth: 0, name: "Q1".into(), collapsed: false, }; @@ -1388,12 +1396,13 @@ mod tests { entries: vec![ FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Q1".into(), collapsed: false, }), FrameEntry::item(vec!["Jan".into()]), FrameEntry::item(vec!["Feb".into()]), - FrameEntry::GroupEnd { level: 0 }, + FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], } @@ -1406,6 +1415,7 @@ mod tests { let group = |level: usize, name: &str| { FrameEntry::Group(FrameGroup { level, + depth: 0, name: name.into(), collapsed: true, }) @@ -1416,7 +1426,7 @@ mod tests { entries: vec![ group(0, "Upper"), group(1, "Q1"), - FrameEntry::GroupEnd { level: 1 }, + FrameEntry::GroupEnd { level: 1, depth: 0 }, ], }; let json = serde_json::to_string(&frame).unwrap(); @@ -1424,10 +1434,13 @@ mod tests { assert_eq!(back, frame); for (from, to) in [ ( - r#""level":1,"name":"Q1""#, - r#""level":4294967295,"name":"Q1""#, + r#""level":1,"depth":0,"name":"Q1""#, + r#""level":4294967295,"depth":0,"name":"Q1""#, + ), + ( + r#"{"GroupEnd":{"level":1,"depth":0}}"#, + r#"{"GroupEnd":{"level":2,"depth":0}}"#, ), - (r#"{"GroupEnd":{"level":1}}"#, r#"{"GroupEnd":{"level":2}}"#), ] { let bad = json.replacen(from, to, 1); assert_ne!(bad, json, "the fault {to} was injected"); @@ -1440,6 +1453,45 @@ mod tests { } } + /// improvise-felo.6: runs nest by `(level, depth)` inside one category, so + /// the end of a quarter leaves its year open. See the evidence file. + #[test] + fn a_quarters_end_leaves_its_year_open_on_the_same_level() { + let group = |depth: usize, name: &str| { + FrameEntry::Group(FrameGroup { + level: 0, + depth, + name: name.into(), + collapsed: false, + }) + }; + let frame = AxisFrame { + cats: vec!["Date".into()], + first: 0, + entries: vec![ + group(0, "2024"), + group(1, "Q1"), + FrameEntry::item(vec!["Jan".into()]), + FrameEntry::GroupEnd { level: 0, depth: 1 }, + FrameEntry::item(vec!["Direct".into()]), + group(1, "Q2"), + FrameEntry::item(vec!["Apr".into()]), + FrameEntry::GroupEnd { level: 0, depth: 0 }, + FrameEntry::item(vec!["Undated".into()]), + ], + }; + let names: Vec> = frame + .groups() + .into_iter() + .map(|g| g.map(|g| g.name.as_str())) + .collect(); + assert_eq!( + names, + vec![Some("Q1"), Some("2024"), Some("Q2"), None], + "Direct sits in 2024 after Q1 ends; Undated in nothing" + ); + } + /// improvise-gd83's property, asserted in the crate that owns the type. /// See `context/test-evidence/protocol.md`. #[test] @@ -1467,10 +1519,11 @@ mod tests { entries: vec![ FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Q1".into(), collapsed: true, }), - FrameEntry::GroupEnd { level: 0 }, + FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], }; diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 146d7035..1b369862 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -591,6 +591,7 @@ mod tests { fn group(name: &str, collapsed: bool) -> FrameEntry { FrameEntry::Group(improvise_protocol::FrameGroup { level: 0, + depth: 0, name: name.into(), collapsed, }) diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index a5a9947d..d76943a7 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -189,6 +189,7 @@ fn a_grouped_row_axis_does_not_shift_the_write() { // two rows — a leading header is dropped by `AxisFrame::body_from`. wb.add_item("Region", "West").unwrap(); wb.add_item_in_group("Region", "East", "Coastal").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index ebfa8d64..93c2d789 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2258,6 +2258,7 @@ fn fold_host() -> App { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for month in ["Jan", "Feb", "Apr", "May"] { @@ -2272,7 +2273,7 @@ fn fold_host() -> App { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2"); @@ -2469,7 +2470,7 @@ fn advancing_the_editor_past_the_window_settles_where_the_tui_does() { let expanded = || { let mut app = fold_host(); let view = app.model_state.workbook.active_view_mut(); - view.toggle_group_collapse("Month", "Q2"); + view.toggle_group_collapse("Month", &["Q2"]); app.rebuild_layout(); app }; @@ -3543,20 +3544,22 @@ fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App { for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("Product", "Widget").unwrap(); for item in ["Gadget", "Gizmo"] { wb.add_item_in_group("Product", item, "Kit").unwrap(); } + wb.active_view_mut().set_expand_depth("Product", 1); wb.add_item("_Measure", "Revenue").unwrap(); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Product", Axis::Row); view.set_axis("_Measure", Axis::Column); if coast_collapsed { - view.toggle_group_collapse("Region", "Coast"); + view.toggle_group_collapse("Region", &["Coast"]); } if kit_collapsed { - view.toggle_group_collapse("Product", "Kit"); + view.toggle_group_collapse("Product", &["Kit"]); } let mut app = App::new(wb, None); app.term_height = 12 + 8; @@ -3614,11 +3617,12 @@ fn folded_host() -> App { for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut() - .toggle_group_collapse("Region", "Coast"); + .toggle_group_collapse("Region", &["Coast"]); let mut app = App::new(wb, None); app.term_height = 4 + 8; app.rebuild_layout(); diff --git a/harness/fixtures/grouped.improv b/harness/fixtures/grouped.improv index 164bce39..8e8d6005 100644 --- a/harness/fixtures/grouped.improv +++ b/harness/fixtures/grouped.improv @@ -11,6 +11,8 @@ _Measure: page, Revenue _Index: none _Dim: none collapsed: Region/Upland +expanded(1): Month +expanded(1): Region format: ,.0 ## Category: Region diff --git a/harness/fixtures/tall.improv b/harness/fixtures/tall.improv index 5daa32d2..c16fc14d 100644 --- a/harness/fixtures/tall.improv +++ b/harness/fixtures/tall.improv @@ -8,6 +8,7 @@ _Dim: none _Measure: page Region: row format: ,.0 +expanded(1): Region ## View: Tall _Index: none @@ -15,6 +16,7 @@ _Dim: none _Measure: column Region: row format: ,.0 +expanded(1): Region ## Category: _Measure - Revenue, Cost diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 106133d5..b4ccfbdc 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -860,6 +860,8 @@ mod tests { m.add_item_in_group("Region", "West", "Inland").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Apr", "Q2").unwrap(); + m.active_view_mut().set_expand_depth("Region", 1); + m.active_view_mut().set_expand_depth("Month", 1); m.add_item("Payer", "Alice").unwrap(); // Pinned so the byte-exact frames below characterize the LAYOUT and not // the view default, which grew two decimals with improvise-e0ex. diff --git a/tests/bug_hunt_reproductions.rs b/tests/bug_hunt_reproductions.rs index c5ceb3b7..68021682 100644 --- a/tests/bug_hunt_reproductions.rs +++ b/tests/bug_hunt_reproductions.rs @@ -12,6 +12,7 @@ fn grouped_item_order_survives_save() { let mut w = Workbook::new("test"); w.add_category("C").unwrap(); w.add_item_in_group("C", "first", "G").unwrap(); + w.active_view_mut().set_expand_depth("C", 1); w.add_item("C", "second").unwrap(); let r = parse_md(&format_md(&w)).unwrap(); assert_eq!( @@ -429,8 +430,9 @@ fn slash_in_hidden_name_survives_roundtrip() { w.add_category("C").unwrap(); w.add_item("C", "A/B").unwrap(); w.add_item_in_group("C", "x", "G/H").unwrap(); + w.active_view_mut().set_expand_depth("C", 1); w.active_view_mut().hide_item("C", "A/B"); - w.active_view_mut().toggle_group_collapse("C", "G/H"); + w.active_view_mut().toggle_group_collapse("C", &["G/H"]); let text = format_md(&w); let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}")); assert!( @@ -438,7 +440,7 @@ fn slash_in_hidden_name_survives_roundtrip() { "hidden lost:\n{text}" ); assert!( - r.active_view().is_group_collapsed("C", "G/H"), + r.active_view().is_group_collapsed("C", &["G/H"]), "collapsed lost:\n{text}" ); } diff --git a/tests/grid_frame_agreement.rs b/tests/grid_frame_agreement.rs index c7d66655..12364556 100644 --- a/tests/grid_frame_agreement.rs +++ b/tests/grid_frame_agreement.rs @@ -4,7 +4,7 @@ use improvise::model::cell::{CellKey, CellValue}; use improvise::ui::app::{App, AppMode, ViewCursor}; use improvise::ui::grid::GridWidget; -use improvise::view::{Axis, GridLayout}; +use improvise::view::{Axis, GridLayout, GroupFold}; use improvise::workbook::Workbook; use improvise_command::ui::project; use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState}; @@ -31,10 +31,12 @@ fn two_level_rows() -> Workbook { m.add_item("Type", "Clothing").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Feb", "Q1").unwrap(); + m.active_view_mut().set_expand_depth("Month", 1); m.add_item("Month", "Sep").unwrap(); m.add_item("Recipient", "Carol").unwrap(); m.add_item_in_group("Recipient", "Alice", "Family").unwrap(); m.add_item_in_group("Recipient", "Bob", "Family").unwrap(); + m.active_view_mut().set_expand_depth("Recipient", 1); m.active_view_mut().set_axis("Recipient", Axis::Row); m.add_item("_Measure", "Revenue").unwrap(); m.add_item("_Measure", "Cost").unwrap(); @@ -188,10 +190,111 @@ fn browser_model(cache: &GridCache) -> RenderModel { #[test] fn the_two_front_ends_draw_the_same_row_header() { - let mut wb = two_level_rows(); - let (_app, cache) = whole_grid(&mut wb); + assert_row_headers_agree(&mut two_level_rows()); +} - let tui = tui_row_headers(&wb, &cache); +/// improvise-felo.6: Year > Quarter > Month in ONE category under a grouped one; +/// Y24 open two levels, Q2 and Y25 closed. Short names fit a TUI band cell. +fn a_date_hierarchy() -> Workbook { + let mut m = Workbook::new("Test"); + m.add_category("Recipient").unwrap(); + m.add_category("Date").unwrap(); + m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); + m.add_item("Recipient", "Carol").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("Jan25", "Q9")] { + m.add_item_in_group("Date", month, quarter).unwrap(); + } + m.add_item("Date", "Undated").unwrap(); + let date = m.model.category_mut("Date").unwrap(); + for (quarter, year) in [("Q1", "Y24"), ("Q2", "Y24"), ("Q9", "Y25")] { + date.add_group(improvise::model::category::Group::new(quarter).with_parent(year)); + } + m.add_item("_Measure", "Revenue").unwrap(); + let view = m.active_view_mut(); + view.set_axis("Recipient", Axis::Row); + view.set_axis("Date", Axis::Row); + view.set_axis("_Measure", Axis::Column); + view.set_expand_depth("Recipient", 1); + view.set_group_fold( + "Date", + "Y24", + GroupFold::Expanded(std::num::NonZeroUsize::new(2).unwrap()), + ); + view.set_group_fold("Date", "Q2", GroupFold::Collapsed); + for r in ["Alice", "Carol"] { + for d in ["Jan", "Feb", "Apr", "Jan25", "Undated"] { + m.model.set_cell( + coord(&[("Recipient", r), ("Date", d), ("_Measure", "Revenue")]), + CellValue::Number(1.0), + ); + } + } + m +} + +/// See `context/test-evidence/tui.md`. +#[test] +fn the_two_front_ends_draw_the_same_nested_hierarchy_on_rows() { + let mut wb = a_date_hierarchy(); + let (_app, cache) = whole_grid(&mut wb); + let groups: Vec = browser_rows(&cache) + .into_iter() + .filter_map(|r| match r { + RenderRow::Group { label, .. } => Some(label), + RenderRow::Data { .. } => None, + }) + .collect(); + assert_eq!( + groups, + ["Fam", "Y24", "Q1", "Q2", "Y25", "Y24", "Q1", "Q2", "Y25"], + "precondition: a year AND its quarters head their runs, under each recipient" + ); + assert_row_headers_agree(&mut wb); +} + +/// The column side of the same hierarchy: the band paints each column's +/// innermost group, coalesced by header, at both ends. +#[test] +fn the_two_front_ends_draw_the_same_nested_hierarchy_on_columns() { + let mut wb = a_date_hierarchy(); + wb.active_view_mut().transpose_axes(); + let (_app, cache) = whole_grid(&mut wb); + let model = browser_model(&cache); + let lines = tui_lines(&wb, &cache); + let rule = header_rule(&lines); + let levels = model.col_levels.len(); + let named: Vec<(Option<&str>, usize)> = model + .col_groups + .iter() + .map(|g| (g.label.as_deref(), g.span)) + .collect(); + assert!( + named.iter().any(|(l, _)| *l == Some("Q1")) && named.iter().any(|(l, _)| *l == Some("Y25")), + "precondition: a quarter run and a folded year run: {named:?}" + ); + let expected: Vec = model + .col_groups + .iter() + .flat_map(|g| { + let head = match &g.label { + Some(name) => format!("{} {name}", indicator(g.collapsed)), + None => String::new(), + }; + std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) + }) + .collect(); + assert_eq!( + &tui_header_cells(&lines[rule - levels - 1], &cache), + &expected, + "the TUI's group line and the browser's runs disagree" + ); +} + +/// The row-header comparison, row by row: both ends, one cache. +fn assert_row_headers_agree(wb: &mut Workbook) { + let (_app, cache) = whole_grid(wb); + + let tui = tui_row_headers(wb, &cache); let browser = browser_rows(&cache); assert_eq!( @@ -376,6 +479,7 @@ fn leading_group_rows() -> Workbook { m.add_category("Recipient").unwrap(); m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); m.add_item_in_group("Recipient", "Bob", "Fam").unwrap(); + m.active_view_mut().set_expand_depth("Recipient", 1); m.add_item("Recipient", "Carol").unwrap(); m.add_item("_Measure", "Revenue").unwrap(); m.active_view_mut().set_axis("Recipient", Axis::Row); @@ -397,7 +501,7 @@ fn a_group_header_first_on_its_axis_reaches_both_front_ends() { let mut wb = leading_group_rows(); if collapse { wb.active_view_mut() - .toggle_group_collapse("Recipient", "Fam"); + .toggle_group_collapse("Recipient", &["Fam"]); } let (_app, cache) = whole_grid(&mut wb); @@ -455,7 +559,7 @@ fn the_collapse_state_crosses_as_a_fact_not_a_glyph() { let mut wb = two_level_rows(); if collapse { wb.active_view_mut() - .toggle_group_collapse("Recipient", "Family"); + .toggle_group_collapse("Recipient", &["Family"]); } let (_app, cache) = whole_grid(&mut wb);