8f3a9768b7
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed together or the test stops comparing anything. improvise-gd83 — AxisEntry carries a group-end boundary ------------------------------------------------------- `expand_category` emitted a header on ENTERING a group and no boundary on leaving one, so `group_of` answered with the nearest preceding header rather than membership, and the TUI painted that span. Copilot was right on PR #23; this is the filing gotcha 44 said the fix needed. `AxisEntry::GroupEnd` is the boundary, carried onto the wire as `FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other: * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition; * across expansions — `cross_product` expands the inner category once per prefix and each call starts with `last_group = None`, so Food/Bob[Family] is followed directly by Clothing/Carol with no transition to hook onto. That needs a `GroupEnd` at the END of an expansion whose last item was grouped. `GroupEnd` is NOT emitted between two groups: the next header already ends the previous run, and emitting one there moved `data_row_to_visual_skips_headers` and its column twin for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims. A trailing marker is trimmed, since the axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no data index. `THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the value they predicted is now asserted element for element — it was right to the element, which is worth recording. improvise-0o6x — a leading group header is drawn ------------------------------------------------ `body_from(idx)` started at the first ITEM, so at the top of an axis a leading `FrameEntry::Group` was dropped on the way to the screen, in both front ends. A collapsed group first on its axis therefore had its rows hidden and its header nowhere: no affordance to get them back. It is a boundary condition as the ticket suspected, but the boundary is not `idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled window for `group_of` and `body_from` discards it — correctly — and a frame cannot tell that from the top of the axis using `idx` alone, because the browser's frame is already windowed and always passes `0`. So `AxisFrame` gained `first`, the absolute data index of its first item, and the rule is `first + idx == 0`. Without it the browser would draw an extra header row at every scrolled position that the TUI does not. RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in `src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws above `East` and everything below shifts down one. The group line, the column headers, the separator, the data rows and the totals strip are byte-identical, which is the evidence this is the boundary condition and not a reshuffle. `harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with three, naming this ticket on the line and saying the count becoming 3 would be the record of when it was fixed. It is 3, verified in Chromium. Three assertions in that file took "the first tbody row" to be a data row; a `firstDataRow` helper now skips the header, which is the same re-pin. improvise-8qlr — the run rule lives once ----------------------------------------- Column-group run coalescing was derived in `src/ui/grid.rs` and again in `improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both ends call it. Runs still coalesce by NAME, which is what both ends did before and is preserved rather than quietly changed. The TUI keeps one divergence, stated: it labels its first VISIBLE column even mid-run, or a scrolled grid draws an unlabelled group line. The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned arm the ticket names. It now compares the painted group line cell for cell against spans derived from the browser's own runs. The test is fixed, not deleted. Injected faults, each failing by name ------------------------------------- * gd83: delete either `GroupEnd` emission — the two new layout tests fail with the leaked group, and the column-header agreement test fails with them. * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged` and the new leading-header agreement test fail. * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on every column. `cargo test -p improvise` was GREEN before this commit and now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...]. What the ticket did not predict ------------------------------- A collapsed leading group has no item to carry its header into a window, so it reaches the frame only through `axis_frame`'s `pending` queue. The first version of the windowing change had `GroupEnd` CLEAR `pending` above the window, which deleted exactly that header — the affordance 0o6x exists to restore. `GroupEnd` is now queued like a header and clears nothing. Found by driving the new agreement test in both collapse states; the uncollapsed arm alone passes against the broken version. 1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
426 lines
15 KiB
Rust
426 lines
15 KiB
Rust
//! 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};
|
||
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.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*, 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<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()
|
||
}
|
||
|
||
/// 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<String> {
|
||
let chars: Vec<char> = 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<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 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<String> = 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`"
|
||
);
|
||
}
|
||
|
||
/// 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.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");
|
||
}
|
||
}
|
||
}
|