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

nordisk/facett

facett-app/src/scene_tests.rs

//! **Robot + unit tests for the declarative UI-SCENE layer** (L4). Asserts, as the
//! mandate requires:
//! - RON round-trips **byte-stable** (parse ∘ serialize is a fixed point) — RED when
//!   the schema/format drifts.
//! - a pane **folds/unfolds** and, folded, its contents are **omitted** from the
//!   AccessKit tree (a real kittest render, mirroring facett-graphnav's ⚙ fold).
//! - a `Split` with a `phone` override renders as **Tabs** under `collapse_below`.
//! - a **draggable Split**'s live ratio drives the layout (the divider moves), and a
//!   **real pointer drag** on the handle re-apportions the ratio — asserted via
//!   `state_json` (RED if the drag is dropped or the ratio isn't live).
//! - N-way split **weights normalise** to fractions (FlexLayout-style relative sizing).
//! - a **drawer** pane auto-folds crossing into phone-class and restores leaving it.
//! - nav **Push / Pop / Toggle / deep-link** resolve by unique pane name over the
//!   shared [`Router`].
//! - the builder and RON produce the **same** [`Scene`] value.

use super::*;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;

/// A tiny host that draws a queryable label per slot (so the AccessKit tree carries a
/// node we can assert present/absent), and returns a small state_json.
struct TestHost;
impl SceneHost for TestHost {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        ui.label(format!("SLOT:{id}"));
        serde_json::json!({ "slot": id })
    }
}

/// A representative shell scene: a menubar + a split with a foldable sidebar and a
/// tabbed body, one leaf phone-collapsing.
fn sample_scene() -> Scene {
    Scene::new(
        "sample",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.25,
            vec![
                Pane::slot("sidebar", "nav").title("Sidebar").foldable_icon(true, "⚙"),
                Pane::tabs(
                    "body",
                    0,
                    vec![
                        Pane::slot("overview", "stat").title("Overview"),
                        Pane::slot("detail", "log").title("Detail").foldable(true),
                    ],
                )
                .title("Body"),
            ],
        ),
    )
    .menu(MenuItem::new("view", "View").items(vec![
        MenuItem::new("view.toggle_sidebar", "Toggle Sidebar").action(Action::Toggle("sidebar".into())),
        MenuItem::new("view.overview", "Overview").action(Action::NavigateTo("overview".into())),
    ]))
    .route(Route::on_menu("view.overview", Target::Show("overview".into())))
}

// ── RON ⇄ builder ⇄ RON (byte-stable) ──────────────────────────────────────────

#[test]
fn ron_round_trips_byte_stable() {
    let scene = sample_scene();
    scene.validate().expect("unique names");
    let ron1 = scene.to_ron();

    // Parse ∘ serialize is a fixed point: the RON is byte-identical after a round trip.
    let reparsed = Scene::from_ron(&ron1).expect("scene parses back from its own RON");
    let ron2 = reparsed.to_ron();
    assert_eq!(ron1, ron2, "RON serialization must be byte-stable across a round trip");

    // And the parsed value equals the original builder value (same Scene out).
    assert_eq!(scene, reparsed, "RON and the builder yield the SAME Scene value");
}

#[test]
fn ron_literal_parses_to_the_builder_scene() {
    // A hand-authored RON scene must equal the equivalent builder scene (both via serde).
    let built = Scene::new(
        "mini",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.3,
            vec![Pane::slot("side", "nav").foldable(false), Pane::slot("main", "body")],
        ),
    );
    let ron = built.to_ron();
    let parsed = Scene::from_ron(&ron).expect("parses");
    assert_eq!(built, parsed);
    // The fold seed came through: `side` is foldable (default closed → starts folded).
    let nav = NavState::new(&parsed);
    assert!(nav.is_folded("side"), "default_open:false seeds the pane FOLDED");
}

// ── Fold / unfold — the ONLY interaction, and it OMITS contents when folded ──────

#[test]
fn fold_omits_contents_unfold_reveals_them() {
    let scene = sample_scene();

    // Unfolded (default_open:true → open): the sidebar's slot content is laid out.
    let mut nav_open = NavState::new(&scene);
    assert!(!nav_open.is_folded("sidebar"), "default_open:true starts unfolded");
    let sc_open = scene.clone();
    let mut h_open = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        render(&sc_open, &mut nav_open, ui, &mut TestHost);
    });
    h_open.run();
    assert!(h_open.query_by_label("SLOT:nav").is_some(), "unfolded: the sidebar slot content is laid out");
    assert!(h_open.query_by_label("⚙").is_none(), "sanity: the icon is part of the header text, not a bare node");

    // Folded: fold the sidebar, render again — its slot content is GONE from the tree,
    // but the fold header (carrying the title) is still present.
    let mut nav_folded = NavState::new(&scene);
    nav_folded.set_fold("sidebar", true);
    assert!(nav_folded.is_folded("sidebar"));
    let sc_folded = scene.clone();
    let mut h_folded = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        render(&sc_folded, &mut nav_folded, ui, &mut TestHost);
    });
    h_folded.run();
    assert!(h_folded.query_by_label("SLOT:nav").is_none(), "folded: the sidebar slot content is OMITTED from the AccessKit tree");
    assert!(
        h_folded.query_by_label_contains("Sidebar").is_some(),
        "folded: the fold header (with the title) is still present"
    );
}

/// The fold header is clickable and toggles the observable fold state (a real frame).
#[test]
fn clicking_the_fold_header_toggles_the_pane() {
    let scene = sample_scene();
    let mut nav = NavState::new(&scene);
    assert!(!nav.is_folded("sidebar"));
    // Drive the toggle headlessly through NavState (the same mutation the header click
    // performs), proving the state contract the renderer reads.
    assert!(nav.toggle_fold("sidebar"), "toggle folds it");
    assert!(nav.is_folded("sidebar"));
    assert!(!nav.toggle_fold("sidebar"), "toggle unfolds it");
    assert!(!nav.is_folded("sidebar"));
    // A non-foldable pane never folds.
    assert!(!nav.toggle_fold("overview"));
    assert!(!nav.is_folded("overview"));
}

/// **F7 — an in-pane `Layout::Tabs` header click drives `active_tab` and swaps the
/// body.** The header strip (a `TabView` inside `draw_pane`) had no real-click test —
/// only the programmatic `set_active_tab`. Click the 2nd header for real and assert
/// BOTH the observable `active_tab` and that the drawn body swapped in the AccessKit
/// tree (a click → geometry oracle, not a flag flip).
#[test]
fn clicking_an_in_pane_tab_header_swaps_the_body() {
    let scene = Scene::new(
        "tabsample",
        Pane::tabs(
            "body",
            0,
            vec![
                Pane::slot("overview", "stat").title("Overview"),
                Pane::slot("detail", "log").title("Detail"),
            ],
        ),
    );
    let nav0 = NavState::new(&scene);
    let sc = scene.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(900.0, 600.0))
        .build_ui_state(move |ui, nav: &mut NavState| {
            render(&sc, nav, ui, &mut TestHost);
        }, nav0);
    h.run();

    // Tab 0 active: the overview slot ("stat") is laid out; the detail slot ("log") is not.
    assert!(h.query_by_label("SLOT:stat").is_some(), "tab 0 body is drawn");
    assert!(h.query_by_label("SLOT:log").is_none(), "tab 1 body is not drawn yet");

    // Click the 2nd header ("Detail") for real.
    h.get_by_label("Detail").click();
    h.run();
    h.run();

    assert_eq!(h.state().active_tab.get("body").copied(), Some(1), "the header click set active_tab");
    assert!(h.query_by_label("SLOT:log").is_some(), "tab 1 body is now drawn (the body swapped)");
    assert!(h.query_by_label("SLOT:stat").is_none(), "tab 0 body is no longer drawn");
}

// ── Responsive: a Split with a phone override renders as Tabs under collapse_below ─

#[test]
fn split_with_phone_override_renders_as_tabs_when_narrow() {
    // A Split pane that collapses to Tabs on a phone-class width.
    let scene = Scene::new(
        "responsive",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.5,
            vec![Pane::slot("left", "a"), Pane::slot("right", "b")],
        )
        .phone(Layout::Tabs { active: 0 }, 700.0),
    );

    // WIDE (1000 > 700): the declared Split lays out — BOTH slots are present.
    let mut nav_wide = NavState::new(&scene);
    let sc_wide = scene.clone();
    let state_wide = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let sw = state_wide.clone();
    let mut h_wide = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        *sw.borrow_mut() = render(&sc_wide, &mut nav_wide, ui, &mut TestHost);
    });
    h_wide.run();
    assert_eq!(state_wide.borrow()["device"], "desktop", "1000pt is desktop-class");
    assert_eq!(state_wide.borrow()["panes"]["root"]["layout"], "split", "wide: the Split layout is used");
    assert!(h_wide.query_by_label("SLOT:a").is_some() && h_wide.query_by_label("SLOT:b").is_some(), "wide: both split panes laid out");

    // NARROW (500 < 700): the phone override wins → Tabs; only the ACTIVE tab's slot
    // is laid out (the other tab's content is not).
    let mut nav_narrow = NavState::new(&scene);
    let sc_narrow = scene.clone();
    let state_narrow = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let sn = state_narrow.clone();
    let mut h_narrow = Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui(move |ui| {
        *sn.borrow_mut() = render(&sc_narrow, &mut nav_narrow, ui, &mut TestHost);
    });
    h_narrow.run();
    assert_eq!(state_narrow.borrow()["device"], "phone", "500pt < collapse_below(700) is phone-class");
    assert_eq!(state_narrow.borrow()["panes"]["root"]["layout"], "tabs", "narrow: the Split collapses to Tabs");
    assert!(h_narrow.query_by_label("SLOT:a").is_some(), "narrow: the active tab's slot is laid out");
    assert!(h_narrow.query_by_label("SLOT:b").is_none(), "narrow: the inactive tab's slot is NOT laid out");
}

// ── Nav over the Router: Push / Pop / Toggle / deep-link by unique name ──────────

#[test]
fn nav_push_pop_toggle_and_deeplink_resolve_by_name() {
    let scene = sample_scene();
    let mut nav = NavState::new(&scene);
    assert_eq!(nav.router.current(), "root", "the router starts at the root pane");

    // Push moves the router (and records history).
    assert!(nav.apply(&Target::Push("overview".into())));
    assert_eq!(nav.router.current(), "overview");
    assert!(nav.apply(&Target::Push("detail".into())));
    assert_eq!(nav.router.current(), "detail");

    // Pop returns to the previous pane.
    assert!(nav.apply(&Target::Pop));
    assert_eq!(nav.router.current(), "overview");

    // Toggle flips a foldable pane's fold (not the router).
    assert!(!nav.is_folded("detail"));
    assert!(nav.apply(&Target::Toggle("detail".into())));
    assert!(nav.is_folded("detail"), "Toggle folded `detail`");
    assert_eq!(nav.router.current(), "overview", "Toggle does not move the router");

    // Deep-link resolves an existing pane by unique name; an unknown name is rejected.
    assert!(nav.deep_link(&scene, "app#body"), "deep-link `app#body` resolves");
    assert_eq!(nav.router.current(), "body");
    assert!(!nav.deep_link(&scene, "app#nope"), "unknown pane name is rejected");
    assert_eq!(nav.router.current(), "body", "a rejected deep-link leaves the route unchanged");
}

