//! holger-server-ui **SCENE robot-UI** suite — drives the newly scene-migrated shell
//! (`facett_app::scene`) *headlessly* through `egui_kittest` (AccessKit clicks) with
//! the reusable `nornir-robotui` framework, asserting the app's observable
//! `state_json()["scene"]` after each interaction. Each `(action → expected)` step is
//! a `nornir-testmatrix` row, so a robot run auto-drives the SCENE surface and **fills
//! the nornir test matrix**.
//!
//! The shell is now ONE `facett_app::scene::Scene` — a grouped menubar over a
//! **draggable master/detail split**: the registry rail (itself a draggable vertical
//! split, and a phone drawer) beside a `Pane::tabs` of foldable slot leaves (one per
//! registered tab) — rendered through facett-app's OWN `Router`/`TabView`/`facett_menu`
//! primitives, DRIVING the existing `<tab>_tab` renderers via a `SceneHost`. This suite
//! comprehensively covers that surface:
//!
//! * every one of the TabView panes, reached by a real header click (route +
//! `active_tab` reflect it AND the pane's slot state appears in the render tree);
//! * fold / unfold of every foldable pane (the `folded` map flips AND the folded
//! content leaves the render tree);
//! * **a real pointer DRAG of each divider**, asserted at the RECT level — the dragged
//! pane's rendered rect grows by (about) the drag distance, its sibling's shrinks by
//! the same, their sum is preserved, and the live ratio moves off its seed. Both
//! dividers, both directions. A boolean "it is resizable" would stay green over a
//! divider that renders but never moves anything; a rect cannot.
//! * the registry rail as the app's MASTER selection — picking a repository in the rail
//! drives the one `repos.selected` every detail tab reads;
//! * every menubar group (View / Operate / Repositories / Action / Look / Help),
//! opened + picked as a real `facett_menu` dropdown → the ONE `dispatch_command`;
//! * deep-link navigation to a pane BY NAME (`goto:<pane>`);
//! * responsive behaviour at a phone-class width — the root split collapses to `Tabs`
//! on the workspace and the rail DRAWER auto-folds, asserted as the device class,
//! the layout tag and the rail's folded state.
//!
//! Every assertion is RED-when-broken (a dropped tab, fold, menu route, or a
//! responsive regression fails the matching cell). Complements — does not duplicate —
//! `robot_ui_app.rs::robot_drives_scene_shell_tabs_and_folds` (the smoke cell).
//!
//! Gated behind `gui`. Run:
//! `cargo test -p holger-server-ui --features gui --test robot_scene`.
#![cfg(feature = "gui")]
use std::sync::Arc;
use egui_kittest::Harness;
// The EXACT extent a minimised pane collapses to — facett-app's own constant, so the
// geometry assertions below are pinned to the renderer's number, never a copied 22.
use facett_app::scene::CHROME_BAR;
use holger_server_ui::app::HolgerUiApp;
use holger_server_ui::data::UiData;
use nornir_robotui::{status, Action, Assert, Observable, RobotSession, Scenario, Step};
use serde_json::{json, Value};
use server_lib::exposed::fast_routes::FastRoutes;
use server_lib::LocalHolger;
use traits::{
ArchiveInfo, ArtifactEntry, ArtifactFormat, ArtifactId, HolgerObject, RepositoryBackendTrait,
};
/// The tab leaves in strip order: `(slot id / state_json tab name, header title)`.
/// The index in this table is the scene's `active_tab` value for that pane, and the
/// pane name is `"<slot>_pane"` — the single source the scene builder uses too. Mirrors
/// the app's own `TAB_ORDER`; the shell reports the same roster under `/tabs`, and
/// `every_registered_tab_is_covered` below asserts this table has not fallen behind it.
const TABS: &[(&str, &str)] = &[
("status", "Status"),
("repos", "Repos"),
("browse", "Browse"),
("archive", "Archive"),
("artifact", "Artifact"),
("graph", "Graph"),
("helix", "Helix"),
("lineage", "Lineage"),
("tools", "Tools"),
("upload", "Upload"),
("signin", "Sign in"),
// ⚙ Settings — the preferences pane and the ONE home of holger's look & feel
// (it moved off the root chrome bar and out of the root menu-bar "Look" submenu,
// matching dwarves-demo's 🎛 `settings` tab). Appended LAST, so every tab above
// keeps the `active_tab` index this table's other cells assert on.
("settings", "Settings"),
];
/// The ROOT pane's name — the app's `SHELL_PANE`, now the DRAGGABLE horizontal
/// master/detail split (rail | workspace). Every route / layout assertion below
/// addresses the shell by this id.
const ROOT: &str = "holger-shell";
/// The registry RAIL pane — the master side, a draggable vertical split and a drawer.
const RAIL: &str = "holger-rail";
/// The rail's top half: the repository roster (the app's one selection).
const RAIL_ROSTER: &str = "rail_roster_pane";
/// The rail's bottom half: the selected repository's cross-tab context.
const RAIL_SELECTION: &str = "rail_selection_pane";
/// The tabbed WORKSPACE pane — the detail side of the master/detail split.
const WORKSPACE: &str = "holger-workspace";
// ── the tab INTERNALS: a decomposed body is a draggable split of two named halves ──
//
// The shell adopted the scene; the tab BODIES did not, so minimise / maximise / drag
// operated on a whole tab and could do nothing inside it. A body that decomposed is
// `Split(axis, ratio, DRAGGABLE)` over `<slot>_main` | `<slot>_inspector`. The helpers
// below read that roster off the SHELL'S OWN `state_json` (`/scene/split_bodies`) rather
// than hard-coding it, so a tab decomposed later is swept by every cell automatically.
/// Did `<slot>`'s tab body decompose into a draggable master/detail split?
fn is_split_body(state: &Value, slot: &str) -> bool {
let body = format!("{slot}_pane");
state["scene"]["split_bodies"]
.as_array()
.unwrap_or_else(|| panic!("the shell reports its split bodies: {}", state["scene"]))
.iter()
.any(|v| v.as_str() == Some(body.as_str()))
}
/// The scene SLOT ids a tab body renders: the two halves of a decomposed body, or the
/// bare tab slot for a body that is honestly one pane.
fn body_slots(state: &Value, slot: &str) -> Vec<String> {
if is_split_body(state, slot) {
vec![format!("{slot}-main"), format!("{slot}-inspector")]
} else {
vec![slot.to_string()]
}
}
/// The half PANE names of a decomposed tab body (empty for a single-pane body).
fn body_halves(state: &Value, slot: &str) -> Vec<String> {
if is_split_body(state, slot) {
vec![format!("{slot}_main"), format!("{slot}_inspector")]
} else {
Vec::new()
}
}
/// A present-but-empty repository backend — enough that the UI lists real repos (so
/// the scene's Repositories submenu + repo panes carry populated data, LAW 2) without
/// depending on any ecosystem's on-disk format. The surface under test is the scene.
struct StubRepo {
name: &'static str,
writable: bool,
}
impl StubRepo {
fn ro(name: &'static str) -> Self {
Self { name, writable: false }
}
fn rw(name: &'static str) -> Self {
Self { name, writable: true }
}
}
impl RepositoryBackendTrait for StubRepo {
fn name(&self) -> &str {
self.name
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Rust
}
fn is_writable(&self) -> bool {
self.writable
}
fn fetch(&self, _id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Ok(())
}
fn list(&self, _name_filter: Option<&str>, _limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
Ok(Vec::new())
}
fn archive_files(&self, _prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
fn archive_info(&self) -> anyhow::Result<ArchiveInfo> {
Ok(ArchiveInfo {
file_count: 0,
total_uncompressed_bytes: 0,
archive_path: self.name.to_string(),
})
}
fn handle_http2_request(
&self,
_method: &str,
_suburl: &str,
_body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
Ok((404, Vec::new(), Vec::new()))
}
}
/// A real holger-server-ui app over a `LocalHolger` seeded with three repositories, splash
/// skipped so the robot starts on the scene. rust-dev is writable (so the server's
/// derived profile is *dynamic* — the app starts non-static, every tab incl. Upload
/// reachable); cache + sparring are read-only.
fn seeded_app() -> HolgerUiApp {
let repos: Vec<(String, Arc<dyn RepositoryBackendTrait>)> = vec![
("rust-dev".to_string(), Arc::new(StubRepo::rw("rust-dev"))),
("cache".to_string(), Arc::new(StubRepo::ro("cache"))),
("sparring".to_string(), Arc::new(StubRepo::ro("sparring"))),
];
let holger: Arc<dyn HolgerObject> = Arc::new(LocalHolger::new(FastRoutes::new(repos)));
let data = UiData::new(holger).expect("runtime");
let mut app = HolgerUiApp::new(data);
app.skip_splash();
app
}
/// Newtype so we can implement the foreign `Observable` trait (orphan rule); a
/// one-line delegate to the app's own `state_json()`.
struct UiApp(HolgerUiApp);
impl Observable for UiApp {
fn state_json(&self) -> Value {
self.0.state_json()
}
}
/// Build a scene-driving robot over a seeded app at the given viewport size, stepped
/// one frame so the scene has rendered once (the render facts are populated).
fn robot_at(width: f32, height: f32) -> RobotSession<'static, UiApp> {
let harness = Harness::builder()
.with_size(egui::Vec2::new(width, height))
.build_ui_state(|ui, app: &mut UiApp| app.0.draw_ui(ui), UiApp(seeded_app()));
let mut robot = RobotSession::new(harness, "holger-server-ui");
robot.step();
robot
}
/// The default desktop-class scene robot (the size the existing robot cells use).
fn robot() -> RobotSession<'static, UiApp> {
robot_at(1100.0, 720.0)
}
// ── rect / divider helpers: the scene reports both, so a robot asserts GEOMETRY ──
/// A rendered pane's rect as `(x, y, w, h)`, read from the scene's own render facts.
/// Panics with the whole scene state when the pane did not render — a pane that
/// silently stops being laid out is exactly the regression these tests exist for.
fn pane_rect(state: &Value, pane: &str) -> (f32, f32, f32, f32) {
let r = &state["scene"]["render"]["panes"][pane]["rect"];
let f = |i: usize| {
r.get(i)
.and_then(Value::as_f64)
.unwrap_or_else(|| panic!("pane `{pane}` reports a rect: {}", state["scene"]["render"]))
as f32
};
(f(0), f(1), f(2), f(3))
}
/// A rendered pane's WIDTH.
fn pane_w(state: &Value, pane: &str) -> f32 {
pane_rect(state, pane).2
}
/// The centre of a split pane's first drag-handle rect, read from the render's
/// observable `separators` — the robot-addressable divider position (the same rect the
/// 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}` reports 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 live first-child ratio of a split pane (what a divider drag moves).
fn split_ratio0(state: &Value, pane: &str) -> f32 {
state["scene"]["split_ratio"][pane][0]
.as_f64()
.unwrap_or_else(|| panic!("split `{pane}` reports live ratios: {}", state["scene"]["split_ratio"]))
as f32
}
/// Drag a divider handle from `from` by `delta` points, as a REAL pointer gesture
/// (hover → press → move → release), stepping a frame at each stage so the renderer
/// sees the drag the way it sees an operator's.
fn drag_divider(robot: &mut RobotSession<'_, UiApp>, from: egui::Pos2, delta: egui::Vec2) {
let to = from + delta;
let h = robot.harness_mut();
h.event(egui::Event::PointerMoved(from));
h.drag_at(from);
h.event(egui::Event::PointerMoved(to));
// `step`, not `run`: the Graph / Helix / Lineage canvases call `request_repaint`
// every frame (they animate), so `Harness::run` — which spins until the UI stops
// asking to repaint — hits its 4-step ceiling and panics the moment one of those
// tabs is the drawn one. A drag needs exactly one frame per pointer event anyway.
h.step();
h.drop_at(to);
h.step();
robot.step();
}
/// **DRAG the master/detail divider — the rail and the workspace RECTS move.**
///
/// The shell's root is a draggable horizontal split, so it reports a real handle rect
/// under `separators`. Grab it, drag it 260pt right, and assert GEOMETRY: the rail's
/// rendered rect is ~260pt wider, the workspace's ~260pt narrower, their widths still
/// sum to what they summed to before (the split conserves area), and the live ratio
/// moved off its 0.22 seed. Then drag 420pt LEFT and assert the rail ends up NARROWER
/// than it started.
///
/// RED-when-broken in the exact way this shell was broken before: swap `Pane::split`
/// for `split_fixed` and there is no handle rect at all; keep the handle but stop
/// writing the ratio back and the rects never move.
#[test]
fn robot_scene_drag_master_detail_divider_moves_both_rects() {
// Wide enough that a 260pt drag is a small fraction of the shell.
let mut robot = robot_at(1400.0, 800.0);
let s0 = robot.state();
assert_eq!(
s0["scene"]["render"]["panes"][ROOT]["resizable"], json!(true),
"the master/detail split declares a DRAGGABLE divider: {}", s0["scene"]["render"]["panes"][ROOT]
);
// The shell's two dividers come first; everything after them is a decomposed tab
// BODY's internal divider (the roster the shell publishes itself).
let splits: Vec<String> = s0["scene"]["draggable_splits"]
.as_array()
.expect("the divider roster")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
let bodies: Vec<String> = s0["scene"]["split_bodies"]
.as_array()
.expect("the split-body roster")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
let mut expected = vec![ROOT.to_string(), RAIL.to_string()];
expected.extend(bodies.iter().cloned());
assert_eq!(
splits, expected,
"the two SHELL dividers, root-first, then one per decomposed tab body: {}",
s0["scene"]["draggable_splits"]
);
assert!(
!bodies.is_empty(),
"at least one tab BODY decomposed into its own draggable split: {}",
s0["scene"]["split_bodies"]
);
let (rail0, work0) = (pane_w(&s0, RAIL), pane_w(&s0, WORKSPACE));
let seed = split_ratio0(&s0, ROOT);
assert!((seed - 0.22).abs() < 1e-4, "the rail seeds at 0.22: {seed}");
// The rail is genuinely the narrow master column, not a hidden zero-width pane.
assert!(rail0 > 100.0 && rail0 < work0, "the rail is a real, narrower column: rail={rail0} workspace={work0}");
let span0 = rail0 + work0;
// ── drag RIGHT: the rail grows, the workspace gives up the same area ──────────
let handle = sep_center(&s0, ROOT);
drag_divider(&mut robot, handle, egui::vec2(260.0, 0.0));
let s1 = robot.state();
let (rail1, work1) = (pane_w(&s1, RAIL), pane_w(&s1, WORKSPACE));
let grew = rail1 - rail0;
let shrank = work0 - work1;
assert!(
(grew - 260.0).abs() < 24.0,
"dragging the divider 260pt right widened the rail's RECT by ~260pt: {rail0} -> {rail1} (+{grew})"
);
assert!(
(shrank - 260.0).abs() < 24.0,
"…and narrowed the workspace's RECT by the same ~260pt: {work0} -> {work1} (-{shrank})"
);
assert!(
((rail1 + work1) - span0).abs() < 8.0,
"the split conserves area: {rail0}+{work0}={span0} -> {rail1}+{work1}={}",
rail1 + work1
);
let after = split_ratio0(&s1, ROOT);
assert!(after > seed + 0.1, "the live ratio moved off its seed: {seed} -> {after}");
// ── drag LEFT past the start: the rail ends up NARROWER than it began ─────────
let handle = sep_center(&s1, ROOT);
drag_divider(&mut robot, handle, egui::vec2(-420.0, 0.0));
let s2 = robot.state();
let (rail2, work2) = (pane_w(&s2, RAIL), pane_w(&s2, WORKSPACE));
assert!(rail2 < rail0, "dragging back left shrank the rail below its start: {rail0} -> {rail2}");
assert!(work2 > work0, "…and the workspace reclaimed the area: {work0} -> {work2}");
assert!(
split_ratio0(&s2, ROOT) < seed,
"the live ratio is below its seed after the reverse drag: {}",
split_ratio0(&s2, ROOT)
);
}
/// **DRAG the rail's own (vertical) divider — the roster and selection RECTS move.**
///
/// The rail is a second draggable split, along the other axis, so this proves the
/// affordance is not a one-off on the root: grab the rail's handle, drag it 120pt DOWN,
/// and assert the roster pane's rendered rect got ~120pt taller while the selection
/// pane's got ~120pt shorter.
///
/// The rail's panes are foldable, so their reported rect is the header/chrome row —
/// which is exactly why this asserts the pane rects of the SPLIT CHILDREN through the
/// split's own live ratios as well: the two together pin both the geometry and the
/// state the geometry is derived from.
#[test]
fn robot_scene_drag_rail_divider_moves_the_rail_panes() {
let mut robot = robot_at(1400.0, 800.0);
let s0 = robot.state();
assert_eq!(
s0["scene"]["render"]["panes"][RAIL]["resizable"], json!(true),
"the rail declares a DRAGGABLE divider too: {}", s0["scene"]["render"]["panes"][RAIL]
);
let seed = split_ratio0(&s0, RAIL);
assert!((seed - 0.60).abs() < 1e-4, "the roster seeds at 0.60 of the rail height: {seed}");
// The rail's two panes both render, stacked (the selection sits BELOW the roster).
let (_, roster_y0, _, _) = pane_rect(&s0, RAIL_ROSTER);
let (_, sel_y0, _, _) = pane_rect(&s0, RAIL_SELECTION);
assert!(
sel_y0 > roster_y0,
"the selection pane is stacked below the roster: roster_y={roster_y0} selection_y={sel_y0}"
);
let handle = sep_center(&s0, RAIL);
drag_divider(&mut robot, handle, egui::vec2(0.0, 120.0));
let s1 = robot.state();
let after = split_ratio0(&s1, RAIL);
assert!(after > seed + 0.05, "dragging the rail divider down grew the roster's share: {seed} -> {after}");
// The GEOMETRY followed: the selection pane's top edge moved down with the divider.
let (_, sel_y1, _, _) = pane_rect(&s1, RAIL_SELECTION);
let moved = sel_y1 - sel_y0;
assert!(
(moved - 120.0).abs() < 24.0,
"the selection pane's rect moved down by ~120pt with the divider: {sel_y0} -> {sel_y1} (+{moved})"
);
// …and back up again: the divider is not one-way.
let handle = sep_center(&s1, RAIL);
drag_divider(&mut robot, handle, egui::vec2(0.0, -200.0));
let s2 = robot.state();
assert!(
split_ratio0(&s2, RAIL) < seed,
"dragging back up shrank the roster below its seed: {} < {seed}",
split_ratio0(&s2, RAIL)
);
let (_, sel_y2, _, _) = pane_rect(&s2, RAIL_SELECTION);
assert!(sel_y2 < sel_y0, "the selection pane's rect moved back up: {sel_y0} -> {sel_y2}");
}
/// A rendered pane's **allotted** rect `(x, y, w, h)` — the area its parent handed it
/// this frame. This is the geometry oracle for chrome: a minimised child is allotted
/// exactly `CHROME_BAR` points along the split axis, and a maximised pane's siblings are
/// allotted a ZERO rect. `rect` (what egui then consumed) can lag by a frame or a margin;
/// `alloc` is the apportionment itself.
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 WIDTH.
fn alloc_w(state: &Value, pane: &str) -> f32 {
pane_alloc(state, pane).2
}
/// A rendered pane's allotted AREA (`w * h`) — 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
}
/// **WINDOW CHROME: minimise · maximise · restore, asserted as RECTS.**
///
/// Every pane but the root draws a chrome title bar (facett-app's `Chrome::Auto`), and
/// each control carries a unique AccessKit label (`"minimize <pane>"`) — so this drives
/// the affordance the way an operator does, by CLICKING the button, and then reads the
/// resulting geometry rather than a flag:
///
/// * **minimise the rail** → the rail collapses to its `CHROME_BAR`-wide title strip and
/// the workspace GROWS by the width the rail gave up (space is handed back, not lost);
/// * **restore** → both rects return to EXACTLY what they were (the split ratios are
/// never overwritten by chrome, so restore is lossless);
/// * **maximise the workspace** → the rail is allotted ZERO area and the workspace fills
/// the shell; **restore** puts the master/detail layout back byte-for-byte.
///
/// RED-when-broken in the way that matters: a chrome bar that draws but doesn't
/// re-apportion, or a restore that "resets" to the seed instead of the prior layout,
/// both pass a boolean check and fail this one.
#[test]
fn robot_scene_chrome_minimise_maximise_restore_move_the_rects() {
let mut robot = robot_at(1400.0, 800.0);
// ── the affordance roster: every pane but the root wears chrome ──────────────
let s0 = robot.state();
let chrome: Vec<String> = s0["scene"]["chrome"]
.as_array()
.expect("the shell reports which panes draw a chrome title bar")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
assert!(!chrome.contains(&ROOT.to_string()), "the scene ROOT is bare (nothing to minimise into): {chrome:?}");
for pane in [RAIL, RAIL_ROSTER, RAIL_SELECTION, WORKSPACE, "status_pane", "signin_pane"] {
assert!(chrome.contains(&pane.to_string()), "`{pane}` draws a chrome title bar: {chrome:?}");
}
// Every pane in the tree except the root — counted off the tree, not a literal.
let pane_count = s0["scene"]["pane_names"].as_array().expect("pane tree").len();
assert_eq!(chrome.len(), pane_count - 1, "chrome on every pane but the root: {chrome:?}");
// The rail's bar really drew the three controls this shell drives. Asserted per
// FIELD, not against a whole object literal: facett-app grows the spec (it just
// gained `editor`), and a shell that only drives fold/minimise/maximise must not go
// red because a field it does not use appeared beside them.
let spec = s0["scene"]["render"]["panes"][RAIL]["chrome"]["spec"].clone();
for control in ["bar", "fold", "minimize", "maximize"] {
assert_eq!(
spec[control], json!(true),
"the rail's title bar carries `{control}`: {}", s0["scene"]["render"]["panes"][RAIL]["chrome"]
);
}
let (rail0, work0) = (alloc_w(&s0, RAIL), alloc_w(&s0, WORKSPACE));
let span0 = rail0 + work0;
assert!(rail0 > 100.0, "the rail starts as a real column: {rail0}");
// ── MINIMISE the rail, by clicking its chrome control ────────────────────────
robot.click_label(&format!("minimize {RAIL}")).expect("the rail's chrome minimise control");
robot.step();
let s1 = robot.state();
assert_eq!(s1["scene"]["minimized"][RAIL], json!(true), "the rail is minimised: {}", s1["scene"]["minimized"]);
assert_eq!(
s1["scene"]["last_chrome"], json!(format!("minimize:{RAIL}")),
"the click landed on the rail's minimise control: {}", s1["scene"]["last_chrome"]
);
let (rail1, work1) = (alloc_w(&s1, RAIL), alloc_w(&s1, WORKSPACE));
assert!(
(rail1 - CHROME_BAR).abs() < 2.0,
"a minimised rail collapses to its {CHROME_BAR}pt title strip, got {rail1} (was {rail0})"
);
assert!(
(work1 - (span0 - CHROME_BAR)).abs() < 8.0,
"…and the workspace is handed the space back: {work0} -> {work1} (of {span0})"
);
// ── RESTORE: the layout returns EXACTLY, not to some reset default ───────────
robot.click_label(&format!("restore {RAIL}")).expect("the minimised rail's restore control");
robot.step();
let s2 = robot.state();
assert_eq!(s2["scene"]["minimized"][RAIL], json!(false), "the rail is restored: {}", s2["scene"]["minimized"]);
let (rail2, work2) = (alloc_w(&s2, RAIL), alloc_w(&s2, WORKSPACE));
assert!(
(rail2 - rail0).abs() < 1.0 && (work2 - work0).abs() < 1.0,
"restore returns the EXACT prior geometry: rail {rail0} -> {rail2}, workspace {work0} -> {work2}"
);
// ── MAXIMISE the workspace: the rail is allotted nothing at all ──────────────
robot.click_label(&format!("maximize {WORKSPACE}")).expect("the workspace's chrome maximise control");
robot.step();
let s3 = robot.state();
assert_eq!(s3["scene"]["maximized"], json!(WORKSPACE), "the workspace holds the maximise: {}", s3["scene"]["maximized"]);
assert_eq!(
alloc_area(&s3, RAIL), 0.0,
"a maximise allots the rail ZERO area: {}", s3["scene"]["render"]["panes"][RAIL]["alloc"]
);
let work3 = alloc_w(&s3, WORKSPACE);
assert!(
work3 > span0 - 8.0,
"the maximised workspace fills the shell: {work3} (the whole split was {span0})"
);
// ── RESTORE the maximise: the master/detail layout comes back unchanged ──────
robot.click_label(&format!("restore {WORKSPACE}")).expect("the maximised workspace's restore control");
robot.step();
let s4 = robot.state();
assert_eq!(s4["scene"]["maximized"], Value::Null, "the maximise is dropped: {}", s4["scene"]["maximized"]);
let (rail4, work4) = (alloc_w(&s4, RAIL), alloc_w(&s4, WORKSPACE));
assert!(
(rail4 - rail0).abs() < 1.0 && (work4 - work0).abs() < 1.0,
"restoring the maximise returns the EXACT master/detail layout: rail {rail0} -> {rail4}, workspace {work0} -> {work4}"
);
}
/// **Every chromed pane can be minimised, and minimising HIDES its body.**
///
/// Sweeps the whole chrome roster — the rail, its two panes, the workspace and every tab
/// leaf — over the headless drive point (the same state the bar's control mutates),
/// asserting for each: it starts un-minimised; minimising flips the state AND drops the
/// pane's slot content out of the render tree (the body is genuinely not laid out, so it
/// leaves the AccessKit tree too); restoring brings it back. One pane's chrome working is
/// a demo; every pane's chrome working is the feature.
#[test]
fn robot_scene_every_chromed_pane_minimises_and_restores() {
let mut robot = robot_at(1400.0, 800.0);
let chrome: Vec<String> = robot.state()["scene"]["chrome"]
.as_array()
.expect("the chrome roster")
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect();
assert!(chrome.len() >= TABS.len() + 4, "the roster covers the rail, its panes, the workspace and every tab: {chrome:?}");
// The slot a chromed pane hosts, when it is a leaf that owns one — the rail's two
// panes, plus one per registered tab (read off TABS, so a new tab is swept too).
// A `Tabs` layout only draws its ACTIVE child, so a tab leaf is made active first;
// otherwise it is simply not in this frame's render tree and there is no geometry to
// assert. The structural panes (the rail, the workspace) are always drawn.
//
// A tab BODY that decomposed hosts no slot of its own — its two HALVES do — so the
// resolver returns both: the tab to activate first, and every scene slot whose
// presence in `render.slots` proves the pane's body is (or is not) in the tree.
let s_roster = robot.state();
let resolve = |pane: &str| -> (Option<String>, Vec<String>) {
match pane {
RAIL_ROSTER => (None, vec!["rail-roster".to_string()]),
RAIL_SELECTION => (None, vec!["rail-selection".to_string()]),
_ => {
for (slot, _) in TABS {
if pane == format!("{slot}_pane") {
return (Some((*slot).to_string()), body_slots(&s_roster, slot));
}
if pane == format!("{slot}_main") {
return (Some((*slot).to_string()), vec![format!("{slot}-main")]);
}
if pane == format!("{slot}_inspector") {
return (Some((*slot).to_string()), vec![format!("{slot}-inspector")]);
}
}
(None, Vec::new())
}
}
};
for pane in &chrome {
// Make the owning tab the drawn tab, so this pane's chrome is on screen. (A
// `Tabs` layout only lays out its ACTIVE child; an inactive tab's body — and both
// halves of a decomposed one — is simply not in this frame's render tree.)
let (tab, slots) = resolve(pane);
if let Some(slot) = &tab {
robot.app_mut().0.run_command(&format!("goto:{slot}"));
robot.step();
robot.step();
}
assert_eq!(
robot.state()["scene"]["minimized"][pane], json!(false),
"`{pane}` starts un-minimised"
);
let minimized = robot.app_mut().0.toggle_scene_minimize(pane);
assert!(minimized, "`{pane}` minimised");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["minimized"][pane], json!(true), "`{pane}` is minimised in state_json: {}", s["scene"]["minimized"]);
assert_eq!(
s["scene"]["render"]["panes"][pane]["minimized"], json!(true),
"the RENDER agrees `{pane}` is minimised: {}", s["scene"]["render"]["panes"][pane]
);
for slot in &slots {
assert!(
s["scene"]["render"]["slots"].get(slot).is_none(),
"minimising `{pane}` drops its `{slot}` body out of the render tree: {}", s["scene"]["render"]["slots"]
);
}
assert!(robot.app_mut().0.restore_scene_pane(pane), "`{pane}` restored");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["minimized"][pane], json!(false), "`{pane}` is restored: {}", s["scene"]["minimized"]);
for slot in &slots {
assert!(
s["scene"]["render"]["slots"].get(slot).is_some(),
"restoring `{pane}` brings its `{slot}` body back: {}", s["scene"]["render"]["slots"]
);
}
}
// `restore_scene_layout` is the "reset layout" one-liner: minimise three panes and a
// maximise, then clear the lot in one call.
robot.app_mut().0.toggle_scene_minimize(RAIL_ROSTER);
robot.app_mut().0.toggle_scene_minimize("status_pane");
robot.app_mut().0.toggle_scene_maximize(WORKSPACE);
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["maximized"], json!(WORKSPACE), "a maximise is in force: {}", s["scene"]["maximized"]);
assert!(robot.app_mut().0.restore_scene_layout(), "reset layout changed something");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["maximized"], Value::Null, "reset dropped the maximise: {}", s["scene"]["maximized"]);
let any_min = s["scene"]["minimized"].as_object().expect("the minimise roster").values().any(|v| v == &json!(true));
assert!(!any_min, "reset cleared every minimise: {}", s["scene"]["minimized"]);
}
/// **The registry rail IS the app's master selection.** Clicking a repository row in the
/// rail drives the SAME `repos.selected` the Repos tab's combo box and the `repo:<name>`
/// menu command drive — so the detail tabs (Browse / Archive / Artifact / Upload /
/// Lineage) follow the rail. The rail's selection pane then reports that repository's
/// cross-tab context. RED-when-broken if the rail ever grows a selection of its own.
#[test]
fn robot_scene_rail_drives_the_one_master_selection() {
let mut robot = robot_at(1400.0, 800.0);
// The roster shows the whole seeded roster and nothing is selected yet.
let s = robot.state();
assert_eq!(
s["scene"]["render"]["slots"]["rail-roster"]["total"], json!(3),
"the rail lists every reported repository: {}", s["scene"]["render"]["slots"]["rail-roster"]
);
assert_eq!(
s["scene"]["render"]["slots"]["rail-roster"]["shown"], json!(3),
"…all of them, unfiltered: {}", s["scene"]["render"]["slots"]["rail-roster"]
);
// Click the read-only `cache` row (sorted index 0 — the roster is alphabetical:
// cache · rust-dev · sparring). The rail's row labels are prefixed (`▪ 🔒 …`)
// precisely so they cannot shadow the bare repo names the Repos combo popup and the
// Repositories menu render.
robot.click_label("▪ 🔒 cache").expect("the rail lists `cache` as a clickable row");
robot.step();
let s = robot.state();
assert_eq!(s["repos"]["selected"], json!(0), "the rail row drove the ONE app selection: {s}");
assert_eq!(
s["scene"]["render"]["slots"]["rail-selection"]["selected"], json!("cache"),
"the rail's selection pane reports the picked repository: {}", s["scene"]["render"]["slots"]["rail-selection"]
);
assert_eq!(
s["scene"]["render"]["slots"]["rail-selection"]["upload_enabled"], json!(false),
"…and that a read-only repo leaves Upload locked: {}", s["scene"]["render"]["slots"]["rail-selection"]
);
// Pick the writable one: the same one selection moves, and Upload unlocks.
robot.click_label("▪ ✎ rust-dev").expect("the rail lists `rust-dev` as a clickable row");
robot.step();
let s = robot.state();
assert_eq!(s["repos"]["selected"], json!(1), "the rail moved the selection to rust-dev: {s}");
assert_eq!(
s["scene"]["render"]["slots"]["rail-selection"]["upload_enabled"], json!(true),
"a writable selection unlocks Upload: {}", s["scene"]["render"]["slots"]["rail-selection"]
);
}
/// A collision-safe visiting order for the scene-tab scenario: `(slot, title, index)`.
/// A few pane bodies render a widget whose label equals a *tab title* — the Repos pane
/// has "Browse"/"Archive" row-action buttons and the Tools pane a "Tools" heading — so
/// a header click made while such a pane is active would match two nodes. This order
/// never transitions FROM the Repos pane straight TO the Browse/Archive headers, so
/// every source pane is free of the next target's title (the header is then the sole
/// match). The `index` is the pane's true `active_tab` value (its slot order).
const VISIT: &[(&str, &str, usize)] = &[
("status", "Status", 0),
("repos", "Repos", 1),
("graph", "Graph", 5),
("browse", "Browse", 2),
("archive", "Archive", 3),
("artifact", "Artifact", 4),
("helix", "Helix", 6),
("lineage", "Lineage", 7),
("tools", "Tools", 8),
("upload", "Upload", 9),
// ⚙ Settings — the preferences pane (the look & feel's home). Visited before
// Sign-in: its body carries only the Appearance block's preset labels, none of
// which is a tab title, so it is a safe source for the next header click.
("settings", "Settings", 11),
// Visited last: the Sign-in pane's own body carries a "Sign in" button that would
// shadow this header, so nothing is clicked while that pane is the active source.
("signin", "Sign in", 10),
];
/// Click a scene `TabView` header UNAMBIGUOUSLY: first reset to the neutral Status pane
/// (whose body carries none of the tab-title labels) via the `goto:status` command, so
/// the ONLY node labelled `title` is the header — the Repos pane's "Browse"/"Archive"
/// buttons and the Tools heading would otherwise collide with those headers. Leaves the
/// newly-selected tab's slot drawn (an extra frame after the header click).
fn click_tab_header(robot: &mut RobotSession<'_, UiApp>, title: &str) {
robot.app_mut().0.run_command("goto:status");
robot.step();
robot.click_label(title).unwrap_or_else(|e| panic!("scene tab header {title:?}: {e}"));
robot.step(); // second frame: the newly-active tab's slot renders
}
/// **This suite covers EVERY registered tab.** The shell reports its own tab roster
/// under `/tabs` (the app's `TAB_ORDER`); this asserts the local [`TABS`] and [`VISIT`]
/// tables list exactly that set, in that order. RED-when-broken the moment a tab is
/// added to the shell without being covered here — which is what stops this suite from
/// silently going green over a pane it never visits.
#[test]
fn every_registered_tab_is_covered() {
let robot = robot();
let s = robot.state();
let roster: Vec<String> = s["tabs"]
.as_array()
.expect("the shell reports its tab roster under /tabs")
.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect();
let covered: Vec<String> = TABS.iter().map(|(slot, _)| slot.to_string()).collect();
assert_eq!(roster, covered, "TABS covers every registered shell tab, in strip order");
// VISIT is a re-ordering of the same set, and each entry carries its true index.
let mut visit: Vec<(usize, String)> =
VISIT.iter().map(|(slot, _, i)| (*i, slot.to_string())).collect();
visit.sort();
let expected: Vec<(usize, String)> =
roster.iter().enumerate().map(|(i, s)| (i, s.clone())).collect();
assert_eq!(visit, expected, "VISIT is the same tab set with true active_tab indices");
}
/// **Every TabView pane is reachable via the scene's own header strip.** Clicking each
/// of the scene `TabView` headers switches `active_tab` (a matrix row per tab),
/// and — once the newly-active tab's slot draws — its `state_json()` appears in the
/// rendered scene tree (`/scene/render/slots/<slot>`) while the root route stays the
/// single shell pane. RED-when-broken if a tab leaf, the header strip, or a slot
/// host ever drops.
#[test]
fn robot_scene_tabview_visits_every_pane() {
let mut robot = robot();
// One matrix row per tab: click the scene header → the scene's active_tab tracks
// it. Driven in a collision-safe order (see VISIT) so each header is the sole node
// carrying its label at click time.
let mut sc = Scenario::new("holger-server-ui-scene-tabs");
for &(slot, title, index) in VISIT.iter() {
sc = sc.step(
Step::new(format!("scene tab '{title}' ({slot}) → active_tab == {index}"))
.action(Action::ClickLabel(title.into()))
.assert(Assert::State { pointer: "/scene/active_tab".into(), expected: json!(index) }),
);
}
let rows = robot.run_scenario(&sc);
assert_eq!(rows.len(), VISIT.len(), "one matrix row per tab pane");
for r in &rows {
assert_eq!(r.status, status::PASS, "tab step {:?}: {}", r.test_name, r.message);
}
// The pane's slot state actually appears in the render tree: reach each pane by a
// header click (from the neutral Status pane, so the click is unambiguous), then
// assert the route/active_tab reflect it AND the slot's own view-model state is
// present in `render.slots`.
for (i, &(slot, title)) in TABS.iter().enumerate() {
click_tab_header(&mut robot, title);
let s = robot.state();
assert_eq!(s["tab"], json!(slot), "the scene strip drove the tab to {slot}: {s}");
assert_eq!(s["scene"]["active_tab"], json!(i), "active_tab tracks the {slot} leaf: {s}");
assert_eq!(
s["scene"]["render"]["route"], json!(ROOT),
"the root route stays the shell pane: {s}"
);
// Every slot the body hosts is in the render tree — BOTH halves for a decomposed
// body, so "the inspector silently stopped drawing" is a red cell.
for id in body_slots(&s, slot) {
assert!(
s["scene"]["render"]["slots"].get(&id).map(|v| !v.is_null()).unwrap_or(false),
"the {slot} body's `{id}` slot state appears in the rendered scene: {s}"
);
}
}
}
/// **Fold / unfold every foldable pane.** For every tab body: make it the
/// active (drawn) pane, assert it starts unfolded with its slot in the tree, FOLD it
/// (the `folded` map flips true AND the pane's slot content leaves `render.slots` — a
/// folded `CollapsingHeader` omits its body from the layout), then UNFOLD (the slot
/// content returns). RED-when-broken if a leaf loses its fold or the fold no longer
/// prunes the content.
#[test]
fn robot_scene_folds_and_unfolds_every_pane() {
let mut robot = robot();
for &(slot, _title) in TABS.iter() {
let pane = format!("{slot}_pane");
let p = pane.as_str();
// Make this pane the active (drawn) tab and let its slot render.
robot.app_mut().0.run_command(&format!("goto:{slot}"));
robot.step();
robot.step();
let s = robot.state();
let hosted = body_slots(&s, slot);
assert_eq!(s["scene"]["folded"][p], json!(false), "{p} starts unfolded: {s}");
for id in &hosted {
assert!(
s["scene"]["render"]["slots"].get(id).is_some(),
"the `{id}` slot renders while {p} is unfolded: {s}"
);
}
// Fold → the tab body's slot content leaves the render tree.
let folded = robot.app_mut().0.toggle_scene_fold(p);
assert!(folded, "toggle_scene_fold({p}) folded it");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["folded"][p], json!(true), "{p} is folded in state_json: {s}");
assert_eq!(
s["scene"]["render"]["panes"][p]["folded"], json!(true),
"the rendered {p} reports folded: {s}"
);
for id in &hosted {
assert!(
s["scene"]["render"]["slots"].get(id).is_none(),
"folding {p} removes the `{id}` slot content from the tree: {s}"
);
}
// Unfold → the slot content returns.
let folded = robot.app_mut().0.toggle_scene_fold(p);
assert!(!folded, "toggle again unfolded {p}");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["folded"][p], json!(false), "{p} unfolded again: {s}");
for id in &hosted {
assert!(
s["scene"]["render"]["slots"].get(id).is_some(),
"unfolding {p} restores the `{id}` slot content: {s}"
);
}
// …and the HALVES of a decomposed body fold independently INSIDE the tab — the
// whole point of the decomposition. Folding the inspector leaves the master's
// slot in the tree; folding the master leaves the inspector's.
let halves = body_halves(&s, slot);
for half in &halves {
let mine = format!("{}-{}", slot, half.rsplit('_').next().unwrap_or_default());
assert!(robot.app_mut().0.toggle_scene_fold(half), "{half} folded");
robot.step();
let s = robot.state();
assert!(
s["scene"]["render"]["slots"].get(&mine).is_none(),
"folding `{half}` drops its own `{mine}` slot: {s}"
);
for other in hosted.iter().filter(|id| **id != mine) {
assert!(
s["scene"]["render"]["slots"].get(other).is_some(),
"folding `{half}` leaves its SIBLING `{other}` drawing — the fold is \
INSIDE the tab, not over it: {s}"
);
}
assert!(!robot.app_mut().0.toggle_scene_fold(half), "{half} unfolded again");
robot.step();
}
}
}
/// **The grouped menubar routes real commands.** Each scene menu group is a live
/// `facett_menu` dropdown: open the top-level menu button, then click a leaf whose
/// `Action::Command(<id>)` routes into the ONE shared `dispatch_command` — asserting
/// the effect in `state_json`. Covers View (`goto:`), Operate (`tool:`), Repositories
/// (`repo:`) and Action (`action:`) — four of the five groups, each a matrix row.
/// RED-when-broken if a group, a wired command, or the shared dispatcher drops.
/// (Help·About is its own cell below.)
///
/// There used to be a fifth row here: a top-level **Look** menu listing every theme
/// preset. Appearance is a preference, not root navigation, so it moved into the ⚙
/// **Settings** pane (matching dwarves-demo's 🎛 `settings` tab). The coverage moved
/// with it rather than being deleted: the scenario now navigates **View ▸ Settings**
/// and clicks the preset INSIDE the pane, so this cell still proves the look control
/// is reachable from the root and still re-themes — just from where it now lives.
#[test]
fn robot_scene_menubar_drives_nav_commands() {
let mut robot = robot();
let sc = Scenario::new("holger-server-ui-scene-menubar")
// View → Helix (goto:helix). The View items carry a Ctrl+n shortcut hint.
.step(Step::new("open View menu").action(Action::ClickLabel("View".into())))
.step(
Step::new("View · Helix → active_tab 6 (goto:helix)")
.action(Action::ClickLabel("Helix Ctrl+7".into()))
.assert(Assert::State { pointer: "/scene/active_tab".into(), expected: json!(6) }),
)
// Operate → Security scan (tool:security).
.step(Step::new("open Operate menu").action(Action::ClickLabel("Operate".into())))
.step(
Step::new("Operate · Security → tool security (tool:security)")
.action(Action::ClickLabel("Security scan (Móðguðr)".into()))
.assert(Assert::State { pointer: "/tool".into(), expected: json!("security") }),
)
// Repositories → Open · sparring (repo:sparring → select the row + Browse).
.step(Step::new("open Repositories menu").action(Action::ClickLabel("Repositories".into())))
.step(
Step::new("Repositories · sparring → Browse (repo:sparring)")
.action(Action::ClickLabel("Open · sparring".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("browse") }),
)
// Tab strip → Settings — the look & feel's new home, one click from the root.
// (Clicked on the STRIP rather than through View ▸ Settings because the View
// leaf and the strip button carry the same plain label "Settings": Settings is
// the twelfth tab, past `view_accelerator`'s ten digits, so its menu entry gets
// no `Ctrl+n` suffix to disambiguate it by. That the View menu DOES carry a
// working `goto:settings` leaf is asserted in
// `app::scene::tests::menubar_groups_route_existing_commands`.)
.step(
Step::new("tab strip · Settings → the ⚙ Settings pane")
.action(Action::ClickLabel("Settings".into()))
.assert(Assert::State { pointer: "/tab".into(), expected: json!("settings") }),
)
// …and the preset picked INSIDE that pane re-themes the app (what the retired
// root "Look ▸ Look: device" menu leaf used to do).
.step(
Step::new("⚙ Settings · Appearance · device → look device")
.action(Action::ClickLabel("device".into()))
.assert(Assert::State { pointer: "/look".into(), expected: json!("device") }),
)
// Action → Toggle static (action:toggle-static). LAST: it re-themes the shell.
.step(Step::new("open Action menu").action(Action::ClickLabel("Action".into())))
.step(
Step::new("Action · Toggle static → static_mode true (action:toggle-static)")
.action(Action::ClickLabel("Toggle static (Skade Vinter)".into()))
.assert(Assert::State { pointer: "/static_mode".into(), expected: json!(true) }),
);
let rows = robot.run_scenario(&sc);
assert_eq!(rows.len(), 10, "one matrix row per menubar step");
for r in &rows {
assert_eq!(r.status, status::PASS, "menubar step {:?}: {}", r.test_name, r.message);
}
// Effects propagated through the shared dispatcher: repo:sparring selected the
// sparring row (sorted index 2), and the Action·toggle engaged the winter look
// (which overrode the earlier Settings·device pick — proof both routes fired).
let s = robot.state();
assert_eq!(s["repos"]["selected"], json!(2), "repo:sparring selected the sparring row: {s}");
assert_eq!(
s["look"], json!("skade_vinter"),
"static overrides the look after Action·toggle (both routes fired): {s}"
);
}
/// **Help · About** is the sixth menu group: open the Help dropdown and pick "About
/// holger" (`about:open`) → the About surface opens and reports holger's identity.
/// Driven as a real menu click; dismissed via the app hook (NOT the positional Close,
/// which the known pre-existing LFS-logo flake targets). RED-when-broken if the Help
/// group or the `about:open` route drops.
#[test]
fn robot_scene_menubar_help_opens_about() {
let mut robot = robot();
robot.click_label("Help").expect("Help menu button");
robot.click_label("About holger").expect("Help · About holger menu item");
let s = robot.state();
assert_eq!(s["about"]["open"], json!(true), "Help·About routed about:open → About opened: {s}");
assert_eq!(s["about"]["wordmark"], json!("holger"), "About reports holger's identity: {s}");
// Dismiss via the app hook (avoids the pre-existing off-viewport Close flake).
robot.app_mut().0.toggle_about();
robot.step();
assert_eq!(robot.state()["about"]["open"], json!(false), "About dismissed");
}
/// **Deep-link a pane BY NAME.** holger's shell is name-addressable: a `goto:<pane>`
/// command deep-links straight to that tab by name (the exact id the View menu and the
/// Ctrl-K palette route into the one dispatcher). Each jump moves `active_tab` to that
/// pane while the root route stays the single shell pane (holger navigates by
/// tab, not by a URL route). RED-when-broken if name-addressed navigation breaks.
#[test]
fn robot_scene_deep_link_navigates_pane_by_name() {
let mut robot = robot();
for (i, &(slot, _title)) in TABS.iter().enumerate() {
robot.app_mut().0.run_command(&format!("goto:{slot}"));
robot.step();
robot.step();
let s = robot.state();
assert_eq!(s["tab"], json!(slot), "deep-link goto:{slot} navigated by name: {s}");
assert_eq!(s["scene"]["active_tab"], json!(i), "active_tab tracks the deep-linked {slot}: {s}");
assert_eq!(
s["scene"]["render"]["route"], json!(ROOT),
"the root route stays the shell pane through deep-links: {s}"
);
}
// The last deep-link is recorded on the shared command interpreter (proof it went
// through the ONE dispatcher the menu + palette also feed). Derived from TABS rather
// than hardcoded, so appending a tab does not silently strand this assertion.
let (last_slot, _) = TABS.last().expect("the shell has at least one tab");
assert_eq!(
robot.state()["palette"]["invoked"], json!(format!("goto:{last_slot}")),
"the last deep-link ({last_slot}) was recorded on the command interpreter"
);
}
/// **Responsive: the master/detail split becomes a phone shell.** Below the 760pt
/// breakpoint the render classifies the device `phone`, the root SPLIT collapses to a
/// `Tabs` layout on the WORKSPACE child (a narrow screen gets the whole detail surface,
/// not a 22% sliver of each side), the rail DRAWER auto-folds, and tab-switching via the
/// workspace header strip + folding still work. A wide viewport classifies `desktop` and
/// lays the root out as a real `split` with the rail open — the contrast is the point.
/// RED-when-broken if the breakpoint, the phone layout fork, or the drawer regresses.
#[test]
fn robot_scene_responsive_phone_collapses_split_and_shuts_the_drawer() {
let mut robot = robot_at(420.0, 720.0);
let s = robot.state();
assert_eq!(s["scene"]["render"]["device"], json!("phone"), "narrow viewport is phone-class: {s}");
assert_eq!(
s["scene"]["render"]["panes"][ROOT]["layout"], json!("tabs"),
"the root split collapses to Tabs at phone width: {s}"
);
assert_eq!(
s["scene"]["folded"][RAIL], json!(true),
"the rail DRAWER auto-folds crossing into phone-class: {s}"
);
assert_eq!(
s["scene"]["drawers"], json!([RAIL]),
"the rail is the shell's one declared drawer: {}", s["scene"]["drawers"]
);
// Tab-switching via the scene TabView strip still works narrow (an early header).
robot.click_label("Repos").expect("scene tab header 'Repos' clickable at phone width");
robot.step();
assert_eq!(
robot.state()["scene"]["active_tab"], json!(1),
"the scene tab strip still switches tabs at phone width"
);
// Folding still works narrow: fold the (active) repos pane → its slot leaves the tree.
let folded = robot.app_mut().0.toggle_scene_fold("repos_pane");
assert!(folded, "repos_pane folded at phone width");
robot.step();
let s = robot.state();
assert_eq!(s["scene"]["folded"]["repos_pane"], json!(true), "fold observable at phone width: {s}");
assert!(
s["scene"]["render"]["slots"].get("repos").is_none(),
"the folded pane's content leaves the tree at phone width: {s}"
);
// ── Contrast: a wide viewport is desktop-class and lays the root out as a REAL
// draggable split, with the rail open and holding real width beside the workspace.
let wide = robot_at(1400.0, 800.0);
let w = wide.state();
assert_eq!(w["scene"]["render"]["device"], json!("desktop"), "a wide viewport is desktop-class: {w}");
assert_eq!(
w["scene"]["render"]["panes"][ROOT]["layout"], json!("split"),
"the root is a split at desktop width: {}", w["scene"]["render"]["panes"][ROOT]
);
assert_eq!(w["scene"]["folded"][RAIL], json!(false), "the drawer rail is open on the desktop: {w}");
let (rail_w, work_w) = (pane_w(&w, RAIL), pane_w(&w, WORKSPACE));
assert!(
rail_w > 100.0 && work_w > rail_w * 2.0,
"the rail holds a real master column beside a wider workspace: rail={rail_w} workspace={work_w}"
);
}
// ── the tab INTERNALS, proved with RECTS ────────────────────────────────────────────
/// Every tab body that decomposed, read off the shell's own roster.
fn split_body_panes(state: &Value) -> Vec<String> {
state["scene"]["split_bodies"]
.as_array()
.unwrap_or_else(|| panic!("the shell reports its split bodies: {}", state["scene"]))
.iter()
.map(|v| v.as_str().unwrap_or_default().to_string())
.collect()
}
/// The extent of a pane's ALLOTTED rect along a split axis (`true` = x / width).
fn alloc_extent(state: &Value, pane: &str, along_x: bool) -> f32 {
let (_, _, w, h) = pane_alloc(state, pane);
if along_x {
w
} else {
h
}
}
/// **THE tab-internals cell: every decomposed body's divider DRAGS, and its halves
/// MAXIMISE / MINIMISE / RESTORE — all asserted as RECTS.**
///
/// This is the regression the whole decomposition exists to kill. The shell adopted
/// `facett_app::scene`, but every tab BODY stayed one imperative egui flow in a single
/// slot pane — so minimise / maximise / drag operated on the whole tab and could do
/// NOTHING inside it. A stats readout that grew simply pushed the canvas off the bottom
/// of the screen with no divider to correct it.
///
/// For EVERY body the shell reports under `/scene/split_bodies` (so a tab decomposed
/// later is swept without touching this test):
///
/// 1. **Drag** the body's own divider one way → the master's allotted extent grows by
/// ~the drag, the inspector's shrinks by the same, **their sum is conserved**; drag
/// back → the master ends up smaller than it began. Two halves moving OPPOSITELY with
/// a conserved sum is the thing a boolean `resizable: true` cannot prove.
/// 2. **Maximise** the master → the inspector's allotted **area is exactly 0**; the
/// master fills the body.
/// 3. **Restore** → both halves return to EXACTLY the rects they held before (chrome
/// never writes the split ratios, so restore is lossless, not a reset-to-seed).
/// 4. **Minimise** the inspector → it collapses to exactly `CHROME_BAR` along the split
/// axis (its chrome title bar, nothing else) and the master takes the space back.
#[test]
fn robot_scene_every_decomposed_tab_body_drags_maximises_and_restores_by_rect() {
let mut robot = robot_at(1500.0, 900.0);
let bodies = split_body_panes(&robot.state());
assert!(
!bodies.is_empty(),
"at least one tab body decomposed into a draggable master/detail split"
);
for body in &bodies {
let slot = body.trim_end_matches("_pane").to_string();
let main = format!("{slot}_main");
let insp = format!("{slot}_inspector");
// Make this the drawn tab — a `Tabs` layout only lays out its ACTIVE child.
robot.app_mut().0.run_command(&format!("goto:{slot}"));
robot.step();
robot.step();
let s0 = robot.state();
assert_eq!(
s0["scene"]["render"]["panes"][body]["resizable"], json!(true),
"`{body}` declares a DRAGGABLE divider INSIDE the tab: {}",
s0["scene"]["render"]["panes"][body]
);
// Which way does this body divide? The handle rect says so without guessing: a
// HORIZONTAL split's handle is a tall thin bar (drag along x), a vertical one's
// is a wide flat bar (drag along y).
let (_, _, sep_w, sep_h) = {
let sep = &s0["scene"]["render"]["panes"][body]["separators"][0];
let f = |i: usize| {
sep.get(i).and_then(Value::as_f64).unwrap_or_else(|| {
panic!("`{body}` publishes a divider handle rect: {}", s0["scene"]["render"]["panes"][body])
}) as f32
};
(f(0), f(1), f(2), f(3))
};
let along_x = sep_w < sep_h;
let d = 160.0;
let delta = if along_x { egui::vec2(d, 0.0) } else { egui::vec2(0.0, d) };
let (m0, i0) = (alloc_extent(&s0, &main, along_x), alloc_extent(&s0, &insp, along_x));
let span0 = m0 + i0;
assert!(
m0 > 40.0 && i0 > 40.0,
"`{body}` really has TWO live halves, not a hidden one: main={m0} inspector={i0}"
);
// ── 1. drag: the halves move oppositely, the sum is conserved ──────────────
let handle = sep_center(&s0, body);
drag_divider(&mut robot, handle, delta);
let s1 = robot.state();
let (m1, i1) = (alloc_extent(&s1, &main, along_x), alloc_extent(&s1, &insp, along_x));
assert!(
(m1 - m0 - d).abs() < 26.0,
"dragging `{body}`'s divider {d}pt grew `{main}` by ~{d}: {m0} -> {m1}"
);
assert!(
(i0 - i1 - d).abs() < 26.0,
"…and shrank `{insp}` by the same ~{d}: {i0} -> {i1}"
);
assert!(
((m1 + i1) - span0).abs() < 8.0,
"`{body}` conserves its extent: {m0}+{i0}={span0} -> {m1}+{i1}={}",
m1 + i1
);
// …and back the other way: the master ends up NARROWER than it started.
let handle = sep_center(&s1, body);
drag_divider(&mut robot, handle, -delta * 2.0);
let s2 = robot.state();
let (m2, i2) = (alloc_extent(&s2, &main, along_x), alloc_extent(&s2, &insp, along_x));
assert!(m2 < m0, "dragging back shrank `{main}` below its seed: {m0} -> {m2}");
assert!(i2 > i0, "…and `{insp}` reclaimed it: {i0} -> {i2}");
// Put the divider back where it started so the restore comparison below is
// against a settled layout.
let handle = sep_center(&s2, body);
drag_divider(&mut robot, handle, delta);
robot.step();
// ── 2/3. maximise a half → its sibling is allotted ZERO; restore is EXACT ──
let before = robot.state();
let (rm, ri) = (pane_alloc(&before, &main), pane_alloc(&before, &insp));
assert!(robot.app_mut().0.toggle_scene_maximize(&main), "`{main}` maximised");
robot.step();
let s3 = robot.state();
assert_eq!(
alloc_area(&s3, &insp), 0.0,
"maximising `{main}` drove its sibling `{insp}` to ZERO area: {}",
s3["scene"]["render"]["panes"][&insp]
);
assert!(
alloc_extent(&s3, &main, along_x) > m0,
"`{main}` took the whole body: {} > {m0}",
alloc_extent(&s3, &main, along_x)
);
assert!(robot.app_mut().0.restore_scene_pane(&main), "`{main}` restored");
robot.step();
let s4 = robot.state();
for (pane, was) in [(&main, rm), (&insp, ri)] {
let now = pane_alloc(&s4, pane);
assert!(
(now.0 - was.0).abs() < 0.5
&& (now.1 - was.1).abs() < 0.5
&& (now.2 - was.2).abs() < 0.5
&& (now.3 - was.3).abs() < 0.5,
"restoring returned `{pane}` to EXACTLY its prior rect: {was:?} -> {now:?}"
);
}
// ── 4. minimise the inspector → only its chrome bar is left ────────────────
assert!(robot.app_mut().0.toggle_scene_minimize(&insp), "`{insp}` minimised");
robot.step();
let s5 = robot.state();
let mini = alloc_extent(&s5, &insp, along_x);
assert!(
(mini - CHROME_BAR).abs() < 1.0,
"minimising `{insp}` left exactly its {CHROME_BAR}pt chrome bar: {mini}"
);
assert!(
alloc_extent(&s5, &main, along_x) > m0,
"…and handed the space to `{main}`: {} > {m0}",
alloc_extent(&s5, &main, along_x)
);
assert!(robot.app_mut().0.restore_scene_pane(&insp), "`{insp}` restored");
robot.step();
}
}