Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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"
    );
}