/// A declared menu route fires by unique menu name and applies its target.
#[test]
fn a_menu_trigger_fires_its_route() {
    let scene = sample_scene();
    let mut nav = NavState::new(&scene);
    assert!(nav.fire(&scene, &Trigger::Menu("view.overview".into())), "the declared route matched");
    assert_eq!(nav.router.current(), "overview");
    assert!(!nav.fire(&scene, &Trigger::Menu("view.toggle_sidebar".into())), "no route declared for this menu (its action runs at render)");
}

// ── Draggable / resizable splits — the LIVE ratio drives the layout ──────────────

/// A helper: render `scene` at `size` once, sharing `nav`, and return the render's
/// `state_json`.
fn render_once(scene: &Scene, nav: &std::rc::Rc<std::cell::RefCell<NavState>>, size: egui::Vec2) -> serde_json::Value {
    let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (o, n, sc) = (out.clone(), nav.clone(), scene.clone());
    let mut h = Harness::builder().with_size(size).build_ui(move |ui| {
        *o.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut TestHost);
    });
    h.run();
    out.borrow().clone()
}

/// The split ratio is LIVE state: seeding it (as a drag would) moves the divider and is
/// reported in `state_json`. RED if the renderer ignores `NavState`'s ratio.
#[test]
fn split_ratio_is_live_and_drives_the_divider() {
    let scene = Scene::new(
        "rz",
        Pane::split("root", Axis::Horizontal, 0.3, vec![Pane::slot("left", "a"), Pane::slot("right", "b")]),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    assert_eq!(nav.borrow().split_ratios("root").unwrap(), &vec![0.3, 0.7], "the builder seeds the live ratio");

    let st0 = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert_eq!(st0["panes"]["root"]["layout"], "split");
    assert_eq!(st0["panes"]["root"]["resizable"], true, "Pane::split is draggable by default");
    let sep_x0 = st0["panes"]["root"]["separators"][0][0].as_f64().expect("a divider handle rect is reported");

    // Widen the first child (what a rightward drag would do) and re-render.
    nav.borrow_mut().set_split_ratios("root", vec![0.6, 0.4]);
    let st1 = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let sep_x1 = st1["panes"]["root"]["separators"][0][0].as_f64().unwrap();
    assert!(sep_x1 > sep_x0 + 100.0, "a larger first ratio pushes the divider right ({sep_x0} → {sep_x1})");
    let live = st1["nav"]["split_ratio"]["root"][0].as_f64().unwrap();
    assert!((live - 0.6).abs() < 1e-4, "the live ratio (0.6) is observable in state_json, got {live}");
}

/// A REAL pointer drag on the divider handle re-apportions the ratio (RED if the drag is
/// dropped — the whole point of the corrected, non-fold-only scope).
#[test]
fn dragging_the_split_handle_reapportions_the_ratio() {
    let scene = Scene::new(
        "dz",
        Pane::split("root", Axis::Horizontal, 0.5, vec![Pane::slot("left", "a"), Pane::slot("right", "b")]),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (s, n, sc) = (state.clone(), nav.clone(), scene.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        *s.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut TestHost);
    });
    h.run();
    assert_eq!(nav.borrow().split_ratios("root").unwrap()[0], 0.5, "starts centred");

    // Grab the reported handle rect and drag its centre 200pt to the LEFT.
    let sep = state.borrow()["panes"]["root"]["separators"][0].clone();
    let (sx, sy, sw, sh) = (
        sep[0].as_f64().unwrap() as f32,
        sep[1].as_f64().unwrap() as f32,
        sep[2].as_f64().unwrap() as f32,
        sep[3].as_f64().unwrap() as f32,
    );
    let from = egui::pos2(sx + sw / 2.0, sy + sh / 2.0);
    let to = egui::pos2(from.x - 200.0, from.y);
    // Press, settle, move-while-pressed, release — the idiom that makes egui register a
    // real `Response::dragged()` under the headless harness (see facett-demo robot_demo).
    h.event(egui::Event::PointerMoved(from));
    h.drag_at(from);
    h.event(egui::Event::PointerMoved(to));
    h.run();
    h.drop_at(to);
    h.run();

    let r0 = nav.borrow().split_ratios("root").unwrap()[0];
    assert!(r0 < 0.45, "dragging the handle left shrank the first child (ratio {r0} < 0.45)");
    assert!(r0 > 0.0, "the ratio stays clamped above zero");
    // And it is observable in the last render's state_json.
    assert!(state.borrow()["nav"]["split_ratio"]["root"][0].as_f64().unwrap() < 0.45);
}

/// N-way split: relative weights normalise to fractions and every child + divider is laid
/// out (FlexLayout-style relative sizing over an egui_dock-style split tree).
#[test]
fn n_way_split_weights_normalize_and_all_children_render() {
    let scene = Scene::new(
        "n3",
        Pane::split_n(
            "root",
            Axis::Horizontal,
            vec![1.0, 2.0, 1.0],
            vec![Pane::slot("a", "a"), Pane::slot("b", "b"), Pane::slot("c", "c")],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let r = nav.borrow().split_ratios("root").unwrap().clone();
    assert!((r[0] - 0.25).abs() < 1e-5 && (r[1] - 0.50).abs() < 1e-5 && (r[2] - 0.25).abs() < 1e-5, "weights 1:2:1 normalise to 0.25/0.5/0.25, got {r:?}");

    let st = render_once(&scene, &nav, egui::vec2(1200.0, 700.0));
    for name in ["a", "b", "c"] {
        assert!(st["panes"].get(name).is_some(), "child `{name}` is laid out");
    }
    assert_eq!(st["panes"]["root"]["separators"].as_array().unwrap().len(), 2, "N children ⇒ N-1 drag handles");
}

/// A `drawer` pane auto-folds when the width crosses into phone-class, and restores to
/// its `default_open` when it crosses back (desktop rail → phone drawer).
#[test]
fn a_drawer_pane_folds_on_phone_and_restores_on_desktop() {
    let scene = Scene::new(
        "drw",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.25,
            vec![Pane::slot("rail", "nav").title("Rail").foldable(true).drawer(700.0), Pane::slot("body", "main")],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));

    // Desktop: the rail is an open pane.
    let st_wide = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert_eq!(st_wide["device"], "desktop");
    assert!(!nav.borrow().is_folded("rail"), "desktop: the rail is open");

    // Cross into phone-class: the rail auto-folds into a drawer.
    let st_narrow = render_once(&scene, &nav, egui::vec2(500.0, 700.0));
    assert_eq!(st_narrow["device"], "phone");
    assert!(nav.borrow().is_folded("rail"), "phone: the rail auto-folds to a drawer");

    // Cross back to desktop: the rail restores.
    render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert!(!nav.borrow().is_folded("rail"), "back to desktop: the rail restores");
}

// ─────────────────────────────────────────────────────────────────────────────
// WINDOW CHROME — minimise / maximise / restore. Every assertion below reads
// GEOMETRY out of `state_json` (`panes.<name>.alloc` = the rect the parent allotted),
// never a boolean flag: a chrome control that flips a flag without moving a pixel is
// exactly the "capability nobody sees" this layer exists to kill.
// ─────────────────────────────────────────────────────────────────────────────

/// `panes.<name>.alloc` as `[x, y, w, h]`.
fn alloc(st: &serde_json::Value, pane: &str) -> [f64; 4] {
    let a = &st["panes"][pane]["alloc"];
    assert!(a.is_array(), "pane `{pane}` reports no alloc rect in state_json: {st}");
    [
        a[0].as_f64().unwrap(),
        a[1].as_f64().unwrap(),
        a[2].as_f64().unwrap(),
        a[3].as_f64().unwrap(),
    ]
}

/// A three-way VERTICAL split (so a child's extent is its HEIGHT, and "only the title
/// bar's height" is directly assertable).
fn stack_scene() -> Scene {
    Scene::new(
        "stack",
        Pane::split_n(
            "root",
            Axis::Vertical,
            vec![0.5, 0.3, 0.2],
            vec![Pane::slot("top", "a").title("Top"), Pane::slot("mid", "b").title("Mid"), Pane::slot("bot", "c").title("Bot")],
        ),
    )
}

/// The chrome glyphs must actually RENDER in egui's bundled fonts. RED-when-broken: a
/// font swap that drops one of them ships a row of tofu boxes into every pane header,
/// and no test would otherwise notice.
#[test]
fn chrome_glyphs_are_renderable() {
    let missing = std::rc::Rc::new(std::cell::RefCell::new(Vec::<char>::new()));
    let m = missing.clone();
    let mut h = Harness::builder().with_size(egui::vec2(400.0, 200.0)).build_ui(move |ui| {
        let font = egui::FontId::proportional(14.0);
        for c in super::CHROME_GLYPHS.chars() {
            if !ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) {
                m.borrow_mut().push(c);
            }
        }
    });
    h.run();
    assert!(missing.borrow().is_empty(), "chrome glyphs with NO font coverage (would render as tofu): {:?}", missing.borrow());
}

/// **The default is ON.** Every pane but the scene root reports a chrome bar with the
/// minimise + maximise controls, without the host asking for anything.
#[test]
fn chrome_is_on_by_default_for_every_pane_but_the_root() {
    let scene = stack_scene();
    // The declarative audit a host asserts against.
    let aff = scene.affordances();
    assert_eq!(aff["chrome"], serde_json::json!(["top", "mid", "bot"]), "every non-root pane is chromed by DEFAULT");
    assert_eq!(aff["draggable_splits"], serde_json::json!(["root"]), "Pane::split_n is draggable by default");

    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    for p in ["top", "mid", "bot"] {
        assert_eq!(st["panes"][p]["chrome"]["spec"]["bar"], true, "`{p}` draws a chrome title bar by default");
        assert_eq!(st["panes"][p]["chrome"]["spec"]["minimize"], true, "`{p}` offers minimise by default");
        assert_eq!(st["panes"][p]["chrome"]["spec"]["maximize"], true, "`{p}` offers maximise by default");
        // Both robot addressings are published: a rect AND a unique AccessKit label.
        let btn = &st["panes"][p]["chrome"]["buttons"];
        assert!(btn["minimize"].is_array() && btn["maximize"].is_array(), "`{p}` publishes its control rects: {btn}");
        assert!(btn["minimize"][2].as_f64().unwrap() > 0.0, "`{p}`'s minimise control has a real, clickable width");
    }
    // The ROOT stays bare (nothing to minimise into; maximising it is the identity).
    assert!(st["panes"]["root"].get("chrome").is_none(), "the scene root has NO chrome bar under Chrome::Auto");
    // Opting out is a one-liner and restores the pre-chrome renderer exactly.
    let bare = Scene::new("bare", Pane::split("r", Axis::Horizontal, 0.5, vec![Pane::slot("l", "a").no_chrome(), Pane::slot("rt", "b")]));
    assert_eq!(bare.affordances()["chrome"], serde_json::json!(["rt"]), "`no_chrome()` opts a pane out");
}

