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