Files
improvise/tests/grid_frame_agreement.rs
T
Edward Langley f7d377b010 refactor(tests): cap the integration tests' comments
grid_frame_agreement.rs is the strongest test in the last wave and its
comments were the record of six injected faults; all of it is now in
context/test-evidence/tui.md, keyed by test name.

THE_MEMBERSHIP_CORRECT_SPANS and its assert_ne! are UNTOUCHED. Its comment
now says what it is — a deliberately wrong constant and a live tripwire —
and points at the derivation rather than carrying it.

shipped_documents_load.rs keeps its two load-bearing claims in the file
(directory walk, full-name match) because both sit directly over the code
that would otherwise be "simplified" back into the bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:51:38 -07:00

367 lines
13 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
//! 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::{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();
assert_eq!(
named,
vec![(Some("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. \
See `THE_MEMBERSHIP_CORRECT_SPANS` for what improvise-gd83 makes \
this line say."
);
// A DELIBERATELY WRONG constant and a live tripwire — do not remove it.
// Derived, not guessed: see `context/test-evidence/tui.md`.
const THE_MEMBERSHIP_CORRECT_SPANS: [(Option<&str>, usize); 4] =
[(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)];
assert_ne!(
named.as_slice(),
THE_MEMBERSHIP_CORRECT_SPANS.as_slice(),
"when this stops differing, improvise-gd83 is fixed and the \
assertion above should become the constant"
);
// 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 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");
}
}
}