/// **RED-when-broken #1 — maximise shrinks its siblings to ZERO AREA.** Asserted on the
/// allotted rects, not on `maximized == true`.
#[test]
fn maximizing_a_pane_shrinks_its_siblings_to_zero_area() {
    let scene = stack_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));

    let before = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let (h_top, h_mid, h_bot) = (alloc(&before, "top")[3], alloc(&before, "mid")[3], alloc(&before, "bot")[3]);
    assert!(h_top > 100.0 && h_mid > 100.0 && h_bot > 100.0, "baseline: all three panes have real height ({h_top}/{h_mid}/{h_bot})");
    let baseline_total = h_top + h_mid + h_bot;

    // Maximise the MIDDLE pane.
    assert!(nav.borrow_mut().set_maximized("mid"));
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));

    assert_eq!(alloc(&st, "top")[3], 0.0, "maximising `mid` allots `top` ZERO height");
    assert_eq!(alloc(&st, "bot")[3], 0.0, "maximising `mid` allots `bot` ZERO height");
    assert_eq!(st["panes"]["top"]["area"], 0.0, "`top`'s allotted AREA is zero");
    assert_eq!(st["panes"]["bot"]["area"], 0.0, "`bot`'s allotted AREA is zero");
    assert_eq!(st["panes"]["top"]["hidden_by"], "maximize", "and it says why");

    // …and the maximised pane took ALL of it — strictly more than it had, and at least
    // the whole extent the three of them shared.
    let h_mid_max = alloc(&st, "mid")[3];
    assert!(h_mid_max > h_mid, "the maximised pane GREW ({h_mid} → {h_mid_max})");
    assert!(h_mid_max >= baseline_total - 1.0, "the maximised pane took the whole split extent ({h_mid_max} vs {baseline_total})");

    // The dividers stand down while maximised — there is nothing left to apportion.
    assert!(st["panes"]["root"].get("separators").is_none(), "no drag handles are drawn while a pane is maximised");
    // The state says so too (both the flag and the geometry must agree).
    assert_eq!(st["nav"]["maximized"], "mid");
    assert_eq!(st["panes"]["mid"]["chrome"]["maximized"], true);
}

/// **RED-when-broken #2 — restore returns the EXACT prior geometry.** Byte-for-byte the
/// same allotted rects, because a maximise never writes the ratios.
#[test]
fn restoring_returns_the_exact_prior_rects_and_ratios() {
    let scene = stack_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));

    // Drag the layout off its authored ratios first, so "exact" means something.
    nav.borrow_mut().set_split_ratios("root", vec![0.62, 0.23, 0.15]);
    let before = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let ratios_before = before["nav"]["split_ratio"]["root"].clone();
    let rects_before: Vec<[f64; 4]> = ["top", "mid", "bot"].iter().map(|p| alloc(&before, p)).collect();

    nav.borrow_mut().set_maximized("top");
    let maxed = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert_eq!(alloc(&maxed, "mid")[3], 0.0, "sanity: the maximise really happened");

    assert!(nav.borrow_mut().restore("top"), "restore reports it changed something");
    let after = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let rects_after: Vec<[f64; 4]> = ["top", "mid", "bot"].iter().map(|p| alloc(&after, p)).collect();

    assert_eq!(rects_before, rects_after, "restore returns the EXACT prior allotted rects");
    assert_eq!(ratios_before, after["nav"]["split_ratio"]["root"], "a maximise never touched the live ratios");
    assert_eq!(after["nav"]["maximized"], serde_json::Value::Null);
}

/// **RED-when-broken #3 — minimise leaves only the title bar's height**, and the space it
/// gives up goes to its siblings *in their unchanged ratios*.
#[test]
fn minimizing_leaves_only_the_title_bar_and_feeds_its_siblings() {
    let scene = stack_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));

    let before = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let (h_top, h_mid, h_bot) = (alloc(&before, "top")[3], alloc(&before, "mid")[3], alloc(&before, "bot")[3]);
    let total = h_top + h_mid + h_bot;

    assert!(nav.borrow_mut().toggle_minimize("top"), "minimise reports the new state");
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));

    // ONLY the title bar is left — the exact, public constant, not "smaller than before".
    assert_eq!(alloc(&st, "top")[3], super::CHROME_BAR as f64, "a minimised pane is allotted exactly its title bar's height");
    // …and the pane it collapsed to really IS just the bar (the drawn rect matches).
    let bar_h = st["panes"]["top"]["chrome"]["bar_height"].as_f64().unwrap();
    let drawn_h = st["panes"]["top"]["rect"][3].as_f64().unwrap();
    assert!((drawn_h - bar_h).abs() < 1.0, "the minimised pane DREW only its bar ({drawn_h} vs bar {bar_h})");
    assert!(st["slots"].get("a").is_none(), "a minimised pane's slot content is omitted entirely");

    // The freed space went to the siblings, and their 0.3 : 0.2 proportion survived.
    let (m2, b2) = (alloc(&st, "mid")[3], alloc(&st, "bot")[3]);
    assert!(m2 > h_mid && b2 > h_bot, "both siblings GREW ({h_mid}→{m2}, {h_bot}→{b2})");
    assert!((m2 + b2 + super::CHROME_BAR as f64 - total).abs() < 2.0, "the extent is conserved: {m2}+{b2}+bar vs {total}");
    assert!(((m2 / b2) - (h_mid / h_bot)).abs() < 0.02, "the siblings kept their 0.3:0.2 proportion ({}, was {})", m2 / b2, h_mid / h_bot);

    // Restoring gives back the exact prior heights.
    assert!(nav.borrow_mut().restore("top"));
    let back = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert_eq!([alloc(&back, "top")[3], alloc(&back, "mid")[3], alloc(&back, "bot")[3]], [h_top, h_mid, h_bot], "restore returns the exact prior heights");
}

/// The chrome works for a pane ANYWHERE in the tree: inside a Split, inside Tabs, nested.
/// Maximising a leaf three levels down zeroes its uncle at the top level AND pulls its own
/// tab to the front.
#[test]
fn maximize_reaches_a_pane_nested_inside_tabs_inside_a_split() {
    let scene = Scene::new(
        "deep",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.3,
            vec![
                Pane::slot("rail", "nav").title("Rail"),
                Pane::tabs("body", 0, vec![Pane::slot("first", "f").title("First"), Pane::slot("second", "s").title("Second")]).title("Body"),
            ],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let before = render_once(&scene, &nav, egui::vec2(1200.0, 700.0));
    assert!(alloc(&before, "rail")[2] > 100.0, "baseline: the rail has real width");
    assert!(before["panes"].get("second").is_none(), "baseline: tab 1 is not laid out");

    // Maximise the pane on the SECOND (inactive) tab.
    nav.borrow_mut().set_maximized("second");
    let st = render_once(&scene, &nav, egui::vec2(1200.0, 700.0));
    assert_eq!(alloc(&st, "rail")[2], 0.0, "the top-level sibling of the maximised pane's ancestor is zeroed");
    assert_eq!(st["panes"]["rail"]["hidden_by"], "maximize");
    assert!(st["panes"].get("second").is_some(), "maximising a pane on an inactive tab brings that tab to the front");
    assert!(alloc(&st, "second")[2] > 900.0, "the maximised leaf spans the whole scene width ({})", alloc(&st, "second")[2]);
    assert!(st["panes"].get("first").is_none(), "the other tab is not laid out");

    assert!(nav.borrow_mut().restore("second"));
    let after = render_once(&scene, &nav, egui::vec2(1200.0, 700.0));
    assert_eq!(alloc(&after, "rail"), alloc(&before, "rail"), "restore returns the rail's exact rect");
}

/// A **real click** on the published chrome control drives it — and the pane's DRAWN
/// area changes as a result. This is the click→geometry oracle, not a flag flip.
#[test]
fn a_real_click_on_the_maximize_control_changes_the_drawn_geometry() {
    let scene = stack_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (s, n, sc) = (state.clone(), nav.clone(), scene.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        *s.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut TestHost);
    });
    h.run();

    let h_top_before = alloc(&state.borrow(), "top")[3];
    let h_mid_before = alloc(&state.borrow(), "mid")[3];
    assert!(h_top_before > 100.0 && h_mid_before > 100.0);

    // Click the control by its UNIQUE AccessKit label — a real AccessKit-backed click.
    h.get_by_label("maximize mid").click();
    h.run();
    h.run();

    assert!(nav.borrow().is_maximized("mid"), "the click reached the state");
    assert_eq!(nav.borrow().last_chrome.as_deref(), Some("maximize:mid"), "…and the robot can read back WHICH control fired");
    let st = state.borrow().clone();
    assert_eq!(alloc(&st, "top")[3], 0.0, "the click moved PIXELS: `top` is now zero-height");
    assert!(alloc(&st, "mid")[3] > h_mid_before, "…and `mid` grew ({h_mid_before} → {})", alloc(&st, "mid")[3]);

    // The same control is now a RESTORE, and clicking it puts everything back exactly.
    h.get_by_label("restore mid").click();
    h.run();
    h.run();
    assert!(!nav.borrow().is_maximized("mid"), "the restore click landed");
    let back = state.borrow().clone();
    assert_eq!(alloc(&back, "top")[3], h_top_before, "restore returned `top` to its exact prior height");
}

/// The **minimise** control, clicked for real, collapses the pane to its bar and the
/// restore control (now in its place) brings it back — geometry asserted both ways.
#[test]
fn a_real_click_on_the_minimize_control_collapses_and_restores_the_pane() {
    let scene = stack_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (s, n, sc) = (state.clone(), nav.clone(), scene.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        *s.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut TestHost);
    });
    h.run();
    let h_bot_before = alloc(&state.borrow(), "bot")[3];

    h.get_by_label("minimize bot").click();
    h.run();
    h.run();
    assert!(nav.borrow().is_minimized("bot"), "the minimise click reached the state");
    assert_eq!(alloc(&state.borrow(), "bot")[3], super::CHROME_BAR as f64, "the click collapsed `bot` to exactly its title bar");
    assert!(alloc(&state.borrow(), "top")[3] > 0.0, "its siblings are still laid out (this is minimise, not maximise)");

    h.get_by_label("restore bot").click();
    h.run();
    h.run();
    assert!(!nav.borrow().is_minimized("bot"), "the restore click landed");
    assert_eq!(alloc(&state.borrow(), "bot")[3], h_bot_before, "restore returned `bot` to its exact prior height");
}

