Files
improvise/crates/improvise-core/src/view/layout.rs
T
Edward Langley 1a359b4ae6 improvise-6qet: CSV export test, formatting, and the reasoning in context/
A collapsed group is written to CSV as its own total row, because an export
writes what the grid draws (improvise-iot6). core.md §14 carries the argument;
the evidence entries record each rival and where it failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:06:41 -07:00

1754 lines
65 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::rc::Rc;
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
use crate::model::cell::{CellKey, CellValue};
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)
}
/// 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 {
GroupHeader {
cat_name: String,
group_name: String,
},
/// The run opened by the last `GroupHeader` ends here (improvise-gd83).
/// Nothing is drawn for it; it exists so membership is not inherited.
GroupEnd,
/// `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.
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,
})
}
/// Whether the data item at `idx` folds a collapsed group.
fn folds(items: &[AxisEntry], idx: usize) -> bool {
items
.iter()
.filter_map(|e| match e {
AxisEntry::DataItem { collapsed, .. } => Some(*collapsed),
AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None,
})
.nth(idx)
.unwrap_or(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], idx: usize) -> String {
data_items(items)
.nth(idx)
.map(|v| v.join("/"))
.unwrap_or_default()
}
/// Visual index of the nth data entry (skipping group headers).
fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option<usize> {
items
.iter()
.enumerate()
.filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. }))
.map(|(vi, _)| vi)
.nth(data_idx)
}
/// The run a header opened has ended. A header still pending saw either no
/// item at all — it is collapsed, and survives — or only rejected ones.
fn close_pending_run(
kept: &mut Vec<AxisEntry>,
pending: Option<AxisEntry>,
had_item: bool,
) -> bool {
pending
.filter(|_| !had_item)
.map(|h| kept.push(h))
.is_some()
}
/// Drop the data items `keep` rejects, keeping a header only if an item under
/// it survives or the group is collapsed. A predicate — why: core.md §6.
fn prune_axis_items(items: &mut Vec<AxisEntry>, keep: impl Fn(usize) -> bool) {
let mut kept: Vec<AxisEntry> = Vec::new();
let mut pending_header: Option<AxisEntry> = None;
let mut run_had_item = false;
let mut run_open = false;
let mut data_idx = 0usize;
for entry in items.drain(..) {
match &entry {
// A header ends the previous run: another group's header is where
// `expand_category` stops emitting a `GroupEnd` (improvise-gd83).
AxisEntry::GroupHeader { .. } => {
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
pending_header = Some(entry);
run_had_item = false;
}
// Kept whenever a run is open in `kept`, even where the header it
// closed was pruned: an earlier one is then re-opened (u6gc).
AxisEntry::GroupEnd => {
run_open |= close_pending_run(&mut kept, pending_header.take(), run_had_item);
run_had_item = false;
if run_open {
kept.push(entry);
run_open = false;
}
}
AxisEntry::DataItem { .. } => {
run_had_item = true;
if keep(data_idx) {
if let Some(h) = pending_header.take() {
kept.push(h);
run_open = true;
}
kept.push(entry);
}
data_idx += 1;
}
}
}
close_pending_run(&mut kept, pending_header.take(), run_had_item);
*items = kept;
}
/// 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>,
/// 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>,
}
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);
}
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_items,
col_items,
none_cats,
records: None,
record_cols: Vec::new(),
}
}
}
/// 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_items,
col_items,
none_cats,
records: Some(Rc::new(records)),
record_cols,
}
}
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?"
let mut has_value = vec![vec![false; cc]; rc];
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| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats)))
.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])
});
}
/// 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 {
data_items(&self.row_items).count()
}
/// Number of data columns (group headers excluded).
pub fn col_count(&self) -> usize {
data_items(&self.col_items).count()
}
pub fn row_label(&self, row: usize) -> String {
axis_label(&self.row_items, row)
}
pub fn col_label(&self, col: usize) -> String {
axis_label(&self.col_items, col)
}
/// Display string for a synthetic records-mode key; `None` for a pivot
/// key.
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
let (row, col) = synthetic_record_info(key)?;
self.records_display(row, col)
}
/// 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 =
model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))?;
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_items(&self.row_items).nth(row)?;
let col_item = data_items(&self.col_items).nth(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_items(&self.col_items).nth(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_items, 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_items, data_col)
}
/// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding
/// the Nth data row.
pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
let vi = self.data_row_to_visual(data_row)?;
self.row_items[..vi]
.iter()
.rev()
.find_map(|e| match e {
AxisEntry::GroupHeader {
cat_name,
group_name,
} => Some(Some((cat_name.clone(), group_name.clone()))),
AxisEntry::GroupEnd => Some(None),
AxisEntry::DataItem { .. } => None,
})
.flatten()
}
/// 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 {
folds(&self.row_items, row) || folds(&self.col_items, col)
}
/// 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_items(&self.row_items).nth(row).map(Vec::as_slice)
}
/// The coordinate tuple of the Nth data column.
pub fn col_tuple(&self, col: usize) -> Option<&[String]> {
data_items(&self.col_items).nth(col).map(Vec::as_slice)
}
/// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding
/// the Nth data column.
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
let vi = self.data_col_to_visual(data_col)?;
self.col_items[..vi]
.iter()
.rev()
.find_map(|e| match e {
AxisEntry::GroupHeader {
cat_name,
group_name,
} => Some(Some((cat_name.clone(), group_name.clone()))),
AxisEntry::GroupEnd => Some(None),
AxisEntry::DataItem { .. } => None,
})
.flatten()
}
}
/// 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![];
};
let mut result = Vec::new();
let mut last_group: Option<&str> = None;
// 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 item_group = cat
.items
.get(item_name.as_str())
.and_then(|i| i.group.as_deref());
let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g));
if collapsed.is_some_and(|g| folded.contains(&g)) {
continue;
}
// A header on entering a group; a `GroupEnd` only on leaving one for
// NO group — another group's header already ends the run.
if item_group != last_group {
if last_group.is_some() && item_group.is_none() {
result.push(AxisEntry::GroupEnd);
}
if let Some(g) = item_group {
result.push(AxisEntry::GroupHeader {
cat_name: cat_name.to_string(),
group_name: g.to_string(),
});
}
last_group = item_group;
}
let mut tuple = prefix.clone();
match collapsed {
Some(g) => {
folded.push(g);
tuple.push(g.to_string());
}
None => tuple.push(item_name.to_string()),
}
result.push(AxisEntry::DataItem {
tuple,
collapsed: prefix_collapsed || collapsed.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).
if last_group.is_some() {
result.push(AxisEntry::GroupEnd);
}
result
}
/// 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
}
#[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::cell::{CellKey, CellValue};
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 {
cat_name: "C".into(),
group_name: "G".into(),
},
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 {
cat_name: "C".into(),
group_name: "G".into(),
},
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_resolve_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();
let key = layout.cell_key(0, value_col).unwrap();
assert_eq!(layout.resolve_display(&key).as_deref(), Some("100"));
// Category column resolves to the coordinate value
let region_col = cols.iter().position(|c| c == "Region").unwrap();
let key = layout.cell_key(0, region_col).unwrap();
let display = layout.resolve_display(&key).unwrap();
assert!(
!display.is_empty(),
"Region column should resolve to a value"
);
}
#[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.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.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.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.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.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.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_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.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.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();
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.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();
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.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 {
cat_name: "Month".into(),
group_name: "Q1".into()
},
AxisEntry::DataItem {
tuple: vec!["Q1".into()],
collapsed: true
},
AxisEntry::GroupEnd,
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"
);
}
/// 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"
);
}
}