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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()][..]))
|
||||
|
||||
Reference in New Issue
Block a user