/// The apportionment MATH, with no GUI in the loop — the pure function every chrome
/// geometry assertion above ultimately rests on.
#[test]
fn split_extents_apportion_minimise_and_maximise() {
    let scene = stack_scene();
    let kids = match &scene.root.content {
        Content::Children(k) => k.clone(),
        _ => unreachable!(),
    };
    let mut nav = NavState::new(&scene);
    let ratios = vec![0.5, 0.3, 0.2];

    // Untouched: the ratios apply straight.
    let ext = super::split_extents(&nav, &kids, &ratios, 1000.0, None);
    assert_eq!(ext, vec![500.0, 300.0, 200.0]);

    // Maximised: the path child takes everything, the others EXACTLY zero.
    let ext = super::split_extents(&nav, &kids, &ratios, 1000.0, Some(1));
    assert_eq!(ext, vec![0.0, 1000.0, 0.0], "maximise gives the siblings exactly zero");

    // Minimised: the bar, and the rest re-apportioned 0.3:0.2 over what's left.
    nav.set_minimized("top", true);
    let ext = super::split_extents(&nav, &kids, &ratios, 1000.0, None);
    let rest = 1000.0 - super::CHROME_BAR;
    assert_eq!(ext[0], super::CHROME_BAR, "the minimised child gets its title bar and nothing more");
    assert!((ext[1] - rest * 0.6).abs() < 0.01 && (ext[2] - rest * 0.4).abs() < 0.01, "the rest keeps 0.3:0.2, got {ext:?}");
    assert!((ext.iter().sum::<f32>() - 1000.0).abs() < 0.01, "the extent is conserved");
}

/// A hand-authored RON `Split` that omits `resizable` gets a **draggable** divider —
/// un-draggable must be a deliberate act, never an accident of omission (the gap that
/// left most real scenes with no reachable handle anywhere).
#[test]
fn ron_split_without_resizable_is_draggable_by_default() {
    let ron = r#"(
        name: "terse",
        root: (
            name: "root",
            title: "Root",
            layout: Split(axis: Horizontal, ratios: [0.4, 0.6]),
            content: Children([
                (name: "l", title: "L", content: Slot("a")),
                (name: "r", title: "R", content: Slot("b")),
            ]),
        ),
    )"#;
    let scene = Scene::from_ron(ron).expect("terse RON parses");
    assert!(matches!(scene.root.layout, Layout::Split { resizable: true, .. }), "an omitted `resizable` means DRAGGABLE");
    assert_eq!(scene.affordances()["draggable_splits"], serde_json::json!(["root"]));

    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    assert!(st["panes"]["root"]["separators"][0][2].as_f64().unwrap() > 0.0, "a real, grabbable handle rect is drawn");
}

/// **The holger / dwarves / nornir shape**: a flat `Tabs` ROOT of foldable slot leaves.
/// Before chrome this shell had no reachable affordance at all — no divider to drag, and
/// the fold header was the only control. It must now come up with a full title bar on
/// every tab body, and `.chrome_always()` must be all it takes to put one on the shell.
#[test]
fn a_tabs_rooted_shell_gets_chrome_on_every_tab_body() {
    let leaves: Vec<Pane> = ["status", "repos", "graph"]
        .iter()
        .map(|t| Pane::slot(format!("{t}_pane"), *t).title(*t).foldable_icon(true, "🗄"))
        .collect();
    let scene = Scene::new("holger-shape", Pane::tabs("holger-shell", 0, leaves));

    // The root is bare under Auto; every tab body is chromed.
    assert_eq!(scene.affordances()["chrome"], serde_json::json!(["status_pane", "repos_pane", "graph_pane"]));
    assert!(!scene.chrome_of("holger-shell").bar, "the Tabs root is bare under Chrome::Auto");
    // …and one call gives the SHELL a bar too.
    let with_shell_bar = Scene::new("h2", Pane::tabs("holger-shell", 0, vec![Pane::slot("x", "x")]).chrome_always());
    assert!(with_shell_bar.chrome_of("holger-shell").bar, "`.chrome_always()` puts a bar on the root");

    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_once(&scene, &nav, egui::vec2(1200.0, 800.0));
    let spec = &st["panes"]["status_pane"]["chrome"]["spec"];
    assert_eq!(spec["bar"], true);
    assert_eq!(spec["fold"], true, "the foldable tab body's fold toggle moved INTO the bar (one header, not two)");
    assert_eq!(spec["minimize"], true);
    assert_eq!(spec["maximize"], true);

    // Maximising the active tab body still works (it is already the only one drawn, so
    // the proof is that it GREW into the tab strip's room and the state agrees).
    let h0 = alloc(&st, "status_pane")[3];
    nav.borrow_mut().set_maximized("status_pane");
    let st2 = render_once(&scene, &nav, egui::vec2(1200.0, 800.0));
    assert_eq!(st2["nav"]["maximized"], "status_pane");
    assert!(alloc(&st2, "status_pane")[3] >= h0, "the maximised tab body is at least as tall as before ({h0})");
    assert!(nav.borrow_mut().restore("status_pane"));
    assert_eq!(alloc(&render_once(&scene, &nav, egui::vec2(1200.0, 800.0)), "status_pane")[3], h0, "restore is exact");
}

/// In a `Flow` row the chrome bar must **shrink to its own content** — a bar that
/// stretched to `available_width` would let the first pane in the row claim the whole row
/// for its title bar and wrap every sibling below it.
#[test]
fn a_chrome_bar_in_a_flow_row_shrinks_to_its_content() {
    let scene = Scene::new(
        "flow",
        Pane::flow("root", vec![Pane::slot("a", "a").title("A"), Pane::slot("b", "b").title("B"), Pane::slot("c", "c").title("C")]),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));

    // All three are laid out — nobody was pushed off the row by a greedy bar.
    for p in ["a", "b", "c"] {
        assert!(st["panes"].get(p).is_some(), "flow child `{p}` is laid out");
        let w = st["panes"][p]["chrome"]["bar_rect"][2].as_f64().unwrap();
        assert!(w > 10.0, "`{p}`'s bar has real width ({w})");
        assert!(w < 400.0, "`{p}`'s bar shrank to its content instead of eating the row ({w} of 1000)");
    }
    // …and they really are side by side (each starts to the right of the last).
    let x = |p: &str| st["panes"][p]["chrome"]["bar_rect"][0].as_f64().unwrap();
    assert!(x("a") < x("b") && x("b") < x("c"), "the flow row runs left to right: {} {} {}", x("a"), x("b"), x("c"));
}

/// Chrome is drivable declaratively too: a menu `Action::Maximize` / a `Target::Restore`
/// route reaches the same state a click does.
#[test]
fn menu_actions_and_routes_drive_the_chrome() {
    let scene = stack_scene().route(Route::on_button("zoom", Target::Maximize("mid".into())));
    let mut nav = NavState::new(&scene);
    assert!(nav.fire(&scene, &Trigger::Button("zoom".into())), "the route matched");
    assert!(nav.is_maximized("mid"), "Target::Maximize maximised the pane");
    assert!(nav.apply(&Target::Minimize("top".into())));
    assert!(nav.is_minimized("top"));
    assert!(nav.apply(&Target::Restore("top".into())));
    assert!(!nav.is_minimized("top"));
    // restore_all is the "reset layout" one-liner.
    assert!(nav.restore_all());
    assert_eq!(nav.maximized_pane(), None);
    assert!(!nav.restore_all(), "a second reset is a no-op");
}

// ── The full scene renders with every declared pane foldable-or-fixed ────────────

#[test]
fn scene_renders_and_reports_every_pane() {
    let scene = sample_scene();
    let mut nav = NavState::new(&scene);
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let s = state.clone();
    let sc = scene.clone();
    let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        *s.borrow_mut() = render(&sc, &mut nav, ui, &mut TestHost);
    });
    h.run();
    let st = state.borrow();
    // Every VISIBLE pane is reported with its fold + layout facts. (`detail` is the
    // inactive tab, so it is lazily NOT laid out — tabs render only the active child.)
    for name in ["root", "sidebar", "body", "overview"] {
        assert!(st["panes"].get(name).is_some(), "pane `{name}` is reported in state_json");
    }
    assert!(st["panes"].get("detail").is_none(), "the inactive tab child is not laid out");
    // The foldable panes advertise foldable:true; the fixed ones foldable:false.
    assert_eq!(st["panes"]["sidebar"]["foldable"], true, "the sidebar is foldable");
    assert_eq!(st["panes"]["overview"]["foldable"], false, "overview is a fixed tab pane");
    assert_eq!(st["scene"], "sample");
}

// ═════════════════════════════════════════════════════════════════════════════════
// AREAS + EDITOR TYPES — Blender's model, proved with RECTS.
//
// The claim under test is structural, not cosmetic:
//   1. an area's rect is a property of the LAYOUT, so swapping its editor cannot move
//      it (and the pane name — the robot's handle — never changes either);
//   2. two sibling areas have their own allotted rects, so **one cannot grow into the
//      other**. That is the korp→Graph bug made impossible: its centrality readout and
//      its graph canvas were consecutive widgets in ONE pane's vertical flow, so the
//      readout's growth came straight out of the canvas's height.
// Both are asserted on measured geometry. The second is asserted RED-when-broken by
// running the SAME two renderers through the one-pane shape and watching the canvas
// actually collapse.
// ═════════════════════════════════════════════════════════════════════════════════

use crate::editor::{Editor, EditorAreas, EditorRegistry};
use facett_core::Facet;

/// A graph-ish editor: draws a queryable marker and reports a fixed canvas size.
struct GraphEd {
    label: String,
}
impl Facet for GraphEd {
    fn title(&self) -> &str {
        "Graph"
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.label(format!("ED:{}", self.label));
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({ "ed": self.label })
    }
}

/// A readout that GROWS — the shape that broke korp→Graph.
struct ReadoutEd {
    rows: std::rc::Rc<std::cell::Cell<usize>>,
}
impl Facet for ReadoutEd {
    fn title(&self) -> &str {
        "Readout"
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        for i in 0..self.rows.get() {
            ui.label(format!("centrality {i}"));
        }
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({ "rows": self.rows.get() })
    }
}

/// A host with a real [`EditorRegistry`] + [`EditorAreas`] — the exact three lines every
/// consumer writes.
struct EdHost {
    registry: EditorRegistry,
    areas: EditorAreas,
}
impl EdHost {
    fn new(rows: std::rc::Rc<std::cell::Cell<usize>>) -> Self {
        let r = rows.clone();
        Self {
            registry: EditorRegistry::new()
                .with(Editor::new("graph", "Graph", || Box::new(GraphEd { label: "graph".into() })))
                .with(Editor::new("tables", "Tables", || Box::new(GraphEd { label: "tables".into() })))
                .with(Editor::new("readout", "Readout", move || Box::new(ReadoutEd { rows: r.clone() }))),
            areas: EditorAreas::new(),
        }
    }
}
impl SceneHost for EdHost {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        ui.label(format!("SLOT:{id}"));
        serde_json::json!({ "slot": id })
    }
    fn area(&mut self, pane: &str, editor_id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        self.areas.draw(&self.registry, pane, editor_id, ui)
    }
    fn editors(&self) -> Vec<(String, String)> {
        self.registry.options()
    }
}

