chore: start testing the grid

This commit is contained in:
Ed L
2026-03-21 23:34:32 -07:00
parent 56d11aee74
commit 77f83bac3a
2 changed files with 282 additions and 347 deletions

View File

@ -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<Vec<String>> = 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<Vec<String>> = cross_product_items(
&row_cats, self.model, view,
);
// Gather col items
let col_items: Vec<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> {
if cats.is_empty() {
return vec![vec![]];
}
let mut result: Vec<Vec<String>> = vec![vec![]];
for &cat_name in cats {
let items: Vec<String> = 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::<String>().trim_end().to_string())
.collect::<Vec<_>>()
.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}");
}
}

View File

@ -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
}