From 77f83bac3a0193c084feff72ecd2aa45479abc3c Mon Sep 17 00:00:00 2001 From: Ed L Date: Sat, 21 Mar 2026 23:34:32 -0700 Subject: [PATCH] chore: start testing the grid --- src/ui/grid.rs | 310 +++++++++++++++++++++++++++++++++++++++++----- whatever.improv | 319 ------------------------------------------------ 2 files changed, 282 insertions(+), 347 deletions(-) delete mode 100644 whatever.improv diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 66f66f4..d85a9a4 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -37,35 +37,15 @@ impl<'a> GridWidget<'a> { let col_cats: Vec<&str> = view.categories_on(Axis::Column); let page_cats: Vec<&str> = view.categories_on(Axis::Page); - // Gather row items - let row_items: Vec> = if row_cats.is_empty() { - vec![vec![]] - } else { - let cat_name = row_cats[0]; - let cat = match self.model.category(cat_name) { - Some(c) => c, - None => return, - }; - cat.ordered_item_names().into_iter() - .filter(|item| !view.is_hidden(cat_name, item)) - .map(|item| vec![item.to_string()]) - .collect() - }; + // Gather row items — cross-product of all row-axis categories + let row_items: Vec> = cross_product_items( + &row_cats, self.model, view, + ); - // Gather col items - let col_items: Vec> = if col_cats.is_empty() { - vec![vec![]] - } else { - let cat_name = col_cats[0]; - let cat = match self.model.category(cat_name) { - Some(c) => c, - None => return, - }; - cat.ordered_item_names().into_iter() - .filter(|item| !view.is_hidden(cat_name, item)) - .map(|item| vec![item.to_string()]) - .collect() - }; + // Gather col items — cross-product of all col-axis categories + let col_items: Vec> = cross_product_items( + &col_cats, self.model, view, + ); // Page filter coords let page_coords: Vec<(String, String)> = page_cats.iter().map(|cat_name| { @@ -227,6 +207,38 @@ impl<'a> GridWidget<'a> { } } +/// Returns the cross-product of items across `cats`, filtered by hidden state. +/// Each element is a Vec of item names, one per category in `cats` order. +fn cross_product_items(cats: &[&str], model: &Model, view: &crate::view::View) -> Vec> { + if cats.is_empty() { + return vec![vec![]]; + } + let mut result: Vec> = vec![vec![]]; + for &cat_name in cats { + let items: Vec = model + .category(cat_name) + .map(|c| { + c.ordered_item_names() + .into_iter() + .filter(|item| !view.is_hidden(cat_name, item)) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + result = result + .into_iter() + .flat_map(|prefix| { + items.iter().map(move |item| { + let mut row = prefix.clone(); + row.push(item.clone()); + row + }) + }) + .collect(); + } + result +} + impl<'a> Widget for GridWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let view_name = self.model.active_view @@ -319,3 +331,245 @@ fn truncate(s: &str, max_width: usize) -> String { s.chars().take(max_width).collect() } } + +#[cfg(test)] +mod tests { + use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + + use crate::model::Model; + use crate::model::cell::{CellKey, CellValue}; + use crate::formula::parse_formula; + use crate::ui::app::AppMode; + use super::GridWidget; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// Render a GridWidget into a fresh buffer of the given size. + fn render(model: &Model, width: u16, height: u16) -> Buffer { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf); + buf + } + + /// Flatten the buffer to a multiline string, trailing spaces trimmed per row. + fn buf_text(buf: &Buffer) -> String { + let w = buf.area().width as usize; + buf.content() + .chunks(w) + .map(|row| row.iter().map(|c| c.symbol()).collect::().trim_end().to_string()) + .collect::>() + .join("\n") + } + + fn coord(pairs: &[(&str, &str)]) -> CellKey { + CellKey::new(pairs.iter().map(|(c, i)| (c.to_string(), i.to_string())).collect()) + } + + /// Minimal model: Type on Row, Month on Column. + fn two_cat_model() -> Model { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + if let Some(c) = m.category_mut("Type") { + c.add_item("Food"); + c.add_item("Clothing"); + } + if let Some(c) = m.category_mut("Month") { + c.add_item("Jan"); + c.add_item("Feb"); + } + m + } + + // ── Column headers ──────────────────────────────────────────────────────── + + #[test] + fn column_headers_appear() { + let m = two_cat_model(); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}"); + assert!(text.contains("Feb"), "expected 'Feb' in:\n{text}"); + } + + // ── Row headers ─────────────────────────────────────────────────────────── + + #[test] + fn row_headers_appear() { + let m = two_cat_model(); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Food"), "expected 'Food' in:\n{text}"); + assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}"); + } + + // ── Cell values ─────────────────────────────────────────────────────────── + + #[test] + fn cell_value_appears_in_correct_position() { + let mut m = two_cat_model(); + m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(123.0)); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("123"), "expected '123' in:\n{text}"); + } + + #[test] + fn multiple_cell_values_all_appear() { + let mut m = two_cat_model(); + m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0)); + m.set_cell(coord(&[("Type", "Food"), ("Month", "Feb")]), CellValue::Number(200.0)); + m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0)); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("100"), "expected '100' in:\n{text}"); + assert!(text.contains("200"), "expected '200' in:\n{text}"); + assert!(text.contains("50"), "expected '50' in:\n{text}"); + } + + #[test] + fn unset_cells_show_no_value() { + let m = two_cat_model(); + let text = buf_text(&render(&m, 80, 24)); + // No digits should appear in the data area if nothing is set + // (Total row shows "0" — exclude that from this check by looking for non-zero) + assert!(!text.contains("100"), "unexpected '100' in:\n{text}"); + } + + // ── Total row ───────────────────────────────────────────────────────────── + + #[test] + fn total_row_label_appears() { + let m = two_cat_model(); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Total"), "expected 'Total' in:\n{text}"); + } + + #[test] + fn total_row_sums_column_correctly() { + let mut m = two_cat_model(); + m.set_cell(coord(&[("Type", "Food"), ("Month", "Jan")]), CellValue::Number(100.0)); + m.set_cell(coord(&[("Type", "Clothing"), ("Month", "Jan")]), CellValue::Number(50.0)); + let text = buf_text(&render(&m, 80, 24)); + // Food(100) + Clothing(50) = 150 for Jan + assert!(text.contains("150"), "expected '150' (total for Jan) in:\n{text}"); + } + + // ── Page filter bar ─────────────────────────────────────────────────────── + + #[test] + fn page_filter_bar_shows_category_and_selection() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + m.add_category("Payer").unwrap(); // → Page + if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } + if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Payer") { + c.add_item("Alice"); + c.add_item("Bob"); + } + if let Some(v) = m.active_view_mut() { + v.set_page_selection("Payer", "Bob"); + } + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Payer = Bob"), "expected 'Payer = Bob' in:\n{text}"); + } + + #[test] + fn page_filter_defaults_to_first_item() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.add_category("Payer").unwrap(); + if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } + if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Payer") { + c.add_item("Alice"); + c.add_item("Bob"); + } + // No explicit selection — should default to first item + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Payer = Alice"), "expected 'Payer = Alice' in:\n{text}"); + } + + // ── Formula evaluation ──────────────────────────────────────────────────── + + #[test] + fn formula_cell_renders_computed_value() { + let mut m = Model::new("Test"); + m.add_category("Measure").unwrap(); // → Row + m.add_category("Region").unwrap(); // → Column + if let Some(c) = m.category_mut("Measure") { + c.add_item("Revenue"); + c.add_item("Cost"); + c.add_item("Profit"); + } + if let Some(c) = m.category_mut("Region") { c.add_item("East"); } + m.set_cell(coord(&[("Measure", "Revenue"), ("Region", "East")]), CellValue::Number(1000.0)); + m.set_cell(coord(&[("Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0)); + m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap()); + + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}"); + } + + // ── Multiple categories on same axis (cross-product) ───────────────────── + + #[test] + fn two_row_categories_produce_cross_product_labels() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + m.add_category("Recipient").unwrap(); // → Page by default; move to Row + if let Some(c) = m.category_mut("Type") { c.add_item("Food"); c.add_item("Clothing"); } + if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } + if let Some(v) = m.active_view_mut() { + v.set_axis("Recipient", crate::view::Axis::Row); + } + + let text = buf_text(&render(&m, 80, 24)); + // Cross-product rows: Food/Alice, Food/Bob, Clothing/Alice, Clothing/Bob + assert!(text.contains("Food/Alice"), "expected 'Food/Alice' in:\n{text}"); + assert!(text.contains("Food/Bob"), "expected 'Food/Bob' in:\n{text}"); + assert!(text.contains("Clothing/Alice"), "expected 'Clothing/Alice' in:\n{text}"); + assert!(text.contains("Clothing/Bob"), "expected 'Clothing/Bob' in:\n{text}"); + } + + #[test] + fn two_row_categories_include_all_coords_in_cell_lookup() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); + m.add_category("Month").unwrap(); + m.add_category("Recipient").unwrap(); + if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } + if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Recipient") { c.add_item("Alice"); c.add_item("Bob"); } + if let Some(v) = m.active_view_mut() { + v.set_axis("Recipient", crate::view::Axis::Row); + } + // Set data at the full 3-coordinate key + m.set_cell( + coord(&[("Month", "Jan"), ("Recipient", "Alice"), ("Type", "Food")]), + CellValue::Number(77.0), + ); + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("77"), "expected '77' in:\n{text}"); + } + + #[test] + fn two_column_categories_produce_cross_product_headers() { + let mut m = Model::new("Test"); + m.add_category("Type").unwrap(); // → Row + m.add_category("Month").unwrap(); // → Column + m.add_category("Year").unwrap(); // → Page by default; move to Column + if let Some(c) = m.category_mut("Type") { c.add_item("Food"); } + if let Some(c) = m.category_mut("Month") { c.add_item("Jan"); } + if let Some(c) = m.category_mut("Year") { c.add_item("2024"); c.add_item("2025"); } + if let Some(v) = m.active_view_mut() { + v.set_axis("Year", crate::view::Axis::Column); + } + + let text = buf_text(&render(&m, 80, 24)); + assert!(text.contains("Jan/2024"), "expected 'Jan/2024' in:\n{text}"); + assert!(text.contains("Jan/2025"), "expected 'Jan/2025' in:\n{text}"); + } +} diff --git a/whatever.improv b/whatever.improv deleted file mode 100644 index ef62240..0000000 --- a/whatever.improv +++ /dev/null @@ -1,319 +0,0 @@ -{ - "name": "New Model", - "categories": { - "Type": { - "id": 0, - "name": "Type", - "items": { - "Food": { - "id": 0, - "name": "Food", - "group": null - }, - "Clothing": { - "id": 1, - "name": "Clothing", - "group": null - }, - "Gas": { - "id": 2, - "name": "Gas", - "group": null - }, - "Medical": { - "id": 3, - "name": "Medical", - "group": null - } - }, - "groups": [], - "next_item_id": 4 - }, - "Month": { - "id": 1, - "name": "Month", - "items": { - "Janury": { - "id": 0, - "name": "Janury", - "group": null - }, - "February": { - "id": 1, - "name": "February", - "group": null - }, - "March": { - "id": 2, - "name": "March", - "group": null - } - }, - "groups": [], - "next_item_id": 3 - }, - "Recipient": { - "id": 2, - "name": "Recipient", - "items": { - "Bob": { - "id": 0, - "name": "Bob", - "group": null - }, - "Joe": { - "id": 1, - "name": "Joe", - "group": null - }, - "Mary": { - "id": 2, - "name": "Mary", - "group": null - }, - "Jane": { - "id": 3, - "name": "Jane", - "group": null - }, - "Will": { - "id": 4, - "name": "Will", - "group": null - } - }, - "groups": [], - "next_item_id": 5 - }, - "Payer": { - "id": 3, - "name": "Payer", - "items": { - "Bernadette": { - "id": 0, - "name": "Bernadette", - "group": null - }, - "Ed": { - "id": 1, - "name": "Ed", - "group": null - } - }, - "groups": [], - "next_item_id": 2 - } - }, - "data": [ - [ - [ - [ - "Month", - "Janury" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Clothing" - ] - ], - { - "Number": 3.0 - } - ], - [ - [ - [ - "Month", - "Janury" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Gas" - ] - ], - { - "Number": 4.0 - } - ], - [ - [ - [ - "Month", - "Janury" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Medical" - ] - ], - { - "Number": 5.0 - } - ], - [ - [ - [ - "Month", - "February" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Medical" - ] - ], - { - "Number": 33.0 - } - ], - [ - [ - [ - "Month", - "February" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Gas" - ] - ], - { - "Number": 55.0 - } - ], - [ - [ - [ - "Month", - "February" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Food" - ] - ], - { - "Number": 4.0 - } - ], - [ - [ - [ - "Month", - "February" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Clothing" - ] - ], - { - "Text": "i5" - } - ], - [ - [ - [ - "Month", - "Janury" - ], - [ - "Payer", - "Bernadette" - ], - [ - "Recipient", - "Bob" - ], - [ - "Type", - "Food" - ] - ], - { - "Number": 12.0 - } - ] - ], - "formulas": [], - "views": { - "Default": { - "name": "Default", - "category_axes": { - "Type": "Row", - "Month": "Column", - "Recipient": "Page", - "Payer": "Page" - }, - "page_selections": { - "Recipient": "Bob" - }, - "hidden_items": {}, - "collapsed_groups": {}, - "number_format": ",.0", - "row_offset": 0, - "col_offset": 0, - "selected": [ - 6, - 2 - ] - } - }, - "active_view": "Default", - "next_category_id": 4 -} \ No newline at end of file