/// Render `scene` once through an [`EdHost`], sharing `nav`, and return `state_json`.
fn render_areas(
    scene: &Scene,
    nav: &std::rc::Rc<std::cell::RefCell<NavState>>,
    host: &std::rc::Rc<std::cell::RefCell<EdHost>>,
    size: egui::Vec2,
) -> serde_json::Value {
    let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (o, n, h, sc) = (out.clone(), nav.clone(), host.clone(), scene.clone());
    let mut harness = Harness::builder().with_size(size).build_ui(move |ui| {
        *o.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut *h.borrow_mut());
    });
    harness.run();
    out.borrow().clone()
}

/// Two areas side by side, each free to host any editor type.
fn area_scene() -> Scene {
    Scene::new(
        "areas",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.5,
            vec![Pane::area("area.left", "graph").title("Left"), Pane::area("area.right", "tables").title("Right")],
        ),
    )
}

fn rect(st: &serde_json::Value, pane: &str) -> (f64, f64, f64, f64) {
    let r = &st["panes"][pane]["alloc"];
    (
        r[0].as_f64().expect("alloc x"),
        r[1].as_f64().expect("alloc y"),
        r[2].as_f64().expect("alloc w"),
        r[3].as_f64().expect("alloc h"),
    )
}

// ── 1. The identity contract: name is identity, editor is STATE ─────────────────

#[test]
fn swapping_an_areas_editor_keeps_its_name_and_its_exact_rect() {
    let scene = area_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(4));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));

    let before = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));
    let r0 = rect(&before, "area.left");
    assert_eq!(before["panes"]["area.left"]["editor"]["id"], "graph", "the authored default is mounted");
    assert_eq!(before["panes"]["area.left"]["content"], "area:graph");
    assert!(before["slots"]["area.left"]["state"]["ed"] == "graph", "the graph editor's own state is published under the PANE name");

    // THE SWAP. Same pane, different editor.
    assert!(nav.borrow_mut().set_editor("area.left", "tables"), "the swap took");
    let after = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));
    let r1 = rect(&after, "area.left");

    assert_eq!(r0, r1, "the area's allotted rect is IDENTICAL across an editor swap {r0:?} → {r1:?}");
    assert_eq!(after["panes"]["area.left"]["editor"]["id"], "tables", "…while the editor underneath changed");
    assert_eq!(after["slots"]["area.left"]["state"]["ed"], "tables", "…and the new editor's state is at the SAME address");
    assert_eq!(after["nav"]["area_editor"]["area.left"], "tables", "the binding is published in nav state");
    assert_eq!(after["nav"]["last_chrome"], "editor:area.left=tables", "the swap is readable back like any chrome action");
    // The sibling was not disturbed either.
    assert_eq!(rect(&before, "area.right"), rect(&after, "area.right"), "the sibling area kept its rect too");
}

#[test]
fn an_area_retains_the_instance_it_had_when_you_swap_back() {
    // Blender keeps an area's visited editors alive; so do we. Swap away and back and
    // the SAME instance answers (not a fresh one), so an analyst does not lose a query.
    let scene = area_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(4));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));

    render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));
    nav.borrow_mut().set_editor("area.left", "tables");
    render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));
    nav.borrow_mut().set_editor("area.left", "graph");
    render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));

    let retained = host.borrow().areas.retained("area.left").iter().map(|s| s.to_string()).collect::<Vec<_>>();
    assert_eq!(retained, vec!["graph".to_string(), "tables".to_string()], "both visited editors are retained for this area");
    assert_eq!(host.borrow().areas.editor_of("area.left"), Some("graph"), "…and we are back on the first one");
    assert_eq!(host.borrow().areas.retained("area.right"), vec!["tables"], "the sibling only ever mounted its own");
}

#[test]
fn two_areas_host_two_different_editors_at_once() {
    // The arrangement a TAB model forbids by construction: Graph and Tables visible
    // simultaneously, in disjoint rects.
    let scene = area_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(4));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));
    let st = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));

    let (lx, _, lw, _) = rect(&st, "area.left");
    let (rx, _, rw, _) = rect(&st, "area.right");
    assert!(lw > 100.0 && rw > 100.0, "both areas got real width ({lw} / {rw})");
    assert!(lx + lw <= rx + 1.0, "the two areas are DISJOINT: left ends at {} before right starts at {rx}", lx + lw);
    assert_eq!(st["panes"]["area.left"]["editor"]["id"], "graph");
    assert_eq!(st["panes"]["area.right"]["editor"]["id"], "tables");

    // Two areas may also host the SAME editor type at different instances — "two Graphs
    // at different queries".
    nav.borrow_mut().set_editor("area.right", "graph");
    let st2 = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));
    assert_eq!(st2["panes"]["area.left"]["editor"]["id"], "graph");
    assert_eq!(st2["panes"]["area.right"]["editor"]["id"], "graph");
    assert_eq!(rect(&st, "area.left"), rect(&st2, "area.left"), "…and the left area still did not move");
}

// ── 2. THE korp→Graph BUG, made structurally impossible ─────────────────────────

/// The scene that fixes it: canvas and readout are SIBLING AREAS of a vertical split.
fn korp_graph_as_areas() -> Scene {
    Scene::new(
        "korp.graph",
        Pane::split(
            "graph_body",
            Axis::Vertical,
            0.7,
            vec![Pane::area("graph_canvas", "graph").title("Canvas"), Pane::area("graph_readout", "readout").title("Centrality")],
        ),
    )
}

/// The bug: consecutive widgets in ONE pane's vertical flow — the readout's growth is
/// taken straight out of whatever is left for the canvas.
struct OnePaneHost {
    rows: std::rc::Rc<std::cell::Cell<usize>>,
}
impl SceneHost for OnePaneHost {
    fn slot(&mut self, _id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        for i in 0..self.rows.get() {
            ui.label(format!("centrality {i}"));
        }
        // Whatever the readout left over is the canvas — exactly korp's shape.
        let left = ui.available_height().max(0.0);
        ui.allocate_space(egui::vec2(ui.available_width(), left));
        serde_json::json!({ "canvas_h": left })
    }
}

#[test]
fn a_growing_readout_cannot_eat_the_canvas_when_they_are_sibling_areas() {
    let scene = korp_graph_as_areas();
    let rows = std::rc::Rc::new(std::cell::Cell::new(3));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows.clone())));

    let small = render_areas(&scene, &nav, &host, egui::vec2(1000.0, 800.0));
    let canvas_small = rect(&small, "graph_canvas").3;

    // The readout explodes — 300 centrality rows, far more than the pane is tall.
    rows.set(300);
    let big = render_areas(&scene, &nav, &host, egui::vec2(1000.0, 800.0));
    let canvas_big = rect(&big, "graph_canvas").3;

    assert!(canvas_small > 300.0, "the canvas got the lion's share of an 800pt window ({canvas_small})");
    assert_eq!(
        canvas_small, canvas_big,
        "a 100× larger readout changed the canvas's allotted height ({canvas_small} → {canvas_big}) — sibling areas must be immune"
    );
    // …and the readout is still on screen with its own rect, not spilling into the canvas.
    let (_, ry, _, rh) = rect(&big, "graph_readout");
    let (_, cy, _, ch) = rect(&big, "graph_canvas");
    assert!(cy + ch <= ry + 1.0, "the canvas ends at {} before the readout begins at {ry}", cy + ch);
    assert!(rh > 100.0, "the readout kept its own allotted height ({rh})");
    // The operator can still re-apportion — which is the affordance korp never had.
    assert_eq!(big["panes"]["graph_body"]["resizable"], true, "the divider between them is draggable");
}

#[test]
#[allow(non_snake_case, reason = "the capitals carry the assertion; the test id stays as published")]
fn the_same_two_renderers_in_ONE_pane_do_lose_the_canvas() {
    // The RED half of the pair: without sibling areas the bug is real and measurable, so
    // the assertion above is testing something.
    let scene = Scene::new("korp.graph.bug", Pane::slot("graph_body", "graph"));
    let rows = std::rc::Rc::new(std::cell::Cell::new(3));

    let measure = |rows: &std::rc::Rc<std::cell::Cell<usize>>| -> f64 {
        let sc = scene.clone();
        let mut nav = NavState::new(&sc);
        let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
        let o = out.clone();
        let mut host = OnePaneHost { rows: rows.clone() };
        let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui(move |ui| {
            *o.borrow_mut() = render(&sc, &mut nav, ui, &mut host);
        });
        h.run();
        out.borrow()["slots"]["graph"]["canvas_h"].as_f64().unwrap_or(-1.0)
    };

    let small = measure(&rows);
    rows.set(300);
    let big = measure(&rows);
    assert!(small > 300.0, "with a 3-line readout the canvas had room ({small})");
    assert!(big <= 0.0, "with a 300-line readout the ONE-pane canvas is gone ({big}) — this is the reported bug");
}

// ── 3. The area HEADER: the editor dropdown is a region of the EXISTING chrome bar ─

#[test]
fn the_area_header_publishes_a_clickable_editor_dropdown_on_the_one_chrome_bar() {
    let scene = area_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(4));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));
    let st = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));

    let ch = &st["panes"]["area.left"]["chrome"];
    let bar_h = ch["bar_height"].as_f64().unwrap();
    assert!(bar_h <= CHROME_BAR as f64 + 0.5, "ONE header row, not two: {bar_h} ≤ {CHROME_BAR}");

    let ed = &ch["buttons"]["editor"];
    let (w, h) = (ed[2].as_f64().expect("dropdown w"), ed[3].as_f64().expect("dropdown h"));
    assert!(w > 1.0 && h > 1.0, "the editor dropdown publishes a clickable rect ({w}×{h})");
    // The dropdown lives inside the bar it extends.
    let (bx, by, bw, bh) = (
        ch["bar_rect"][0].as_f64().unwrap(),
        ch["bar_rect"][1].as_f64().unwrap(),
        ch["bar_rect"][2].as_f64().unwrap(),
        ch["bar_rect"][3].as_f64().unwrap(),
    );
    let (ex, ey) = (ed[0].as_f64().unwrap(), ed[1].as_f64().unwrap());
    assert!(ex >= bx - 1.0 && ex + w <= bx + bw + 1.0, "the dropdown sits within the bar horizontally");
    assert!(ey >= by - 1.0 && ey + h <= by + bh + 1.0, "…and within the bar vertically (no second row)");
    // …alongside the window controls it did not replace.
    for c in ["minimize", "maximize"] {
        assert!(ch["buttons"][c][2].as_f64().unwrap() > 1.0, "the `{c}` control is still on the same bar");
    }
    // …and it offers every registered editor type.
    let opts = st["panes"]["area.left"]["editor"]["options"].as_array().unwrap().clone();
    let ids: Vec<String> = opts.iter().map(|o| o["id"].as_str().unwrap().to_string()).collect();
    assert_eq!(ids, vec!["graph", "tables", "readout"], "the dropdown offers the registry, in registration order");
}

