Refactor records mode to use synthetic CellKeys (_Index, _Dim) for all columns, allowing uniform handling of display values and edits across both pivot and records modes. - Introduce `synthetic_record_info` to extract metadata from synthetic keys. - Update `GridLayout::cell_key` to return synthetic keys in records mode. - Add `GridLayout::resolve_display` to handle value resolution for synthetic keys. - Replace `records_col` and `records_value` in `CmdContext` with a unified `display_value`. - Update `EditOrDrill` and `AddRecordRow` to use synthetic key detection. - Refactor `CommitCellEdit` to use a shared `commit_cell_value` helper. BREAKING CHANGE: CmdContext fields `records_col` and `records_value` are replaced by `display_value` . Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
993 lines
34 KiB
Rust
993 lines
34 KiB
Rust
use crate::model::cell::{CellKey, CellValue};
|
||
use crate::model::Model;
|
||
use crate::view::{Axis, View};
|
||
|
||
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
|
||
/// Returns None for normal pivot-mode keys.
|
||
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
|
||
let idx: usize = key.get("_Index")?.parse().ok()?;
|
||
let dim = key.get("_Dim")?.to_string();
|
||
Some((idx, dim))
|
||
}
|
||
|
||
/// One entry on a grid axis: either a visual group header or a data-item tuple.
|
||
///
|
||
/// `GroupHeader` entries are always visible so the user can see the group label
|
||
/// and toggle its collapse state. `DataItem` entries are absent when their
|
||
/// group is collapsed.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum AxisEntry {
|
||
GroupHeader {
|
||
cat_name: String,
|
||
group_name: String,
|
||
},
|
||
DataItem(Vec<String>),
|
||
}
|
||
|
||
/// The resolved 2-D layout of a view: which item tuples appear on each axis,
|
||
/// what page filter is active, and how to map (row, col) → CellKey.
|
||
///
|
||
/// This is the single authoritative place that converts the multi-dimensional
|
||
/// model into the flat grid consumed by both the terminal renderer and CSV exporter.
|
||
pub struct GridLayout {
|
||
pub row_cats: Vec<String>,
|
||
pub col_cats: Vec<String>,
|
||
pub page_coords: Vec<(String, String)>,
|
||
pub row_items: Vec<AxisEntry>,
|
||
pub col_items: Vec<AxisEntry>,
|
||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||
pub none_cats: Vec<String>,
|
||
/// In records mode: the filtered cell list, one per row.
|
||
/// None for normal pivot views.
|
||
pub records: Option<Vec<(CellKey, CellValue)>>,
|
||
}
|
||
|
||
impl GridLayout {
|
||
/// Build a layout. When records-mode is active and `frozen_records`
|
||
/// is provided, use that snapshot instead of re-querying the store.
|
||
pub fn with_frozen_records(
|
||
model: &Model,
|
||
view: &View,
|
||
frozen_records: Option<Vec<(CellKey, CellValue)>>,
|
||
) -> Self {
|
||
let mut layout = Self::new(model, view);
|
||
if layout.is_records_mode() {
|
||
if let Some(records) = frozen_records {
|
||
// Re-build with the frozen records instead
|
||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||
.collect();
|
||
layout.row_items = row_items;
|
||
layout.records = Some(records);
|
||
}
|
||
}
|
||
if view.prune_empty {
|
||
layout.prune_empty(model);
|
||
}
|
||
layout
|
||
}
|
||
|
||
pub fn new(model: &Model, view: &View) -> Self {
|
||
let row_cats: Vec<String> = view
|
||
.categories_on(Axis::Row)
|
||
.into_iter()
|
||
.map(String::from)
|
||
.collect();
|
||
let col_cats: Vec<String> = view
|
||
.categories_on(Axis::Column)
|
||
.into_iter()
|
||
.map(String::from)
|
||
.collect();
|
||
let page_cats: Vec<String> = view
|
||
.categories_on(Axis::Page)
|
||
.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()
|
||
.map(|cat| {
|
||
let items: Vec<String> = model
|
||
.category(cat)
|
||
.map(|c| {
|
||
c.ordered_item_names()
|
||
.into_iter()
|
||
.map(String::from)
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let sel = view
|
||
.page_selection(cat)
|
||
.map(String::from)
|
||
.or_else(|| items.first().cloned())
|
||
.unwrap_or_default();
|
||
(cat.clone(), sel)
|
||
})
|
||
.collect();
|
||
|
||
// Detect records mode: _Index on Row and _Dim on Col
|
||
let is_records_mode =
|
||
row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");
|
||
|
||
if is_records_mode {
|
||
Self::build_records_mode(model, view, page_coords, none_cats)
|
||
} else {
|
||
let row_items = cross_product(model, view, &row_cats);
|
||
let col_items = cross_product(model, view, &col_cats);
|
||
Self {
|
||
row_cats,
|
||
col_cats,
|
||
page_coords,
|
||
row_items,
|
||
col_items,
|
||
none_cats,
|
||
records: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a records-mode layout: rows are individual cells, columns are
|
||
/// category names + "Value". Cells matching the page filter are enumerated.
|
||
fn build_records_mode(
|
||
model: &Model,
|
||
_view: &View,
|
||
page_coords: Vec<(String, String)>,
|
||
none_cats: Vec<String>,
|
||
) -> Self {
|
||
// Filter cells by page_coords
|
||
let partial: Vec<(String, String)> = page_coords.clone();
|
||
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
|
||
model
|
||
.data
|
||
.iter_cells()
|
||
.map(|(k, v)| (k, v.clone()))
|
||
.collect()
|
||
} else {
|
||
model
|
||
.data
|
||
.matching_cells(&partial)
|
||
.into_iter()
|
||
.map(|(k, v)| (k, v.clone()))
|
||
.collect()
|
||
};
|
||
// Sort for deterministic ordering
|
||
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||
|
||
// Synthesize row items: one per record, labeled with its index
|
||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||
.collect();
|
||
|
||
// Synthesize col items: one per non-virtual category + "Value"
|
||
let cat_names: Vec<String> = model
|
||
.category_names()
|
||
.into_iter()
|
||
.filter(|c| !c.starts_with('_'))
|
||
.map(String::from)
|
||
.collect();
|
||
let mut col_items: Vec<AxisEntry> = cat_names
|
||
.iter()
|
||
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
|
||
.collect();
|
||
col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));
|
||
|
||
Self {
|
||
row_cats: vec!["_Index".to_string()],
|
||
col_cats: vec!["_Dim".to_string()],
|
||
page_coords,
|
||
row_items,
|
||
col_items,
|
||
none_cats,
|
||
records: Some(records),
|
||
}
|
||
}
|
||
|
||
/// Get the display string for the cell at (row, col) in records mode.
|
||
/// Returns None for normal (non-records) layouts.
|
||
pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
|
||
let records = self.records.as_ref()?;
|
||
let record = records.get(row)?;
|
||
let col_item = self.col_label(col);
|
||
if col_item == "Value" {
|
||
Some(record.1.to_string())
|
||
} else {
|
||
// col_item is a category name
|
||
let found = record
|
||
.0
|
||
.0
|
||
.iter()
|
||
.find(|(c, _)| c == &col_item)
|
||
.map(|(_, v)| v.clone());
|
||
Some(found.unwrap_or_default())
|
||
}
|
||
}
|
||
|
||
/// Remove data rows where every column is empty and data columns
|
||
/// where every row is empty. Group headers are kept if at least one
|
||
/// of their data items survives.
|
||
///
|
||
/// In records mode every column is shown (the user drilled in to see
|
||
/// all the raw data). In pivot mode, rows and columns where every
|
||
/// cell is empty are hidden to reduce clutter.
|
||
pub fn prune_empty(&mut self, model: &Model) {
|
||
if self.is_records_mode() {
|
||
return;
|
||
}
|
||
let rc = self.row_count();
|
||
let cc = self.col_count();
|
||
if rc == 0 || cc == 0 {
|
||
return;
|
||
}
|
||
|
||
// Build a row×col grid of "has content?"
|
||
let mut has_value = vec![vec![false; cc]; rc];
|
||
for ri in 0..rc {
|
||
for ci in 0..cc {
|
||
has_value[ri][ci] = if self.is_records_mode() {
|
||
let s = self.records_display(ri, ci).unwrap_or_default();
|
||
!s.is_empty()
|
||
} else {
|
||
self.cell_key(ri, ci)
|
||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||
.is_some()
|
||
};
|
||
}
|
||
}
|
||
|
||
// Which data-row indices are non-empty?
|
||
let keep_row: Vec<bool> = (0..rc)
|
||
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
|
||
.collect();
|
||
// Which data-col indices are non-empty?
|
||
let keep_col: Vec<bool> = (0..cc)
|
||
.map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
|
||
.collect();
|
||
|
||
// Filter row_items, preserving group headers when at least one
|
||
// subsequent data item survives.
|
||
let mut new_rows = Vec::new();
|
||
let mut pending_header: Option<AxisEntry> = None;
|
||
let mut data_idx = 0usize;
|
||
for entry in self.row_items.drain(..) {
|
||
match &entry {
|
||
AxisEntry::GroupHeader { .. } => {
|
||
pending_header = Some(entry);
|
||
}
|
||
AxisEntry::DataItem(_) => {
|
||
if data_idx < rc && keep_row[data_idx] {
|
||
if let Some(h) = pending_header.take() {
|
||
new_rows.push(h);
|
||
}
|
||
new_rows.push(entry);
|
||
}
|
||
data_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
self.row_items = new_rows;
|
||
|
||
// Filter col_items (same logic)
|
||
let mut new_cols = Vec::new();
|
||
let mut pending_header: Option<AxisEntry> = None;
|
||
let mut data_idx = 0usize;
|
||
for entry in self.col_items.drain(..) {
|
||
match &entry {
|
||
AxisEntry::GroupHeader { .. } => {
|
||
pending_header = Some(entry);
|
||
}
|
||
AxisEntry::DataItem(_) => {
|
||
if data_idx < cc && keep_col[data_idx] {
|
||
if let Some(h) = pending_header.take() {
|
||
new_cols.push(h);
|
||
}
|
||
new_cols.push(entry);
|
||
}
|
||
data_idx += 1;
|
||
}
|
||
}
|
||
}
|
||
self.col_items = new_cols;
|
||
|
||
// If records mode, also prune the records vec and re-index row_items
|
||
if let Some(records) = &self.records {
|
||
let new_records: Vec<_> = keep_row
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, keep)| **keep)
|
||
.map(|(i, _)| records[i].clone())
|
||
.collect();
|
||
let new_row_items: Vec<AxisEntry> = (0..new_records.len())
|
||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||
.collect();
|
||
self.row_items = new_row_items;
|
||
self.records = Some(new_records);
|
||
}
|
||
}
|
||
|
||
/// 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_items
|
||
.iter()
|
||
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||
.count()
|
||
}
|
||
|
||
/// Number of data columns (group headers excluded).
|
||
pub fn col_count(&self) -> usize {
|
||
self.col_items
|
||
.iter()
|
||
.filter(|e| matches!(e, AxisEntry::DataItem(_)))
|
||
.count()
|
||
}
|
||
|
||
pub fn row_label(&self, row: usize) -> String {
|
||
self.row_items
|
||
.iter()
|
||
.filter_map(|e| {
|
||
if let AxisEntry::DataItem(v) = e {
|
||
Some(v)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.nth(row)
|
||
.map(|r| r.join("/"))
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub fn col_label(&self, col: usize) -> String {
|
||
self.col_items
|
||
.iter()
|
||
.filter_map(|e| {
|
||
if let AxisEntry::DataItem(v) = e {
|
||
Some(v)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.nth(col)
|
||
.map(|c| c.join("/"))
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Resolve the display string for a synthetic records-mode CellKey.
|
||
/// Returns None for non-synthetic (pivot) keys.
|
||
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
|
||
let (idx, dim) = synthetic_record_info(key)?;
|
||
let records = self.records.as_ref()?;
|
||
let (orig_key, value) = records.get(idx)?;
|
||
if dim == "Value" {
|
||
Some(value.to_string())
|
||
} else {
|
||
Some(orig_key.get(&dim).unwrap_or("").to_string())
|
||
}
|
||
}
|
||
|
||
/// Build the CellKey for the data cell at (row, col), including the active
|
||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||
/// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
|
||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||
if self.records.is_some() {
|
||
let records = self.records.as_ref().unwrap();
|
||
if row >= records.len() {
|
||
return None;
|
||
}
|
||
let col_label = self.col_label(col);
|
||
if col_label.is_empty() {
|
||
return None;
|
||
}
|
||
return Some(CellKey::new(vec![
|
||
("_Index".to_string(), row.to_string()),
|
||
("_Dim".to_string(), col_label),
|
||
]));
|
||
}
|
||
let row_item = self
|
||
.row_items
|
||
.iter()
|
||
.filter_map(|e| {
|
||
if let AxisEntry::DataItem(v) = e {
|
||
Some(v)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.nth(row)?;
|
||
let col_item = self
|
||
.col_items
|
||
.iter()
|
||
.filter_map(|e| {
|
||
if let AxisEntry::DataItem(v) = e {
|
||
Some(v)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.nth(col)?;
|
||
let mut coords = self.page_coords.clone();
|
||
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))
|
||
}
|
||
|
||
/// Visual index of the nth data row (skipping group headers).
|
||
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
|
||
let mut count = 0;
|
||
for (vi, entry) in self.row_items.iter().enumerate() {
|
||
if let AxisEntry::DataItem(_) = entry {
|
||
if count == data_row {
|
||
return Some(vi);
|
||
}
|
||
count += 1;
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Visual index of the nth data column (skipping group headers).
|
||
pub fn data_col_to_visual(&self, data_col: usize) -> Option<usize> {
|
||
let mut count = 0;
|
||
for (vi, entry) in self.col_items.iter().enumerate() {
|
||
if let AxisEntry::DataItem(_) = entry {
|
||
if count == data_col {
|
||
return Some(vi);
|
||
}
|
||
count += 1;
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Find the group containing the Nth data row.
|
||
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
|
||
pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
|
||
let vi = self.data_row_to_visual(data_row)?;
|
||
self.row_items[..vi].iter().rev().find_map(|e| {
|
||
if let AxisEntry::GroupHeader {
|
||
cat_name,
|
||
group_name,
|
||
} = e
|
||
{
|
||
Some((cat_name.clone(), group_name.clone()))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Find the group containing the Nth data column.
|
||
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
|
||
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
|
||
let vi = self.data_col_to_visual(data_col)?;
|
||
self.col_items[..vi].iter().rev().find_map(|e| {
|
||
if let AxisEntry::GroupHeader {
|
||
cat_name,
|
||
group_name,
|
||
} = e
|
||
{
|
||
Some((cat_name.clone(), group_name.clone()))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
|
||
/// Emits a `GroupHeader` at each group boundary, then `DataItem` entries for
|
||
/// visible, non-collapsed items.
|
||
fn expand_category(
|
||
model: &Model,
|
||
view: &View,
|
||
cat_name: &str,
|
||
prefix: Vec<String>,
|
||
) -> Vec<AxisEntry> {
|
||
let Some(cat) = model.category(cat_name) else {
|
||
return vec![];
|
||
};
|
||
let mut result = Vec::new();
|
||
let mut last_group: Option<&str> = None;
|
||
|
||
for item_name in cat.ordered_item_names() {
|
||
if view.is_hidden(cat_name, item_name) {
|
||
continue;
|
||
}
|
||
let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref());
|
||
|
||
// Emit a group header at each group boundary.
|
||
if item_group != last_group {
|
||
if let Some(g) = item_group {
|
||
result.push(AxisEntry::GroupHeader {
|
||
cat_name: cat_name.to_string(),
|
||
group_name: g.to_string(),
|
||
});
|
||
}
|
||
last_group = item_group;
|
||
}
|
||
|
||
// Skip the data item if its group is collapsed.
|
||
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
|
||
continue;
|
||
}
|
||
|
||
let mut row = prefix.clone();
|
||
row.push(item_name.to_string());
|
||
result.push(AxisEntry::DataItem(row));
|
||
}
|
||
result
|
||
}
|
||
|
||
/// Cartesian product of visible items across `cats`, in category order.
|
||
/// Hidden items and items in collapsed groups are excluded from `DataItem`
|
||
/// entries; group headers are always emitted.
|
||
/// Returns `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::DataItem(vec![])];
|
||
}
|
||
let mut result: Vec<AxisEntry> = vec![AxisEntry::DataItem(vec![])];
|
||
for cat_name in cats {
|
||
result = result
|
||
.into_iter()
|
||
.flat_map(|entry| match entry {
|
||
AxisEntry::DataItem(prefix) => expand_category(model, view, cat_name, prefix),
|
||
header @ AxisEntry::GroupHeader { .. } => vec![header],
|
||
})
|
||
.collect();
|
||
}
|
||
result
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{synthetic_record_info, AxisEntry, GridLayout};
|
||
use crate::model::cell::{CellKey, CellValue};
|
||
use crate::model::Model;
|
||
use crate::view::Axis;
|
||
|
||
fn records_model() -> Model {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Region").unwrap();
|
||
m.add_category("Measure").unwrap();
|
||
m.category_mut("Region").unwrap().add_item("North");
|
||
m.category_mut("Measure").unwrap().add_item("Revenue");
|
||
m.category_mut("Measure").unwrap().add_item("Cost");
|
||
m.set_cell(
|
||
CellKey::new(vec![
|
||
("Region".into(), "North".into()),
|
||
("Measure".into(), "Revenue".into()),
|
||
]),
|
||
CellValue::Number(100.0),
|
||
);
|
||
m.set_cell(
|
||
CellKey::new(vec![
|
||
("Region".into(), "North".into()),
|
||
("Measure".into(), "Cost".into()),
|
||
]),
|
||
CellValue::Number(50.0),
|
||
);
|
||
m
|
||
}
|
||
|
||
#[test]
|
||
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Row").unwrap();
|
||
m.add_category("Col").unwrap();
|
||
m.category_mut("Row").unwrap().add_item("A");
|
||
m.category_mut("Col").unwrap().add_item("X");
|
||
m.category_mut("Col").unwrap().add_item("Y");
|
||
// Only X has data; Y is entirely empty
|
||
m.set_cell(
|
||
CellKey::new(vec![
|
||
("Row".into(), "A".into()),
|
||
("Col".into(), "X".into()),
|
||
]),
|
||
CellValue::Number(1.0),
|
||
);
|
||
|
||
let mut layout = GridLayout::new(&m, m.active_view());
|
||
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
||
layout.prune_empty(&m);
|
||
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 m = records_model();
|
||
let v = m.active_view_mut();
|
||
v.set_axis("_Index", Axis::Row);
|
||
v.set_axis("_Dim", Axis::Column);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
assert!(layout.is_records_mode());
|
||
assert_eq!(layout.row_count(), 2); // 2 cells
|
||
}
|
||
|
||
#[test]
|
||
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
|
||
let mut m = records_model();
|
||
let v = m.active_view_mut();
|
||
v.set_axis("_Index", Axis::Row);
|
||
v.set_axis("_Dim", Axis::Column);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
assert!(layout.is_records_mode());
|
||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||
// All columns return synthetic keys
|
||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||
let key = layout.cell_key(0, value_col).unwrap();
|
||
assert_eq!(key.get("_Index"), Some("0"));
|
||
assert_eq!(key.get("_Dim"), Some("Value"));
|
||
|
||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||
let key = layout.cell_key(0, region_col).unwrap();
|
||
assert_eq!(key.get("_Index"), Some("0"));
|
||
assert_eq!(key.get("_Dim"), Some("Region"));
|
||
}
|
||
|
||
#[test]
|
||
fn records_mode_resolve_display_returns_values() {
|
||
let mut m = records_model();
|
||
let v = m.active_view_mut();
|
||
v.set_axis("_Index", Axis::Row);
|
||
v.set_axis("_Dim", Axis::Column);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||
|
||
// Value column resolves to the cell value
|
||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||
let key = layout.cell_key(0, value_col).unwrap();
|
||
let display = layout.resolve_display(&key);
|
||
assert!(display.is_some(), "Value column should resolve");
|
||
|
||
// Category column resolves to the coordinate value
|
||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||
let key = layout.cell_key(0, region_col).unwrap();
|
||
let display = layout.resolve_display(&key).unwrap();
|
||
assert!(!display.is_empty(), "Region column should resolve to a value");
|
||
}
|
||
|
||
#[test]
|
||
fn synthetic_record_info_returns_none_for_pivot_keys() {
|
||
let key = CellKey::new(vec![
|
||
("Region".to_string(), "East".to_string()),
|
||
("Product".to_string(), "Shoes".to_string()),
|
||
]);
|
||
assert!(synthetic_record_info(&key).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn synthetic_record_info_extracts_index_and_dim() {
|
||
let key = CellKey::new(vec![
|
||
("_Index".to_string(), "3".to_string()),
|
||
("_Dim".to_string(), "Region".to_string()),
|
||
]);
|
||
let (idx, dim) = synthetic_record_info(&key).unwrap();
|
||
assert_eq!(idx, 3);
|
||
assert_eq!(dim, "Region");
|
||
}
|
||
|
||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||
CellKey::new(
|
||
pairs
|
||
.iter()
|
||
.map(|(c, i)| (c.to_string(), i.to_string()))
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
fn two_cat_model() -> Model {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Type").unwrap();
|
||
m.add_category("Month").unwrap();
|
||
for item in ["Food", "Clothing"] {
|
||
m.category_mut("Type").unwrap().add_item(item);
|
||
}
|
||
for item in ["Jan", "Feb"] {
|
||
m.category_mut("Month").unwrap().add_item(item);
|
||
}
|
||
m
|
||
}
|
||
|
||
#[test]
|
||
fn row_and_col_counts_match_item_counts() {
|
||
let m = two_cat_model();
|
||
let layout = GridLayout::new(&m, m.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 m = two_cat_model();
|
||
let layout = GridLayout::new(&m, m.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 m = two_cat_model();
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Type").unwrap();
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Region").unwrap();
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month").unwrap().add_item("Jan");
|
||
m.category_mut("Region").unwrap().add_item("East");
|
||
m.category_mut("Region").unwrap().add_item("West");
|
||
m.active_view_mut().set_page_selection("Region", "West");
|
||
let layout = GridLayout::new(&m, m.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 m = two_cat_model();
|
||
m.set_cell(
|
||
coord(&[("Month", "Feb"), ("Type", "Clothing")]),
|
||
CellValue::Number(42.0),
|
||
);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
// Clothing = row 1, Feb = col 1
|
||
let key = layout.cell_key(1, 1).unwrap();
|
||
assert_eq!(m.evaluate(&key), Some(CellValue::Number(42.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn labels_join_with_slash_for_multi_cat_axis() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Type").unwrap();
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Year").unwrap();
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month").unwrap().add_item("Jan");
|
||
m.category_mut("Year").unwrap().add_item("2025");
|
||
m.active_view_mut()
|
||
.set_axis("Year", crate::view::Axis::Column);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
assert_eq!(layout.col_label(0), "Jan/2025");
|
||
}
|
||
|
||
#[test]
|
||
fn row_count_excludes_group_headers() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Feb", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
let layout = GridLayout::new(&m, m.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")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_group_has_header_but_no_data_items() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Feb", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.active_view_mut().toggle_group_collapse("Month", "Q1");
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
// Q1 collapsed: header present, Jan and Feb absent; Q2 intact
|
||
assert_eq!(layout.row_count(), 1); // only Apr
|
||
let q1_header = layout
|
||
.row_items
|
||
.iter()
|
||
.find(|e| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q1"));
|
||
assert!(q1_header.is_some());
|
||
let jan = layout
|
||
.row_items
|
||
.iter()
|
||
.find(|e| matches!(e, AxisEntry::DataItem(v) if v.contains(&"Jan".to_string())));
|
||
assert!(jan.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ungrouped_items_produce_no_headers() {
|
||
let m = two_cat_model();
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.set_cell(
|
||
coord(&[("Month", "Apr"), ("Type", "Food")]),
|
||
CellValue::Number(99.0),
|
||
);
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
// data row 0 = Jan, data row 1 = Apr
|
||
let key = layout.cell_key(1, 0).unwrap();
|
||
assert_eq!(m.evaluate(&key), Some(CellValue::Number(99.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn data_row_to_visual_skips_headers() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Type").unwrap(); // Row
|
||
m.add_category("Month").unwrap(); // Column
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Month").unwrap();
|
||
m.add_category("Type").unwrap();
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Type").unwrap();
|
||
m.add_category("Month").unwrap();
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month").unwrap().add_item("Jan");
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
assert_eq!(layout.row_group_for(0), None);
|
||
}
|
||
|
||
#[test]
|
||
fn col_group_for_finds_enclosing_group() {
|
||
let mut m = Model::new("T");
|
||
m.add_category("Type").unwrap(); // Row
|
||
m.add_category("Month").unwrap(); // Column
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Jan", "Q1");
|
||
m.category_mut("Month")
|
||
.unwrap()
|
||
.add_item_in_group("Apr", "Q2");
|
||
let layout = GridLayout::new(&m, m.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 m = Model::new("T");
|
||
m.add_category("Type").unwrap();
|
||
m.add_category("Month").unwrap();
|
||
m.category_mut("Type").unwrap().add_item("Food");
|
||
m.category_mut("Month").unwrap().add_item("Jan");
|
||
let layout = GridLayout::new(&m, m.active_view());
|
||
assert_eq!(layout.col_group_for(0), None);
|
||
}
|
||
}
|