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:
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user