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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user