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