use std::collections::{HashMap, HashSet}; use std::ops::ControlFlow; use std::rc::Rc; use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY}; use crate::model::cell::{CellKey, CellValue}; use crate::model::types::FoldMemo; use crate::model::{Aggregation, Model}; use crate::view::{Axis, View}; /// The trailing column that creates a new measure: typing a name into it /// adds that item to `_Measure` (improvise-c5v). pub const NEW_MEASURE_COL: &str = "+"; /// The column that creates a new **category** (improvise-2zq). Its position /// — end of the category block, not of the grid — is argued in core.md §6. pub const NEW_CATEGORY_COL: &str = "+cat"; /// Extract (record_index, column_index) from a synthetic records-mode key; /// `None` for a pivot key. `_Dim` carries an index, not a label (§6). pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, usize)> { let idx: usize = key.get(INDEX_CATEGORY)?.parse().ok()?; let col: usize = key.get(DIM_CATEGORY)?.parse().ok()?; Some((idx, col)) } /// One row of the records grid: the coordinates its cells share (never /// `_Measure`) and the measure values against them. Wide format (§6). #[derive(Debug, Clone, PartialEq)] pub struct RecordRow { pub coords: CellKey, pub values: Vec<(String, CellValue)>, } impl RecordRow { /// Collapse loose cells into wide rows, one per coordinate tuple, in /// first-appearance order so the store's order survives (§8). pub fn group(cells: impl IntoIterator) -> Vec { let mut rows: Vec = Vec::new(); for (key, value) in cells { let measure = key.get(MEASURE_CATEGORY).map(String::from); let coords = key.without(MEASURE_CATEGORY); let row = match rows.iter().position(|r| r.coords == coords) { Some(i) => &mut rows[i], None => { rows.push(RecordRow { coords, values: Vec::new(), }); rows.last_mut().expect("just pushed") } }; // A cell with no `_Measure` still anchors a row (that is how // `add-record-row` seeds one) but holds no column value. if let Some(measure) = measure { row.values.push((measure, value)); } } rows } /// The value recorded for `measure` in this row, if any. pub fn value(&self, measure: &str) -> Option<&CellValue> { self.values .iter() .find(|(m, _)| m == measure) .map(|(_, v)| v) } /// The model cell holding this row's `measure` value. pub fn cell_key(&self, measure: &str) -> CellKey { self.coords.clone().with(MEASURE_CATEGORY, measure) } } /// The key a drill into `key` actually constrains by: a formula target is /// stripped, or `matching_cells` returns nothing (gotcha 10). pub fn drill_constraint(model: &Model, key: &CellKey) -> CellKey { let names_a_formula = key.get(MEASURE_CATEGORY).is_some_and(|target| { model.formulas().iter().any(|f| { crate::model::category::CategoryKind::of_name(&f.target_category).is_measure() && f.target == target }) }); match names_a_formula { true => key.without(MEASURE_CATEGORY), false => key.clone(), } } /// The frozen record list a drill into `constraint` shows. **One home**: a /// second derivation would freeze a different order — core.md §8, gotcha 18. pub fn drill_snapshot(model: &Model, constraint: &CellKey) -> Vec { let cells: Vec<(CellKey, CellValue)> = match constraint.0.is_empty() { true => model .data .iter_cells() .map(|(k, v)| (k, v.clone())) .collect(), false => model .data .matching_cells(&constraint.0) .into_iter() .map(|(k, v)| (k, v.clone())) .collect(), }; RecordRow::group(cells) } /// The records row `record` is, as the records grid groups it; empty when no /// cell stores it. The one lookup a rename by record uses (improvise-fdk1). pub fn record_row(model: &Model, record: &CellKey) -> RecordRow { drill_snapshot(model, record) .into_iter() .find(|row| &row.coords == record) .unwrap_or_else(|| RecordRow { coords: record.clone(), values: Vec::new(), }) } /// One step of a view configuration: **what** a view must be, with no answer /// to how a front end gets there. The model half of drill — core.md §8. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ViewStep { Axis { category: String, axis: Axis }, PageSelection { category: String, item: String }, } /// The records-mode spine: `_Index` on Row and `_Dim` on Column, which is /// what `GridLayout` auto-detects records mode by (§6). pub fn records_spine() -> Vec { vec![ ViewStep::Axis { category: INDEX_CATEGORY.to_string(), axis: Axis::Row, }, ViewStep::Axis { category: DIM_CATEGORY.to_string(), axis: Axis::Column, }, ] } /// The `_Records` view over `model`: the spine, and every other category /// aggregated away onto `Axis::None`. pub fn records_steps(model: &Model) -> Vec { let mut steps = records_spine(); let rest = model .categories .keys() .filter(|name| !crate::model::category::CategoryKind::of_name(name).is_index_or_dim()); steps.extend(rest.map(|name| ViewStep::Axis { category: name.clone(), axis: Axis::None, })); steps } /// The `_Drill` view a drill into `key` needs, given the categories the grid /// it was launched from was aggregating over. Coordinates fix on **Filter**. pub fn drill_steps(key: &CellKey, none_cats: &[String]) -> Vec { let mut steps = records_spine(); // A drilled coordinate is an immutable constraint of this drill, not a // dimension to page through (improvise-dz8, gotcha 27). for (cat, item) in &key.0 { steps.push(ViewStep::Axis { category: cat.clone(), axis: Axis::Filter, }); steps.push(ViewStep::PageSelection { category: cat.clone(), item: item.clone(), }); } // Previously-aggregated categories stay on `Axis::None` so they do not // filter records; they appear as columns. let fixed: std::collections::HashSet<&str> = key.0.iter().map(|(c, _)| c.as_str()).collect(); let free = none_cats .iter() .filter(|cat| !fixed.contains(cat.as_str()) && !cat.starts_with('_')); steps.extend(free.map(|cat| ViewStep::Axis { category: cat.clone(), axis: Axis::None, })); steps } /// What a records-mode column holds, resolved from the column index so a /// category and a measure sharing a name stay distinguishable. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum RecordCol { /// A coordinate of the row; editing it moves every measure cell in it. Category(String), /// A measure; editing it writes that one cell. Measure(String), /// The trailing column that creates a new measure. NewMeasure, /// The column that creates a new category, sitting where the column it /// creates will appear (improvise-2zq). NewCategory, } /// One entry on a grid axis. A `GroupHeader` is always visible so its label /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14). #[derive(Debug, Clone, PartialEq)] pub enum AxisEntry { /// Opens a run at `(level, depth)` — the category's place on the axis, then the /// group's depth in its hierarchy — and closes every run inside it (§6, §19). GroupHeader { level: usize, depth: usize, cat_name: String, group_name: String, /// The view's answer for this group, so no reader re-derives it. collapsed: bool, }, /// Ends the run at `(level, depth)` and every run inside it (improvise-gd83, /// e265). Nothing is drawn for it; it exists so membership is not inherited. GroupEnd { level: usize, depth: usize }, /// `collapsed` is not derivable from `tuple`: a group and an item may share a /// name. Set for a folded group and for everything expanded beneath one. DataItem { tuple: Vec, collapsed: bool }, } impl AxisEntry { /// An ordinary, uncollapsed data item. pub fn item(tuple: Vec) -> Self { AxisEntry::DataItem { tuple, collapsed: false, } } } /// The data-item tuples on an axis, skipping group headers. Every public /// `row`/`col` argument indexes this sequence, not the visual entry list. #[cfg(test)] fn data_items(items: &[AxisEntry]) -> impl Iterator> { items .iter() .filter_map(|e| match e { AxisEntry::DataItem { tuple, .. } => Some(tuple), AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }) .inspect(|_| DATA_ITEM_STEPS.with(|n| n.set(n.get() + 1))) } #[cfg(test)] thread_local! { /// Items `data_items` has yielded on this thread: the work a lookup did. static DATA_ITEM_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Entry `positions(items)[n]` is data item `n`: a lookup by data index is O(1), /// not a walk past every header before it (layout-cost.md). fn positions(items: &[AxisEntry]) -> Vec { items .iter() .enumerate() .filter_map(|(at, e)| matches!(e, AxisEntry::DataItem { .. }).then_some(at)) .collect() } /// The data item at `idx`, by the axis's position index. fn data_item<'a>(items: &'a [AxisEntry], pos: &[usize], idx: usize) -> Option<&'a Vec> { match items.get(*pos.get(idx)?)? { AxisEntry::DataItem { tuple, .. } => Some(tuple), AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, } } /// Whether the data item at `idx` folds a collapsed group. fn folds(items: &[AxisEntry], pos: &[usize], idx: usize) -> bool { match pos.get(idx).and_then(|at| items.get(*at)) { Some(AxisEntry::DataItem { collapsed, .. }) => *collapsed, Some(AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) | None => false, } } /// The data index of the first item in the run `cat`/`group` opens under /// `prefix` — where a toggle's cursor lands, collapsing or expanding (§14). fn run_start( cats: &[String], items: &[AxisEntry], cat: &str, group: &str, prefix: &[String], ) -> Option { let level = cats.iter().position(|c| c == cat)?; let mut data_idx = 0usize; let mut armed = false; for entry in items { match entry { AxisEntry::GroupHeader { cat_name, group_name, .. } => armed |= cat_name == cat && group_name == group, AxisEntry::GroupEnd { .. } => {} AxisEntry::DataItem { tuple, .. } => { if armed && tuple.get(..level) == Some(prefix) { return Some(data_idx); } armed = false; data_idx += 1; } } } None } /// The display label of the data item at `idx`, or empty when out of bounds. fn axis_label(items: &[AxisEntry], pos: &[usize], idx: usize) -> String { data_item(items, pos, idx) .map(|v| v.join("/")) .unwrap_or_default() } /// Visual index of the nth data entry (skipping group headers). fn data_index_to_visual(pos: &[usize], data_idx: usize) -> Option { pos.get(data_idx).copied() } /// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than". /// Across categories the level decides; within one, the depth (§19). pub type RunKey = (usize, usize); /// One open run: its key and the position of the header that opened it. pub type OpenRun = (RunKey, usize); /// Each open run, innermost last, with its header. A header at K replaces K /// and closes every deeper run; a `GroupEnd` at K closes K and deeper. type OpenRuns = Vec<(RunKey, usize, AxisEntry)>; /// Close every run at or inside `key`, then open `run` there if one is given. fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) { let outside = runs.partition_point(|(k, _, _)| *k < key); runs.truncate(outside); runs.extend(run.map(|(at, header)| (key, at, header))); } /// Drop the data items `keep` rejects, then re-mark the survivors so each keeps /// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265. fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { let mut kept: Vec = Vec::new(); // What the input has open, and what `kept` has open so far. let mut input: OpenRuns = Vec::new(); let mut output: Vec = Vec::new(); let mut data_idx = 0usize; for (at, entry) in items.drain(..).enumerate() { match entry { AxisEntry::GroupHeader { level, depth, .. } => { open_at(&mut input, (level, depth), Some((at, entry))) } AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None), AxisEntry::DataItem { .. } => { if keep(data_idx) { remark(&input, &mut output, &mut kept); kept.push(entry); } data_idx += 1; } } } *items = kept; } fn group_end((level, depth): RunKey) -> AxisEntry { AxisEntry::GroupEnd { level, depth } } /// Emit the markers that make `kept`'s open runs equal the input's: past the /// runs they share, end the deeper ones innermost out, then open what differs. fn remark(input: &OpenRuns, output: &mut Vec, kept: &mut Vec) { let shared = input .iter() .zip(output.iter()) .take_while(|((_, want, _), (_, have))| want == have) .count(); let want = input.get(shared); let have = output.get(shared).copied(); let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)] .into_iter() .flatten() .min() else { return; }; // Deeper runs end first, innermost out, as `expand_category` ends them. for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) { kept.push(group_end(*key)); } output.truncate(shared); let replaces = want.is_some_and(|w| w.0 == slot); if !replaces && have.is_some_and(|h| h.0 == slot) { kept.push(group_end(slot)); } for (key, at, header) in &input[shared..] { kept.push(header.clone()); output.push((*key, *at)); } } /// The one reading of the markers: each data item's open runs, innermost last, /// lent to `visit` — one stack, never copied (0r01). fn walk_open_runs( items: &[AxisEntry], mut visit: impl FnMut(&[OpenRun]) -> ControlFlow, ) -> Option { let mut open: Vec = Vec::new(); for (at, entry) in items.iter().enumerate() { match entry { AxisEntry::GroupHeader { level, depth, .. } => { open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))); open.push(((*level, *depth), at)); } AxisEntry::GroupEnd { level, depth } => { open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))) } AxisEntry::DataItem { .. } => { if let ControlFlow::Break(found) = visit(&open) { return Some(found); } } } } None } /// 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 data_idx = 0usize; let at = walk_open_runs(items, |open| match data_idx == idx { true => ControlFlow::Break(open.last().map(|(_, at)| *at)), false => { data_idx += 1; ControlFlow::Continue(()) } })??; match &items[at] { AxisEntry::GroupHeader { cat_name, group_name, .. } => Some((cat_name.clone(), group_name.clone())), AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None, } } /// One category constraining which records are visible: what it is fixed to, /// and — when the user may page it — what `[`/`]` cycle. core.md §7. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PageAxis { pub cat: String, pub selected: String, /// `Some` exactly for `Axis::Page`. A `Filter` axis constrains without /// being navigable, so a client can compose no cycle for one (gotcha 27). pub items: Option>, } /// The page axes as the plain coordinate pairs `matching_cells` and /// `CellKey::new` take. pub fn page_constraints(axes: &[PageAxis]) -> Vec<(String, String)> { axes.iter() .map(|p| (p.cat.clone(), p.selected.clone())) .collect() } impl PageAxis { /// The items to cycle and the index `selected` sits at, for the two /// callers that page. `None` where the axis is not navigable. pub fn cycle(&self) -> Option<(&[String], usize)> { let items = self.items.as_deref().filter(|i| !i.is_empty())?; let at = items.iter().position(|i| *i == self.selected).unwrap_or(0); Some((items, at)) } } /// The resolved 2-D layout of a view, and the one place the multi-dimensional /// model becomes the flat grid the renderer and the CSV exporter consume. pub struct GridLayout { pub row_cats: Vec, pub col_cats: Vec, pub page_coords: Vec, pub row_items: Vec, pub col_items: Vec, /// `positions(row_items)` / `positions(col_items)`: derived, so every writer of /// the items calls `reindex`, and nothing outside this module writes them. row_pos: Vec, col_pos: Vec, /// Categories on `Axis::None` — hidden, implicitly aggregated. pub none_cats: Vec, /// Records mode: one entry per row, grouping the cells that share every /// coordinate but `_Measure`. `None` for a pivot view; `Rc` to share. pub records: Option>>, /// In records mode: what each column holds, parallel to `col_items`. /// Empty for normal pivot views. record_cols: Vec, /// The view's hidden items: a fold covers only the members this grid draws. hidden: HashMap>, } impl GridLayout { /// Build a layout, using `frozen_records` instead of re-querying the /// store when records mode is active and a snapshot is supplied. pub fn with_frozen_records( model: &Model, view: &View, frozen_records: Option>>, ) -> Self { let mut layout = Self::new(model, view); if layout.is_records_mode() && let Some(records) = frozen_records { let row_items: Vec = (0..records.len()) .map(|i| AxisEntry::item(vec![i.to_string()])) .collect(); layout.row_items = row_items; layout.records = Some(records); layout.reindex(); } if view.prune_empty { layout.prune_empty(model); } layout } pub fn new(model: &Model, view: &View) -> Self { let row_cats: Vec = view .categories_on(Axis::Row) .into_iter() .map(String::from) .collect(); let col_cats: Vec = view .categories_on(Axis::Column) .into_iter() .map(String::from) .collect(); // Page *and* Filter: both constrain the visible records, and the // layout does not care which the user can navigate (§7). let page_cats: Vec = view .filtering_cats() .into_iter() .map(String::from) .collect(); let none_cats: Vec = view .categories_on(Axis::None) .into_iter() .map(String::from) .collect(); let page_coords = page_cats .iter() .filter_map(|cat| { let items: Vec = model.effective_item_names(cat); let selected = view .page_selection(cat) .map(String::from) .or_else(|| items.first().cloned())?; let navigable = view.try_axis_of(cat) == Some(Axis::Page); Some(PageAxis { cat: cat.clone(), selected, items: navigable.then_some(items), }) }) .collect(); // `View` owns the one definition of records mode (improvise-2zq): // `on_category_added` asks it too, and two derivations would drift. if view.is_records_layout() { Self::build_records_mode(model, view, page_coords, none_cats) } else { let row_items = cross_product(model, view, &row_cats); let col_items = cross_product(model, view, &col_cats); Self { row_cats, col_cats, page_coords, row_pos: positions(&row_items), col_pos: positions(&col_items), row_items, col_items, none_cats, records: None, record_cols: Vec::new(), hidden: view.hidden_items.clone(), } } } /// Build a records-mode layout in wide format, each measure getting its /// own value column so data entry is direct (improvise-c5v, §6). fn build_records_mode( model: &Model, _view: &View, page_coords: Vec, none_cats: Vec, ) -> Self { let records = Self::group_records(model, &page_constraints(&page_coords)); let row_items: Vec = (0..records.len()) .map(|i| AxisEntry::item(vec![i.to_string()])) .collect(); let record_cols = Self::records_columns(model); let col_items: Vec = record_cols .iter() .map(|c| AxisEntry::item(vec![Self::record_col_label(c)])) .collect(); Self { row_cats: vec![INDEX_CATEGORY.to_string()], col_cats: vec![DIM_CATEGORY.to_string()], page_coords, row_pos: positions(&row_items), col_pos: positions(&col_items), row_items, col_items, none_cats, records: Some(Rc::new(records)), record_cols, hidden: HashMap::new(), } } fn group_records(model: &Model, page_coords: &[(String, String)]) -> Vec { RecordRow::group( model .data .matching_cells(page_coords) .into_iter() .map(|(k, v)| (k, v.clone())), ) } /// `[coordinates…, +cat, measures…, +]`. Formula targets are not columns: /// they are computed, so there is nothing to type into them (§6). fn records_columns(model: &Model) -> Vec { let mut cols: Vec = model .category_names() .into_iter() .filter(|c| { model .category(c) .map(|cat| cat.kind.is_regular()) .unwrap_or(false) }) .map(|c| RecordCol::Category(c.to_string())) .collect(); cols.push(RecordCol::NewCategory); cols.extend( model .category(MEASURE_CATEGORY) .map(|c| c.ordered_item_names()) .unwrap_or_default() .into_iter() .map(|m| RecordCol::Measure(m.to_string())), ); cols.push(RecordCol::NewMeasure); cols } fn record_col_label(col: &RecordCol) -> String { match col { RecordCol::Category(name) | RecordCol::Measure(name) => name.clone(), RecordCol::NewMeasure => NEW_MEASURE_COL.to_string(), RecordCol::NewCategory => NEW_CATEGORY_COL.to_string(), } } /// What the records-mode column at `col` holds; `None` outside records /// mode or past the last column. pub fn record_col(&self, col: usize) -> Option<&RecordCol> { self.record_cols.get(col) } /// The grouped row at `row`, in records mode. pub fn record_row(&self, row: usize) -> Option<&RecordRow> { self.records.as_ref()?.get(row) } /// The model cell a records position addresses, for the callers that must /// ask the *document* about it. `None` where the column stores no value. pub fn record_cell_key(&self, row: usize, col: usize) -> Option { let record = self.record_row(row)?; match self.record_col(col)? { RecordCol::Measure(name) => Some(record.cell_key(name)), RecordCol::Category(_) | RecordCol::NewMeasure | RecordCol::NewCategory => None, } } /// The same, from a synthetic `(_Index, _Dim)` key — the shape a /// `CmdContext` carries in records mode (gotcha 1). pub fn record_cell_key_of(&self, key: &CellKey) -> Option { let (row, col) = synthetic_record_info(key)?; self.record_cell_key(row, col) } /// The **one** resolution of a records-mode cell (improvise-tom1). `None` /// for a pivot layout and out of bounds, symmetric with `cell_key`. pub fn records_value(&self, row: usize, col: usize) -> Option { let record = self.record_row(row)?; let blank = || CellValue::Text(String::new()); match self.record_col(col)? { // A coordinate is an item NAME, so it is text whatever it looks // like: `2024` is a label, not a number. RecordCol::Category(name) => Some(CellValue::Text( record.coords.get(name).unwrap_or_default().to_string(), )), RecordCol::Measure(name) => Some(record.value(name).cloned().unwrap_or_else(blank)), // Always blank: where a new name is typed, never where a value // is stored. RecordCol::NewMeasure | RecordCol::NewCategory => Some(blank()), } } /// The same cell as the string the editor and the terminal want. One /// `to_string`, so the text can never disagree with the value. pub fn records_display(&self, row: usize, col: usize) -> Option { self.records_value(row, col).map(|v| v.to_string()) } /// Drop entirely empty data rows and columns, keeping a group header if /// an item under it survives. Records mode is exempt (§6). pub fn prune_empty(&mut self, model: &Model) { if self.is_records_mode() { return; } let rc = self.row_count(); let cc = self.col_count(); if rc == 0 || cc == 0 { return; } // Build a row×col grid of "has content?", one fold memo for the whole // walk (improvise-metf). It is dropped before the axes are pruned. let mut has_value = vec![vec![false; cc]; rc]; { let eval = self.evaluator(model); for (ri, row) in has_value.iter_mut().enumerate() { for (ci, cell) in row.iter_mut().enumerate() { *cell = self.cell_key(ri, ci).and_then(|k| eval.cell(&k)).is_some(); } } } // Pruning rows touches neither `col_items` nor `has_value`, so the // column pass still reads the original grid. prune_axis_items(&mut self.row_items, |ri| { (0..cc).any(|ci| has_value[ri][ci]) }); prune_axis_items(&mut self.col_items, |ci| { (0..rc).any(|ri| has_value[ri][ci]) }); self.reindex(); } /// Rebuild the position indexes after the items change. fn reindex(&mut self) { self.row_pos = positions(&self.row_items); self.col_pos = positions(&self.col_items); } /// Whether this layout is in records mode. pub fn is_records_mode(&self) -> bool { self.records.is_some() } /// Number of data rows (group headers excluded). pub fn row_count(&self) -> usize { self.row_pos.len() } /// Number of data columns (group headers excluded). pub fn col_count(&self) -> usize { self.col_pos.len() } pub fn row_label(&self, row: usize) -> String { axis_label(&self.row_items, &self.row_pos, row) } pub fn col_label(&self, col: usize) -> String { axis_label(&self.col_items, &self.col_pos, col) } /// The value of a pivot key as THIS grid reads it: aggregated over its None /// axis, and a fold over the members it draws (core.md §14). One evaluation. pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option { self.evaluator(model).cell(key) } /// One grid build's evaluator, whose fold memo this layout built, so no other /// grid's memo can reach its cells or totals (improvise-h6z1, pntu, opbo). pub fn evaluator<'a>(&'a self, model: &'a Model) -> GridEval<'a> { let memo = FoldMemo::new(Aggregation::of(&self.none_cats), |cat, item| { !self.hidden.get(cat).is_some_and(|h| h.contains(item)) }); GridEval { layout: self, model, memo, } } /// Display text for (row, col) in either mode. pub fn display_text( &self, model: &Model, row: usize, col: usize, plan: &crate::format::FormatPlan, ) -> String { if self.is_records_mode() { self.records_display(row, col).unwrap_or_default() } else { self.cell_key(row, col) .and_then(|key| { let value = self.evaluate(model, &key)?; Some(plan.at(Some(&key)).value(Some(&value))) }) .unwrap_or_default() } } /// The `CellKey` at (row, col) including the active page/filter coords; /// `None` out of bounds. Records mode yields a synthetic key (§6). pub fn cell_key(&self, row: usize, col: usize) -> Option { if let Some(records) = &self.records { if row >= records.len() || col >= self.record_cols.len() { return None; } return Some(CellKey::new(vec![ (INDEX_CATEGORY.to_string(), row.to_string()), (DIM_CATEGORY.to_string(), col.to_string()), ])); } let row_item = data_item(&self.row_items, &self.row_pos, row)?; let col_item = data_item(&self.col_items, &self.col_pos, col)?; let mut coords = page_constraints(&self.page_coords); for (cat, item) in self.row_cats.iter().zip(row_item.iter()) { coords.push((cat.clone(), item.clone())); } for (cat, item) in self.col_cats.iter().zip(col_item.iter()) { coords.push((cat.clone(), item.clone())); } Some(CellKey::new(coords)) } /// The coordinates a COLUMN answers for once its rows are aggregated away: /// the page/filter constraints and the column's own tuple (core.md §6). pub fn column_key(&self, col: usize) -> Option { let col_item = data_item(&self.col_items, &self.col_pos, col)?; let mut coords = page_constraints(&self.page_coords); for (cat, item) in self.col_cats.iter().zip(col_item.iter()) { coords.push((cat.clone(), item.clone())); } Some(CellKey::new(coords)) } /// Visual index of the nth data row (skipping group headers). pub fn data_row_to_visual(&self, data_row: usize) -> Option { data_index_to_visual(&self.row_pos, data_row) } /// Visual index of the nth data column (skipping group headers). pub fn data_col_to_visual(&self, data_col: usize) -> Option { data_index_to_visual(&self.col_pos, data_col) } /// The `(cat_name, group_name)` of the innermost group the Nth data row /// is in — what `z` toggles (improvise-e265). pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> { innermost_group(&self.row_items, data_row) } /// Whether the cell at `(row, col)` folds a collapsed group: an aggregate /// with no record under it. The one home of that answer (§14). pub fn folds_a_group(&self, row: usize, col: usize) -> bool { self.row_folds_a_group(row) || folds(&self.col_items, &self.col_pos, col) } /// The row half of [`Self::folds_a_group`], for a command about the row alone. pub fn row_folds_a_group(&self, row: usize) -> bool { folds(&self.row_items, &self.row_pos, row) } /// The first data row of `cat`/`group`'s run under `prefix` — where a toggle /// of that group puts the cursor, asked of the layout AFTER the toggle (§14). pub fn row_run_start(&self, cat: &str, group: &str, prefix: &[String]) -> Option { run_start(&self.row_cats, &self.row_items, cat, group, prefix) } /// [`Self::row_run_start`] for a column group. pub fn col_run_start(&self, cat: &str, group: &str, prefix: &[String]) -> Option { run_start(&self.col_cats, &self.col_items, cat, group, prefix) } /// The coordinate tuple of the Nth data row. pub fn row_tuple(&self, row: usize) -> Option<&[String]> { data_item(&self.row_items, &self.row_pos, row).map(Vec::as_slice) } /// The coordinate tuple of the Nth data column. pub fn col_tuple(&self, col: usize) -> Option<&[String]> { data_item(&self.col_items, &self.col_pos, col).map(Vec::as_slice) } /// The `(cat_name, group_name)` of the innermost group the Nth data /// column is in. pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> { innermost_group(&self.col_items, data_col) } } /// Expand one category into `AxisEntry` values under a coordinate prefix: a /// `GroupHeader` per boundary, then each visible item or collapsed group (§14). fn expand_category( model: &Model, view: &View, cat_name: &str, prefix: Vec, prefix_collapsed: bool, ) -> Vec { let Some(cat) = model.category(cat_name) else { return vec![]; }; // The prefix holds one item per outer category, so its length is this level. let mut runs = CategoryRuns { level: prefix.len(), cat_name, view, open: Vec::new(), out: Vec::new(), }; // A group's items need not be contiguous, so a collapsed one folds ONCE. let mut folded: Vec<&str> = Vec::new(); // effective_item_names, so _Measure picks up formula targets. let effective_names = model.effective_item_names(cat_name); for item_name in &effective_names { if view.is_hidden(cat_name, item_name) { continue; } let chain = cat.item_chain(item_name); // The OUTERMOST collapsed group folds every leaf beneath it (§19). let fold_depth = (0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth])); let fold = fold_depth.map(|depth| chain[depth]); if fold.is_some_and(|g| folded.contains(&g)) { continue; } runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]); let mut tuple = prefix.clone(); match fold { Some(g) => { folded.push(g); tuple.push(g.to_string()); } None => tuple.push(item_name.to_string()), } runs.out.push(AxisEntry::DataItem { tuple, collapsed: prefix_collapsed || fold.is_some(), }); } // The expansion is the end of the run too: the next prefix's items follow // immediately and must not inherit it (improvise-gd83). runs.enter(&[]); runs.out } /// One category's group runs as `expand_category` emits them: the chain of groups /// open, root first, and the markers that move it from item to item. struct CategoryRuns<'a> { level: usize, cat_name: &'a str, view: &'a View, open: Vec<&'a str>, out: Vec, } impl<'a> CategoryRuns<'a> { /// Move the open chain to `chain` in `remark`'s own convention, so an unpruned /// axis survives pruning marker for marker (core.md §6). fn enter(&mut self, chain: &[&'a str]) { let shared = self .open .iter() .zip(chain) .take_while(|(open, want)| open == want) .count(); let level = self.level; for depth in (shared + 1..self.open.len()).rev() { self.out.push(AxisEntry::GroupEnd { level, depth }); } if chain.len() == shared && self.open.len() > shared { self.out.push(AxisEntry::GroupEnd { level, depth: shared, }); } self.open.truncate(shared); for (depth, group) in chain.iter().enumerate().skip(shared) { self.out.push(AxisEntry::GroupHeader { level, depth, cat_name: self.cat_name.to_string(), group_name: group.to_string(), collapsed: self .view .is_group_collapsed(self.cat_name, &chain[..=depth]), }); self.open.push(group); } } } /// Cartesian product of visible items across `cats`, headers always emitted. /// `vec![DataItem(vec![])]` when `cats` is empty. fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec { if cats.is_empty() { return vec![AxisEntry::item(vec![])]; } let mut result: Vec = vec![AxisEntry::item(vec![])]; for cat_name in cats { result = result .into_iter() .flat_map(|entry| match entry { AxisEntry::DataItem { tuple, collapsed } => { expand_category(model, view, cat_name, tuple, collapsed) } marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) => { vec![marker] } }) .collect(); } // The axis itself ends the last run; a trailing marker names nothing. while matches!(result.last(), Some(AxisEntry::GroupEnd { .. })) { result.pop(); } result } /// One grid build's evaluations under one fold memo its own layout built, valid /// for one model state (improvise-h6z1, opbo). `context/code/core.md` §14. pub struct GridEval<'a> { layout: &'a GridLayout, model: &'a Model, memo: FoldMemo<'a>, } impl GridEval<'_> { /// A pivot key's value as this grid reads it: [`GridLayout::evaluate`], shared. pub fn cell(&self, key: &CellKey) -> Option { self.model.evaluate_covering_in(key, &self.memo) } /// A column's total: its cell as if every drawn row were ONE fold, so a /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). pub fn column_total(&self, col: usize) -> Option { let key = self.layout.column_key(col)?; self.model .evaluate_spanning_in(&key, &self.layout.row_cats, &self.memo) } } #[cfg(test)] mod tests { use super::{ AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, data_items, prune_axis_items, synthetic_record_info, }; use crate::model::Aggregation; use crate::model::cell::{CellKey, CellValue}; use crate::model::types::{FOLD_FORMULA_EVALUATIONS, FoldMemo}; use crate::view::Axis; use crate::workbook::Workbook; fn records_workbook() -> Workbook { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_category("_Measure").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Number(100.0), ); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "North".into()), ("_Measure".into(), "Cost".into()), ]), CellValue::Number(50.0), ); // Records mode setup: _Measure must not filter. wb.active_view_mut().set_axis("_Measure", Axis::None); wb } /// The predicate sees *data* indices, not visual ones — see /// context/test-evidence/core.md. #[test] fn prune_axis_items_indexes_data_positions_not_visual_ones() { let mut items = vec![ AxisEntry::item(vec!["A".into()]), AxisEntry::GroupHeader { level: 0, depth: 0, cat_name: "C".into(), group_name: "G".into(), collapsed: false, }, AxisEntry::item(vec!["B".into()]), AxisEntry::item(vec!["C".into()]), ]; // Data index 1 is "B": the header does not consume an index. If the // predicate saw visual indices, "A" would be the one dropped. prune_axis_items(&mut items, |idx| idx != 1); let survivors: Vec<&str> = data_items(&items).map(|v| v[0].as_str()).collect(); assert_eq!(survivors, vec!["A", "C"]); // "C" survived, so its group header is retained. assert!(items.iter().any(|e| matches!( e, AxisEntry::GroupHeader { group_name, .. } if group_name == "G" ))); } /// A group header is dropped when every data item under it is pruned. #[test] fn prune_axis_items_drops_a_header_whose_items_all_go() { let mut items = vec![ AxisEntry::GroupHeader { level: 0, depth: 0, cat_name: "C".into(), group_name: "G".into(), collapsed: false, }, AxisEntry::item(vec!["A".into()]), AxisEntry::item(vec!["B".into()]), ]; prune_axis_items(&mut items, |_| false); assert!(items.is_empty()); } /// improvise-l4qx seen at the layout — see context/test-evidence/core.md. #[test] fn a_text_cell_renders_in_a_default_view() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "West").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "West".into()), ("_Measure".into(), "Revenue".into()), ]), CellValue::Text("pending".into()), ); let layout = GridLayout::new(&wb.model, wb.active_view()); // A default view leaves _Index and _Dim on Axis::None. assert!( !layout.none_cats.is_empty(), "a default view must have None-axis categories for this to be the bug" ); assert_eq!( layout.display_text(&wb.model, 0, 0, &crate::format::FormatPlan::uniform("")), "pending" ); } #[test] fn prune_empty_removes_all_empty_columns_in_pivot_mode() { let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); wb.add_category("Col").unwrap(); wb.add_item("Row", "A").unwrap(); wb.add_item("Col", "X").unwrap(); wb.add_item("Col", "Y").unwrap(); // Only X has data; Y is entirely empty. wb.model.set_cell( CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]), CellValue::Number(1.0), ); let mut layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_count(), 2); // X and Y before pruning layout.prune_empty(&wb.model); assert_eq!(layout.col_count(), 1); // only X after pruning assert_eq!(layout.col_label(0), "X"); } #[test] fn records_mode_activated_when_index_and_dim_on_axes() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); // Wide format: both measures of Region=North share one row. assert_eq!(layout.row_count(), 1); } #[test] fn records_mode_cell_key_returns_synthetic_for_all_columns() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); // Every column yields a synthetic key carrying the row and the column // *index* — see `synthetic_record_info`. for col in 0..layout.col_count() { let key = layout.cell_key(0, col).unwrap(); assert_eq!(key.get("_Index"), Some("0")); assert_eq!(key.get("_Dim"), Some(col.to_string().as_str())); } assert!( layout.cell_key(0, layout.col_count()).is_none(), "past the last column there is no cell" ); } #[test] fn records_mode_records_display_returns_values() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); let cols: Vec = (0..layout.col_count()) .map(|i| layout.col_label(i)) .collect(); // A measure column resolves to that measure's value let value_col = cols.iter().position(|c| c == "Revenue").unwrap(); assert_eq!(layout.records_display(0, value_col).as_deref(), Some("100")); // Category column resolves to the coordinate value let region_col = cols.iter().position(|c| c == "Region").unwrap(); assert_eq!( layout.records_display(0, region_col).as_deref(), Some("North") ); } #[test] fn synthetic_record_info_returns_none_for_pivot_keys() { let key = CellKey::new(vec![ ("Region".to_string(), "East".to_string()), ("Product".to_string(), "Shoes".to_string()), ]); assert!(synthetic_record_info(&key).is_none()); } /// `_Dim` carries the column *index*, not its label: a category and a /// measure may share a name (improvise-c5v). #[test] fn synthetic_record_info_extracts_index_and_column() { let key = CellKey::new(vec![ ("_Index".to_string(), "3".to_string()), ("_Dim".to_string(), "2".to_string()), ]); let (idx, col) = synthetic_record_info(&key).unwrap(); assert_eq!(idx, 3); assert_eq!(col, 2); } /// Pins the whole column vocabulary in order — see /// context/test-evidence/core.md. #[test] fn records_mode_gives_each_measure_its_own_value_column() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); let cols: Vec = (0..layout.col_count()) .map(|i| layout.col_label(i)) .collect(); assert_eq!( cols, vec![ "Region", NEW_CATEGORY_COL, "Revenue", "Cost", NEW_MEASURE_COL ], "categories, the new-category column, one column per measure, \ then the new-measure column — each `+` adjacent to what it adds \ to (improvise-2zq)" ); assert_eq!( layout.record_col(0), Some(&RecordCol::Category("Region".to_string())) ); assert_eq!(layout.record_col(1), Some(&RecordCol::NewCategory)); assert_eq!( layout.record_col(2), Some(&RecordCol::Measure("Revenue".to_string())) ); assert_eq!(layout.record_col(4), Some(&RecordCol::NewMeasure)); // Both measures are readable side by side, and `+cat` is blank. assert_eq!(layout.records_display(0, 1).as_deref(), Some("")); assert_eq!(layout.records_display(0, 2).as_deref(), Some("100")); assert_eq!(layout.records_display(0, 3).as_deref(), Some("50")); } /// On entry into records mode rows are `CellKey`-sorted regardless of /// insertion order — see context/test-evidence/core.md. #[test] fn records_mode_initial_layout_is_sorted() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "East").unwrap(); // Insert East before North — opposite of alphabetical wb.model.set_cell( CellKey::new(vec![("Region".into(), "East".into())]), CellValue::Number(2.0), ); wb.model.set_cell( CellKey::new(vec![("Region".into(), "North".into())]), CellValue::Number(1.0), ); // Sort the store before entering records mode, as ToggleRecordsMode would wb.model.data.sort_by_key(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 2); let region_col = (0..layout.col_count()) .find(|&c| layout.col_label(c) == "Region") .unwrap(); let row0_region = layout.records_display(0, region_col).unwrap(); let row1_region = layout.records_display(1, region_col).unwrap(); assert_eq!( row0_region, "East", "first row should be East (alphabetical)" ); assert_eq!( row1_region, "North", "second row should be North (alphabetical)" ); } /// A record added later appends rather than re-sorting into the middle — /// see context/test-evidence/core.md. #[test] fn records_mode_new_record_appends_at_bottom() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 1); // The Cost column, not the trailing new-measure column. let cost_col = (0..layout.col_count()) .find(|&c| layout.record_col(c) == Some(&RecordCol::Measure("Cost".to_string()))) .unwrap(); let first_value = layout.records_display(0, cost_col).unwrap(); // Add a record whose CellKey sorts BEFORE the existing ones wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "AAA".into()), ("_Measure".into(), "Cost".into()), ]), CellValue::Number(999.0), ); let layout2 = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout2.row_count(), 2); // First row should still be the same record — new one appends at bottom let first_value2 = layout2.records_display(0, cost_col).unwrap(); assert_eq!( first_value, first_value2, "first row should be unchanged after adding a new record; \ new record should append at bottom, not re-sort" ); // New record should be the last row let last_value = layout2.records_display(1, cost_col).unwrap(); assert_eq!(last_value, "999", "new record should be the last row"); } /// improvise-byz regression guard — see context/test-evidence/core.md. #[test] fn records_display_out_of_bounds_col_returns_none() { let mut wb = records_workbook(); let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.is_records_mode()); assert_eq!(layout.records_display(0, layout.col_count()), None); } fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( pairs .iter() .map(|(c, i)| (c.to_string(), i.to_string())) .collect(), ) } fn two_cat_workbook() -> Workbook { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Month").unwrap(); for item in ["Food", "Clothing"] { wb.add_item("Type", item).unwrap(); } for item in ["Jan", "Feb"] { wb.add_item("Month", item).unwrap(); } wb } #[test] fn row_and_col_counts_match_item_counts() { let wb = two_cat_workbook(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 2); // Food, Clothing assert_eq!(layout.col_count(), 2); // Jan, Feb } #[test] fn cell_key_encodes_correct_coordinates() { let wb = two_cat_workbook(); let layout = GridLayout::new(&wb.model, wb.active_view()); // row 0 = Food, col 1 = Feb let key = layout.cell_key(0, 1).unwrap(); assert_eq!(key, coord(&[("Month", "Feb"), ("Type", "Food")])); } #[test] fn cell_key_out_of_bounds_returns_none() { let wb = two_cat_workbook(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!(layout.cell_key(99, 0).is_none()); assert!(layout.cell_key(0, 99).is_none()); } #[test] fn cell_key_includes_page_coords() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Month").unwrap(); wb.add_category("Region").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.add_item("Month", "Jan").unwrap(); wb.add_item("Region", "East").unwrap(); wb.add_item("Region", "West").unwrap(); wb.active_view_mut().set_page_selection("Region", "West"); let layout = GridLayout::new(&wb.model, wb.active_view()); let key = layout.cell_key(0, 0).unwrap(); assert_eq!(key.get("Region"), Some("West")); } #[test] fn cell_key_round_trips_through_model_evaluate() { let mut wb = two_cat_workbook(); wb.model.set_cell( coord(&[("Month", "Feb"), ("Type", "Clothing")]), CellValue::Number(42.0), ); let layout = GridLayout::new(&wb.model, wb.active_view()); // Clothing = row 1, Feb = col 1 let key = layout.cell_key(1, 1).unwrap(); assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(42.0))); } #[test] fn labels_join_with_slash_for_multi_cat_axis() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Month").unwrap(); wb.add_category("Year").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.add_item("Month", "Jan").unwrap(); wb.add_item("Year", "2025").unwrap(); wb.active_view_mut() .set_axis("Year", crate::view::Axis::Column); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_label(0), "Jan/2025"); } #[test] fn row_count_excludes_group_headers() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count } #[test] fn group_header_emitted_at_group_boundary() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); let headers: Vec<_> = layout .row_items .iter() .filter(|e| matches!(e, AxisEntry::GroupHeader { .. })) .collect(); assert_eq!(headers.len(), 2); assert!( matches!(&headers[0], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1") ); assert!( matches!(&headers[1], AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2") ); } /// The inversion of `collapsed_group_has_header_but_no_data_items`: a /// collapsed group is ONE row. Evidence: context/test-evidence/core.md. #[test] fn a_collapsed_group_contributes_one_aggregate_row() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "May", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); let labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) .collect(); assert_eq!( labels, ["Q1", "Apr", "May"], "Q1 folds into one row; Q2 stays open" ); assert!(layout.folds_a_group(0, 0), "the Q1 row is a fold"); assert!( !layout.folds_a_group(1, 0), "Apr, under the OPEN group, is not" ); assert_eq!( layout.row_group_for(0), Some(("Month".into(), "Q1".into())), "the fold sits under its own header, so z can name it" ); } /// Evidence: context/test-evidence/core.md. #[test] fn a_non_contiguous_collapsed_group_folds_once() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "A", "G1").unwrap(); wb.add_item_in_group("Month", "B", "G2").unwrap(); wb.add_item_in_group("Month", "C", "G1").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().toggle_group_collapse("Month", &["G1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); let labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) .collect(); assert_eq!(labels, ["G1", "B"], "one G1 row, not one per run"); let g1_headers = layout .row_items .iter() .filter( |e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "G1"), ) .count(); assert_eq!( g1_headers, 1, "a second G1 header would open a run with no row" ); } /// Evidence: context/test-evidence/core.md. #[test] fn a_category_nested_under_a_collapsed_group_is_folded_too() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Region").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Region", "North").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_tuple(0), Some(&["Q1".to_string(), "North".to_string()][..]) ); assert!( layout.folds_a_group(0, 0), "Q1/North has no record under it either" ); assert!(!layout.folds_a_group(1, 0), "Apr/North does"); } /// improvise-gd83: two arms — an ungrouped item AFTER a grouped run, and /// the same item under a later prefix. context/test-evidence/core.md. #[test] fn an_ungrouped_item_after_a_grouped_run_belongs_to_no_group() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Month", "Sep").unwrap(); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_group_for(0), Some(("Month".into(), "Q1".into())), "precondition: Jan really is in Q1" ); assert_eq!( layout.row_group_for(2), None, "Sep is in no group — the run ended before it" ); } /// The second arm: the run ends at the end of ONE expansion, so the next /// prefix's first item does not inherit it. context/test-evidence/core.md. #[test] fn a_grouped_run_does_not_leak_into_the_next_prefix() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Recipient").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Clothing").unwrap(); wb.add_item("Recipient", "Carol").unwrap(); wb.add_item_in_group("Recipient", "Alice", "Family") .unwrap(); wb.add_item_in_group("Recipient", "Bob", "Family").unwrap(); wb.active_view_mut().set_expand_depth("Recipient", 1); wb.active_view_mut().set_axis("Recipient", Axis::Row); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_group_for(2), Some(("Recipient".into(), "Family".into())), "precondition: Food/Bob is in Family" ); assert_eq!( layout.row_group_for(3), None, "Clothing/Carol opens a new prefix and is in no group" ); } #[test] fn ungrouped_items_produce_no_headers() { let wb = two_cat_workbook(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert!( !layout .row_items .iter() .any(|e| matches!(e, AxisEntry::GroupHeader { .. })) ); assert!( !layout .col_items .iter() .any(|e| matches!(e, AxisEntry::GroupHeader { .. })) ); } #[test] fn cell_key_correct_with_grouped_items() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.model.set_cell( coord(&[("Month", "Apr"), ("Type", "Food")]), CellValue::Number(99.0), ); let layout = GridLayout::new(&wb.model, wb.active_view()); // data row 0 = Jan, data row 1 = Apr let key = layout.cell_key(1, 0).unwrap(); assert_eq!(wb.model.evaluate(&key), Some(CellValue::Number(99.0))); } #[test] fn data_row_to_visual_skips_headers() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_row_to_visual(0), Some(1)); // Jan is at visual index 1 assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3 assert_eq!(layout.data_row_to_visual(2), None); } #[test] fn data_col_to_visual_skips_headers() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); // Row wb.add_category("Month").unwrap(); // Column wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_col_to_visual(0), Some(1)); assert_eq!(layout.data_col_to_visual(1), Some(3)); assert_eq!(layout.data_col_to_visual(2), None); } #[test] fn row_group_for_finds_enclosing_group() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_group_for(0), Some(("Month".to_string(), "Q1".to_string())) ); assert_eq!( layout.row_group_for(1), Some(("Month".to_string(), "Q2".to_string())) ); } #[test] fn row_group_for_returns_none_for_ungrouped() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Month").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.add_item("Month", "Jan").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_group_for(0), None); } #[test] fn col_group_for_finds_enclosing_group() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); // Row wb.add_category("Month").unwrap(); // Column wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.col_group_for(0), Some(("Month".to_string(), "Q1".to_string())) ); assert_eq!( layout.col_group_for(1), Some(("Month".to_string(), "Q2".to_string())) ); } #[test] fn col_group_for_returns_none_for_ungrouped() { let mut wb = Workbook::new("T"); wb.add_category("Type").unwrap(); wb.add_category("Month").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.add_item("Month", "Jan").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.col_group_for(0), None); } /// Fund exactly the `(Month, Type=Food)` cells named, so `prune_empty` /// keeps the rows the fixture pays for. context/test-evidence/core.md. fn grouped_month_book(months: &[(&str, Option<&str>)], funded: &[&str]) -> Workbook { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); for (name, group) in months { match group { Some(g) => wb.add_item_in_group("Month", *name, *g).unwrap(), None => wb.add_item("Month", *name).unwrap(), }; } wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for m in funded { wb.model.set_cell( CellKey::new(vec![ ("Month".into(), (*m).into()), ("Type".into(), "Food".into()), ]), CellValue::Number(1.0), ); } wb.active_view_mut().prune_empty = true; wb } fn has_header(items: &[AxisEntry], group: &str) -> bool { items .iter() .any(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == group)) } /// improvise-lhi4: a COLLAPSED group contributes zero data items, so the /// pruner must not read its empty run as "pruned away". #[test] fn a_collapsed_groups_header_survives_pruning() { // `Mar` is unfunded, so pruning demonstrably runs: with `prune_empty` // off this fixture keeps Mar and the assertion below fails. let mut wb = grouped_month_book( &[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)], &["Jan", "Sep"], ); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); // The whole sequence, not `any(is Q1)`: that cannot see a header // pushed in the wrong place, nor a stray `GroupEnd`. assert_eq!( layout.row_items, vec![ AxisEntry::GroupHeader { level: 0, depth: 0, cat_name: "Month".into(), group_name: "Q1".into(), collapsed: true, }, AxisEntry::DataItem { tuple: vec!["Q1".into()], collapsed: true }, AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Sep".into()]), ], "Q1 folds to one funded row under its header, and Mar pruned" ); assert_eq!( layout.row_group_for(0), Some(("Month".into(), "Q1".into())), "the fold is where z names the collapsed group from" ); assert_eq!( layout.row_group_for(1), None, "Sep must not inherit the collapsed run" ); } /// improvise-lhi4's other half, and what separates the two cases: a header /// whose items really did prune away still goes. #[test] fn a_header_whose_items_all_prune_away_is_dropped() { let wb = grouped_month_book(&[("Jan", Some("Q1")), ("Mar", Some("Q2"))], &["Jan"]); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert_eq!(layout.row_count(), 1, "only Jan is funded"); assert!(has_header(&layout.row_items, "Q1"), "Jan survives under Q1"); assert!( !has_header(&layout.row_items, "Q2"), "Q2's only item pruned away, so its header goes with it" ); } /// improvise-b5zh: an OUTER header is followed by an inner one, not by an item /// — see context/test-evidence/core.md. #[test] fn an_outer_group_whose_rows_all_prune_loses_its_header() { let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); wb.add_item_in_group("Region", "North", "Upper").unwrap(); wb.add_item_in_group("Region", "South", "Lower").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.active_view_mut().set_expand_depth("Region", 1); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for month in ["Jan", "Feb"] { wb.model.set_cell( CellKey::new(vec![ ("Region".into(), "South".into()), ("Month".into(), month.into()), ("Type".into(), "Food".into()), ]), CellValue::Number(1.0), ); } wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().prune_empty = true; let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert_eq!(layout.row_count(), 2, "only South's rows are funded"); assert!( has_header(&layout.row_items, "Lower"), "South's group stays" ); assert!( !has_header(&layout.row_items, "Upper"), "every North row pruned, so Upper has nothing under it: {:?}", layout.row_items ); } /// The commonest case and the one the other three cannot see: SOME of a /// group's items prune. context/test-evidence/core.md. #[test] fn a_partly_pruned_group_keeps_the_header_its_survivors_sit_under() { let wb = grouped_month_book( &[("Jan", Some("Q1")), ("Feb", Some("Q1")), ("Sep", None)], &["Jan", "Sep"], ); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert_eq!(layout.row_count(), 2, "Feb pruned; Jan and Sep remain"); assert_eq!( layout.row_group_for(0), Some(("Month".into(), "Q1".into())), "Jan survived, so Q1 still has a member and must keep its header" ); assert_eq!(layout.row_group_for(1), None, "Sep is in no group"); } /// improvise-u6gc: pruning the intervening header re-opens the earlier /// run, so the `GroupEnd` that closed it must survive. Regresses gd83. #[test] fn an_ungrouped_item_after_a_pruned_group_belongs_to_no_group() { let wb = grouped_month_book( &[ ("Jan", Some("Q1")), ("Feb", Some("Q1")), ("Mar", Some("Q2")), ("Sep", None), ], &["Jan", "Feb", "Sep"], ); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert_eq!(layout.row_count(), 3, "Mar pruned; Jan, Feb, Sep remain"); assert_eq!( layout.row_group_for(0), Some(("Month".into(), "Q1".into())), "precondition: Jan is in Q1, so the walk has somewhere wrong to land" ); assert_eq!( layout.row_group_for(2), None, "Sep is in no group — Q2's GroupEnd closed Q1's re-opened run" ); } /// Q2 = {Apr}, Q1 = {Jan, Feb}, so the Q1 fold is row 1. Jan and Feb carry /// ratios 0.5 and 0.9 whose sum, mean and pooled ratio all differ; Apr is empty. fn margin_book(formulas: &[&str]) -> Workbook { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] { for (measure, n) in [("Revenue", revenue), ("Cost", cost)] { wb.model.set_cell( CellKey::new(vec![ ("Month".into(), month.into()), ("_Measure".into(), measure.into()), ]), CellValue::Number(n), ); } } for f in formulas { wb.model .add_formula(crate::formula::parse_formula(f, "_Measure").unwrap()); } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb } /// `measure` at the row labelled `row_label`, as the grid reads it. fn grid_value(wb: &mut Workbook, row_label: &str, measure: &str) -> Option { wb.model.recompute_formulas(&wb.active_view().none_cats()); let layout = GridLayout::new(&wb.model, wb.active_view()); let row = (0..layout.row_count()).find(|&r| layout.row_label(r) == row_label)?; let col = (0..layout.col_count()).find(|&c| layout.col_label(c) == measure)?; layout .evaluate(&wb.model, &layout.cell_key(row, col)?) .and_then(|v| v.as_f64()) } /// improvise-metf: pruning evaluates each formula a fold row reaches once, and /// keeps what a fresh evaluation per cell keeps. context/test-evidence/core.md. #[test] fn pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows() { let chain: Vec = std::iter::once("F0 = Revenue".to_string()) .chain((1..=8).map(|i| format!("F{i} = F{p} + F{p}", p = i - 1))) .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let unpruned = GridLayout::new(&wb.model, wb.active_view()); let kept_by_fresh_evaluation: Vec = (0..unpruned.row_count()) .filter(|&r| { (0..unpruned.col_count()).any(|c| { let k = unpruned.cell_key(r, c).unwrap(); unpruned.evaluate(&wb.model, &k).is_some() }) }) .map(|r| unpruned.row_label(r)) .collect(); wb.active_view_mut().prune_empty = true; FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); let pruned = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); let evaluations = FOLD_FORMULA_EVALUATIONS.with(|n| n.get()); let rows: Vec = (0..pruned.row_count()) .map(|r| pruned.row_label(r)) .collect(); assert_eq!( rows, kept_by_fresh_evaluation, "sharing the memo changed what is pruned" ); assert!( rows.iter().any(|r| r == "Q1"), "setup: the fold row survives pruning" ); assert_eq!(evaluations, 9, "F0..F8 once each for the fold row, not 45"); } /// improvise-h6z1: one grid build evaluates each formula a fold row reaches /// once, not once per column. context/test-evidence/core.md. #[test] fn a_fold_row_evaluates_each_formula_once_across_the_row() { let chain: Vec = std::iter::once("F0 = Revenue".to_string()) .chain((1..=8).map(|i| format!("F{i} = F{p} + F{p}", p = i - 1))) .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let layout = GridLayout::new(&wb.model, wb.active_view()); let q1 = (0..layout.row_count()) .find(|&r| layout.row_label(r) == "Q1") .expect("a Q1 fold row"); let row_keys: Vec = (0..layout.col_count()) .filter_map(|c| layout.cell_key(q1, c)) .collect(); let evaluations = |shared: bool| { let eval = layout.evaluator(&wb.model); FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); let values: Vec<_> = row_keys .iter() .map(|k| match shared { true => eval.cell(k), false => layout.evaluate(&wb.model, k), }) .collect(); (FOLD_FORMULA_EVALUATIONS.with(|n| n.get()), values) }; let (apart, separately) = evaluations(false); let (together, shared) = evaluations(true); assert_eq!(shared, separately, "sharing the memo changed a value"); assert_eq!( apart, 45, "setup: each column re-derives the chain below it" ); assert_eq!(together, 9, "F0..F8 once each across the whole row"); } /// improvise-pntu: context/test-evidence/core.md. #[test] fn a_memo_answers_for_the_covers_it_was_built_with() { let wb = margin_book(&["R = Revenue / Cost"]); let key = CellKey::new(vec![ ("Month".into(), "Q1".into()), ("_Measure".into(), "R".into()), ]); let all = FoldMemo::new(Aggregation::Exact, |_, _| true); let no_feb = FoldMemo::new(Aggregation::Exact, |_, item| item != "Feb"); let pooled = wb.model.evaluate_covering_in(&key, &all); let jan_only = wb.model.evaluate_covering_in(&key, &no_feb); assert_eq!( pooled, Some(CellValue::Number(5.0)), "40 / 8 over Jan and Feb" ); assert_eq!( jan_only, Some(CellValue::Number(2.0)), "10 / 5 over Jan alone" ); let fresh = wb .model .evaluate_covering(&key, Aggregation::Exact, &|_, item| item != "Feb"); assert_eq!(jan_only, fresh, "a memo agrees with a fresh evaluation"); assert_eq!(wb.model.evaluate_covering_in(&key, &all), pooled); } /// improvise-pntu's other half: the aggregation is the memo's too. #[test] fn a_memo_answers_for_the_aggregation_it_was_built_with() { let wb = margin_book(&[]); let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]); let exact = FoldMemo::new(Aggregation::Exact, |_, _| true); let over = FoldMemo::new(Aggregation::OverHidden, |_, _| true); assert_eq!(wb.model.evaluate_covering_in(&key, &exact), None); assert_eq!( wb.model.evaluate_covering_in(&key, &over), Some(CellValue::Number(40.0)), "Jan and Feb summed" ); } /// improvise-opbo: a grid's evaluation cannot be handed another grid's memo. #[test] fn a_grid_evaluates_under_its_own_covers_not_another_grids() { let mut wb = margin_book(&["R = Revenue / Cost"]); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let all = GridLayout::new(&wb.model, wb.active_view()); let mut no_feb_view = wb.active_view().clone(); no_feb_view.hide_item("Month", "Feb"); let no_feb = GridLayout::new(&wb.model, &no_feb_view); let key = CellKey::new(vec![ ("Month".into(), "Q1".into()), ("_Measure".into(), "R".into()), ]); let on_all = all.evaluator(&wb.model); let on_no_feb = no_feb.evaluator(&wb.model); assert_eq!( on_all.cell(&key), Some(CellValue::Number(5.0)), "Jan and Feb" ); assert_eq!( on_no_feb.cell(&key), Some(CellValue::Number(2.0)), "the grid hiding Feb reads Jan alone" ); assert_eq!( on_all.cell(&key), Some(CellValue::Number(5.0)), "and neither moved the other" ); } /// improvise-eccl — see context/test-evidence/core.md. #[test] fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() { for formulas in [ &["Margin = (Revenue - Cost) / Revenue"][..], &["Profit = Revenue - Cost", "Margin = Profit / Revenue"][..], ] { let mut folded = margin_book(formulas); folded .active_view_mut() .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold"); let mut none = margin_book(formulas); none.active_view_mut().set_axis("Month", Axis::None); let over_none = grid_value(&mut none, "", "Margin").expect("a None-axis cell"); assert!((fold - 0.8).abs() < 1e-9, "{formulas:?}: fold {fold}"); assert!( (fold - over_none).abs() < 1e-9, "{formulas:?}: {fold} vs {over_none}" ); } } /// improvise-lnkb: the folded category reads as absent, as on `Axis::None` — see /// context/test-evidence/core.md. #[test] fn a_fold_answers_is_and_where_on_its_category_as_the_none_axis_does() { let formulas = [ "Flag = Month IS \"Q1\"", "Marked = Revenue * 2 WHERE Month = \"Q1\"", ]; for measure in ["Flag", "Marked"] { let mut folded = margin_book(&formulas); folded .active_view_mut() .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", measure); let mut none = margin_book(&formulas); none.active_view_mut().set_axis("Month", Axis::None); let over_none = grid_value(&mut none, "", measure); assert_eq!( fold, over_none, "{measure}: the fold and the None axis differ" ); } let mut folded = margin_book(&formulas); folded .active_view_mut() .toggle_group_collapse("Month", &["Q1"]); assert_eq!( grid_value(&mut folded, "Q1", "Flag"), Some(0.0), "false, not absent" ); } /// improvise-eccl's guard on the other side: an aggregate INSIDE the formula /// still pools every record the fold covers. #[test] fn a_folds_aggregate_formula_pools_its_members_records() { let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0)); assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0)); } /// An inline `WHERE` on the folded category makes every member context the /// same record set — see context/test-evidence/core.md. #[test] fn a_folds_aggregate_filtered_on_the_folded_category_counts_each_record_once() { let formula = ["Jans = SUM(Revenue WHERE Month = \"Jan\")"]; let mut folded = margin_book(&formula); folded .active_view_mut() .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Jans"); let mut none = margin_book(&formula); none.active_view_mut().set_axis("Month", Axis::None); assert_eq!(fold, Some(10.0), "Jan's revenue, once"); assert_eq!( fold, grid_value(&mut none, "", "Jans"), "the None-axis answer" ); } /// Every row's value in column 0, summed: the totals strip's arithmetic. fn column_total(wb: &Workbook) -> f64 { let layout = GridLayout::new(&wb.model, wb.active_view()); (0..layout.row_count()) .filter_map(|r| layout.evaluate(&wb.model, &layout.cell_key(r, 0)?)) .filter_map(|v| v.as_f64()) .sum() } /// improvise-f2oo — see context/test-evidence/core.md. #[test] fn collapsing_a_group_with_a_hidden_member_leaves_the_total_where_it_was() { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_category("Type").unwrap(); 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.model.set_cell( CellKey::new(vec![ ("Month".into(), month.into()), ("Type".into(), "Food".into()), ]), CellValue::Number(n), ); } wb.active_view_mut().set_expand_depth("Month", 1); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().hide_item("Month", "Feb"); assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn"); wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(column_total(&wb), 103.0, "z moved the total"); assert_eq!( grid_value(&mut wb, "Q1", "Food"), Some(3.0), "Feb is hidden" ); // Every member hidden: no fold is drawn, and the total still agrees. wb.active_view_mut().hide_item("Month", "Jan"); assert_eq!(grid_value(&mut wb, "Q1", "Food"), None, "no Q1 row"); assert_eq!(column_total(&wb), 100.0); } /// improvise-t0ma: the steps themselves, not their translation into effects — /// see context/test-evidence/core.md. #[test] fn records_steps_are_the_spine_then_every_non_index_dim_category_on_none() { use super::{ViewStep, records_spine, records_steps}; let mut m = crate::model::Model::new("T"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); let none = |c: &str| ViewStep::Axis { category: c.to_string(), axis: Axis::None, }; let mut expected = records_spine(); expected.extend( m.categories .keys() .filter(|c| !matches!(c.as_str(), "_Index" | "_Dim")) .map(|c| none(c)), ); assert!( expected.contains(&none("_Measure")) && expected.contains(&none("Region")), "setup: the model carries _Measure and a regular category" ); assert_eq!(records_steps(&m), expected); } /// improvise-t0ma: a drill FIXES its coordinates on Filter and frees only the /// unfixed, non-virtual aggregated categories. #[test] fn drill_steps_filter_each_coordinate_and_free_the_rest() { use super::{ViewStep, drill_steps, records_spine}; let key = CellKey::new(vec![ ("Region".into(), "North".into()), ("Month".into(), "Feb".into()), ("_Measure".into(), "Revenue".into()), ]); let none_cats: Vec = ["Product", "Region", "_Index", "Customer"] .map(String::from) .to_vec(); let fixed = |c: &str, i: &str| { [ ViewStep::Axis { category: c.into(), axis: Axis::Filter, }, ViewStep::PageSelection { category: c.into(), item: i.into(), }, ] }; let mut expected = records_spine(); expected.extend(fixed("Month", "Feb")); expected.extend(fixed("Region", "North")); expected.extend(fixed("_Measure", "Revenue")); expected.extend(["Product", "Customer"].map(|c| ViewStep::Axis { category: c.into(), axis: Axis::None, })); assert_eq!(drill_steps(&key, &none_cats), expected); } } /// Group membership on a multi-level axis is per level, and pruning cannot move /// it (improvise-e265, improvise-6u6s). `context/test-evidence/core.md`. #[cfg(test)] mod group_membership { use super::{AxisEntry, GridLayout, data_items, prune_axis_items, walk_open_runs}; use crate::view::Axis; use crate::workbook::Workbook; use std::ops::ControlFlow; /// Every data item's open runs, collected through the one reader. fn open_runs(items: &[AxisEntry]) -> Vec> { let mut all = Vec::new(); walk_open_runs(items, |open| { all.push(open.to_vec()); ControlFlow::<()>::Continue(()) }); all } 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>]); /// `(category, group, parent)`: a `>` line. type Parent<'a> = (&'a str, &'a str, &'a str); const TWO: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), ( "Month", &[ ("Jan", Some("Q1")), ("Feb", Some("Q1")), ("Mar", None), ("Apr", Some("Q2")), ], ), ]; const THREE: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), ("Month", &[("Jan", Some("Q1")), ("Mar", None)]), ("Kind", &[("Food", Some("Goods")), ("Tools", None)]), ]; /// `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"))], ), ]; /// 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")], &[], ), ( NESTED, &[ ("Region", "Upper"), ("Month", "H1"), ("Month", "Q1"), ("Month", "H2"), ], NESTED_PARENTS, ), ]; /// improvise-felo.6: a hierarchy INSIDE one category, under an outer group. /// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2. const NESTED: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), ( "Month", &[ ("Jan", Some("Q1")), ("Apr", Some("Q2")), ("Feb", Some("Q1")), ("Jun", Some("H1")), ("Jul", Some("Q3")), ], ), ]; const NESTED_PARENTS: &[Parent] = &[ ("Month", "Q1", "H1"), ("Month", "Q2", "H1"), ("Month", "Q3", "H2"), ]; /// [`book`] with `parents` declared and every group OPEN before `collapsed` /// folds its own, so a subset of groups is exactly what is collapsed. fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook { let mut wb = book(levels, &[]); for (cat, group, parent) in parents { let c = wb.model.category_mut(cat).unwrap(); c.add_group(crate::model::category::Group::new(*group).with_parent(*parent)); } for (cat, _) in levels { wb.active_view_mut().set_expand_depth(cat, 8); } for (cat, group) in collapsed { let chain: Vec = wb .model .category(cat) .unwrap() .group_chain(group) .into_iter() .map(String::from) .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); wb.active_view_mut().toggle_group_collapse(cat, &chain); } wb } /// `levels` on Row, one column, the groups `collapsed` names folded. No cell /// is set: the unpruned layout has every row, and pruning is driven directly. fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook { let mut wb = Workbook::new("T"); for (cat, items) in levels { wb.add_category(*cat).unwrap(); for (item, group) in items.iter() { match group { Some(g) => wb.add_item_in_group(cat, *item, *g).unwrap(), None => wb.add_item(cat, *item).unwrap(), }; } wb.active_view_mut().set_axis(cat, Axis::Row); wb.active_view_mut().set_expand_depth(cat, 1); } wb.add_category("Col").unwrap(); wb.add_item("Col", "X").unwrap(); wb.active_view_mut().set_axis("Col", Axis::Column); for (cat, group) in collapsed { wb.active_view_mut().toggle_group_collapse(cat, &[group]); } wb } fn layout(wb: &Workbook) -> GridLayout { GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) } /// One case: its name, the kept-row mask, and the unpruned and pruned rows. struct Case { name: String, mask: u32, full: Vec, pruned: Vec, } /// 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 { let mut out = Vec::new(); for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() { for folds in 0..(1u32 << groups.len()) { let collapsed: Vec<(&str, &str)> = (0..groups.len()) .filter(|g| folds & (1 << g) != 0) .map(|g| groups[g]) .collect(); let full = layout(&nested_book(levels, parents, &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 } /// The `(category, group)` a run's header names. fn named(items: &[AxisEntry], run: Option) -> 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: &[super::OpenRun], key: super::RunKey) -> Option { runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at) } #[test] fn pruning_keeps_every_surviving_rows_runs_at_every_level() { for case in cases() { let Case { name, mask, full, pruned, } = &case; let kept: Vec = (0..data_items(full).count()) .filter(|i| mask & (1 << i) != 0) .collect(); let full_runs = open_runs(full); let pruned_runs = open_runs(pruned); assert_eq!(pruned_runs.len(), kept.len(), "{name}: the survivors"); if kept.len() == full_runs.len() { assert_eq!(pruned, full, "{name}: nothing pruned"); } let levels: std::collections::BTreeSet = full_runs.iter().flatten().map(|(k, _)| *k).collect(); for (r, &u) in kept.iter().enumerate() { for &level in &levels { assert_eq!( named(pruned, at(&pruned_runs[r], level)), named(full, at(&full_runs[u], level)), "{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}" ); if r == 0 { continue; } let same = |runs: &[Vec], a: usize, b: usize| { at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level) }; assert_eq!( same(&pruned_runs, r - 1, r), same(&full_runs, kept[r - 1], u), "{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}" ); } } } } #[test] fn a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker() { for Case { name, pruned: items, .. } in cases() { for (i, entry) in items.iter().enumerate() { if let AxisEntry::GroupHeader { .. } = entry { let next = items[i + 1..] .iter() .find(|e| !matches!(e, AxisEntry::GroupHeader { .. })); assert!( matches!(next, Some(AxisEntry::DataItem { .. })), "{name}: header {entry:?} has no member in {items:?}" ); } } assert!( !matches!(items.last(), Some(AxisEntry::GroupEnd { .. })), "{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, depth: 0, cat_name: "Region".into(), group_name: "Upper".into(), collapsed: false, }, AxisEntry::item(vec!["North".into()]), AxisEntry::GroupHeader { level: usize::MAX, depth: 0, cat_name: "Nowhere".into(), group_name: "Unreachable".into(), collapsed: false, }, AxisEntry::item(vec!["South".into()]), ]; assert_eq!( super::innermost_group(&items, 0), Some(("Region".into(), "Upper".into())) ); } /// A lookup past the first run answers for its own row, not a neighbour's /// (improvise-0r01's late index). Evidence file. #[test] fn a_late_lookup_answers_for_its_own_row() { let header = |group: &str| AxisEntry::GroupHeader { level: 0, depth: 0, cat_name: "Month".into(), group_name: group.into(), collapsed: false, }; let items = vec![ header("Q1"), AxisEntry::item(vec!["Jan".into()]), AxisEntry::item(vec!["Feb".into()]), AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Mar".into()]), header("Q2"), AxisEntry::item(vec!["Apr".into()]), AxisEntry::item(vec!["May".into()]), ]; let q = |group: &str| Some(("Month".to_string(), group.to_string())); let found: Vec<_> = (0..6).map(|i| super::innermost_group(&items, i)).collect(); assert_eq!(found, vec![q("Q1"), q("Q1"), None, q("Q2"), q("Q2"), None]); } /// 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() { 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()][..])) .unwrap() }; assert_eq!( l.row_group_for(at(["North", "Jan"])), Some(("Month".into(), "Q1".into())), "the innermost open group" ); assert_eq!( l.row_group_for(at(["North", "Mar"])), Some(("Region".into(), "Upper".into())), "Q1 ended; Upper did not" ); assert_eq!(l.row_group_for(at(["South", "Mar"])), None); } /// Precondition for the enumeration above: NESTED must really produce runs /// below depth 0, or it enumerates nothing new. #[test] fn the_nested_fixture_draws_runs_inside_one_category() { let wb = nested_book(NESTED, NESTED_PARENTS, &[]); let deep = layout(&wb) .row_items .iter() .filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. })) .count(); assert!( deep >= 4, "Q1, Q2, Q1 again and Q3 under each region: {deep}" ); } } /// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month. #[cfg(test)] mod hierarchy { use super::{AxisEntry, GridLayout}; use crate::model::category::Group; use crate::model::cell::{CellKey, CellValue}; use crate::view::Axis; use crate::workbook::Workbook; /// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are /// non-contiguous. Values are powers of ten, so every fold is its own sum. fn dates() -> Workbook { let mut wb = Workbook::new("T"); wb.add_category("Date").unwrap(); for (month, quarter) in [ ("2024-01", Some("Q1-2024")), ("2024-04", Some("Q2-2024")), ("2025-01", Some("Q1-2025")), ("Undated", None), ("2024-02", Some("Q1-2024")), ] { match quarter { Some(q) => wb.add_item_in_group("Date", month, q).unwrap(), None => wb.add_item("Date", month).unwrap(), }; } let cat = wb.model.category_mut("Date").unwrap(); for (quarter, year) in [ ("Q1-2024", "2024"), ("Q2-2024", "2024"), ("Q1-2025", "2025"), ] { cat.add_group(Group::new(quarter).with_parent(year)); } wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Date", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); for (month, v) in [ ("2024-01", 1.0), ("2024-02", 10.0), ("2024-04", 100.0), ("2025-01", 1000.0), ("Undated", 5.0), ] { wb.model.set_cell(key(month), CellValue::Number(v)); } wb } fn key(month: &str) -> CellKey { CellKey::new(vec![ ("Date".into(), month.into()), ("_Measure".into(), "Revenue".into()), ]) } fn layout(wb: &Workbook) -> GridLayout { GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) } fn rows(l: &GridLayout) -> Vec { (0..l.row_count()).map(|r| l.row_label(r)).collect() } fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option { let r = rows(l).iter().position(|x| x == label)?; l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64() } fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry { AxisEntry::GroupHeader { level: 0, depth, cat_name: "Date".into(), group_name: group.into(), collapsed, } } fn end(depth: usize) -> AxisEntry { AxisEntry::GroupEnd { level: 0, depth } } fn item(name: &str) -> AxisEntry { AxisEntry::item(vec![name.into()]) } /// Opened to depth 2, the whole sequence: runs nest by depth, a new year's /// header replaces the old, and a non-contiguous quarter reopens both. #[test] fn a_year_quarter_month_hierarchy_nests_its_runs() { let mut wb = dates(); wb.active_view_mut().set_expand_depth("Date", 2); assert_eq!( layout(&wb).row_items, vec![ header(0, "2024", false), header(1, "Q1-2024", false), item("2024-01"), header(1, "Q2-2024", false), item("2024-04"), end(1), header(0, "2025", false), header(1, "Q1-2025", false), item("2025-01"), end(1), end(0), item("Undated"), header(0, "2024", false), header(1, "Q1-2024", false), item("2024-02"), ] ); } /// The user's ruling: a hierarchy starts closed, one row per root. #[test] fn a_hierarchy_opens_closed_as_one_fold_per_root() { let wb = dates(); let l = layout(&wb); assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]); assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024"); assert_eq!(value(&wb, &l, "2025"), Some(1000.0)); assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2)); } /// Open the years only: each quarter folds its months, ONCE, even when its /// months are not contiguous. #[test] fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() { let mut wb = dates(); wb.active_view_mut().set_expand_depth("Date", 1); let l = layout(&wb); assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]); assert_eq!( value(&wb, &l, "Q1-2024"), Some(11.0), "Jan and the late Feb" ); assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0)); } /// Collapsing an outer group folds everything under it, whatever its /// children's own state. #[test] fn collapsing_a_parent_folds_its_children_whatever_their_state() { let mut wb = dates(); wb.active_view_mut().set_expand_depth("Date", 2); wb.active_view_mut() .toggle_group_collapse("Date", &["2024"]); let l = layout(&wb); assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]); assert_eq!(value(&wb, &l, "2024"), Some(111.0)); } /// `z` names the innermost open group: a month's quarter, a quarter fold's /// own quarter, a year fold's year — at a nonzero row each time. #[test] fn the_group_under_the_cursor_is_the_innermost_at_every_depth() { let mut wb = dates(); wb.active_view_mut().set_expand_depth("Date", 1); wb.active_view_mut() .toggle_group_collapse("Date", &["2024", "Q2-2024"]); let l = layout(&wb); let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap(); let g = |name: &str| Some(("Date".to_string(), name.to_string())); assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024")); assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025")); assert_eq!(l.row_group_for(at("Undated")), None); wb.active_view_mut().set_expand_depth("Date", 0); let l = layout(&wb); assert_eq!(l.row_group_for(1), g("2025")); } /// A hidden month leaves every fold above it; a quarter whose every month /// is hidden has no row and no header. #[test] fn hidden_items_leave_every_fold_above_them() { let mut wb = dates(); wb.active_view_mut().hide_item("Date", "2024-04"); let l = layout(&wb); assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden"); wb.active_view_mut().set_expand_depth("Date", 2); let l = layout(&wb); let q2 = |e: &AxisEntry| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024"); assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items); } /// `prune_empty` at depth: an empty quarter goes, header and all, and the /// year above it stays for the quarter that has data. #[test] fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() { let mut wb = dates(); wb.model.clear_cell(&key("2024-04")); wb.active_view_mut().set_expand_depth("Date", 1); wb.active_view_mut().prune_empty = true; let l = layout(&wb); assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]); assert_eq!(l.row_items[0], header(0, "2024", false)); } } #[cfg(test)] mod lookup_cost_tests { use super::{DATA_ITEM_STEPS, GridLayout}; use crate::view::Axis; use crate::workbook::Workbook; fn tall(rows: usize) -> GridLayout { let mut wb = Workbook::new("T"); wb.add_category("Row").unwrap(); wb.add_category("Col").unwrap(); for i in 0..rows { wb.add_item("Row", format!("r{i}")).unwrap(); } wb.add_item("Col", "c0").unwrap(); wb.add_item("Col", "c1").unwrap(); wb.active_view_mut().set_axis("Row", Axis::Row); wb.active_view_mut().set_axis("Col", Axis::Column); GridLayout::new(&wb.model, wb.active_view()) } fn steps(f: impl FnOnce()) -> usize { DATA_ITEM_STEPS.with(|n| n.set(0)); f(); DATA_ITEM_STEPS.with(|n| n.get()) } /// improvise-felo.13: context/test-evidence/layout-cost.md. #[test] fn an_axis_lookup_does_work_independent_of_the_row_index() { let layout = tall(400); let last = layout.row_count() - 1; assert_eq!(last, 399, "the fixture is as tall as the test needs"); let lookups: [(&str, &dyn Fn(usize)); 4] = [ ("cell_key", &|r| { let _ = layout.cell_key(r, 1); }), ("row_tuple", &|r| { let _ = layout.row_tuple(r); }), ("row_label", &|r| { let _ = layout.row_label(r); }), ("column_key", &|r| { let _ = layout.column_key(r % 2); }), ]; for (name, lookup) in lookups { let first = steps(|| lookup(0)); let deep = steps(|| lookup(last)); assert!( deep <= first + 2, "{name}: row {last} cost {deep} steps against {first} at row 0" ); } } /// improvise-felo.13: the index answers what the walk it replaced did, through /// groups, a collapsed fold and a pruned axis. Evidence: layout-cost.md. #[test] fn the_position_index_answers_what_the_walk_answered() { use super::{AxisEntry, data_items}; use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); for cat in ["Row", "Col"] { wb.add_category(cat).unwrap(); } for (i, g) in ["Q1", "Q1", "Q2", "Q2", "Q3"].iter().enumerate() { wb.add_item_in_group("Row", format!("r{i}"), *g).unwrap(); } wb.add_item("Row", "ghost").unwrap(); wb.add_item("Col", "c0").unwrap(); wb.active_view_mut().set_axis("Row", Axis::Row); wb.active_view_mut().set_axis("Col", Axis::Column); wb.active_view_mut().set_expand_depth("Row", 1); wb.active_view_mut().toggle_group_collapse("Row", &["Q2"]); wb.active_view_mut().prune_empty = true; for i in 0..5 { let key = CellKey::new(vec![ ("Row".into(), format!("r{i}")), ("Col".into(), "c0".into()), ]); wb.model.set_cell(key, CellValue::Number(1.0)); } let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert!( layout .row_items .iter() .any(|e| !matches!(e, AxisEntry::DataItem { .. })) ); assert!(layout.row_items.iter().any(|e| matches!( e, AxisEntry::DataItem { collapsed: true, .. } ))); let walked: Vec<&Vec> = data_items(&layout.row_items).collect(); assert_eq!(layout.row_count(), walked.len()); let visual: Vec = layout .row_items .iter() .enumerate() .filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. })) .map(|(at, _)| at) .collect(); for i in 0..walked.len() + 2 { assert_eq!( layout.row_tuple(i), walked.get(i).map(|t| t.as_slice()), "row {i}" ); assert_eq!( layout.data_row_to_visual(i), visual.get(i).copied(), "visual {i}" ); let folded = matches!( data_items_entry(&layout.row_items, i), Some(AxisEntry::DataItem { collapsed: true, .. }) ); assert_eq!(layout.row_folds_a_group(i), folded, "fold {i}"); } } /// improvise-felo.13: a drill held open while records are removed elsewhere. /// Evidence: layout-cost.md. #[test] fn a_frozen_records_snapshot_replaces_the_live_row_index() { use crate::model::cell::{CellKey, CellValue}; let mut wb = Workbook::new("T"); wb.add_category("Region").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); let cell = |i: usize| { CellKey::new(vec![ ("Region".into(), format!("r{i}")), ("_Measure".into(), "Revenue".into()), ]) }; for i in 0..3 { wb.add_item("Region", format!("r{i}")).unwrap(); wb.model.set_cell(cell(i), CellValue::Number(1.0)); } let v = wb.active_view_mut(); v.set_axis("_Index", Axis::Row); v.set_axis("_Dim", Axis::Column); let frozen = GridLayout::new(&wb.model, wb.active_view()).records; wb.model.clear_cell(&cell(1)); wb.model.clear_cell(&cell(2)); let live = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); assert_eq!(live.row_count(), 1, "the control: one record is live"); let held = GridLayout::with_frozen_records(&wb.model, wb.active_view(), frozen); assert_eq!(held.row_count(), 3); assert_eq!(held.row_tuple(2), Some(&["2".to_string()][..])); assert!(held.cell_key(2, 0).is_some()); } fn data_items_entry(items: &[super::AxisEntry], n: usize) -> Option<&super::AxisEntry> { items .iter() .filter(|e| matches!(e, super::AxisEntry::DataItem { .. })) .nth(n) } }