feat: nested group runs reach the wire, and views persist expanded(N) and collapsed folds (improvise-felo.6)
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:
committed by
Edward Langley
parent
8dad4b7476
commit
e497aef110
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ®);
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
})
|
||||
.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),
|
||||
@@ -3425,8 +3437,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
|
||||
|
||||
@@ -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 {
|
||||
@@ -3875,10 +3881,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,
|
||||
@@ -3887,10 +3894,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"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<usize>,
|
||||
) -> AxisFrame {
|
||||
fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range<usize>) -> 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<FrameEntry> = Vec::new();
|
||||
let mut pending: Vec<FrameEntry> = 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<Option<FrameEntry>> = 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<Option<(String, String)>> = frame
|
||||
.groups()
|
||||
.into_iter()
|
||||
.map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone())))
|
||||
.collect();
|
||||
let from_layout: Vec<Option<(String, String)>> = (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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -231,11 +231,6 @@ impl Category {
|
||||
chain
|
||||
}
|
||||
|
||||
/// How many ancestors `group` has: 0 for a root.
|
||||
pub fn group_depth(&self, group: &str) -> usize {
|
||||
self.group_chain(group).len() - 1
|
||||
}
|
||||
|
||||
/// The groups an item sits in, root first; empty for an ungrouped item.
|
||||
pub fn item_chain(&self, item: &str) -> Vec<&str> {
|
||||
self.items
|
||||
@@ -376,12 +371,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_items_chain_runs_root_first_and_depth_counts_ancestors() {
|
||||
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_depth("2024"), 0);
|
||||
assert_eq!(c.group_depth("Q2-2024"), 1);
|
||||
assert_eq!(c.group_chain("2024"), vec!["2024"]);
|
||||
assert_eq!(c.group_chain("Q2-2024"), vec!["2024", "Q2-2024"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,7 +390,10 @@ mod tests {
|
||||
"{cycle:?}"
|
||||
);
|
||||
assert!(!cycle.contains(&"Q1-2025"), "{cycle:?}");
|
||||
assert!(c.group_chain("Q1-2024").len() <= 3, "a walk through a cycle stops");
|
||||
assert!(
|
||||
c.group_chain("Q1-2024").len() <= 3,
|
||||
"a walk through a cycle stops"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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<String> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -977,7 +977,9 @@ impl<'a> CategoryRuns<'a> {
|
||||
depth,
|
||||
cat_name: self.cat_name.to_string(),
|
||||
group_name: group.to_string(),
|
||||
collapsed: self.view.is_group_collapsed(self.cat_name, &chain[..=depth]),
|
||||
collapsed: self
|
||||
.view
|
||||
.is_group_collapsed(self.cat_name, &chain[..=depth]),
|
||||
});
|
||||
self.open.push(group);
|
||||
}
|
||||
@@ -2266,7 +2268,6 @@ mod tests {
|
||||
wb.add_item("Type", "Food").unwrap();
|
||||
for (month, quarter, n) in [("Jan", "Q1", 3.0), ("Feb", "Q1", 4.0), ("Apr", "Q2", 100.0)] {
|
||||
wb.add_item_in_group("Month", month, quarter).unwrap();
|
||||
wb.active_view_mut().set_expand_depth("Month", 1);
|
||||
wb.model.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Month".into(), month.into()),
|
||||
@@ -2275,6 +2276,7 @@ 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");
|
||||
@@ -2377,11 +2379,7 @@ 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)],
|
||||
&'a [Parent<'a>],
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -2497,8 +2495,14 @@ mod group_membership {
|
||||
wb.active_view_mut().set_expand_depth(cat, 8);
|
||||
}
|
||||
for (cat, group) in collapsed {
|
||||
let chain: Vec<String> = wb.model.category(cat).unwrap().group_chain(group)
|
||||
.into_iter().map(String::from).collect();
|
||||
let chain: Vec<String> = 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);
|
||||
}
|
||||
@@ -2517,8 +2521,8 @@ mod group_membership {
|
||||
None => wb.add_item(cat, *item).unwrap(),
|
||||
};
|
||||
}
|
||||
wb.active_view_mut().set_expand_depth(cat, 1);
|
||||
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();
|
||||
@@ -2758,7 +2762,10 @@ mod group_membership {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. }))
|
||||
.count();
|
||||
assert!(deep >= 4, "Q1, Q2, Q1 again and Q3 under each region: {deep}");
|
||||
assert!(
|
||||
deep >= 4,
|
||||
"Q1, Q2, Q1 again and Q3 under each region: {deep}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2896,7 +2903,11 @@ mod hierarchy {
|
||||
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, "Q1-2024"),
|
||||
Some(11.0),
|
||||
"Jan and the late Feb"
|
||||
);
|
||||
assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0));
|
||||
}
|
||||
|
||||
@@ -2906,7 +2917,8 @@ mod hierarchy {
|
||||
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"]);
|
||||
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));
|
||||
@@ -2941,9 +2953,7 @@ mod hierarchy {
|
||||
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")
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,5 @@ pub use layout::{
|
||||
records_steps, synthetic_record_info,
|
||||
};
|
||||
pub use types::{
|
||||
DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View,
|
||||
unused_view_name,
|
||||
DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name,
|
||||
};
|
||||
|
||||
@@ -688,10 +688,16 @@ mod tests {
|
||||
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(1)));
|
||||
assert_eq!(
|
||||
v.group_fold("Time", "Q1-2024"),
|
||||
Some(GroupFold::Expanded(1))
|
||||
);
|
||||
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");
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(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"));
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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<PFold>,
|
||||
}
|
||||
|
||||
/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category
|
||||
/// form, which only `expanded` has.
|
||||
struct PFold {
|
||||
category: String,
|
||||
group: Option<String>,
|
||||
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<String>,
|
||||
},
|
||||
/// One view gives one group (or one category's depth) two different folds.
|
||||
FoldConflict {
|
||||
view: String,
|
||||
category: String,
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<PFold> {
|
||||
let mut depth = 1usize;
|
||||
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<PView> {
|
||||
let mut inner = pair.into_inner();
|
||||
let mut pv = PView {
|
||||
@@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
|
||||
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<PView> {
|
||||
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),
|
||||
held if held != *n => 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<String> = (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::<Refusal>().is_some());
|
||||
prop_assert!(result.is_ok() || refused,
|
||||
"Generated file failed to parse:\n{}\nError: {}",
|
||||
file, result.unwrap_err());
|
||||
}
|
||||
|
||||
@@ -267,10 +267,195 @@ 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(1))
|
||||
);
|
||||
assert_eq!(v.group_fold("Date", "2025"), Some(GroupFold::Expanded(3)));
|
||||
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::<Refusal>(),
|
||||
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::<Refusal>().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::<Refusal>() {
|
||||
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 +521,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 +547,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UncheckedAxisFrame> 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<Option<&FrameGroup>> {
|
||||
let mut open: Vec<Option<&FrameGroup>> = 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<Option<&str>> = 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()]),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,107 @@ 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(2));
|
||||
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<String> = 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<String> = 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 +475,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 +497,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 +555,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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user