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

nordisk/holger

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