838 lines
30 KiB
Rust
838 lines
30 KiB
Rust
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::Rect,
|
|
style::{Color, Modifier, Style},
|
|
widgets::{Block, Borders, Widget},
|
|
};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::model::cell::CellValue;
|
|
use crate::model::Model;
|
|
use crate::ui::app::AppMode;
|
|
use crate::view::{AxisEntry, GridLayout};
|
|
|
|
const ROW_HEADER_WIDTH: u16 = 16;
|
|
const COL_WIDTH: u16 = 10;
|
|
const GROUP_EXPANDED: &str = "▼";
|
|
const GROUP_COLLAPSED: &str = "▶";
|
|
|
|
pub struct GridWidget<'a> {
|
|
pub model: &'a Model,
|
|
pub mode: &'a AppMode,
|
|
pub search_query: &'a str,
|
|
}
|
|
|
|
impl<'a> GridWidget<'a> {
|
|
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
|
|
Self {
|
|
model,
|
|
mode,
|
|
search_query,
|
|
}
|
|
}
|
|
|
|
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
|
let view = self.model.active_view();
|
|
|
|
let layout = GridLayout::new(self.model, view);
|
|
let (sel_row, sel_col) = view.selected;
|
|
let row_offset = view.row_offset;
|
|
let col_offset = view.col_offset;
|
|
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
|
|
|
let n_col_levels = layout.col_cats.len().max(1);
|
|
let n_row_levels = layout.row_cats.len().max(1);
|
|
|
|
// Sub-column widths for row header area
|
|
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
|
|
let sub_widths: Vec<u16> = (0..n_row_levels)
|
|
.map(|d| {
|
|
if d < n_row_levels - 1 {
|
|
sub_col_w
|
|
} else {
|
|
ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1))
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Flat lists of data-only tuples for repeat-suppression in headers
|
|
let data_col_items: Vec<&Vec<String>> = layout
|
|
.col_items
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AxisEntry::DataItem(v) = e {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
let data_row_items: Vec<&Vec<String>> = layout
|
|
.row_items
|
|
.iter()
|
|
.filter_map(|e| {
|
|
if let AxisEntry::DataItem(v) = e {
|
|
Some(v)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Map each data-col index to its group name (None if ungrouped)
|
|
let col_groups: Vec<Option<String>> = {
|
|
let mut groups = Vec::new();
|
|
let mut current: Option<String> = None;
|
|
for entry in &layout.col_items {
|
|
match entry {
|
|
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
|
|
AxisEntry::DataItem(_) => groups.push(current.clone()),
|
|
}
|
|
}
|
|
groups
|
|
};
|
|
let has_col_groups = col_groups.iter().any(|g| g.is_some());
|
|
|
|
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
|
|
let visible_col_range =
|
|
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
|
|
|
|
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
|
|
|
|
let visual_row_start = layout
|
|
.data_row_to_visual(row_offset)
|
|
.unwrap_or(layout.row_items.len());
|
|
|
|
let mut y = area.y;
|
|
|
|
// Optional column group header row
|
|
if has_col_groups {
|
|
let group_style = Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::BOLD);
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
|
Style::default(),
|
|
);
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
let mut prev_group: Option<&str> = None;
|
|
for ci in visible_col_range.clone() {
|
|
if x >= area.x + area.width {
|
|
break;
|
|
}
|
|
let group = col_groups[ci].as_deref();
|
|
let label = if group != prev_group {
|
|
group.unwrap_or("")
|
|
} else {
|
|
""
|
|
};
|
|
prev_group = group;
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!(
|
|
"{:<width$}",
|
|
truncate(label, COL_WIDTH as usize),
|
|
width = COL_WIDTH as usize
|
|
),
|
|
group_style,
|
|
);
|
|
x += COL_WIDTH;
|
|
}
|
|
y += 1;
|
|
}
|
|
|
|
// Column headers — one row per level, with repeat suppression
|
|
let header_style = Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD);
|
|
for d in 0..n_col_levels {
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
|
|
Style::default(),
|
|
);
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
for ci in visible_col_range.clone() {
|
|
let label = if layout.col_cats.is_empty() {
|
|
layout.col_label(ci)
|
|
} else {
|
|
let show = ci == 0 || data_col_items[ci][..=d] != data_col_items[ci - 1][..=d];
|
|
if show {
|
|
data_col_items[ci][d].clone()
|
|
} else {
|
|
String::new()
|
|
}
|
|
};
|
|
let styled = if ci == sel_col {
|
|
header_style.add_modifier(Modifier::UNDERLINED)
|
|
} else {
|
|
header_style
|
|
};
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!(
|
|
"{:>width$}",
|
|
truncate(&label, COL_WIDTH as usize),
|
|
width = COL_WIDTH as usize
|
|
),
|
|
styled,
|
|
);
|
|
x += COL_WIDTH;
|
|
if x >= area.x + area.width {
|
|
break;
|
|
}
|
|
}
|
|
y += 1;
|
|
}
|
|
|
|
// Separator
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
"─".repeat(area.width as usize),
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
y += 1;
|
|
|
|
// Data rows — iterate visual entries, rendering group headers inline
|
|
let group_header_style = Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::BOLD);
|
|
let mut data_row_idx = row_offset;
|
|
for entry in &layout.row_items[visual_row_start..] {
|
|
if y >= area.y + area.height {
|
|
break;
|
|
}
|
|
match entry {
|
|
AxisEntry::GroupHeader {
|
|
cat_name,
|
|
group_name,
|
|
} => {
|
|
let indicator = if view.is_group_collapsed(cat_name, group_name) {
|
|
GROUP_COLLAPSED
|
|
} else {
|
|
GROUP_EXPANDED
|
|
};
|
|
let label = format!("{indicator} {group_name}");
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
format!(
|
|
"{:<width$}",
|
|
truncate(&label, ROW_HEADER_WIDTH as usize),
|
|
width = ROW_HEADER_WIDTH as usize
|
|
),
|
|
group_header_style,
|
|
);
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
while x < area.x + area.width {
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!("{:─<width$}", "", width = COL_WIDTH as usize),
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
x += COL_WIDTH;
|
|
}
|
|
}
|
|
AxisEntry::DataItem(_) => {
|
|
let ri = data_row_idx;
|
|
data_row_idx += 1;
|
|
|
|
let row_style = if ri == sel_row {
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
// Multi-level row header — one sub-column per row category
|
|
let mut hx = area.x;
|
|
for d in 0..n_row_levels {
|
|
let sw = sub_widths[d] as usize;
|
|
let label = if layout.row_cats.is_empty() {
|
|
layout.row_label(ri)
|
|
} else {
|
|
let show =
|
|
ri == 0 || data_row_items[ri][..=d] != data_row_items[ri - 1][..=d];
|
|
if show {
|
|
data_row_items[ri][d].clone()
|
|
} else {
|
|
String::new()
|
|
}
|
|
};
|
|
buf.set_string(
|
|
hx,
|
|
y,
|
|
format!("{:<width$}", truncate(&label, sw), width = sw),
|
|
row_style,
|
|
);
|
|
hx += sub_widths[d];
|
|
}
|
|
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
for ci in visible_col_range.clone() {
|
|
if x >= area.x + area.width {
|
|
break;
|
|
}
|
|
|
|
let key = match layout.cell_key(ri, ci) {
|
|
Some(k) => k,
|
|
None => {
|
|
x += COL_WIDTH;
|
|
continue;
|
|
}
|
|
};
|
|
let value = self.model.evaluate(&key);
|
|
|
|
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
|
let is_selected = ri == sel_row && ci == sel_col;
|
|
let is_search_match = !self.search_query.is_empty()
|
|
&& cell_str
|
|
.to_lowercase()
|
|
.contains(&self.search_query.to_lowercase());
|
|
|
|
let cell_style = if is_selected {
|
|
Style::default()
|
|
.fg(Color::Black)
|
|
.bg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else if is_search_match {
|
|
Style::default().fg(Color::Black).bg(Color::Yellow)
|
|
} else if value.is_none() {
|
|
Style::default().fg(Color::DarkGray)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!(
|
|
"{:>width$}",
|
|
truncate(&cell_str, COL_WIDTH as usize),
|
|
width = COL_WIDTH as usize
|
|
),
|
|
cell_style,
|
|
);
|
|
x += COL_WIDTH;
|
|
}
|
|
|
|
// Edit indicator
|
|
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
|
|
if let AppMode::Editing { buffer } = self.mode {
|
|
let edit_x = area.x
|
|
+ ROW_HEADER_WIDTH
|
|
+ (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
|
|
buf.set_string(
|
|
edit_x,
|
|
y,
|
|
truncate(
|
|
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
|
|
COL_WIDTH as usize,
|
|
),
|
|
Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::UNDERLINED),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
y += 1;
|
|
}
|
|
|
|
// Total row
|
|
if layout.row_count() > 0 && layout.col_count() > 0 {
|
|
if y < area.y + area.height {
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
"─".repeat(area.width as usize),
|
|
Style::default().fg(Color::DarkGray),
|
|
);
|
|
y += 1;
|
|
}
|
|
if y < area.y + area.height {
|
|
buf.set_string(
|
|
area.x,
|
|
y,
|
|
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
let mut x = area.x + ROW_HEADER_WIDTH;
|
|
for ci in visible_col_range {
|
|
if x >= area.x + area.width {
|
|
break;
|
|
}
|
|
let total: f64 = (0..layout.row_count())
|
|
.filter_map(|ri| layout.cell_key(ri, ci))
|
|
.map(|key| self.model.evaluate_f64(&key))
|
|
.sum();
|
|
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
|
buf.set_string(
|
|
x,
|
|
y,
|
|
format!(
|
|
"{:>width$}",
|
|
truncate(&total_str, COL_WIDTH as usize),
|
|
width = COL_WIDTH as usize
|
|
),
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
x += COL_WIDTH;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Widget for GridWidget<'a> {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
let view_name = self.model.active_view.clone();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(format!(" View: {} ", view_name));
|
|
let inner = block.inner(area);
|
|
block.render(area, buf);
|
|
|
|
// Page axis bar
|
|
let layout = GridLayout::new(self.model, self.model.active_view());
|
|
if !layout.page_coords.is_empty() && inner.height > 0 {
|
|
let page_info: Vec<String> = layout
|
|
.page_coords
|
|
.iter()
|
|
.map(|(cat, sel)| format!("{cat} = {sel}"))
|
|
.collect();
|
|
let page_str = format!(" [{}] ", page_info.join(" | "));
|
|
buf.set_string(
|
|
inner.x,
|
|
inner.y,
|
|
&page_str,
|
|
Style::default().fg(Color::Magenta),
|
|
);
|
|
|
|
let grid_area = Rect {
|
|
y: inner.y + 1,
|
|
height: inner.height.saturating_sub(1),
|
|
..inner
|
|
};
|
|
self.render_grid(grid_area, buf);
|
|
} else {
|
|
self.render_grid(inner, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
|
match v {
|
|
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
|
Some(CellValue::Text(s)) => s.clone(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
|
let comma = fmt.contains(',');
|
|
let decimals = fmt
|
|
.rfind('.')
|
|
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
|
.unwrap_or(0);
|
|
(comma, decimals)
|
|
}
|
|
|
|
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
|
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
|
|
if !comma {
|
|
return formatted;
|
|
}
|
|
// Split integer and decimal parts
|
|
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
|
(&formatted[..dot], Some(&formatted[dot..]))
|
|
} else {
|
|
(&formatted[..], None)
|
|
};
|
|
let is_neg = int_part.starts_with('-');
|
|
let digits = if is_neg { &int_part[1..] } else { int_part };
|
|
let mut result = String::new();
|
|
for (idx, c) in digits.chars().rev().enumerate() {
|
|
if idx > 0 && idx % 3 == 0 {
|
|
result.push(',');
|
|
}
|
|
result.push(c);
|
|
}
|
|
if is_neg {
|
|
result.push('-');
|
|
}
|
|
let mut out: String = result.chars().rev().collect();
|
|
if let Some(dec) = dec_part {
|
|
out.push_str(dec);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn truncate(s: &str, max_width: usize) -> String {
|
|
let w = s.width();
|
|
if w <= max_width {
|
|
s.to_string()
|
|
} else if max_width > 1 {
|
|
let mut result = String::new();
|
|
let mut width = 0;
|
|
for c in s.chars() {
|
|
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
|
|
if width + cw + 1 > max_width {
|
|
break;
|
|
}
|
|
result.push(c);
|
|
width += cw;
|
|
}
|
|
result.push('…');
|
|
result
|
|
} else {
|
|
s.chars().take(max_width).collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
|
|
|
|
use super::GridWidget;
|
|
use crate::formula::parse_formula;
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::model::Model;
|
|
use crate::ui::app::AppMode;
|
|
|
|
// ── 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");
|
|
}
|
|
m.active_view_mut().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");
|
|
}
|
|
m.active_view_mut()
|
|
.set_axis("Recipient", crate::view::Axis::Row);
|
|
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
// Multi-level row headers: category values shown separately, not joined with /
|
|
assert!(
|
|
!text.contains("Food/Alice"),
|
|
"slash-joined labels should be gone:\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Clothing/Bob"),
|
|
"slash-joined labels should be gone:\n{text}"
|
|
);
|
|
// Each item name appears on its own
|
|
assert!(text.contains("Food"), "expected 'Food' in:\n{text}");
|
|
assert!(text.contains("Clothing"), "expected 'Clothing' in:\n{text}");
|
|
assert!(text.contains("Alice"), "expected 'Alice' in:\n{text}");
|
|
assert!(text.contains("Bob"), "expected '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");
|
|
}
|
|
m.active_view_mut()
|
|
.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");
|
|
}
|
|
m.active_view_mut()
|
|
.set_axis("Year", crate::view::Axis::Column);
|
|
|
|
let text = buf_text(&render(&m, 80, 24));
|
|
// Multi-level column headers: category values shown separately, not joined with /
|
|
assert!(
|
|
!text.contains("Jan/2024"),
|
|
"slash-joined headers should be gone:\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Jan/2025"),
|
|
"slash-joined headers should be gone:\n{text}"
|
|
);
|
|
assert!(text.contains("Jan"), "expected 'Jan' in:\n{text}");
|
|
assert!(text.contains("2024"), "expected '2024' in:\n{text}");
|
|
assert!(text.contains("2025"), "expected '2025' in:\n{text}");
|
|
}
|
|
}
|