fix(protocol): a group run is opened by a header, not by a name (improvise-h0iv)

group_runs coalesced adjacent positions by group NAME, so two runs under two
different headers sharing a name read as one and both front ends drew a single
span across the boundary.

THE TICKET'S PROPOSED FIX DOES NOT WORK, and that is worth more than the fix.
h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat for
exactly that reason. But its own reachability case is ONE category -- Month
grouped into Q1 nested under Region, giving North/Q1 and South/Q1 adjacent --
so both headers are (Month, Q1), equal as values, and the pair separates
nothing. A test pins that so the next reader does not re-propose it. What does
separate them is that they are two distinct FrameEntry::Group entries, which
groups() already borrows from self.entries: std::ptr::eq on the header is the
literal statement of "the same header opened both".

THE PRODUCT QUESTION, decided here and reversible in one comparison. Whether
two adjacent same-named runs are one span a user wants spanned is a matter of
taste, which is why #77 filed rather than fixed. What settled it is not the
span but the indicator: the existing wasm-client test merged Month/Q1
(expanded) with Fiscal/Q1 (COLLAPSED) and its own message said the merged span
is drawn with the first one's indicator -- a collapsed group rendered as
expanded, with no affordance saying otherwise. A span boundary is taste; a
wrong collapse glyph is a wrong fact.

That test's justification was also spent: it said the rule must be name-only
"because that is what src/ui/grid.rs uses", and since improvise-8qlr grid.rs
calls group_run_heads. There is no second copy left to agree with, which is the
general point -- a consolidation's frozen behaviour reads as decided afterwards,
and nothing says it was frozen only for the duration of the move.

The agreement fixture gains the case it could not see: two_level_rows minus the
ungrouped Sep, so the two Q1 runs abut, end to end through a real GridLayout and
both renderers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 03:57:07 -07:00
parent 9b81324d6a
commit c4bf74608f
7 changed files with 252 additions and 28 deletions
+82 -4
View File
@@ -322,14 +322,13 @@ impl AxisFrame {
.flatten()
}
/// The runs of adjacent positions sharing a group, coalesced by NAME —
/// the coalescing rule's **one home**, both front ends (improvise-8qlr).
/// The runs of adjacent positions sharing a group — one home, both front
/// ends (improvise-8qlr). Opened by a HEADER, not a name: §3a, improvise-h0iv.
pub fn group_runs(&self) -> Vec<(Option<&FrameGroup>, usize)> {
let mut runs: Vec<(Option<&FrameGroup>, usize)> = Vec::new();
for group in self.groups() {
let name = group.map(|g| g.name.as_str());
match runs.last_mut() {
Some((prev, span)) if prev.map(|p| p.name.as_str()) == name => *span += 1,
Some((prev, span)) if opened_by(*prev, group) => *span += 1,
_ => runs.push((group, 1)),
}
}
@@ -377,6 +376,16 @@ impl AxisFrame {
}
}
/// Same `FrameEntry::Group`, by identity — two equal headers under different
/// outer items are two runs. Both borrow `self.entries`, so this is total.
fn opened_by(one: Option<&FrameGroup>, other: Option<&FrameGroup>) -> bool {
match (one, other) {
(None, None) => true,
(Some(one), Some(other)) => std::ptr::eq(one, other),
_ => false,
}
}
/// The row-header width rule's **one home** (improvise-1125). It borrows
/// rather than taking an [`AxisFrame`], for the keypress path — see §3.
pub fn header_level_widths<'a>(
@@ -1012,6 +1021,75 @@ mod tests {
}
}
/// Two runs of one group name under two different outer items, with nothing
/// ungrouped between them — improvise-h0iv's reachable shape.
fn repeated_group_frame() -> AxisFrame {
let q1 = || FrameGroup {
cat: "Month".into(),
name: "Q1".into(),
collapsed: false,
};
AxisFrame {
first: 0,
cats: vec!["Region".into(), "Month".into()],
entries: vec![
FrameEntry::Group(q1()),
FrameEntry::Item(vec!["North".into(), "Jul".into()]),
FrameEntry::Item(vec!["North".into(), "Aug".into()]),
FrameEntry::Group(q1()),
FrameEntry::Item(vec!["South".into(), "Jul".into()]),
FrameEntry::Item(vec!["South".into(), "Aug".into()]),
],
}
}
/// improvise-h0iv: a run is opened by a header, not by a name. See
/// `context/test-evidence/protocol.md`.
#[test]
fn two_runs_under_two_headers_of_one_name_are_two_runs() {
let frame = repeated_group_frame();
let spans: Vec<(Option<&str>, usize)> = frame
.group_runs()
.into_iter()
.map(|(group, span)| (group.map(|g| g.name.as_str()), span))
.collect();
assert_eq!(
frame
.entries
.iter()
.filter(|e| matches!(e, FrameEntry::Group(_)))
.count(),
2,
"precondition: two separate headers, or there is nothing to split"
);
assert_eq!(
spans,
vec![(Some("Q1"), 2), (Some("Q1"), 2)],
"one span of 4 draws a single colspan across the North/South boundary"
);
}
/// The pair `FrameGroup` carries cannot separate them — h0iv's own proposed
/// fix. See `context/test-evidence/protocol.md`.
#[test]
fn the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them() {
let frame = repeated_group_frame();
let headers: Vec<&FrameGroup> = frame
.entries
.iter()
.filter_map(|e| match e {
FrameEntry::Group(g) => Some(g),
_ => None,
})
.collect();
assert_eq!(
(&headers[0].cat, &headers[0].name),
(&headers[1].cat, &headers[1].name),
"both headers name the same category AND the same group, so the \
ticket's (cat, name) comparison would still merge them"
);
}
/// improvise-1wl0: a dropped `first` must not deserialize as the claim that
/// this frame LEADS the axis. See `context/test-evidence/protocol.md`.
#[test]
+10 -6
View File
@@ -513,10 +513,10 @@ mod tests {
FrameEntry::Item(vec![label.into()])
}
/// The run boundary must be the **name alone**, or the two front ends
/// disagree about where a span begins. See the evidence file.
/// A run is opened by a HEADER, not by a name — improvise-h0iv, and both
/// ends now ask the one rule. See the evidence file.
#[test]
fn column_groups_coalesce_by_name_as_the_tui_does() {
fn column_groups_coalesce_per_header_as_the_tui_does() {
let frame = AxisFrame {
first: 0,
cats: vec!["Month".into()],
@@ -534,9 +534,13 @@ mod tests {
.iter()
.map(|g| (g.label.as_deref(), g.span, g.collapsed))
.collect::<Vec<_>>(),
vec![(Some("Q1"), 2, false), (Some("Q2"), 1, false)],
"the two Q1 headers are one span, drawn with the FIRST one's \
indicator; keying on (name, collapsed) would split them"
vec![
(Some("Q1"), 1, false),
(Some("Q1"), 1, true),
(Some("Q2"), 1, false)
],
"merging the two Q1 headers drew the SECOND one with the first's \
collapse indicator, so a collapsed group looked expanded"
);
}