#[test]
fn clicking_the_header_dropdown_swaps_the_editor_and_the_rect_survives() {
    // The right area hosts `readout` here, so "did the LEFT area become Tables?" has an
    // unambiguous answer in the AccessKit tree.
    let scene = Scene::new(
        "areas.click",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.5,
            vec![Pane::area("area.left", "graph").title("Left"), Pane::area("area.right", "readout").title("Right")],
        ),
    );
    let rows = std::rc::Rc::new(std::cell::Cell::new(4));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (s, n, hh, sc) = (state.clone(), nav.clone(), host.clone(), scene.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui(move |ui| {
        *s.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut *hh.borrow_mut());
    });
    h.run();
    let r0 = rect(&state.borrow(), "area.left");
    assert!(h.query_by_label("ED:graph").is_some(), "the graph editor is mounted in the left area");

    // Open the header dropdown by its AccessKit label, then pick "Tables" by its.
    h.get_by_label("editor area.left").click();
    h.run();
    assert_eq!(state.borrow()["panes"]["area.left"]["editor"]["menu_open"], true, "the dropdown opened");
    h.get_by_label("editor area.left = tables").click();
    h.run();
    h.run();

    let st = state.borrow().clone();
    assert_eq!(st["panes"]["area.left"]["editor"]["id"], "tables", "the click swapped the editor");
    assert_eq!(rect(&st, "area.left"), r0, "…and the area's rect is unchanged by the swap");
    assert_eq!(st["nav"]["editor_menu"], serde_json::Value::Null, "picking an option closed the menu");
    assert!(h.query_by_label("ED:tables").is_some(), "the tables editor is what draws now");
    assert!(h.query_by_label("ED:graph").is_none(), "…and the graph editor left the tree");
}

// ── 4. Additive: an area is a new Content variant, nothing else changed ──────────

#[test]
fn areas_round_trip_through_ron_and_leave_existing_scenes_byte_identical() {
    // An area scene round-trips byte-stably, like every other pane shape.
    let scene = area_scene();
    scene.validate().expect("unique names");
    let ron1 = scene.to_ron();
    let back = Scene::from_ron(&ron1).expect("area RON parses");
    assert_eq!(scene, back, "the builder and RON forms agree");
    assert_eq!(ron1, back.to_ron(), "an area scene's RON is byte-stable");
    assert!(ron1.contains("Area("), "an area serializes as its Content variant");
    assert!(ron1.contains("editor: \"graph\""), "the DEFAULT editor id is the only editor data in the scene");
    assert!(!ron1.contains("allow:"), "Allow::Any is the default and is skipped on serialize");
    assert!(!ron1.contains("area_editor"), "the LIVE binding is nav STATE, never scene data");

    // The pre-existing scene shape is untouched by the new variant.
    let old = sample_scene();
    assert_eq!(old.to_ron(), Scene::from_ron(&old.to_ron()).unwrap().to_ron());
    assert!(old.areas().is_empty(), "a scene with no areas reports none");

    // The affordance audit now names them.
    let aff = scene.affordances();
    assert_eq!(aff["areas"]["area.left"], "graph", "the audit maps area -> its default editor");
    assert_eq!(aff["areas"]["area.right"], "tables");
    assert_eq!(aff["areas"].as_object().unwrap().len(), 2);
    assert_eq!(aff["editors"]["graph"][0], "area.left", "…and the inverse: editor -> the areas that host it");
    assert_eq!(scene.area_editor("area.left"), Some("graph"));
    assert_eq!(scene.area_editor("root"), None, "a split pane is not an area");
}

#[test]
fn a_route_and_a_menu_action_can_swap_an_areas_editor() {
    let scene = area_scene().route(Route::on_shortcut(
        "Ctrl+T",
        Target::SetEditor { pane: "area.left".into(), editor: "tables".into() },
    ));
    let mut nav = NavState::new(&scene);
    assert_eq!(nav.editor_of("area.left"), Some("graph"));
    assert!(nav.fire(&scene, &Trigger::Shortcut("Ctrl+T".into())), "the route matched");
    assert_eq!(nav.editor_of("area.left"), Some("tables"), "Target::SetEditor swapped it");
    assert!(!nav.set_editor("area.left", "tables"), "a no-change swap reports false");
    assert!(!nav.set_editor("root", "tables"), "a non-area pane is a no-op, never a panic");
    assert!(!nav.set_editor("nope", "tables"), "an unknown pane is a no-op, never a panic");
}

#[test]
fn an_unregistered_editor_id_degrades_instead_of_panicking() {
    // A persisted layout naming an editor a later build removed must still open.
    let scene = Scene::new("gone", Pane::area("solo", "removed_in_v2"));
    let rows = std::rc::Rc::new(std::cell::Cell::new(1));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));
    let st = render_areas(&scene, &nav, &host, egui::vec2(800.0, 600.0));
    assert_eq!(st["slots"]["solo"]["error"], "unregistered", "the miss is OBSERVABLE, not silent");
    assert!(rect(&st, "solo").2 > 100.0, "…and the area still holds its rect");
}

// ═════════════════════════════════════════════════════════════════════════════════
// THE SHELL'S OWN CHROME IS PANES — menu bar, toolbar and status bar are fixed-height
// ROWS of the root split, not special-cased widgets outside the tree. Each therefore
// has a name, a rect and an `alloc` in state_json, and each is addressable.
// ═════════════════════════════════════════════════════════════════════════════════

/// A host whose `body` slot draws `rows` labels — so "the body grew" is a real event.
struct ShellHost {
    rows: std::rc::Rc<std::cell::Cell<usize>>,
}
impl SceneHost for ShellHost {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> serde_json::Value {
        if id == "body" {
            for i in 0..self.rows.get() {
                ui.label(format!("row {i}"));
            }
        } else {
            ui.label(format!("SLOT:{id}"));
        }
        serde_json::json!({ "slot": id })
    }
}

/// The whole shell as one vertical stack: menu bar · toolbar · body · status bar.
fn shell_scene() -> Scene {
    Scene::new(
        "shell",
        Pane::shell(
            "root",
            vec![
                Pane::menubar("menubar"),
                Pane::bar("toolbar", "tools", 28.0),
                Pane::slot("body", "body").title("Body"),
                Pane::bar("statusbar", "status", 22.0),
            ],
        ),
    )
    .menu(MenuItem::new("view", "View").items(vec![
        MenuItem::new("view.zoom", "Zoom Body").action(Action::Maximize("body".into())),
    ]))
}

fn render_shell(
    scene: &Scene,
    nav: &std::rc::Rc<std::cell::RefCell<NavState>>,
    rows: &std::rc::Rc<std::cell::Cell<usize>>,
    size: egui::Vec2,
) -> serde_json::Value {
    let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (o, n, sc, r) = (out.clone(), nav.clone(), scene.clone(), rows.clone());
    let mut h = Harness::builder().with_size(size).build_ui(move |ui| {
        *o.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut ShellHost { rows: r.clone() });
    });
    h.run();
    out.borrow().clone()
}

#[test]
fn menu_toolbar_and_status_bar_are_panes_with_exact_allotted_heights() {
    let scene = shell_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(3));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));

    // They are PANES: named, reported, with rects.
    for p in ["menubar", "toolbar", "body", "statusbar"] {
        assert!(st["panes"].get(p).is_some(), "shell row `{p}` is a reported PANE");
    }
    let h = |p: &str| rect(&st, p).3;
    let y = |p: &str| rect(&st, p).1;
    assert!((h("menubar") - MENUBAR_H as f64).abs() < 0.5, "the menu bar row is exactly {MENUBAR_H}pt ({})", h("menubar"));
    assert!((h("toolbar") - 28.0).abs() < 0.5, "the toolbar row is exactly 28pt ({})", h("toolbar"));
    assert!((h("statusbar") - 22.0).abs() < 0.5, "the status bar row is exactly 22pt ({})", h("statusbar"));
    assert!(h("body") > 600.0, "the body took everything left over ({})", h("body"));

    // …stacked in the authored order, top to bottom.
    assert!(y("menubar") < y("toolbar") && y("toolbar") < y("body") && y("body") < y("statusbar"), "rows stack in order");

    // The scene advertises them as fixed rows, so a shell audit can check for a status
    // bar rather than take one on trust.
    let aff = scene.affordances();
    let fixed: Vec<String> = aff["fixed_panes"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().into()).collect();
    assert_eq!(fixed, vec!["menubar", "toolbar", "statusbar"], "the fixed rows are named in the audit");
    assert_eq!(aff["menubar_pane"], "menubar");
}

#[test]
fn a_growing_body_cannot_move_the_status_bar() {
    // The same class of bug as korp→Graph, one level up: in a shell where the status bar
    // is drawn after the body in one flow, a tall body pushes it off screen. As a fixed
    // row of the split it is apportioned FIRST and cannot be pushed.
    let scene = shell_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(2));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let small = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));

    rows.set(400); // far more content than the window is tall
    let big = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));

    assert_eq!(rect(&small, "statusbar"), rect(&big, "statusbar"), "a 200× larger body left the status bar exactly where it was");
    assert_eq!(rect(&small, "menubar"), rect(&big, "menubar"), "…and the menu bar too");
    let (_, sy, _, sh) = rect(&big, "statusbar");
    assert!(sy + sh <= 800.0 + 1.0, "the status bar is still ON SCREEN (bottom at {})", sy + sh);
}

#[test]
fn the_menu_bar_pane_hosts_the_menu_and_the_implicit_strip_stands_down() {
    let scene = shell_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(2));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (o, n, sc, r) = (out.clone(), nav.clone(), scene.clone(), rows.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui(move |ui| {
        *o.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut ShellHost { rows: r.clone() });
    });
    h.run();

    assert_eq!(out.borrow()["panes"]["menubar"]["content"], "menubar", "the pane hosts the menu bar");
    assert_eq!(scene.menubar_pane().as_deref(), Some("menubar"));
    // Drawn ONCE — a second, implicit strip would put two `View` menus in the tree and
    // make both unaddressable (`query_by_label` errors on ambiguity).
    assert_eq!(h.query_all_by_label("View").count(), 1, "exactly one menu bar is drawn");
    // …and it lives inside the pane's own rect, not above the tree.
    let (_, my, _, mh) = rect(&out.borrow(), "menubar");
    let (_, ty, _, _) = rect(&out.borrow(), "toolbar");
    assert!(my + mh <= ty + 1.0, "the menu bar occupies its row, ending at {} before the toolbar at {ty}", my + mh);

    // The menu still WORKS from inside the pane: click it and fire an item.
    h.get_by_label("View").click();
    h.run();
    h.get_by_label("Zoom Body").click();
    h.run();
    h.run();
    assert!(nav.borrow().is_maximized("body"), "the menu-bar PANE fires actions like the strip does");

    // A scene WITHOUT a menubar pane keeps the implicit strip, byte-for-byte as before.
    let plain = sample_scene();
    assert_eq!(plain.menubar_pane(), None);
    let mut nav2 = NavState::new(&plain);
    let p2 = plain.clone();
    let mut h2 = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
        render(&p2, &mut nav2, ui, &mut TestHost);
    });
    h2.run();
    assert_eq!(h2.query_all_by_label("View").count(), 1, "the implicit top strip still draws for a scene with no menubar pane");
}

