//! The two front ends draw the same grid frame (improvise-anr9/s1xw). The //! shipped divergence and the injected faults: `context/test-evidence/tui.md`. use improvise::model::cell::{CellKey, CellValue}; use improvise::ui::app::{App, AppMode, ViewCursor}; use improvise::ui::grid::GridWidget; use improvise::view::{Axis, GridLayout, GroupFold}; use improvise::workbook::Workbook; use improvise_command::ui::project; use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState}; use improvise_wasm_client::render::{RenderModel, RenderRow}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( pairs .iter() .map(|(c, i)| (c.to_string(), i.to_string())) .collect(), ) } /// Both axes multi-level and grouped. Its two deliberate asymmetries — Carol /// outside Family, Sep ungrouped — are in `context/test-evidence/tui.md`. fn two_level_rows() -> Workbook { let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); m.add_category("Month").unwrap(); m.add_category("Recipient").unwrap(); m.add_item("Type", "Food").unwrap(); m.add_item("Type", "Clothing").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Feb", "Q1").unwrap(); m.active_view_mut().set_expand_depth("Month", 1); m.add_item("Month", "Sep").unwrap(); m.add_item("Recipient", "Carol").unwrap(); m.add_item_in_group("Recipient", "Alice", "Family").unwrap(); m.add_item_in_group("Recipient", "Bob", "Family").unwrap(); m.active_view_mut().set_expand_depth("Recipient", 1); m.active_view_mut().set_axis("Recipient", Axis::Row); m.add_item("_Measure", "Revenue").unwrap(); m.add_item("_Measure", "Cost").unwrap(); m.active_view_mut().set_axis("_Measure", Axis::Column); for t in ["Food", "Clothing"] { for r in ["Carol", "Alice", "Bob"] { for mo in ["Jan", "Feb", "Sep"] { for meas in ["Revenue", "Cost"] { m.model.set_cell( coord(&[ ("Type", t), ("Month", mo), ("Recipient", r), ("_Measure", meas), ]), CellValue::Number(1.0), ); } } } } m } /// The one cache both ends are driven from — built exactly as the TUI builds /// it, over the whole grid at offsets zero (gotcha 44). fn whole_grid(wb: &mut Workbook) -> (App, GridCache) { let none_cats = wb.active_view().none_cats(); wb.model.recompute_formulas(&none_cats); let app = App::new(wb.clone(), None); let cache = project::grid_of( &app, project::Viewport { row_offset: 0, col_offset: 0, rows: app.layout.row_count(), cols: app.layout.col_count(), }, ); (app, cache) } /// The row-header text the TUI *paints*, sliced by `AxisFrame::header_widths` /// — the widget's own rule, never a second reading (improvise-1125). fn tui_row_headers(wb: &Workbook, cache: &GridCache) -> Vec> { let lines = tui_lines(wb, cache); let sep = header_rule(&lines); let widths = cache.row_frame.header_widths(); lines[sep + 1..] .iter() // The body ends at the blank space below the grid, or at the rule // above the totals strip — which is not a row of the frame. .take_while(|l| !l.trim().is_empty() && !l.trim_end().chars().all(|c| c == '─')) .map(|line| { let chars: Vec = line.chars().collect(); let mut at = 0usize; widths .iter() .map(|w| { let end = (at + *w as usize).min(chars.len()); let cell: String = chars[at.min(chars.len())..end].iter().collect(); at = end; cell.trim_end().to_string() }) .collect() }) .collect() } /// One painted header line, cut by the cache's own `col_widths` for the same /// reason. Labels are right-aligned into `width - 1`, so a trim recovers them. fn tui_header_cells(line: &str, cache: &GridCache) -> Vec { let chars: Vec = line.chars().collect(); let mut at: usize = cache .row_frame .header_widths() .iter() .map(|w| *w as usize) .sum(); cache .col_widths .iter() .map(|w| { let end = (at + *w as usize).min(chars.len()); let cell: String = chars[at.min(chars.len())..end].iter().collect(); at = end; cell.trim().to_string() }) .collect() } /// The index of the rule under the column headers: the one line that is /// nothing else. fn header_rule(lines: &[String]) -> usize { lines .iter() .position(|l| !l.trim().is_empty() && l.chars().all(|c| c == '─')) .expect("the header separator") } /// The buffer the widget paints, one string per line, borders stripped. fn tui_lines(wb: &Workbook, cache: &GridCache) -> Vec { let area = Rect::new(0, 0, 100, 30); let mut buf = Buffer::empty(area); let layout = GridLayout::new(&wb.model, wb.active_view()); let view_name = wb.active_view.clone(); let buffers = std::collections::HashMap::new(); GridWidget::new( cache, ViewCursor::default(), &view_name, &layout, &AppMode::Normal, "", &buffers, None, ) .render(area, &mut buf); let width = area.width as usize; buf.content() .chunks(width) // Strip the block's left and right border columns. .map(|row| { row[1..width - 1] .iter() .map(|c| c.symbol()) .collect::() }) .collect() } /// What the browser client decides to draw, from a `Snapshot` of the same /// cache — the real bootstrap path, not a hand-built client state. fn browser_rows(cache: &GridCache) -> Vec { browser_model(cache).rows } fn browser_model(cache: &GridCache) -> RenderModel { let mut client = improvise_wasm_client::init(); client.bootstrap(Snapshot { view: ViewState::default(), seed: cache .seed() .into_iter() .map(improvise_protocol::Projection::Grid) .collect(), }); client.model() } #[test] fn the_two_front_ends_draw_the_same_row_header() { assert_row_headers_agree(&mut two_level_rows()); } /// improvise-felo.6: Year > Quarter > Month in ONE category under a grouped one; /// Y24 open two levels, Q2 and Y25 closed. Short names fit a TUI band cell. fn a_date_hierarchy() -> Workbook { let mut m = Workbook::new("Test"); m.add_category("Recipient").unwrap(); m.add_category("Date").unwrap(); m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); m.add_item("Recipient", "Carol").unwrap(); for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("Jan25", "Q9")] { m.add_item_in_group("Date", month, quarter).unwrap(); } m.add_item("Date", "Undated").unwrap(); let date = m.model.category_mut("Date").unwrap(); for (quarter, year) in [("Q1", "Y24"), ("Q2", "Y24"), ("Q9", "Y25")] { date.add_group(improvise::model::category::Group::new(quarter).with_parent(year)); } m.add_item("_Measure", "Revenue").unwrap(); let view = m.active_view_mut(); view.set_axis("Recipient", Axis::Row); view.set_axis("Date", Axis::Row); view.set_axis("_Measure", Axis::Column); view.set_expand_depth("Recipient", 1); view.set_group_fold( "Date", "Y24", GroupFold::Expanded(std::num::NonZeroUsize::new(2).unwrap()), ); view.set_group_fold("Date", "Q2", GroupFold::Collapsed); for r in ["Alice", "Carol"] { for d in ["Jan", "Feb", "Apr", "Jan25", "Undated"] { m.model.set_cell( coord(&[("Recipient", r), ("Date", d), ("_Measure", "Revenue")]), CellValue::Number(1.0), ); } } m } /// See `context/test-evidence/tui.md`. #[test] fn the_two_front_ends_draw_the_same_nested_hierarchy_on_rows() { let mut wb = a_date_hierarchy(); let (_app, cache) = whole_grid(&mut wb); let groups: Vec = browser_rows(&cache) .into_iter() .filter_map(|r| match r { RenderRow::Group { label, .. } => Some(label), RenderRow::Data { .. } => None, }) .collect(); assert_eq!( groups, ["Fam", "Y24", "Q1", "Q2", "Y25", "Y24", "Q1", "Q2", "Y25"], "precondition: a year AND its quarters head their runs, under each recipient" ); assert_row_headers_agree(&mut wb); } /// The column side of the same hierarchy: the band paints each column's /// innermost group, coalesced by header, at both ends. #[test] fn the_two_front_ends_draw_the_same_nested_hierarchy_on_columns() { let mut wb = a_date_hierarchy(); wb.active_view_mut().transpose_axes(); let (_app, cache) = whole_grid(&mut wb); let model = browser_model(&cache); let lines = tui_lines(&wb, &cache); let rule = header_rule(&lines); let levels = model.col_levels.len(); let named: Vec<(Option<&str>, usize)> = model .col_groups .iter() .map(|g| (g.label.as_deref(), g.span)) .collect(); assert!( named.iter().any(|(l, _)| *l == Some("Q1")) && named.iter().any(|(l, _)| *l == Some("Y25")), "precondition: a quarter run and a folded year run: {named:?}" ); let expected: Vec = model .col_groups .iter() .flat_map(|g| { let head = match &g.label { Some(name) => format!("{} {name}", indicator(g.collapsed)), None => String::new(), }; std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) }) .collect(); assert_eq!( &tui_header_cells(&lines[rule - levels - 1], &cache), &expected, "the TUI's group line and the browser's runs disagree" ); } /// The row-header comparison, row by row: both ends, one cache. fn assert_row_headers_agree(wb: &mut Workbook) { let (_app, cache) = whole_grid(wb); let tui = tui_row_headers(wb, &cache); let browser = browser_rows(&cache); assert_eq!( tui.len(), browser.len(), "the two ends drew a different number of rows\nTUI: {tui:?}\nbrowser: {browser:?}" ); assert!( tui.len() > 4, "precondition: the fixture must have group headers AND several data \ rows, or nothing here is being compared: {tui:?}" ); for (i, (painted, decided)) in tui.iter().zip(browser.iter()).enumerate() { // A group label lands in the first sub-column, a data row's tuple one // per level, so the group arm compares the joined non-empty text. let painted_text = painted.join("").trim().to_string(); match decided { // Glyph AND name: checking only the glyph would pass on any group // name whatsoever. RenderRow::Group { label, collapsed } => assert_eq!( painted_text, format!("{} {label}", indicator(*collapsed)), "row {i}: the two ends disagree about the group header" ), RenderRow::Data { labels, .. } => assert_eq!( painted, labels, "row {i}: the TUI painted {painted:?} and the browser decided \ {labels:?} — one of them re-derived the frame" ), } } } /// The TUI's glyph, spelled here so the comparison above is against what is /// really painted rather than against a prefix test. fn indicator(collapsed: bool) -> &'static str { match collapsed { true => "▶", false => "▼", } } /// The column side, separate because the row test drives ONE arm /// (`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`). #[test] fn the_two_front_ends_draw_the_same_column_header() { let mut wb = two_level_rows(); let (_app, cache) = whole_grid(&mut wb); let model = browser_model(&cache); let lines = tui_lines(&wb, &cache); let rule = header_rule(&lines); // The header block is the group line (when there is one) plus one line // per level, ending at the rule. let levels = model.col_levels.len(); assert_eq!(levels, 2, "precondition: Month × _Measure is two levels"); assert!( !model.col_groups.is_empty(), "precondition: Q1 groups two of the three months" ); // Compared EXACTLY: a `contains` is one-directional and can see neither a // wrongly-blanked label nor one painted under the wrong column. for (level, labels) in model.col_levels.iter().enumerate() { assert_eq!( &tui_header_cells(&lines[rule - levels + level], &cache), labels, "level {level}: the TUI's header columns and the browser's \ {labels:?} disagree — on suppression, on placement, or on both" ); } // The spans partition the columns, and they are not all one column wide // — the fixture has a grouped run of two and an ungrouped run of one. let total: usize = model.col_groups.iter().map(|g| g.span).sum(); assert_eq!( total, cache.col_frame.len(), "the spans must cover every column, or a colspan layout leaves a hole" ); let named: Vec<(Option<&str>, usize)> = model .col_groups .iter() .map(|g| (g.label.as_deref(), g.span)) .collect(); // improvise-gd83: MEMBERSHIP, not nearest-preceding. Sep is ungrouped, so // the run ends before it and resumes under the next measure's Q1 header. assert_eq!( named, vec![(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)], "the two Q1 runs are separated by the ungrouped Sep; a run that \ swallowed it would be `expand_category` leaking a group across its \ own boundary (see `AxisFrame::group_of`)" ); assert!( named.iter().any(|(label, _)| label.is_none()), "precondition: the fixture must contain an UNGROUPED column, or this \ test cannot see a leaked group at all" ); // improvise-8qlr: the TUI's group line, compared EXACTLY. A `contains` // passes while the TUI labels every column, which is a different grid. let group_line = &lines[rule - levels - 1]; let expected: Vec = model .col_groups .iter() .flat_map(|g| { let head = match &g.label { Some(name) => format!("{} {name}", indicator(g.collapsed)), None => String::new(), }; std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) }) .collect(); assert_eq!( &tui_header_cells(group_line, &cache), &expected, "the TUI's group line and the browser's runs disagree — one of them \ coalesces the runs itself instead of asking `group_runs`" ); } /// `two_level_rows` without `Sep`, so the two `Q1` runs ABUT. The existing /// fixture is saved by the ungrouped month between them — improvise-h0iv. fn abutting_column_groups() -> Workbook { let mut m = two_level_rows(); m.remove_item("Month", "Sep"); m } /// improvise-h0iv, end to end: two `Q1` runs under two measures are two spans, /// and both ends draw them. See `context/test-evidence/tui.md`. #[test] fn two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends() { let mut wb = abutting_column_groups(); let (_app, cache) = whole_grid(&mut wb); let model = browser_model(&cache); let lines = tui_lines(&wb, &cache); let rule = header_rule(&lines); let levels = model.col_levels.len(); let named: Vec<(Option<&str>, usize)> = model .col_groups .iter() .map(|g| (g.label.as_deref(), g.span)) .collect(); assert!( named.iter().all(|(label, _)| label.is_some()), "precondition: NOTHING ungrouped may separate the runs, which is the \ whole difference from the fixture above: {named:?}" ); assert_eq!( named, vec![(Some("Q1"), 2), (Some("Q1"), 2)], "one span of four draws a single header across the measure boundary" ); let group_line = &lines[rule - levels - 1]; let expected: Vec = model .col_groups .iter() .flat_map(|g| { let head = match &g.label { Some(name) => format!("{} {name}", indicator(g.collapsed)), None => String::new(), }; std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) }) .collect(); assert_eq!( &tui_header_cells(group_line, &cache), &expected, "the TUI draws one group line and the browser decided another" ); } /// improvise-0o6x: a group FIRST on its axis. Separate from `two_level_rows`, /// which orders Carol first to dodge this case on purpose. fn leading_group_rows() -> Workbook { let mut m = Workbook::new("Test"); m.add_category("Recipient").unwrap(); m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); m.add_item_in_group("Recipient", "Bob", "Fam").unwrap(); m.active_view_mut().set_expand_depth("Recipient", 1); m.add_item("Recipient", "Carol").unwrap(); m.add_item("_Measure", "Revenue").unwrap(); m.active_view_mut().set_axis("Recipient", Axis::Row); m.active_view_mut().set_axis("_Measure", Axis::Column); for r in ["Alice", "Bob", "Carol"] { m.model.set_cell( coord(&[("Recipient", r), ("_Measure", "Revenue")]), CellValue::Number(1.0), ); } m } /// A collapsed group at position zero is otherwise a trap: its rows are hidden /// and its header is the only affordance. context/test-evidence/tui.md. #[test] fn a_group_header_first_on_its_axis_reaches_both_front_ends() { for collapse in [false, true] { let mut wb = leading_group_rows(); if collapse { wb.active_view_mut() .toggle_group_collapse("Recipient", &["Fam"]); } let (_app, cache) = whole_grid(&mut wb); assert!( matches!(cache.row_frame.entries.first(), Some(FrameEntry::Group(g)) if g.name == "Fam"), "precondition (collapse={collapse}): the frame must LEAD with the \ header, or body_from has nothing at position zero to drop" ); let browser = browser_rows(&cache); assert!( matches!(browser.first(), Some(RenderRow::Group { label, .. }) if label == "Fam"), "the browser dropped the leading group header: {browser:?}" ); let painted = tui_row_headers(&wb, &cache); assert_eq!( painted.first().map(|r| r.join("").trim().to_string()), Some(format!("{} Fam", indicator(collapse))), "the TUI dropped the leading group header: {painted:?}" ); } } /// The bug's own words, separate because the agreement above would also hold /// if *both* ends started slash-joining. #[test] fn neither_front_end_produces_a_slash_joined_label() { let mut wb = two_level_rows(); let (_app, cache) = whole_grid(&mut wb); for row in browser_rows(&cache) { for label in row.labels() { assert!( !label.contains('/'), "the browser drew the joined label {label:?}" ); } } for painted in tui_row_headers(&wb, &cache) { for label in painted { assert!( !label.contains('/'), "the TUI painted the joined label {label:?}" ); } } } /// The collapse state crosses as a boolean, not a glyph (gotcha 26). BOTH /// states, because a hard-wired `false` would pass the first half. #[test] fn the_collapse_state_crosses_as_a_fact_not_a_glyph() { for collapse in [false, true] { let mut wb = two_level_rows(); if collapse { wb.active_view_mut() .toggle_group_collapse("Recipient", &["Family"]); } let (_app, cache) = whole_grid(&mut wb); let groups: Vec<(String, bool)> = browser_rows(&cache) .into_iter() .filter_map(|r| match r { RenderRow::Group { label, collapsed } => Some((label, collapsed)), RenderRow::Data { .. } => None, }) .collect(); assert!( !groups.is_empty(), "precondition (collapse={collapse}): a collapsed group still owes \ the user a header to expand again" ); for (label, collapsed) in groups { assert_eq!(label, "Family"); assert_eq!(collapsed, collapse, "the frame states the view's answer"); } } }