Files
improvise/tests/grid_frame_agreement.rs
T
Edward Langley 7c4b90c537 fix: four Copilot findings on the grid frame
All four were right; two more were right and are declined on the threads.

**A frame's pre-window headers were O(offset).** `axis_frame` accumulated
every group header above the window and flushed the run at the first
in-window item, so a deep scroll past many groups serialized headers that
`body_from` discards on arrival. Only the *nearest* one can ever be answered
with, so only that one is kept.

**`web/app.js` captured the row counter, not its value.** `r` is one binding
shared by every click-handler closure, so after the loop every editable cell
would have called `edit` with the last data row. The group-aware counter was
right; capturing it was not. It is a bug this PR introduced — the old code
took `r` as a `forEach` parameter, which is per-call.

**The agreement suite drove one arm.** It only read `rows`, and its fixture
had no grouped column axis, so `col_levels` and `col_groups` could regress
behind a green suite
(`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`).
The fixture now groups two of three months under `Q1` and leaves `Sep`
outside, with `_Measure` giving a second column level, and
`the_two_front_ends_draw_the_same_column_header` checks the levels against
the painted header lines and the spans against the column count. One span
per column, and a dropped level suppression, were each injected and watched
to fail.

**The group branch did not compare the label.** It asserted only that the
painted text began with an indicator, so any group name passed. It now
compares the whole painted header against the browser's glyph-plus-label.

The column test also *demonstrated* the finding this PR declines: `Q1` spans
all six columns including the two under the ungrouped `Sep`, because
`expand_category` emits a header on entering a group and no boundary on
leaving one. That is `GridLayout`'s rule and the TUI paints the same span.
It is asserted as it is, with the defect and the corrected expectation
written next to it, and stated on `AxisFrame::group_of`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:52:18 -07:00

376 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The two front ends draw the same grid frame (improvise-anr9/s1xw).
//!
//! The divergence this exists to rule out was real and shipped:
//! `GridCache::row_labels` was a `Vec<String>` built by
//! `GridLayout::row_label`, which **slash-joins a multi-level tuple** — the
//! exact rendering `src/ui/grid.rs::two_row_categories_produce_cross_product_labels`
//! asserts must not appear. So the protocol carried a form of the labels the
//! TUI itself considered wrong, and the browser drew `Food/Alice` where the
//! TUI drew two header columns, with no groups and no collapse indicators.
//!
//! The shape of the check matters. Each front end is driven the way it is
//! really driven — the widget into a ratatui buffer, the client through a
//! `Snapshot` of the same cache's `seed()` — and the two are compared on
//! what they *draw*, not on what they were handed. Comparing the caches
//! would be vacuous: it is one cache.
//!
//! Injected-fault record: re-introducing the slash-join on either side —
//! `labels: vec![rows.item(r).join("/")]` in the client's `render_model`, or
//! `rows.item(ri).join("/")` in the widget's row header — makes
//! `the_two_front_ends_draw_the_same_row_header` fail, naming the row and
//! the two spellings. Watched both fail before keeping it.
use improvise::model::cell::{CellKey, CellValue};
use improvise::ui::app::{App, AppMode, ViewCursor};
use improvise::ui::grid::GridWidget;
use improvise::view::{Axis, GridLayout};
use improvise::workbook::Workbook;
use improvise_command::ui::project;
use improvise_protocol::{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 — the cross product the slash-join used
/// to flatten, on rows *and* columns, so the frame carries header levels,
/// repeat suppression, group spans and a collapse state in one fixture.
///
/// Two deliberate asymmetries. `Carol` sits outside the `Family` group and
/// *before* it, so collapsing `Family` still leaves a row: an all-collapsed
/// axis draws no group headers at all, in both front ends and before this
/// change (see the hand-back), and a fixture that hit it would be testing
/// that bug. And `Month` has one grouped quarter and one ungrouped month, so
/// the column runs are not uniform — a `col_groups` that emitted one span
/// per column, or one span for the whole axis, would pass a uniform fixture.
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.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_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*, one entry per drawn row, each split
/// into its level sub-columns and trimmed.
///
/// It slices by `AxisFrame::header_widths` because that is the same rule the
/// widget lays the sub-columns out with (improvise-1125) — reading the
/// widths a second way here would be the very duplication under test.
fn tui_row_headers(wb: &Workbook, cache: &GridCache) -> Vec<Vec<String>> {
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<char> = 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()
}
/// 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<String> {
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::<String>()
})
.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<RenderRow> {
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() {
let mut wb = two_level_rows();
let (_app, cache) = whole_grid(&mut 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 header is one label across the whole row header; a data
// row is one label per level. The TUI's slicing puts a group label
// in the first sub-column, so compare the joined non-empty text.
let painted_text = painted.join("").trim().to_string();
match decided {
// The full painted string, glyph and name: a branch that checked
// 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 of the same agreement, and it is a separate test because
/// `the_two_front_ends_draw_the_same_row_header` drives **one arm**: a
/// `col_levels` that stopped suppressing repeats, or a `col_groups` that
/// emitted one span per column, would sit green behind it
/// (`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"
);
// Every label the browser decided to draw is painted by the TUI on the
// matching header line, and every one it blanked is absent from it.
for (level, labels) in model.col_levels.iter().enumerate() {
let line = &lines[rule - levels + level];
for label in labels.iter().filter(|l| !l.is_empty()) {
assert!(
line.contains(label.as_str()),
"level {level}: the browser drew {label:?}, the TUI painted \
{line:?}"
);
}
}
// 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<(&str, usize)> = model
.col_groups
.iter()
.map(|g| (g.label.as_str(), g.span))
.collect();
assert_eq!(
named,
vec![("Q1", 6)],
"Q1 covers all six columns, INCLUDING the two under the ungrouped \
Sep — `expand_category` emits a header on entering a group and no \
boundary on leaving one, so the nearest-preceding walk keeps \
answering Q1 (see `AxisFrame::group_of`). That is `GridLayout`'s \
rule and the TUI paints the same span; it is reproduced here rather \
than corrected, because correcting it changes what the TUI draws. \
When it is fixed this line becomes [(\"Q1\", 4), (\"\", 2)] and this \
is the test that says so."
);
// And the group line the TUI paints names the same group.
let group_line = &lines[rule - levels - 1];
assert!(
group_line.contains("▼ Q1"),
"the TUI paints the same group above the same columns: {group_line:?}"
);
}
/// The joined form appears in neither, which is the bug's own words. It is a
/// separate assertion 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 group's collapse state reaches the browser as a *fact*, not as a
/// glyph. The TUI spells it ▼/▶ (presentation, gotcha 26); the client gets
/// the boolean and spells it however the DOM wants. Both states are checked,
/// because a `collapsed` hard-wired to `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");
}
}
}