1a359b4ae6
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
1754 lines
65 KiB
Rust
1754 lines
65 KiB
Rust
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"
|
||
);
|
||
}
|
||
}
|