Pin the column group toggle to the cursor; guard run identity in e265's enumeration (improvise-uv9e, improvise-6u6s)
uv9e: ToggleGroupAtCursor { is_row: false } (gz) had no command-level
test, and a version reading col_group_for(0) passed the workspace. A new
test seats the cursor at (0, 2) with Month on the columns and requires
Q2. Reading column 0, or reading the row half of the cursor, both fail
it. The row half was already closed: the grid tests sit on row 2, and
injecting row_group_for(0) fails five of them. The browser's z is still
Upstream with no resolver arm (improvise-uln5), so it has no cursor
reading to test.
6u6s: the e265 enumeration could not see run identity. A pruner keying
runs by (level, category, group name) instead of header position
passed. The enumeration now:
- compares every level's run name and whether each survivor shares a
run with the previous one;
- adds three fixtures: a non-contiguous group, a level with no groups,
and two adjacent outer groups;
- builds each layout once per collapse subset and drives
prune_axis_items with a keep predicate per row set: 4,992 cases.
The name-keyed rival now fails at fixture 2, mask 0b101. open_runs is
the one reader of the markers. improvise-core's lib went from
0.86-0.92s to 0.17-0.23s on an Apple M4 Max under load average 39-77.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
@@ -296,6 +296,14 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -6237,3 +6237,26 @@ 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -177,6 +177,41 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Evidence: `context/test-evidence/command.md`.
|
||||
#[test]
|
||||
fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() {
|
||||
|
||||
@@ -348,34 +348,38 @@ fn remark(input: &OpenRuns, output: &mut Vec<Option<usize>>, kept: &mut Vec<Axis
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// Each data item's open runs, by level, as the position of the header that
|
||||
/// opened each: the one reading of the markers (improvise-e265).
|
||||
fn open_runs(items: &[AxisEntry]) -> Vec<Vec<Option<usize>>> {
|
||||
let mut open: Vec<Option<usize>> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for (at, entry) in items.iter().enumerate() {
|
||||
match entry {
|
||||
AxisEntry::GroupHeader { level, .. } => {
|
||||
open_at(&mut open, *level, Some((at, entry.clone())))
|
||||
open.truncate(*level);
|
||||
open.resize(*level, None);
|
||||
open.push(Some(at));
|
||||
}
|
||||
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,
|
||||
AxisEntry::GroupEnd { level } => open.truncate(*level),
|
||||
AxisEntry::DataItem { .. } => out.push(open.clone()),
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 runs = open_runs(items);
|
||||
let at = runs.get(idx)?.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,
|
||||
}
|
||||
}
|
||||
|
||||
/// One category constraining which records are visible: what it is fixed to,
|
||||
@@ -2059,16 +2063,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 +2092,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 +2155,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 +2165,106 @@ 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, pruned_runs) = (open_runs(full), open_runs(pruned));
|
||||
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 +2272,40 @@ 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())));
|
||||
}
|
||||
|
||||
/// 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()][..]))
|
||||
|
||||
Reference in New Issue
Block a user