7c4b90c537
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>
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! 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");
|
||
}
|
||
}
|
||
}
|