facett-app/tests/tabstrip_wraps.rs
//! **The tab strip WRAPS.** A strip that lays its last headers out past the container's
//! right edge puts them outside the AccessKit tree, where they are unreachable by mouse
//! and by robot alike — dwarves lost its 18th tab exactly that way.
//!
//! Scrolling was the first cure (facett `cdb3cb63`, 2026-07-22) and it made every tab
//! REACHABLE. It did not make them VISIBLE: with 24 tabs the operator still has to drag a
//! strip and read as they go, and a tab they do not know exists is one they will not go
//! looking for. Wrapping shows all of them at once.
//!
//! The two are mutually exclusive, which is the trap this file exists to pin. A
//! `horizontal_wrapped` row INSIDE a `ScrollArea::horizontal` never wraps: the scroll
//! area hands the row unbounded width, so the wrap point is never reached and the change
//! is a silent no-op. Swapping the call without removing the scroll area looks like a fix
//! and is not one.
//!
//! **And the scroll area is not the only parent that lies about the room.** ANY `Ui` built
//! from a computed rect rather than a measured one can report more width than the window
//! has, and `horizontal_wrapped` wraps against whatever it is handed — so removing the
//! scroll area fixed the strip only where the parent happened to be honest. The last two
//! tests below pin the wrap under a lying parent twice over: at the geometry the escape
//! was reported at (thirteen columns, a 1400pt canvas, a parent claiming 2100pt), and
//! under dwarves' own 24-tab shell at the 900pt canvas where seven of them escaped.
//! `TabView::show` clamps the wrap point to the clip rect and the viewport; dwarves'
//! `kernel_scene.rs` clamps its own canvas the same way, for the same reason.
use egui_kittest::Harness;
use facett_app::TabView;
/// Twenty-four tabs, the count dwarves actually ships, at labels of a realistic length.
fn many_tabs() -> Vec<(String, String)> {
[
"Översikt", "Bygg", "Kernel", "Data", "Mímir", "Täckning", "Kodrum", "Binär",
"Lager", "Flotta", "Bänk", "Saga", "Matris", "Släpp", "Dubbletter", "Linser",
"Tratt", "Världen", "Jobb", "Loggar", "Nycklar", "Valv", "Hälsa", "Om",
]
.iter()
.enumerate()
.map(|(i, l)| (format!("t{i}"), (*l).to_string()))
.collect()
}
/// **Thirteen columns at the width the escape was reported at.** The count (13) and the
/// canvas (1400pt) are the reported geometry; the LABELS are not — the reporting strip's
/// own column names live in a repo this crate cannot see, so these are fixture names
/// (`orten-*`, the spelling dwarves' scene tests use) chosen to total the same overrun,
/// which reproduces the last column at x≈1371 rather than the reported x≈1420. The
/// property is "the last column runs past the canvas", and a fabricated label list that
/// claimed to be someone else's would make it unfalsifiable rather than more precise.
fn thirteen_tabs() -> Vec<(String, String)> {
[
"orten-exe-linux", "orten-lib-static", "orten-wasm-web", "orten-container-oci",
"orten-vm-appliance", "orten-iso-live", "orten-installer-uefi", "orten-service-unit",
"orten-bootstrap", "orten-toolchain", "orten-rootfs-min", "orten-appliance-full",
"kernel",
]
.iter()
.enumerate()
.map(|(i, l)| (format!("t{i:02}"), (*l).to_string()))
.collect()
}
/// How wide the `Ui` handed to [`TabView::show`] claims to be.
#[derive(Clone, Copy)]
enum Parent {
/// The canvas itself — a pane that was measured against the window it lives in.
Honest,
/// A parent whose `max_rect` is `factor` × the window. Not a contrivance: dwarves'
/// kernel pane measured ~2250pt of "available" width inside a 1500pt window
/// (`dwarves-gui/src/kernel_scene.rs`), which is 1.5×, and any caller that builds a
/// `Ui` from a computed rect rather than a measured one can do the same.
Oversized(f32),
}
/// Lay `tabs` out in a canvas of `width` under `parent`, and hand back every header's rect
/// as `(id, x, y, w, h)` — the same `rects` a robot reads out of `state_json`.
fn rects_in(
width: f32,
tabs: Vec<(String, String)>,
parent: Parent,
) -> Vec<(String, f32, f32, f32, f32)> {
let mut view = TabView::new(tabs.iter().map(|(i, l)| (i.as_str(), l.as_str())));
let mut harness = Harness::builder()
.with_size(egui::vec2(width, 700.0))
.build_ui(move |ui| {
let mut draw = |ui: &mut egui::Ui| {
view.show(ui);
// Read the rects back through the public state surface, not a private
// field: if a robot cannot see it, this test must not either.
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::new("tabview-rects"), view.state_json()["rects"].clone())
});
};
match parent {
Parent::Honest => draw(ui),
Parent::Oversized(factor) => {
let r = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(width * factor, 60.0),
);
ui.scope_builder(egui::UiBuilder::new().max_rect(r), draw);
}
}
});
harness.run();
harness.run();
let v: serde_json::Value = harness
.ctx
.data_mut(|d| d.get_temp(egui::Id::new("tabview-rects")))
.expect("the strip published its rects");
v.as_object()
.expect("rects is an object keyed by tab id")
.iter()
.map(|(id, r)| {
let a = r.as_array().expect("rect is [x, y, w, h]");
let f = |i: usize| a[i].as_f64().unwrap() as f32;
(id.clone(), f(0), f(1), f(2), f(3))
})
.collect()
}
/// The 24-tab dwarves shell in a canvas of `width`, measured honestly.
fn rects_at(width: f32) -> Vec<(String, f32, f32, f32, f32)> {
rects_in(width, many_tabs(), Parent::Honest)
}
/// Distinct header rows in a set of rects, in top-to-bottom order.
fn rows_of(rects: &[(String, f32, f32, f32, f32)]) -> Vec<f32> {
let mut rows: Vec<f32> = rects.iter().map(|(_, _, y, _, _)| *y).collect();
rows.sort_by(|a, b| a.partial_cmp(b).unwrap());
rows.dedup_by(|a, b| (*a - *b).abs() < 1.0);
rows
}
/// Every header whose rect runs past the canvas's right edge — the ones that leave the
/// AccessKit tree, reported as `id @ x..right` so a failure names the geometry.
fn escaped(rects: &[(String, f32, f32, f32, f32)], width: f32) -> Vec<String> {
rects
.iter()
.filter(|(_, x, _, w, _)| x + w > width + 1.0)
.map(|(i, x, _, w, _)| format!("{i} @ {x:.1}..{:.1}", x + w))
.collect()
}
/// **RED WHEN REVERTED:** put the header row back inside `ScrollArea::horizontal` and
/// the tabs lay out on ONE row far past the canvas, so both assertions fail at once.
#[test]
fn twenty_four_tabs_wrap_onto_several_rows_and_none_escapes_the_canvas() {
const W: f32 = 900.0;
let rects = rects_at(W);
assert_eq!(rects.len(), 24, "premise: every tab laid out and published a rect");
// 1. NOTHING past the right edge. This is the reachability half — a header whose rect
// starts beyond the container is the header that vanished from the AccessKit tree.
let escaped: Vec<_> =
rects.iter().filter(|(_, x, _, w, _)| x + w > W + 1.0).map(|(i, ..)| i.clone()).collect();
assert!(
escaped.is_empty(),
"{} of 24 headers lay out past the {W}pt canvas: {escaped:?} — those are exactly \
the tabs that drop out of the AccessKit tree and become unreachable by mouse AND \
by robot",
escaped.len()
);
// 2. It actually WRAPPED. Without this, a strip that simply shrank its buttons to fit
// would satisfy (1) while proving nothing about the layout rule.
let mut rows: Vec<f32> = rects.iter().map(|(_, _, y, _, _)| *y).collect();
rows.sort_by(|a, b| a.partial_cmp(b).unwrap());
rows.dedup_by(|a, b| (*a - *b).abs() < 1.0);
assert!(
rows.len() > 1,
"all 24 headers share one row at y={:?} in a {W}pt canvas — the row did not wrap, \
which is what happens when `horizontal_wrapped` is left inside a \
`ScrollArea::horizontal` that hands it unbounded width",
rows.first()
);
}
/// **THE GUARD ON THE GUARD.** Wrapping must not fire when there is room — a strip that
/// wraps a handful of tabs in a wide window has just wasted a row of vertical space that
/// the pane below it needed.
#[test]
fn a_wide_canvas_keeps_the_strip_on_one_row() {
let rects = rects_at(4000.0);
let mut rows: Vec<f32> = rects.iter().map(|(_, _, y, _, _)| *y).collect();
rows.sort_by(|a, b| a.partial_cmp(b).unwrap());
rows.dedup_by(|a, b| (*a - *b).abs() < 1.0);
assert_eq!(
rows.len(),
1,
"24 headers fit a 4000pt canvas and must stay on ONE row — wrapping when there is \
room steals vertical space from the pane below, got rows at {rows:?}"
);
}
/// **THE FOURTH PROPERTY — the wrap point must be a width that EXISTS ON SCREEN.**
///
/// `horizontal_wrapped` breaks the row wherever its parent `Ui` says the room ends, and a
/// parent is free to say more than the window has: `max_rect` is whatever the caller built
/// the `Ui` from, and a caller that computes a rect instead of measuring one hands over a
/// width nobody can see. dwarves' kernel pane measured ~2250pt of "available" inside a
/// 1500pt window (`dwarves-gui/src/kernel_scene.rs`, which clamps for exactly this reason)
/// — 1.5×, the factor used here. Note that pane clamps a WIDTH to the window's width
/// (`avail.x.min(screen.x)`), which still overruns by the pane's own left offset; the clamp
/// here is on the right EDGE instead, so it holds for a pane that does not start at x=0.
///
/// Against such a parent the wrap point is never reached, the row stays flat, and the last
/// headers land past the right edge: the ORIGINAL defect, unchanged, behind a call that
/// looks like the cure. MEASURED before the clamp, at the geometry it was reported at —
/// thirteen columns, 1400pt canvas, parent claiming 2100pt: ONE row, and the last column
/// at x=1370.8 running to 1414.8, off the surface with its click centre.
///
/// **RED WHEN REVERTED:** drop the clamp in `TabView::show` and `kernel` escapes again.
#[test]
fn thirteen_tabs_wrap_at_1400_even_when_the_parent_claims_more_width_than_the_window() {
const W: f32 = 1400.0;
let rects = rects_in(W, thirteen_tabs(), Parent::Oversized(1.5));
assert_eq!(rects.len(), 13, "premise: every column laid out and published a rect");
// 1. Nothing past the right edge — the reachability half, at the real geometry.
let out = escaped(&rects, W);
assert!(
out.is_empty(),
"{} of 13 headers lay out past the {W}pt canvas: {out:?} — the parent claimed \
{}pt and the row wrapped against a width nobody has, so these headers are off the \
surface: out of the AccessKit tree, and a click at their centre lands on nothing",
out.len(),
W * 1.5,
);
// 2. It actually WRAPPED — the same guard as the 900pt case, so a strip that merely
// shrank its buttons cannot satisfy (1) and call itself fixed.
let rows = rows_of(&rects);
assert!(
rows.len() > 1,
"all 13 headers share one row at y={:?} in a {W}pt canvas — the row did not break, \
which is what an unbounded parent's reported width buys you",
rows.first()
);
// 3. …and the LAST column is the one to name, because it is the one that escaped:
// `kernel`, 13th of 13, measured at x=1370.8..1414.8 before the clamp.
let (id, x, _, w, _) = rects.iter().max_by(|a, b| a.0.cmp(&b.0)).expect("a last column");
assert!(
x + w <= W + 1.0,
"the last column ({id}) runs to {:.1} in a {W}pt canvas",
x + w
);
}
/// The same clamp under dwarves' OWN shell — 24 tabs, the 900pt canvas where SEVEN of
/// them escaped, with the parent now claiming 1350pt.
///
/// It is the 900pt canvas and not 1400 on purpose, and the reason is worth writing down:
/// these 24 labels TOTAL about 1250pt, so at 1400 they fit on one honest row and the
/// oversized parent costs nothing. Asserting there would have passed for the wrong reason.
/// The two canvases divide the work — 13 wide columns are the 1400pt witness above, 24
/// short ones are the 900pt witness here — and between them the clamp is pinned at both a
/// realistic tab count and a realistic width.
#[test]
fn the_twenty_four_tab_shell_stays_on_the_canvas_under_an_oversized_parent() {
const W: f32 = 900.0;
let rects = rects_in(W, many_tabs(), Parent::Oversized(1.5));
assert_eq!(rects.len(), 24, "premise: every tab laid out and published a rect");
let out = escaped(&rects, W);
assert!(
out.is_empty(),
"{} of 24 headers lay out past the {W}pt canvas: {out:?} — dwarves lost its 18th \
tab to exactly this, and a parent reporting {}pt reproduces it WITH the wrap in \
place, because the wrap believed the parent",
out.len(),
W * 1.5,
);
assert!(
rows_of(&rects).len() > 1,
"24 tabs do not fit {W}pt on one row; the row must break regardless of what the \
parent claims"
);
}