Merge pull request #263 from fiddlerwoaroof/fix/uv9e-toggle-group-cursor

Pin the column group toggle to the cursor; guard run identity in e265's enumeration (uv9e, 6u6s)
This commit is contained in:
Ed Langley
2026-09-23 23:02:48 -07:00
committed by GitHub
5 changed files with 438 additions and 135 deletions
+11
View File
@@ -296,6 +296,17 @@ closes the deeper runs innermost-out before it touches a level, as
equivalent but not equal (`End0` where the input has `End1, End0`), and the
enumeration test asserts equality whenever every row survives.
**How it is tested** (improvise-6u6s). An enumeration compares each
surviving row's whole open-run stack, and whether it shares a run with the
previous survivor, pruned against unpruned. It runs over five fixtures,
including a non-contiguous group, a level with no groups, and two adjacent
outer groups. Without the non-contiguous fixture, a pruner that keyed runs by
name instead of header position passed every test. `open_runs` is the one
reading of the markers: `innermost_group` and the tests both use it. It is a
lazy iterator, so a single lookup (`z`, which asks about one row) walks the axis
only up to that row, rather than building and cloning a stack for every row of
it (improvise-0r01).
A header's level also names its category (`cats[level]`) on the wire, where
`FrameGroup` carries `level` and not the category (improvise-7xyx removed that
field): see `context/code/protocol.md` §3a.
+44
View File
@@ -6237,3 +6237,47 @@ folds `Q1`, the innermost group, and not `Upper`. `z` on `North/Mar` folds
`Upper`, and not `Q1`. The flat-membership rival fails it: `z` on `North/Mar`
found no group and did nothing. It is not a lockstep test, because a browser's
`z` is still a no-op (improvise-uln5).
### a_column_toggle_names_the_group_under_the_cursors_column
improvise-uv9e. `ToggleGroupAtCursor { is_row: false }`
(`toggle-col-group-under-cursor`, bound to `gz`) had no command-level test. A
version that read `col_group_for(0)` passed the whole workspace: 1,946 passed,
0 failed, with that rival injected. The fixture puts `Month`, with `Q1 = {Jan,
Feb}` and `Q2 = {Apr, May}`, on the columns and seats the cursor at `(0, 2)`,
on `Apr`. That is off the origin on the axis the toggle reads, and on the origin
of the other. The command must name `Q2`, and applying it must fold `Q2` and
leave `Q1`.
Rivals, each injected into `ToggleGroupAtCursor::execute` and restored, over
the whole workspace:
- `col_group_for(0)`: only this test fails;
- `col_group_for(ctx.selected.0)`, the row half of the cursor: only this test
fails.
The **row** half of improvise-uv9e was already closed when this landed. The
ticket was written at `a4910eb4`, when every row test sat at `(0, 0)`. Since
then, the grid tests seat the cursor on `Apr` (row 2). Injecting
`row_group_for(0)` fails five of them, plus `z_on_a_row_past_an_inner_run_folds_the_outer_group`
from improvise-e265.
### a_column_collapse_lands_on_the_cursors_own_run_and_keeps_its_row
improvise-pb30. `gz` (`ToggleGroupAtCursor { is_row: false }`) puts the cursor
on the collapsed group afterwards, through `landing` and `place_cursor`. The
column arm of both was untested: three rivals passed all 1948 tests. The
fixture puts two column levels (`Region` over `Month`) and a two-item row
category in place. The cursor sits at `(1, 7)`, which is South/May, the tail of
South's `Q2` run, and off the origin on both axes. The test asserts:
- the collapsed column frame;
- that the cursor lands on `(1, 5)`, South's `Q2`, on the same row.
Rivals, each injected and run over the whole workspace. Each fails this test
and nothing else:
- **`place_cursor`'s column arm swapped** (`(at, ctx.selected.0)`): the cursor
lands transposed.
- **`landing` returning `None` for columns**: the cursor is left where the
toggle's reframe put it.
- **`landing` reading `col_tuple(0)`**: the prefix is North's, so the cursor
lands on North's `Q2`. This one needs the two column levels. With one level,
the prefix is empty and every column reads the same.
+75 -25
View File
@@ -1512,41 +1512,68 @@ are each compared with the same document with Month on `Axis::None`. At
cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`).
This test fails.
### pruning_does_not_move_a_surviving_row_to_another_group
### pruning_keeps_every_surviving_rows_runs_at_every_level
improvise-e265. On a two-level grouped axis, `prune_empty` used to change a
surviving row's group. Pruning the inner header dropped its `GroupEnd` too, and
under the flat reading the ungrouped inner item then joined the outer group.
The test enumerates every funded-cell mask and every collapse subset of two
fixtures:
- `Region` over `Month` (two outer items, four inner, three groups);
- `Region` over `Month` over `Kind` (three levels).
improvise-e265, strengthened by improvise-6u6s. On a two-level grouped axis,
`prune_empty` used to change a surviving row's group. Pruning the inner header
dropped its `GroupEnd` too, and under the flat reading the ungrouped inner item
then joined the outer group.
That is about 4,000 cases in both prune states, built once in a `OnceLock` for
both enumerating tests. For every surviving row it asserts that the innermost
group (`innermost_group`, which `row_group_for` is) is the same pruned as
unpruned. When every row survives
it also asserts the pruned `row_items` equal the unpruned ones, marker for
marker.
**What it asserts.** For every surviving row and at every level:
- the run the row is in names the same `(category, group)` pruned as unpruned
(the whole open-run stack, from `open_runs`, not only the innermost);
- whether it is in the **same run** as the previous survivor is the same
answer pruned as unpruned, where "the same run" means the same header entry.
Failed at base 202e7eac on `mask 0b100` (only `North/Mar` funded): pruned said
`Upper`, unpruned said `None`. Rivals, each injected and restored:
- a flat `innermost_group` (every marker read at level 0) fails it;
- a pruner that owes no `GroupEnd` (u6gc's shape: a run whose closing header
was pruned is left open) fails it;
- a pruner that keeps headers as they arrive fails it.
When every row survives, the pruned entries must equal the unpruned ones,
marker for marker.
**The fixtures** are five, each under every collapse subset of its groups:
- `Region` over `Month`;
- three levels, `Region` over `Month` over `Kind`;
- `Month = [Jan in Q1, Mar, Feb in Q1]`, a non-contiguous group, so one parent
holds two `Q1` runs (improvise-h0iv);
- a middle level with no groups at all (`Kind` between `Region` and `Month`);
- two outer groups side by side (`North` in `Upper`, `South` in `Lower`).
**How it is built** (improvise-6u6s). Each fixture's layout is built once per
collapse subset, with no cells, so every row is present. `prune_axis_items` is
then called directly with a keep predicate for every non-empty set of rows.
That makes 4,992 cases. The first version built two `Workbook`s per funded-cell
mask, about 4,000 cases, and put `improvise-core`'s lib at 0.86–0.92s on an
Apple M4 Max (16 cores) under load average 39–77, against about 0.16s without
it. The reviewer measured 0.94–2.46s under heavier load, once past the 2s
tripwire. Now the lib runs in 0.17–0.23s on the same machine, against 0.16–0.18s
with the module skipped. `prune_empty` is still covered, because it calls the
same pruner, and `prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group`
pins that wiring.
**Red and rivals.** The e265 form failed at base 202e7eac, on `mask 0b100` with
only `North/Mar` funded: pruned said `Upper`, unpruned said `None`. Rivals, each
injected and restored:
- a flat reader (every marker read at level 0) fails it;
- a pruner that owes no `GroupEnd` fails it (u6gc's shape: a run whose closing
header was pruned is left open);
- a pruner that keeps headers as they arrive fails it;
- **runs keyed by `(level, category, group name)` instead of header position**
fails it, at `fixture 2, mask 0b101, collapsed []: run identity at level 1,
row 1`. Pruning `Mar` must leave North's two `Q1` runs as two, and the rival
merges them.
That last rival passed every test in #250 (improvise-6u6s). Those fixtures had
no non-contiguous group and compared only the innermost group, so they could not
see a merged run.
The equality half was added after the first version of `remark` emitted `End0`
where the input had `End1, End0`. That output was equivalent but not equal, and
the new assertion caught it.
the assertion caught it.
### a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker
improvise-e265's other property, over the same enumeration. Every kept header
improvise-e265's other property, over the same 4,992 cases. Every kept header
must be followed, past any deeper headers, by a data item, and the axis must
not end on a `GroupEnd`. Both failed at base 202e7eac: `mask 0b1` left a
trailing `GroupEnd`, and the ticket counted the header-over-nothing shape on
main before #200.
not end on a `GroupEnd`. At base 202e7eac, `mask 0b1` left a trailing
`GroupEnd`. The ticket counted the header-over-nothing shape on main before #200.
Rivals:
- a pruner that keeps headers as they arrive fails the first half;
@@ -1558,6 +1585,14 @@ emitted only before an item. So the obvious rival, deleting a trailing-marker
strip, has nothing to delete. An earlier draft had that strip, and it was dead
code.
### prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group
The enumeration drives `prune_axis_items` directly, so this test pins that
`prune_empty` reaches it. With only `North/Mar` funded, the pruned grid has one
row, and it is in `Upper`. It is a wiring test, not a discriminating one: #200's
pruner also answered `Upper` here. What it catches is a `prune_empty` that
stops pruning (the row count) or a reader that loses the outer run.
### a_row_after_an_inner_groups_end_is_still_in_the_outer_group
The user's ruling on improvise-e265, at the layout: membership is per level,
@@ -1569,3 +1604,18 @@ folded:
Failed at base 202e7eac on the second assertion: the flat reading said `None`.
The flat-membership rival fails it again.
### a_lookup_does_not_read_past_its_row
improvise-0r01, which is ChatGPT Codex's P2 on #263. `innermost_group` built
`open_runs` for the whole axis, cloning a level stack per row, to answer for one
row on every `z` keypress. `open_runs` is now lazy and the lookup takes
`.nth(idx)`.
The walk's extent cannot be seen through a well-formed axis, so the test builds
an axis whose second header is unreadable on purpose: `level: usize::MAX`, which
`Vec::resize` cannot allocate. A lookup for row 0 must answer `Upper` without
reaching it.
Rival: collecting `open_runs` before `.nth(idx)`, which is the eager shape. It
fails with `capacity overflow`, and the other 335 core tests pass.
@@ -177,6 +177,100 @@ mod tests {
);
}
/// The column twin had no command-level test: an `is_row: false` toggle that
/// read column 0 passed the workspace (improvise-uv9e). Evidence file.
#[test]
fn a_column_toggle_names_the_group_under_the_cursors_column() {
use crate::command::key::{KeyCode, KeyModifiers};
use crate::ui::app::App;
use crate::view::Axis;
let mut wb = two_groups_on_the_row_axis();
wb.active_view_mut().set_axis("Month", Axis::Column);
wb.active_view_mut().set_axis("Type", Axis::Row);
let mut app = App::new(wb, None);
app.rebuild_layout();
// Row 0 and column 2: off the origin on the axis the toggle reads, and on
// the origin of the other, so reading the wrong half is seen too.
app.cursor_mut().selected = (0, 2);
assert_eq!(app.layout.col_label(2), "Apr", "the cursor sits on Apr");
let effects = {
let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE);
ToggleGroupAtCursor { is_row: false }.execute(&ctx)
};
let dbg = effects_debug(&effects);
assert!(
dbg.contains(r#"category: "Month", group: "Q2""#),
"Apr is in Q2, got: {dbg}"
);
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", "Q1"),
"the neighbour stays"
);
}
/// The column twin of the row landing tests: two column levels, the cursor off
/// both origins (improvise-pb30). Evidence: `context/test-evidence/command.md`.
#[test]
fn a_column_collapse_lands_on_the_cursors_own_run_and_keeps_its_row() {
use crate::command::key::{KeyCode, KeyModifiers};
use crate::ui::app::App;
use crate::view::Axis;
let mut wb = crate::workbook::Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_category("Month").unwrap();
wb.add_category("Type").unwrap();
wb.add_item("Region", "North").unwrap();
wb.add_item("Region", "South").unwrap();
for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] {
wb.add_item_in_group("Month", month, quarter).unwrap();
}
wb.add_item("Type", "Food").unwrap();
wb.add_item("Type", "Tools").unwrap();
wb.active_view_mut().set_axis("Region", Axis::Column);
wb.active_view_mut().set_axis("Month", Axis::Column);
wb.active_view_mut().set_axis("Type", Axis::Row);
let mut app = App::new(wb, None);
app.rebuild_layout();
assert_eq!(app.layout.col_cats, ["Region", "Month"], "Month is inner");
app.cursor_mut().selected = (1, 7);
assert_eq!(
app.layout.col_tuple(7),
Some(&["South".into(), "May".into()][..]),
"the cursor sits on South/May, the tail of South's Q2 run"
);
let effects = {
let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE);
ToggleGroupAtCursor { is_row: false }.execute(&ctx)
};
app.apply_effects(effects);
let cols: Vec<String> = (0..app.layout.col_count())
.map(|c| app.layout.col_label(c))
.collect();
assert_eq!(
cols,
[
"North/Jan",
"North/Feb",
"North/Q2",
"South/Jan",
"South/Feb",
"South/Q2"
]
);
assert_eq!(
app.cursor().selected,
(1, 5),
"South's Q2 column, on the row the cursor was on"
);
}
/// Evidence: `context/test-evidence/command.md`.
#[test]
fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
+214 -110
View File
@@ -348,34 +348,44 @@ fn remark(input: &OpenRuns, output: &mut Vec<Option<usize>>, kept: &mut Vec<Axis
}
}
/// Each data item's open runs, by level, as the position of the header that
/// opened each: the one reading of the markers, lazy so a lookup stops (0r01).
fn open_runs(items: &[AxisEntry]) -> impl Iterator<Item = Vec<Option<usize>>> + '_ {
let mut open: Vec<Option<usize>> = Vec::new();
items
.iter()
.enumerate()
.filter_map(move |(at, entry)| match entry {
AxisEntry::GroupHeader { level, .. } => {
open.truncate(*level);
open.resize(*level, None);
open.push(Some(at));
None
}
AxisEntry::GroupEnd { level } => {
open.truncate(*level);
None
}
AxisEntry::DataItem { .. } => Some(open.clone()),
})
}
/// The innermost run open at the data item `idx` — the group nearest it, which
/// is what `z` toggles (improvise-e265's ruling).
fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> {
let mut open: OpenRuns = Vec::new();
let mut data_idx = 0usize;
for (at, entry) in items.iter().enumerate() {
match entry {
AxisEntry::GroupHeader { level, .. } => {
open_at(&mut open, *level, Some((at, entry.clone())))
}
AxisEntry::GroupEnd { level } => open_at(&mut open, *level, None),
AxisEntry::DataItem { .. } if data_idx == idx => {
return open.into_iter().rev().find_map(|run| match run {
Some((
_,
AxisEntry::GroupHeader {
cat_name,
group_name,
..
},
)) => Some((cat_name, group_name)),
_ => None,
});
}
AxisEntry::DataItem { .. } => data_idx += 1,
}
let at = open_runs(items)
.nth(idx)?
.into_iter()
.rev()
.find_map(|run| run)?;
match &items[at] {
AxisEntry::GroupHeader {
cat_name,
group_name,
..
} => Some((cat_name.clone(), group_name.clone())),
AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None,
}
None
}
/// One category constraining which records are visible: what it is fixed to,
@@ -2059,16 +2069,15 @@ mod tests {
}
/// Group membership on a multi-level axis is per level, and pruning cannot move
/// it (improvise-e265). `context/test-evidence/core.md`.
/// it (improvise-e265, improvise-6u6s). `context/test-evidence/core.md`.
#[cfg(test)]
mod group_membership {
use super::{AxisEntry, GridLayout, data_items, innermost_group};
use crate::model::cell::{CellKey, CellValue};
use super::{AxisEntry, GridLayout, data_items, open_runs, prune_axis_items};
use crate::view::Axis;
use crate::workbook::Workbook;
use std::sync::OnceLock;
type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]);
type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]);
const TWO: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
@@ -2089,24 +2098,55 @@ mod group_membership {
("Kind", &[("Food", Some("Goods")), ("Tools", None)]),
];
/// Every row-coordinate tuple of `levels`, outermost first.
fn tuples(levels: &[Level]) -> Vec<Vec<String>> {
levels.iter().fold(vec![vec![]], |acc, (_, items)| {
acc.iter()
.flat_map(|t| {
items.iter().map(move |(item, _)| {
let mut t = t.clone();
t.push(item.to_string());
t
})
})
.collect()
})
}
/// `Q1` is not contiguous, so one parent holds two `Q1` runs (h0iv).
const NONCONTIGUOUS: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
(
"Month",
&[("Jan", Some("Q1")), ("Mar", None), ("Feb", Some("Q1"))],
),
];
/// `levels` on Row, one column, the tuples `mask` sets funded, the groups
/// `collapsed` names folded.
fn book(levels: &[Level], mask: u32, collapsed: &[(&str, &str)], prune: bool) -> Workbook {
/// A middle level with no groups at all.
const HOLE: &[Level] = &[
("Region", &[("North", Some("Upper")), ("South", None)]),
("Kind", &[("Food", None), ("Tools", None)]),
("Month", &[("Jan", Some("Q1")), ("Mar", None)]),
];
/// Two outer groups side by side, with no ungrouped item between them.
const ADJACENT: &[Level] = &[
(
"Region",
&[
("North", Some("Upper")),
("South", Some("Lower")),
("West", None),
],
),
("Month", &[("Jan", Some("Q1")), ("Mar", None)]),
];
const FIXTURES: &[Fixture] = &[
(
TWO,
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
),
(
THREE,
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
),
(NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]),
(HOLE, &[("Region", "Upper"), ("Month", "Q1")]),
(
ADJACENT,
&[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")],
),
];
/// `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 {
let mut wb = Workbook::new("T");
for (cat, items) in levels {
wb.add_category(*cat).unwrap();
@@ -2121,22 +2161,9 @@ mod group_membership {
wb.add_category("Col").unwrap();
wb.add_item("Col", "X").unwrap();
wb.active_view_mut().set_axis("Col", Axis::Column);
for (i, tuple) in tuples(levels).iter().enumerate() {
if mask & (1 << i) != 0 {
let mut coords: Vec<(String, String)> = levels
.iter()
.zip(tuple)
.map(|((cat, _), item)| (cat.to_string(), item.clone()))
.collect();
coords.push(("Col".into(), "X".into()));
wb.model
.set_cell(CellKey::new(coords), CellValue::Number(1.0));
}
}
for (cat, group) in collapsed {
wb.active_view_mut().toggle_group_collapse(cat, group);
}
wb.active_view_mut().prune_empty = prune;
wb
}
@@ -2144,70 +2171,107 @@ mod group_membership {
GridLayout::with_frozen_records(&wb.model, wb.active_view(), None)
}
/// One enumerated case: its name, then the unpruned and pruned row entries.
type Case = (String, Vec<AxisEntry>, Vec<AxisEntry>);
/// One case: its name, the kept-row mask, and the unpruned and pruned rows.
struct Case {
name: String,
mask: u32,
full: Vec<AxisEntry>,
pruned: Vec<AxisEntry>,
}
/// Every (mask, collapse subset) of `levels`, unpruned and pruned.
fn every_case(levels: &[Level], groups: &[(&str, &str)]) -> Vec<Case> {
let cells = tuples(levels).len();
/// Every collapse subset of every fixture, one layout each, pruned by every
/// non-empty set of kept rows. The layout is the cost, so it is built once.
fn cases() -> Vec<Case> {
let mut out = Vec::new();
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();
for mask in 1..(1u32 << cells) {
let case = format!("mask {mask:#b}, collapsed {collapsed:?}");
let mut wb = book(levels, mask, &collapsed, false);
let full = layout(&wb).row_items;
wb.active_view_mut().prune_empty = true;
out.push((case, full, layout(&wb).row_items));
for (fixture, (levels, groups)) 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 rows = data_items(&full).count();
for mask in 1..(1u32 << rows) {
let mut pruned = full.clone();
prune_axis_items(&mut pruned, |i| mask & (1 << i) != 0);
out.push(Case {
name: format!("fixture {fixture}, mask {mask:#b}, collapsed {collapsed:?}"),
mask,
full: full.clone(),
pruned,
});
}
}
}
out
}
/// Built once for both enumerating tests: the enumeration is the cost.
fn cases() -> &'static [Case] {
static CASES: OnceLock<Vec<Case>> = OnceLock::new();
CASES.get_or_init(|| {
let mut all = every_case(
TWO,
&[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")],
);
all.extend(every_case(
THREE,
&[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")],
));
all
})
/// The `(category, group)` a run's header names.
fn named(items: &[AxisEntry], run: Option<usize>) -> Option<(&str, &str)> {
match &items[run?] {
AxisEntry::GroupHeader {
cat_name,
group_name,
..
} => Some((cat_name, group_name)),
AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None,
}
}
fn at(runs: &[Option<usize>], level: usize) -> Option<usize> {
runs.get(level).copied().flatten()
}
#[test]
fn pruning_does_not_move_a_surviving_row_to_another_group() {
for (case, full, pruned) in cases() {
let full_rows: Vec<&Vec<String>> = data_items(full).collect();
let pruned_rows: Vec<&Vec<String>> = data_items(pruned).collect();
if pruned_rows.len() == full_rows.len() {
assert_eq!(pruned, full, "{case}: nothing pruned");
fn pruning_keeps_every_surviving_rows_runs_at_every_level() {
for case in cases() {
let Case {
name,
mask,
full,
pruned,
} = &case;
let kept: Vec<usize> = (0..data_items(full).count())
.filter(|i| mask & (1 << i) != 0)
.collect();
let full_runs: Vec<_> = open_runs(full).collect();
let pruned_runs: Vec<_> = open_runs(pruned).collect();
assert_eq!(pruned_runs.len(), kept.len(), "{name}: the survivors");
if kept.len() == full_runs.len() {
assert_eq!(pruned, full, "{name}: nothing pruned");
}
for (r, tuple) in pruned_rows.iter().enumerate() {
let u = full_rows
.iter()
.position(|t| t == tuple)
.expect("a pruned row is a row of the unpruned grid");
assert_eq!(
innermost_group(pruned, r),
innermost_group(full, u),
"{case}: row {tuple:?}\n pruned {pruned:?}\n full {full:?}",
);
let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0);
for (r, &u) in kept.iter().enumerate() {
for level in 0..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:?}"
);
if r == 0 {
continue;
}
let same = |runs: &[Vec<Option<usize>>], 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:?}"
);
}
}
}
}
#[test]
fn a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker() {
for (case, _, items) in cases() {
for Case {
name,
pruned: items,
..
} in cases()
{
for (i, entry) in items.iter().enumerate() {
if let AxisEntry::GroupHeader { .. } = entry {
let next = items[i + 1..]
@@ -2215,24 +2279,64 @@ mod group_membership {
.find(|e| !matches!(e, AxisEntry::GroupHeader { .. }));
assert!(
matches!(next, Some(AxisEntry::DataItem { .. })),
"{case}: header {entry:?} has no member in {items:?}"
"{name}: header {entry:?} has no member in {items:?}"
);
}
}
assert!(
!matches!(items.last(), Some(AxisEntry::GroupEnd { .. })),
"{case}: trailing marker in {items:?}"
"{name}: trailing marker in {items:?}"
);
}
}
/// `prune_empty` reaches the same pruner from funded cells: the ticket's own
/// shape, only `North/Mar` funded, keeps `North/Mar` in `Upper`.
#[test]
fn prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group() {
let mut wb = book(TWO, &[]);
let cell = crate::model::cell::CellKey::new(vec![
("Region".into(), "North".into()),
("Month".into(), "Mar".into()),
("Col".into(), "X".into()),
]);
wb.model
.set_cell(cell, crate::model::cell::CellValue::Number(1.0));
wb.active_view_mut().prune_empty = true;
let l = layout(&wb);
assert_eq!(l.row_count(), 1);
assert_eq!(l.row_group_for(0), Some(("Region".into(), "Upper".into())));
}
/// A lookup reads only up to its own row (improvise-0r01). The header after
/// row 0 is unreadable on purpose: reaching it would try to allocate usize::MAX.
#[test]
fn a_lookup_does_not_read_past_its_row() {
let items = vec![
AxisEntry::GroupHeader {
level: 0,
cat_name: "Region".into(),
group_name: "Upper".into(),
},
AxisEntry::item(vec!["North".into()]),
AxisEntry::GroupHeader {
level: usize::MAX,
cat_name: "Nowhere".into(),
group_name: "Unreachable".into(),
},
AxisEntry::item(vec!["South".into()]),
];
assert_eq!(
super::innermost_group(&items, 0),
Some(("Region".into(), "Upper".into()))
);
}
/// The user's ruling on improvise-e265: membership is per level, and `z`
/// names the innermost open group — the one nearest the cursor.
#[test]
fn a_row_after_an_inner_groups_end_is_still_in_the_outer_group() {
// North/Jan (0) and North/Mar (2) funded; nothing is pruned or folded.
let wb = book(TWO, 0b101, &[], false);
let l = layout(&wb);
let l = layout(&book(TWO, &[]));
let at = |tuple: [&str; 2]| {
(0..l.row_count())
.find(|&r| l.row_tuple(r) == Some(&[tuple[0].into(), tuple[1].into()][..]))