#[test]
fn a_fixed_row_is_immune_to_the_split_ratios() {
    let scene = shell_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(2));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let before = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));

    // Re-apportion the root split hard — a drag that would starve the last child.
    nav.borrow_mut().set_split_ratios("root", vec![0.01, 0.01, 0.01, 0.97]);
    let after = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));
    assert_eq!(rect(&before, "statusbar").3, rect(&after, "statusbar").3, "the status bar keeps its 22pt whatever the ratios say");
    assert_eq!(rect(&before, "menubar").3, rect(&after, "menubar").3, "so does the menu bar");
    assert_eq!(rect(&before, "body").3, rect(&after, "body").3, "…and the sole flexible row still takes all the rest");
}

#[test]
fn split_extents_pin_fixed_rows_before_apportioning_the_rest() {
    // The pure function, no GUI in the loop.
    let scene = shell_scene();
    let kids = match &scene.root.content {
        Content::Children(k) => k.clone(),
        _ => unreachable!(),
    };
    let mut nav = NavState::new(&scene);
    let ratios = vec![0.25, 0.25, 0.25, 0.25];
    let e = split_extents(&nav, &kids, &ratios, 800.0, None);
    assert_eq!(e[0], MENUBAR_H, "menubar pinned at its fixed extent, not its 25% share");
    assert_eq!(e[1], 28.0);
    assert_eq!(e[3], 22.0);
    assert!((e[2] - (800.0 - MENUBAR_H - 28.0 - 22.0)).abs() < 0.01, "the body got the whole remainder ({})", e[2]);

    // A MINIMISE beats a fixed extent — you asked for that row to collapse.
    nav.set_minimized("toolbar", true);
    let e2 = split_extents(&nav, &kids, &ratios, 800.0, None);
    assert_eq!(e2[1], CHROME_BAR, "the minimised fixed row collapses to CHROME_BAR");
    assert!(e2[2] > e[2], "and its space went to the flexible body");

    // A maximise zeroes every FLEXIBLE sibling — but the shell's fixed rows survive it.
    // Maximising a graph must not make the status bar vanish, for the same reason
    // switching workspace must not move the menu bar.
    let e3 = split_extents(&nav, &kids, &ratios, 800.0, Some(2));
    assert_eq!(e3[0], MENUBAR_H, "the menu bar keeps its row through a maximise");
    assert_eq!(e3[1], 28.0, "so does the toolbar");
    assert_eq!(e3[3], 22.0, "so does the status bar");
    assert!((e3[2] - (800.0 - MENUBAR_H - 28.0 - 22.0)).abs() < 0.01, "the maximised pane took everything else ({})", e3[2]);

    // Maximising the fixed row ITSELF still gives it everything.
    let e4 = split_extents(&nav, &kids, &ratios, 800.0, Some(3));
    assert_eq!(e4[3], 800.0 - MENUBAR_H - 28.0, "the maximised status bar takes all but the other fixed rows");
    assert_eq!(e4[2], 0.0, "and the flexible body goes to zero");
}

#[test]
fn the_fixed_field_is_skipped_on_serialize_so_old_scenes_are_byte_identical() {
    let old = sample_scene();
    assert!(!old.to_ron().contains("fixed"), "a scene with no fixed rows never mentions the field");
    assert_eq!(old.to_ron(), Scene::from_ron(&old.to_ron()).unwrap().to_ron());
    assert!(old.fixed_panes().is_empty());

    let shell = shell_scene();
    let ron = shell.to_ron();
    assert!(ron.contains("fixed: Some(26"), "an authored fixed row IS serialized");
    assert!(ron.contains("Menubar"), "so is the menu-bar content variant");
    let back = Scene::from_ron(&ron).expect("parses");
    assert_eq!(shell, back);
    assert_eq!(ron, back.to_ron(), "the shell scene's RON is byte-stable");
    // Builders round-trip through the field too.
    assert_eq!(Pane::slot("p", "s").fixed(40.0).flexible().fixed, None);
}

// ═════════════════════════════════════════════════════════════════════════════════
// LOCK 2 — THE CLIP LOCK. Lock 1 (sibling areas) protects the ALLOTMENT: `split_extents`
// takes (parent rect, ratios) and content length is not an input, so a growing editor
// cannot shrink its sibling. It does NOT protect the PIXELS: a child `Ui` built with
// `UiBuilder::max_rect(alloc)` inherits its parent's clip rect, so an over-long leaf
// paints straight over its neighbour — the rect assertion passes while the screen is
// wrong. These assert the second half: content_rect ⊆ alloc, and `overflowed == false`.
// ═════════════════════════════════════════════════════════════════════════════════

#[test]
fn a_bounded_leafs_content_stays_inside_its_own_allotment() {
    let scene = korp_graph_as_areas();
    let rows = std::rc::Rc::new(std::cell::Cell::new(3));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows.clone())));

    rows.set(400); // 400 centrality rows into a pane a few hundred points tall
    let st = render_areas(&scene, &nav, &host, egui::vec2(1000.0, 800.0));

    for area in ["graph_canvas", "graph_readout"] {
        let (_, _, aw, ah) = rect(&st, area);
        let cr = &st["panes"][area]["content_rect"];
        let (cw, chh) = (cr[2].as_f64().unwrap(), cr[3].as_f64().unwrap());
        assert!(chh <= ah + 0.5, "`{area}` content height {chh} escaped its allotment {ah}");
        assert!(cw <= aw + 0.5, "`{area}` content width {cw} escaped its allotment {aw}");
        assert_eq!(st["panes"][area]["overflowed"], false, "`{area}` reports no overflow");
        assert_eq!(st["panes"][area]["clipped"], true, "…because it is clipped to its allotment");
        assert_eq!(st["panes"][area]["overflow"], "scroll", "Auto under a Split resolves to Scroll");
    }
}

#[test]
fn overflow_auto_resolves_by_parent_and_grow_is_the_explicit_opt_out() {
    // Under a Flow there is no exact allotment to escape, so Auto means Grow.
    let flow = Scene::new(
        "ov.flow",
        Pane::flow("root", vec![Pane::slot("a", "a").title("A"), Pane::slot("b", "b").title("B")]),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&flow)));
    let st = render_once(&flow, &nav, egui::vec2(1000.0, 700.0));
    assert_eq!(st["panes"]["a"]["overflow"], "grow", "Auto under a Flow parent resolves to Grow");
    assert_eq!(st["panes"]["a"]["clipped"], false, "…and Grow does not clip");

    // Under a Split it resolves to Scroll — and an explicit Grow opts back out, which is
    // exactly the shape the korp bug had, so it must remain expressible and NAMED.
    let split = Scene::new(
        "ov.split",
        Pane::split(
            "root",
            Axis::Vertical,
            0.5,
            vec![Pane::slot("top", "a").overflow(Overflow::Grow), Pane::slot("bot", "b").overflow(Overflow::Clip)],
        ),
    );
    let nav2 = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&split)));
    let st2 = render_once(&split, &nav2, egui::vec2(1000.0, 700.0));
    assert_eq!(st2["panes"]["top"]["overflow"], "grow");
    assert_eq!(st2["panes"]["top"]["clipped"], false, "an explicit Grow is an explicit, named opt-out");
    assert_eq!(st2["panes"]["bot"]["overflow"], "clip");
    assert_eq!(st2["panes"]["bot"]["clipped"], true);

    // Auto is the default and is skipped on serialize — no existing RON moved a byte.
    assert!(!flow.to_ron().contains("overflow"));
    assert!(split.to_ron().contains("overflow: Grow"));
    assert_eq!(Scene::from_ron(&split.to_ron()).unwrap(), split);
}

// ── Allow: which editor types an area's header offers ───────────────────────────

#[test]
fn an_area_can_restrict_or_pin_the_editor_types_it_offers() {
    let scene = Scene::new(
        "allow",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.34,
            vec![
                Pane::area("any", "graph"),
                Pane::area("some", "graph").only_editors(["readout", "graph"]),
                Pane::area("pinned", "tables").fixed_editor(),
            ],
        ),
    );
    let rows = std::rc::Rc::new(std::cell::Cell::new(2));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let host = std::rc::Rc::new(std::cell::RefCell::new(EdHost::new(rows)));
    let st = render_areas(&scene, &nav, &host, egui::vec2(1200.0, 800.0));

    let ids = |p: &str| -> Vec<String> {
        st["panes"][p]["editor"]["options"].as_array().unwrap().iter().map(|o| o["id"].as_str().unwrap().into()).collect()
    };
    assert_eq!(ids("any"), vec!["graph", "tables", "readout"], "Allow::Any offers the whole registry");
    assert_eq!(ids("some"), vec!["readout", "graph"], "Allow::Only offers just those, in the order given");
    assert!(ids("pinned").is_empty(), "Allow::Fixed offers nothing");

    // The picker is a resolved CHROME control, so a host can ask before rendering.
    assert!(scene.chrome_of("any").editor, "an unrestricted area advertises the picker");
    assert!(scene.chrome_of("some").editor);
    assert!(!scene.chrome_of("pinned").editor, "a pinned area claims no affordance it cannot honour");
    assert!(!scene.chrome_of("root").editor, "a split pane has no editor picker");
    assert_eq!(st["panes"]["pinned"]["chrome"]["spec"]["editor"], false);
    // …and a pinned area still keeps every other chrome control.
    assert_eq!(st["panes"]["pinned"]["chrome"]["spec"]["minimize"], true);
}

// ── A fixed row draws no divider ────────────────────────────────────────────────

#[test]
fn no_drag_handle_is_drawn_beside_a_fixed_row() {
    // A handle between the status bar and the body could not move anything — the status
    // bar has no ratio to give. A handle that cannot move is a promise the layout breaks.
    let scene = shell_scene();
    let rows = std::rc::Rc::new(std::cell::Cell::new(2));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_shell(&scene, &nav, &rows, egui::vec2(1000.0, 800.0));
    // menubar · toolbar · body · statusbar — every adjacent pair involves a fixed row, so
    // no divider is drawn at all.
    assert!(st["panes"]["root"].get("separators").is_none(), "a stack of fixed rows draws no dividers");

    // Two flexible siblings still get theirs.
    let flex = Scene::new(
        "flex",
        Pane::shell("root", vec![Pane::bar("statusbar", "s", 22.0), Pane::slot("a", "a"), Pane::slot("b", "b")]),
    );
    let nav2 = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&flex)));
    let st2 = render_shell(&flex, &nav2, &rows, egui::vec2(1000.0, 800.0));
    let seps = st2["panes"]["root"]["separators"].as_array().expect("the flexible pair has a divider");
    assert_eq!(seps.len(), 1, "exactly one divider — between `a` and `b`, not beside the status bar");
    let sep_y = seps[0][1].as_f64().unwrap();
    assert!(sep_y > rect(&st2, "a").1, "…and it sits between the two flexible rows");
}

