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), } /// 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, pub col_cats: Vec, pub page_coords: Vec<(String, String)>, pub row_items: Vec, pub col_items: Vec, /// Categories on `Axis::None` — hidden, implicitly aggregated. pub none_cats: Vec, /// In records mode: the filtered cell list, one per row. /// None for normal pivot views. pub records: Option>, } 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>, ) -> 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 = (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 = view .categories_on(Axis::Row) .into_iter() .map(String::from) .collect(); let col_cats: Vec = view .categories_on(Axis::Column) .into_iter() .map(String::from) .collect(); let page_cats: Vec = view .categories_on(Axis::Page) .into_iter() .map(String::from) .collect(); let none_cats: Vec = view .categories_on(Axis::None) .into_iter() .map(String::from) .collect(); let page_coords = page_cats .iter() .map(|cat| { let items: Vec = 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, ) -> 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 = (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 = model .category_names() .into_iter() .filter(|c| !c.starts_with('_')) .map(String::from) .collect(); let mut col_items: Vec = 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 { 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 = (0..rc) .map(|ri| (0..cc).any(|ci| has_value[ri][ci])) .collect(); // Which data-col indices are non-empty? let keep_col: Vec = (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 = 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 = 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 = (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 { 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 { 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 { 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 { 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, ) -> Vec { 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 { if cats.is_empty() { return vec![AxisEntry::DataItem(vec![])]; } let mut result: Vec = 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 = (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 = (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); } }