Files
improvise/crates/improvise-core/src/view/layout.rs
T

3175 lines
119 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Item = (CellKey, CellValue)>) -> Vec<RecordRow> {
let mut rows: Vec<RecordRow> = 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<RecordRow> {
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<ViewStep> {
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<ViewStep> {
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<ViewStep> {
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<String>, collapsed: bool },
}
impl AxisEntry {
/// An ordinary, uncollapsed data item.
pub fn item(tuple: Vec<String>) -> 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<Item = &Vec<String>> {
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<usize> = 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<usize> {
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<String>> {
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<usize> {
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<usize> {
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<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new();
// What the input has open, and what `kept` has open so far.
let mut input: OpenRuns = Vec::new();
let mut output: Vec<OpenRun> = 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<OpenRun>, kept: &mut Vec<AxisEntry>) {
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<T>(
items: &[AxisEntry],
mut visit: impl FnMut(&[OpenRun]) -> ControlFlow<T>,
) -> Option<T> {
let mut open: Vec<OpenRun> = 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<Vec<String>>,
}
/// 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<String>,
pub col_cats: Vec<String>,
pub page_coords: Vec<PageAxis>,
pub row_items: Vec<AxisEntry>,
pub col_items: Vec<AxisEntry>,
/// `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<usize>,
col_pos: Vec<usize>,
/// Categories on `Axis::None` — hidden, implicitly aggregated.
pub none_cats: Vec<String>,
/// 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<Rc<Vec<RecordRow>>>,
/// In records mode: what each column holds, parallel to `col_items`.
/// Empty for normal pivot views.
record_cols: Vec<RecordCol>,
/// The view's hidden items: a fold covers only the members this grid draws.
hidden: HashMap<String, HashSet<String>>,
}
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<Rc<Vec<RecordRow>>>,
) -> Self {
let mut layout = Self::new(model, view);
if layout.is_records_mode()
&& let Some(records) = frozen_records
{
let row_items: Vec<AxisEntry> = (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<String> = view
.categories_on(Axis::Row)
.into_iter()
.map(String::from)
.collect();
let col_cats: Vec<String> = 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<String> = view
.filtering_cats()
.into_iter()
.map(String::from)
.collect();
let none_cats: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
let page_coords = page_cats
.iter()
.filter_map(|cat| {
let items: Vec<String> = 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<PageAxis>,
none_cats: Vec<String>,
) -> Self {
let records = Self::group_records(model, &page_constraints(&page_coords));
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::item(vec![i.to_string()]))
.collect();
let record_cols = Self::records_columns(model);
let col_items: Vec<AxisEntry> = 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> {
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<RecordCol> {
let mut cols: Vec<RecordCol> = 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<CellKey> {
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<CellKey> {
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<CellValue> {
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<String> {
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<CellValue> {
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<CellKey> {
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<CellKey> {
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<String>,
prefix_collapsed: bool,
) -> Vec<AxisEntry> {
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<AxisEntry>,
}
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<AxisEntry> {
if cats.is_empty() {
return vec![AxisEntry::item(vec![])];
}
let mut result: Vec<AxisEntry> = 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<CellValue> {
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<CellValue> {
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<String> = (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<String> = (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<String> = (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<String> = (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<f64> {
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<String> = 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<String> = (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<String> = (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<String> = 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<CellKey> = (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<String> = ["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<Vec<super::OpenRun>> {
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<String> = wb
.model
.category(cat)
.unwrap()
.group_chain(group)
.into_iter()
.map(String::from)
.collect();
let chain: Vec<&str> = chain.iter().map(String::as_str).collect();
wb.active_view_mut().toggle_group_collapse(cat, &chain);
}
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<AxisEntry>,
pruned: Vec<AxisEntry>,
}
/// Every collapse subset of every fixture, one layout each, pruned by every
/// non-empty set of kept rows. The layout is the cost, so it is built once.
fn cases() -> Vec<Case> {
let mut out = Vec::new();
for (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<usize>) -> Option<(&str, &str)> {
match &items[run?] {
AxisEntry::GroupHeader {
cat_name,
group_name,
..
} => Some((cat_name, group_name)),
AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None,
}
}
fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option<usize> {
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<usize> = (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<super::RunKey> =
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<super::OpenRun>], 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<String> {
(0..l.row_count()).map(|r| l.row_label(r)).collect()
}
fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option<f64> {
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<String>> = data_items(&layout.row_items).collect();
assert_eq!(layout.row_count(), walked.len());
let visual: Vec<usize> = 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)
}
}