mannequin/tests/robot_shell.rs
//! Mannequin **SHELL robot-UI** cells — the master/detail scene, driven headlessly.
//!
//! The mannequin GUI used to be two things stacked in one `ScrollArea` with a
//! `ui.separator()` between them. The operator could not resize either half, could not
//! collapse the map to read a long log, and could not blow the log up to read it. The
//! shell is now one `facett_app::scene::Scene` — a DRAGGABLE vertical master/detail split
//! of the system map over the selection's detail, both panes wearing facett-app's window
//! chrome (fold · minimise · maximise).
//!
//! These cells drive that shell through a real `egui_kittest` frame and assert
//! **GEOMETRY**, not flags:
//!
//! * dragging the divider moves BOTH panes' rendered rects by the distance dragged, and
//! the split conserves area;
//! * minimising a pane collapses it to exactly `CHROME_BAR` points and hands the space to
//! its sibling; restoring returns the EXACT prior rects;
//! * maximising a pane allots its sibling ZERO area.
//!
//! A boolean `resizable == true` stays green over a divider that renders but never moves
//! anything, and a `minimized == true` stays green over a minimise that changes no
//! layout. A rect does not. (LAW 4 — assert the observable, RED when broken.)
use std::sync::Arc;
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
use facett_app::scene::CHROME_BAR;
use mannequin::event_log::{Direction, EventLog, SystemKind};
use mannequin::gui::{mannequin_scene, MannequinGui, DETAIL_PANE, MAP_PANE, SHELL_PANE};
use mannequin::PkiService;
use serde_json::{json, Value};
/// A mannequin GUI over a live event log with a few real entries per system, so the
/// detail pane has something to draw whatever the selection.
fn gui() -> MannequinGui {
let log = EventLog::new();
for (kind, n) in [
(SystemKind::Pki, 3),
(SystemKind::Oidc, 2),
(SystemKind::Nexus, 5),
] {
for i in 0..n {
log.log(
kind.clone(),
Direction::Inbound,
&format!("request {i}"),
None,
);
}
}
let pki = Arc::new(PkiService::new().expect("build a mannequin PkiService"));
MannequinGui::new(log, pki)
}
/// A kittest harness over the REAL shipped shell (`eframe::App::ui`), stepped once so the
/// scene has rendered and its geometry is published.
fn harness(w: f32, h: f32) -> Harness<'static, MannequinGui> {
let mut hn = Harness::builder()
.with_size(egui::Vec2::new(w, h))
.build_ui_state(
|ui, app: &mut MannequinGui| {
let mut frame = eframe::Frame::_new_kittest();
eframe::App::ui(app, ui, &mut frame);
},
gui(),
);
hn.run();
hn
}
/// A rendered pane's ALLOTTED rect `(x, y, w, h)` — the area its parent handed it this
/// frame. `rect` (what egui then consumed) can lag by a margin; `alloc` IS the
/// apportionment, which is what a divider drag and a minimise actually change.
fn pane_alloc(state: &Value, pane: &str) -> (f32, f32, f32, f32) {
let r = &state["scene"]["render"]["panes"][pane]["alloc"];
let f = |i: usize| {
r.get(i).and_then(Value::as_f64).unwrap_or_else(|| {
panic!(
"pane `{pane}` reports an alloc rect: {}",
state["scene"]["render"]
)
}) as f32
};
(f(0), f(1), f(2), f(3))
}
/// A rendered pane's allotted HEIGHT (the split here is vertical).
fn alloc_h(state: &Value, pane: &str) -> f32 {
pane_alloc(state, pane).3
}
/// A rendered pane's allotted AREA — zero for a pane a maximise hid.
fn alloc_area(state: &Value, pane: &str) -> f32 {
state["scene"]["render"]["panes"][pane]["area"]
.as_f64()
.unwrap_or_else(|| {
panic!(
"pane `{pane}` reports its allotted area: {}",
state["scene"]["render"]
)
}) as f32
}
/// The centre of the split's drag-handle rect, read from the render's published
/// `separators` — the robot-addressable divider position (the same strip an operator's
/// cursor lands on).
fn sep_center(state: &Value, pane: &str) -> egui::Pos2 {
let sep = &state["scene"]["render"]["panes"][pane]["separators"][0];
let f = |i: usize| {
sep.get(i).and_then(Value::as_f64).unwrap_or_else(|| {
panic!(
"split `{pane}` publishes a divider handle rect: {}",
state["scene"]["render"]["panes"][pane]
)
}) as f32
};
egui::pos2(f(0) + f(2) / 2.0, f(1) + f(3) / 2.0)
}
/// **The shell is declared as a draggable master/detail split with chrome on both panes.**
/// Pure shape assertion — no window needed. RED-when-broken the moment the split is
/// downgraded to `split_fixed`, a pane is opted out of chrome, or the root grows a bar it
/// has nothing to minimise into.
#[test]
fn mannequin_shell_is_a_draggable_master_detail_split_with_chrome() {
let scene = mannequin_scene();
scene.validate().expect("unique pane names");
assert_eq!(
scene.pane_names(),
vec![
SHELL_PANE.to_string(),
MAP_PANE.to_string(),
DETAIL_PANE.to_string()
],
"the shell is the split over the map and the detail, in that order"
);
assert_eq!(
scene.draggable_splits(),
vec![SHELL_PANE.to_string()],
"the master/detail divider is DRAGGABLE (not a static fraction)"
);
assert_eq!(
scene.foldable_panes(),
vec![MAP_PANE.to_string(), DETAIL_PANE.to_string()],
"both content panes fold"
);
// Chrome on both panes, bare root — facett-app's `Chrome::Auto`.
assert_eq!(
scene.chrome_panes(),
vec![MAP_PANE.to_string(), DETAIL_PANE.to_string()],
"both panes wear a window title bar; the root stays bare"
);
for pane in [MAP_PANE, DETAIL_PANE] {
let spec = scene.chrome_of(pane);
assert!(spec.bar, "`{pane}` draws a chrome title bar");
assert!(spec.fold, "`{pane}` draws a fold toggle (it is foldable)");
assert!(spec.minimize, "`{pane}` draws a minimise control");
assert!(spec.maximize, "`{pane}` draws a maximise control");
}
assert!(
!scene.chrome_of(SHELL_PANE).bar,
"the root has nothing to minimise into"
);
// The scene round-trips through RON byte-stably (the on-disk authoring form).
let ron = scene.to_ron();
let reparsed =
facett_app::scene::Scene::from_ron(&ron).expect("the shell parses back from its own RON");
assert_eq!(ron, reparsed.to_ron(), "the shell's RON is byte-stable");
assert_eq!(scene, reparsed, "RON and the builder yield the SAME shell");
}
/// **DRAG the master/detail divider — both panes' RECTS move.**
///
/// Grab the handle the render publishes, drag it 140pt DOWN, and assert the map pane's
/// allotted rect got ~140pt taller while the detail pane's got ~140pt shorter and their
/// sum is preserved. Then drag 240pt UP and assert the map ends up SHORTER than it
/// started. RED-when-broken: a static split publishes no handle at all; a handle that
/// doesn't write the ratio back leaves the rects where they were.
#[test]
fn robot_drag_divider_moves_both_pane_rects() {
let mut h = harness(900.0, 800.0);
let s0 = h.state().state_json();
assert_eq!(
s0["scene"]["render"]["panes"][SHELL_PANE]["resizable"],
json!(true),
"the shell publishes a DRAGGABLE divider: {}",
s0["scene"]["render"]["panes"][SHELL_PANE]
);
let seed = s0["scene"]["split_ratio"][SHELL_PANE][0]
.as_f64()
.expect("the live ratio") as f32;
assert!(
(seed - 0.55).abs() < 1e-4,
"the map seeds at 0.55 of the shell height: {seed}"
);
let (map0, detail0) = (alloc_h(&s0, MAP_PANE), alloc_h(&s0, DETAIL_PANE));
let span0 = map0 + detail0;
assert!(
map0 > 100.0 && detail0 > 100.0,
"both halves start as real panes: map={map0} detail={detail0}"
);
// ── drag DOWN: the map grows, the detail gives up the same height ────────────
let from = sep_center(&s0, SHELL_PANE);
let to = egui::pos2(from.x, from.y + 140.0);
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 s1 = h.state().state_json();
let (map1, detail1) = (alloc_h(&s1, MAP_PANE), alloc_h(&s1, DETAIL_PANE));
let grew = map1 - map0;
let shrank = detail0 - detail1;
assert!(
(grew - 140.0).abs() < 24.0,
"dragging the divider 140pt down grew the map's RECT by ~140pt: {map0} -> {map1} (+{grew})"
);
assert!(
(shrank - 140.0).abs() < 24.0,
"…and shrank the detail's RECT by the same ~140pt: {detail0} -> {detail1} (-{shrank})"
);
assert!(
((map1 + detail1) - span0).abs() < 8.0,
"the split conserves area: {span0} -> {}",
map1 + detail1
);
let after = s1["scene"]["split_ratio"][SHELL_PANE][0].as_f64().unwrap() as f32;
assert!(
after > seed + 0.1,
"the live ratio moved off its seed: {seed} -> {after}"
);
// ── drag UP past the start: the map ends up SHORTER than it began ────────────
let from = sep_center(&s1, SHELL_PANE);
let to = egui::pos2(from.x, from.y - 240.0);
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 s2 = h.state().state_json();
let (map2, detail2) = (alloc_h(&s2, MAP_PANE), alloc_h(&s2, DETAIL_PANE));
assert!(
map2 < map0,
"dragging back up shrank the map below its start: {map0} -> {map2}"
);
assert!(
detail2 > detail0,
"…and the detail reclaimed the height: {detail0} -> {detail2}"
);
}
/// **WINDOW CHROME: minimise · maximise · restore, asserted as RECTS.**
///
/// Drives the affordance the way an operator does — clicking the title-bar control by its
/// unique AccessKit label (`"minimize detail_pane"`) — then reads back geometry:
/// minimising the detail collapses it to its `CHROME_BAR` strip and gives the map the
/// height; restoring returns the EXACT prior rects (chrome never overwrites the split
/// ratio, so restore is lossless, not a reset to the 0.55 seed); maximising the detail
/// allots the map ZERO area.
#[test]
fn robot_chrome_minimise_maximise_restore_move_the_rects() {
let mut h = harness(900.0, 800.0);
let s0 = h.state().state_json();
assert_eq!(
s0["scene"]["render"]["panes"][DETAIL_PANE]["chrome"]["spec"],
json!({ "bar": true, "fold": true, "minimize": true, "maximize": true, "editor": false }),
"the detail pane's title bar carries fold + minimise + maximise: {}",
s0["scene"]["render"]["panes"][DETAIL_PANE]["chrome"]
);
let (map0, detail0) = (alloc_h(&s0, MAP_PANE), alloc_h(&s0, DETAIL_PANE));
let span0 = map0 + detail0;
// ── MINIMISE the detail pane by clicking its chrome control ──────────────────
h.get_by_label(&format!("minimize {DETAIL_PANE}")).click();
h.run();
h.run();
let s1 = h.state().state_json();
assert_eq!(
s1["scene"]["minimized"][DETAIL_PANE],
json!(true),
"the detail pane is minimised: {}",
s1["scene"]["minimized"]
);
assert_eq!(
s1["scene"]["last_chrome"],
json!(format!("minimize:{DETAIL_PANE}")),
"the click landed on the detail pane's minimise control: {}",
s1["scene"]["last_chrome"]
);
let (map1, detail1) = (alloc_h(&s1, MAP_PANE), alloc_h(&s1, DETAIL_PANE));
assert!(
(detail1 - CHROME_BAR).abs() < 2.0,
"a minimised pane collapses to its {CHROME_BAR}pt title strip: {detail0} -> {detail1}"
);
assert!(
(map1 - (span0 - CHROME_BAR)).abs() < 8.0,
"…and the map is handed the height back: {map0} -> {map1} (of {span0})"
);
// ── RESTORE: the EXACT prior geometry, not a reset to the seed ───────────────
h.get_by_label(&format!("restore {DETAIL_PANE}")).click();
h.run();
h.run();
let s2 = h.state().state_json();
assert_eq!(
s2["scene"]["minimized"][DETAIL_PANE],
json!(false),
"the detail pane is restored"
);
let (map2, detail2) = (alloc_h(&s2, MAP_PANE), alloc_h(&s2, DETAIL_PANE));
assert!(
(map2 - map0).abs() < 1.0 && (detail2 - detail0).abs() < 1.0,
"restore returns the EXACT prior geometry: map {map0} -> {map2}, detail {detail0} -> {detail2}"
);
// ── MAXIMISE the detail: the map is allotted nothing at all ──────────────────
h.get_by_label(&format!("maximize {DETAIL_PANE}")).click();
h.run();
h.run();
let s3 = h.state().state_json();
assert_eq!(
s3["scene"]["maximized"],
json!(DETAIL_PANE),
"the detail pane holds the maximise"
);
assert_eq!(
alloc_area(&s3, MAP_PANE),
0.0,
"a maximise allots the map ZERO area: {}",
s3["scene"]["render"]["panes"][MAP_PANE]["alloc"]
);
let detail3 = alloc_h(&s3, DETAIL_PANE);
assert!(
detail3 > span0 - 8.0,
"the maximised detail fills the shell: {detail3} (the split was {span0})"
);
// ── RESTORE the maximise: the master/detail layout comes back unchanged ──────
h.get_by_label(&format!("restore {DETAIL_PANE}")).click();
h.run();
h.run();
let s4 = h.state().state_json();
assert_eq!(
s4["scene"]["maximized"],
Value::Null,
"the maximise is dropped"
);
let (map4, detail4) = (alloc_h(&s4, MAP_PANE), alloc_h(&s4, DETAIL_PANE));
assert!(
(map4 - map0).abs() < 1.0 && (detail4 - detail0).abs() < 1.0,
"restoring the maximise returns the EXACT master/detail layout: map {map0} -> {map4}, detail {detail0} -> {detail4}"
);
}
/// **Folding a pane hides its body but keeps the pane addressable.** Folding the map
/// drops the `map` slot's content out of the render tree (so it leaves the AccessKit tree
/// too) while the pane's chrome bar — and its unfold control — stay, so the operator can
/// always get it back. A fold with no way back is a pane the operator has lost.
#[test]
fn robot_fold_hides_the_body_and_keeps_the_way_back() {
let mut h = harness(900.0, 800.0);
let s0 = h.state().state_json();
assert_eq!(
s0["scene"]["folded"][MAP_PANE],
json!(false),
"the map starts unfolded"
);
assert!(
s0["scene"]["render"]["slots"].get("map").is_some(),
"the map slot renders while unfolded: {}",
s0["scene"]["render"]["slots"]
);
// Fold it via its chrome bar's fold control (a real AccessKit click).
h.get_by_label(&format!("fold {MAP_PANE}")).click();
h.run();
h.run();
let s1 = h.state().state_json();
assert_eq!(
s1["scene"]["folded"][MAP_PANE],
json!(true),
"the map is folded: {}",
s1["scene"]["folded"]
);
assert!(
s1["scene"]["render"]["slots"].get("map").is_none(),
"folding drops the map's body out of the render tree: {}",
s1["scene"]["render"]["slots"]
);
// The way back is still on screen.
assert!(
h.query_by_label(&format!("unfold {MAP_PANE}")).is_some(),
"the folded pane's chrome bar still offers the unfold control"
);
// Unfold: the body returns.
h.get_by_label(&format!("unfold {MAP_PANE}")).click();
h.run();
h.run();
let s2 = h.state().state_json();
assert_eq!(
s2["scene"]["folded"][MAP_PANE],
json!(false),
"the map unfolded again"
);
assert!(
s2["scene"]["render"]["slots"].get("map").is_some(),
"unfolding restores the map's body: {}",
s2["scene"]["render"]["slots"]
);
}
/// **The map drives the detail — it is one selection, not two.** Selecting a system on the
/// master pane is what the detail pane renders; the detail reports the selected system and
/// its real event count, and deselecting empties it. RED-when-broken if the detail ever
/// grows a selection of its own.
#[test]
fn robot_map_selection_drives_the_detail_pane() {
let mut h = harness(900.0, 800.0);
// Nothing selected → the detail pane says so, honestly.
let s = h.state().state_json();
assert_eq!(
s["scene"]["render"]["slots"]["detail"]["selected"],
Value::Null,
"no selection yet: {s}"
);
// Select Nexus (5 seeded events) on the MASTER pane.
h.state_mut().select_system("nexus");
h.run();
h.run();
let s = h.state().state_json();
assert_eq!(
s["selected_system"],
json!("nexus"),
"the map holds the selection: {s}"
);
assert_eq!(
s["scene"]["render"]["slots"]["detail"]["selected"],
json!("nexus"),
"the DETAIL pane renders the map's selection: {}",
s["scene"]["render"]["slots"]["detail"]
);
assert_eq!(
s["scene"]["render"]["slots"]["detail"]["events"],
json!(5),
"…with that system's real event count: {}",
s["scene"]["render"]["slots"]["detail"]
);
// Move the selection: the detail follows, with the other system's count.
h.state_mut().select_system("oidc");
h.run();
h.run();
let s = h.state().state_json();
assert_eq!(
s["scene"]["render"]["slots"]["detail"]["selected"],
json!("oidc"),
"the detail follows the master: {}",
s["scene"]["render"]["slots"]["detail"]
);
assert_eq!(
s["scene"]["render"]["slots"]["detail"]["events"],
json!(2),
"…and its own count"
);
}