#[test]
fn a_long_tab_strip_keeps_every_tab_reachable() {
    // dwarves lost its 18th tab: the strip neither wrapped nor scrolled, so the last
    // headers laid out past the container's edge and fell out of the AccessKit tree.
    let kids: Vec<Pane> = (0..24).map(|i| Pane::slot(format!("t{i}"), "s").title(format!("Tab {i}"))).collect();
    let scene = Scene::new("many", Pane::tabs("strip", 0, kids));
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let out = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let (o, n, sc) = (out.clone(), nav.clone(), scene.clone());
    // A NARROW window — the case that broke.
    let mut h = Harness::builder().with_size(egui::vec2(500.0, 600.0)).build_ui(move |ui| {
        *o.borrow_mut() = render(&sc, &mut n.borrow_mut(), ui, &mut TestHost);
    });
    h.run();
    // Every tab header is in the AccessKit tree, at 500pt wide.
    // (`query_all`, not `query`: the ACTIVE tab's title also appears on its pane's chrome
    // bar — the label-ambiguity hazard the adoption graph flags. Reachability is what is
    // under test here.)
    for i in 0..24 {
        assert!(h.query_all_by_label(&format!("Tab {i}")).count() >= 1, "tab {i} is reachable in a 500pt-wide strip");
    }
    assert!(out.borrow()["panes"]["strip"].get("rect").is_some());
}

// ── RUNTIME PRESENCE — an ABSENT pane does not exist on screen ───────────────────
//
// `NavState::set_absent` is the host's per-frame declaration that a pane's SUBJECT does
// not exist right now (dwarves' ⚒ kernel leaf under a recipe that declares no
// `[[build.kernel]]` is the case that forced this). Fold and minimise both keep chrome
// on screen — a title bar promising content that cannot come — so presence had to be a
// capability of its own. The claims: zero extent, no chrome, no slot fill, adjacent
// dividers stand down, siblings absorb the space by their UNCHANGED ratios, and
// presence returning restores the exact prior arrangement.

/// **An absent pane is allotted NOTHING and its divider stands down.** GEOMETRY, not a
/// flag: the sibling measurably takes the whole split, the absent pane's alloc is
/// zero-extent with `hidden_by: "absent"`, its slot is never filled, and the split
/// reports no separator (a handle beside a pane that does not exist would drag against
/// nothing).
#[test]
fn an_absent_pane_is_allotted_nothing_and_its_divider_stands_down() {
    let scene = Scene::new(
        "gone",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.4,
            vec![
                Pane::slot("left", "a").title("Left").foldable_icon(true, "⚙"),
                Pane::slot("right", "b").title("Right").foldable_icon(true, "🖥"),
            ],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));

    // PRESENT first — both drawn, one divider.
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let [_, _, lw0, _] = alloc(&st, "left");
    assert!(lw0 > 300.0, "present, `left` holds its 0.4 share: {lw0}");
    assert_eq!(st["panes"]["root"]["separators"].as_array().map(Vec::len), Some(1));

    // ABSENT: the host says the pane's subject does not exist this frame.
    nav.borrow_mut().set_absent("left", true);
    let st = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let [_, _, lw, _] = alloc(&st, "left");
    assert_eq!(lw, 0.0, "an absent pane is allotted exactly nothing: {st}");
    assert_eq!(
        st["panes"]["left"]["hidden_by"], "absent",
        "…and the REASON is data, distinct from a maximise elsewhere: {st}"
    );
    assert!(
        st["slots"].get("a").is_none(),
        "the host is never asked to fill an absent pane's slot: {}",
        st["slots"]
    );
    let [_, _, rw, _] = alloc(&st, "right");
    assert!(rw > 900.0, "the present sibling absorbs the whole split: {rw}");
    assert!(
        st["panes"]["root"]["separators"].as_array().is_none_or(Vec::is_empty),
        "no divider beside a pane that does not exist: {}",
        st["panes"]["root"]
    );
}

/// **Presence returning restores the EXACT prior arrangement** — the operator's dragged
/// ratio and fold state survive an absence untouched, because absence re-apportions
/// extents and never rewrites stored state (the maximise discipline).
#[test]
fn presence_returning_restores_the_exact_prior_arrangement() {
    let scene = Scene::new(
        "back",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.4,
            vec![Pane::slot("left", "a").title("Left"), Pane::slot("right", "b").title("Right")],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    // The operator dragged the divider to 0.7 — the state that must survive.
    nav.borrow_mut().set_split_ratios("root", vec![0.7, 0.3]);
    let st0 = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let w0 = alloc(&st0, "left")[2];

    nav.borrow_mut().set_absent("left", true);
    let gone = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    // The absence really happened — a no-op "absence" would make the restoration
    // claim below pass vacuously.
    assert_eq!(alloc(&gone, "left")[2], 0.0, "mid-flight, `left` is genuinely gone: {gone}");
    let live = nav.borrow().split_ratios("root").cloned().expect("ratios survive");
    assert!((live[0] - 0.7).abs() < 1e-4, "absence must not rewrite the stored ratio: {live:?}");

    nav.borrow_mut().set_absent("left", false);
    let st1 = render_once(&scene, &nav, egui::vec2(1000.0, 700.0));
    let w1 = alloc(&st1, "left")[2];
    assert!((w1 - w0).abs() < 0.5, "presence returns to the exact dragged extent: {w0} -> {w1}");
    assert_eq!(st1["panes"]["root"]["separators"].as_array().map(Vec::len), Some(1), "…and the divider is back");
}

/// **Absence beats minimise and survives maximise zeroing** at the extent level, and a
/// maximise NAMING an absent pane holds nothing (the pane cannot hold a screen it is
/// not on) — while the maximise itself is kept, so presence returning restores it.
#[test]
fn split_extents_absence_beats_minimise_and_maximise() {
    let scene = stack_scene();
    let kids = match &scene.root.content {
        Content::Children(k) => k.clone(),
        _ => unreachable!(),
    };
    let mut nav = NavState::new(&scene);
    let ratios = vec![0.5, 0.3, 0.2];

    // Absent + minimised: absence wins — zero, not the CHROME_BAR a minimise keeps.
    nav.set_minimized("top", true);
    nav.set_absent("top", true);
    let ext = super::split_extents(&nav, &kids, &ratios, 1000.0, None);
    assert_eq!(ext[0], 0.0, "an absent pane keeps NO title bar: {ext:?}");
    // The siblings share by their unchanged 0.3:0.2.
    assert!((ext[1] - 600.0).abs() < 0.01 && (ext[2] - 400.0).abs() < 0.01, "unchanged ratios: {ext:?}");

    // A maximise ELSEWHERE: the absent sibling stays at exactly zero (it already was).
    let ext = super::split_extents(&nav, &kids, &ratios, 1000.0, Some(1));
    assert_eq!(ext, vec![0.0, 1000.0, 0.0]);

    // A maximise naming the ABSENT pane itself: at render level it resolves to
    // "nothing maximised" — the siblings keep their ratios, and the maximise is kept
    // for the pane's return.
    let scene2 = Scene::new(
        "maxgone",
        Pane::split(
            "root",
            Axis::Horizontal,
            0.4,
            vec![Pane::slot("left", "a").title("Left"), Pane::slot("right", "b").title("Right")],
        ),
    );
    let nav2 = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene2)));
    nav2.borrow_mut().set_maximized("left");
    nav2.borrow_mut().set_absent("left", true);
    let st = render_once(&scene2, &nav2, egui::vec2(1000.0, 700.0));
    assert!(alloc(&st, "right")[2] > 900.0, "the present sibling holds the split: {st}");
    assert_eq!(st["maximized"], "left", "the maximise is KEPT for the pane's return");
}

/// **An absent tab grows no header, and a selection resting on it falls to a present
/// sibling** — without rewriting the stored active index, so presence returning
/// restores the exact tab.
#[test]
fn an_absent_tab_grows_no_header_and_the_strip_falls_to_a_present_sibling() {
    let scene = Scene::new(
        "strip",
        Pane::tabs(
            "tabs",
            0,
            vec![Pane::slot("first", "a").title("First"), Pane::slot("second", "b").title("Second")],
        ),
    );
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    nav.borrow_mut().set_absent("first", true);
    let st = render_once(&scene, &nav, egui::vec2(800.0, 600.0));

    assert!(st["tab_rects"].get("first").is_none(), "no header for an absent tab: {}", st["tab_rects"]);
    assert!(st["tab_rects"].get("second").is_some(), "the present sibling keeps its header");
    assert!(st["slots"].get("b").is_some(), "…and its body is the one drawn: {}", st["slots"]);
    assert!(st["slots"].get("a").is_none(), "the absent tab's body is not drawn");
    assert_eq!(
        nav.borrow().active_tab.get("tabs"),
        Some(&0),
        "the stored active index is NOT rewritten — presence returning restores the tab"
    );

    nav.borrow_mut().set_absent("first", false);
    let st = render_once(&scene, &nav, egui::vec2(800.0, 600.0));
    assert!(st["slots"].get("a").is_some(), "present again, the original tab is back on screen");
}

/// **Presence is runtime state, never authored data.** A scene's RON carries no absence
/// (nothing to round-trip), an empty absent set serializes to NOTHING (persisted
/// layouts from before this capability parse unchanged, and new ones stay byte-compatible),
/// and a populated set round-trips — then is reconciled by `adopt` like every other map.
#[test]
fn absence_is_runtime_state_not_authored_data() {
    let scene = sample_scene();
    // The RON authoring surface is untouched by the capability.
    assert!(!scene.to_ron().contains("absent"), "a scene must not author presence");

    let mut nav = NavState::new(&scene);
    let clean = serde_json::to_string(&nav).expect("NavState serializes");
    assert!(!clean.contains("\"absent\""), "an empty absent set serializes to nothing: {clean}");
    let back: NavState = serde_json::from_str(&clean).expect("…and parses back");
    assert!(back.absent.is_empty());

    nav.set_absent("sidebar", true);
    nav.set_absent("no-such-pane", true);
    let full = serde_json::to_string(&nav).expect("serializes");
    let mut back: NavState = serde_json::from_str(&full).expect("parses");
    assert!(back.is_absent("sidebar") && back.is_absent("no-such-pane"), "a populated set round-trips");
    back.adopt(&scene);
    assert!(back.is_absent("sidebar"), "a live pane's absence survives adopt (one frame at most — the host re-asserts it)");
    assert!(!back.is_absent("no-such-pane"), "an absence naming no authored pane is dropped");
}