chore: start testing the grid
This commit is contained in:
310
src/ui/grid.rs
310
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<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}");
|
||||
}
|
||||
}
|
||||
|
||||
319
whatever.improv
319
whatever.improv
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user