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

nordisk/facett

facett-app/src/workspace_tests.rs

//! **Tests for workspace tabs** — Blender's named body arrangements inside ONE shell.
//!
//! Three claims, all asserted on **rects**:
//!
//! 1. **The shell does not move.** Switching workspace changes only what hangs under the
//!    [`Content::Body`] splice; `shell.menubar` and `shell.statusbar` keep byte-identical
//!    rects. That is the Eclipse failure this shape exists to avoid — a menu that moves
//!    when you switch is a menu nobody can find.
//! 2. **Shell pane ADDRESSES are stable.** The same names resolve, in every workspace, to
//!    the same panes — so a saved route, a persisted key and a robot's `panes["…"]` do
//!    not shift underneath the caller.
//! 3. **A switch and back restores the EXACT arrangement**, asserted `!=` the authored
//!    one so the test is testing something.

use super::*;
use crate::scene::{
    Action, Axis, Content, MenuItem, NavState, Pane, Route, STATUSBAR_H, Scene, SceneHost, Target, Trigger,
    render as render_scene,
};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;

struct WsHost;
impl SceneHost for WsHost {
    fn slot(&mut self, id: &str, ui: &mut egui::Ui) -> Value {
        ui.label(format!("SLOT:{id}"));
        json!({ "slot": id })
    }
    fn area(&mut self, pane: &str, editor_id: &str, ui: &mut egui::Ui) -> Value {
        ui.label(format!("AREA:{pane}:{editor_id}"));
        json!({ "editor": editor_id })
    }
    fn editors(&self) -> Vec<(String, String)> {
        vec![("graph".into(), "Graph".into()), ("tables".into(), "Tables".into())]
    }
}

/// One shell, authored ONCE, with two workspaces spliced into its body.
///
/// Note both workspaces name their panes `main` and `side` — that is the normal case and
/// the uniqueness rule explicitly allows it.
fn app_scene() -> Scene {
    Scene::new(
        "korp",
        Pane::shell(
            "shell.root",
            vec![
                Pane::menubar("shell.menubar"),
                Pane::workspace_bar("shell.workspaces"),
                Pane::body("shell.body"),
                Pane::bar("shell.statusbar", "status", STATUSBAR_H),
            ],
        ),
    )
    .menu(MenuItem::new("view", "View").items(vec![
        MenuItem::new("view.data", "Go Data").action(Action::Workspace("data".into())),
    ]))
    .workspace(Workspace::new(
        "investigate",
        "Investigate",
        Pane::split("ws.root", Axis::Horizontal, 0.6, vec![Pane::area("main", "graph"), Pane::area("side", "tables")]),
    ))
    .workspace(
        Workspace::new(
            "data",
            "Data",
            Pane::split("ws.root", Axis::Vertical, 0.5, vec![Pane::area("main", "tables"), Pane::area("side", "graph")]),
        )
        .icon("🧮"),
    )
}

fn render_app(scene: &Scene, nav: &std::rc::Rc<std::cell::RefCell<NavState>>, size: egui::Vec2) -> Value {
    let out = std::rc::Rc::new(std::cell::RefCell::new(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_scene(&sc, &mut n.borrow_mut(), ui, &mut WsHost);
    });
    h.run();
    out.borrow().clone()
}

fn alloc(st: &Value, pane: &str) -> Vec<f64> {
    st["panes"][pane]["alloc"].as_array().unwrap_or_else(|| panic!("pane `{pane}` was laid out")).iter().map(|v| v.as_f64().unwrap()).collect()
}

// ── 1. A workspace is not a Scene: ONE shell, N bodies ──────────────────────────

#[test]
fn one_shell_hosts_every_workspace_and_the_body_is_the_only_thing_that_varies() {
    let scene = app_scene();
    scene.validate().expect("valid");
    assert_eq!(scene.workspace_names(), vec!["investigate", "data"]);
    assert_eq!(scene.body_pane().as_deref(), Some("shell.body"), "the shell declares a splice point");
    assert_eq!(scene.workspace_bar_pane().as_deref(), Some("shell.workspaces"));
    assert_eq!(scene.workspaces.len(), 2, "two workspaces, ONE shell");

    // The shell tree contains no body panes; the body panes belong to the workspaces.
    let shell: Vec<String> = scene.affordances()["shell_panes"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v.as_str().unwrap().into())
        .collect();
    assert!(shell.contains(&"shell.menubar".to_string()));
    assert!(!shell.contains(&"main".to_string()), "`main` is a workspace's, not the shell's");

    // Scoped lookup: shell ∪ ONE workspace, and the same name means different panes in
    // different workspaces.
    assert!(scene.pane_in(Some("investigate"), "shell.menubar").is_some(), "shell panes resolve in every workspace");
    assert!(scene.pane_in(Some("data"), "shell.menubar").is_some());
    let inv = scene.pane_in(Some("investigate"), "main").expect("investigate has `main`");
    let dat = scene.pane_in(Some("data"), "main").expect("data has `main` too");
    assert_eq!(inv.name, dat.name, "same NAME…");
    assert_eq!(scene.workspace_named("investigate").unwrap().default_editor("main"), Some("graph"));
    assert_eq!(scene.workspace_named("data").unwrap().default_editor("main"), Some("tables"), "…different editor");
}

#[test]
fn name_uniqueness_is_scoped_to_shell_union_one_workspace() {
    // The same body pane name in two workspaces is legal, and must be.
    app_scene().validate().expect("`main` in both workspaces is fine");

    // A body pane colliding with a SHELL pane is not — they are on screen together.
    let clash = Scene::new("x", Pane::shell("root", vec![Pane::slot("main", "m"), Pane::body("b")]))
        .workspace(Workspace::new("w", "W", Pane::area("main", "graph")));
    assert!(clash.validate().unwrap_err().contains("workspace w: duplicate pane name: main"));

    // Duplicate workspace names, and workspaces with nowhere to splice into.
    let dupw = Scene::new("x", Pane::body("b"))
        .workspace(Workspace::new("w", "W", Pane::slot("a", "a")))
        .workspace(Workspace::new("w", "W", Pane::slot("b2", "b")));
    assert!(dupw.validate().unwrap_err().contains("duplicate workspace name"));
    let nobody = Scene::new("x", Pane::slot("root", "r")).workspace(Workspace::new("w", "W", Pane::slot("a", "a")));
    assert!(nobody.validate().unwrap_err().contains("Content::Body"));
}

// ── 2. THE claim: the shell does not move, and its addresses do not shift ────────

#[test]
#[allow(non_snake_case, reason = "the capitals carry the assertion; the test id stays as published")]
fn switching_workspace_moves_the_body_and_NOTHING_else() {
    let scene = app_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let before = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    assert_eq!(before["workspace"], "investigate");

    assert!(nav.borrow_mut().set_workspace(&scene, "data"), "switched");
    let after = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    assert_eq!(after["workspace"], "data");

    // The shell is byte-identical. This is the assertion the whole shape exists for.
    for p in ["shell.root", "shell.menubar", "shell.workspaces", "shell.body", "shell.statusbar"] {
        assert_eq!(alloc(&before, p), alloc(&after, p), "shell pane `{p}` MOVED across a workspace switch");
    }
    // …and its addresses still resolve, in both workspaces.
    assert!(after["panes"]["shell.statusbar"].is_object(), "`shell.statusbar` addresses the same pane after the switch");

    // The BODY did change: investigate splits horizontally, data vertically.
    let (bx, _by, bw, bh) = {
        let a = alloc(&before, "shell.body");
        (a[0], a[1], a[2], a[3])
    };
    let inv_main_w = alloc(&before, "main")[2];
    let dat_main_w = alloc(&after, "main")[2];
    assert!(inv_main_w < bw - 10.0, "investigate splits H, so `main` is narrower than the body ({inv_main_w} of {bw})");
    assert!((dat_main_w - bw).abs() < 2.0, "data splits V, so `main` spans the body's full width ({dat_main_w} of {bw})");
    assert!(alloc(&after, "main")[3] < bh - 10.0, "…and is shorter than it");
    assert!(alloc(&after, "main")[0] >= bx - 1.0, "the body tree stays inside the splice rect");
}

#[test]
#[allow(non_snake_case, reason = "the capitals carry the assertion; the test id stays as published")]
fn switching_workspace_and_back_restores_the_EXACT_body_rects() {
    let scene = app_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let authored = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    let authored_main = alloc(&authored, "main");

    // Move `investigate` off its authored arrangement: drag, minimise, swap an editor.
    {
        let mut n = nav.borrow_mut();
        n.set_split_ratios("ws.root", vec![0.82, 0.18]);
        n.set_minimized("side", true);
        n.set_editor("main", "tables");
    }
    let moved = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    let moved_main = alloc(&moved, "main");
    assert_ne!(moved_main, authored_main, "the arrangement really moved off its authored ratios");

    // Away to `data` — whose OWN `main` is untouched and at ITS authored ratio.
    nav.borrow_mut().set_workspace(&scene, "data");
    let other = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    assert_eq!(other["panes"]["main"]["editor"]["id"], "tables", "data's `main` authors `tables` — its own default");
    assert_eq!(other["nav"]["minimized"]["side"], false, "data's `side` is NOT minimised: the flag was parked with investigate");

    // …and back. The exact moved rects return.
    nav.borrow_mut().set_workspace(&scene, "investigate");
    let restored = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    assert_eq!(alloc(&restored, "main"), moved_main, "the exact dragged rect came back");
    assert_ne!(alloc(&restored, "main"), authored_main, "…and it is NOT the authored one (which a rebuild would give)");
    assert_eq!(restored["nav"]["minimized"]["side"], true, "the minimise came back with it");
    assert_eq!(restored["panes"]["main"]["editor"]["id"], "tables", "so did the editor swap");
    assert_eq!(restored["nav"]["last_workspace"], "workspace:investigate", "the switch is readable back");
}

#[test]
fn each_workspaces_body_state_is_parked_under_its_own_name() {
    // The answer to "how does a layout tell `main` in one workspace from `main` in
    // another": the flat maps hold ONLY the panes on screen; everyone else is parked.
    let scene = app_scene();
    let mut nav = NavState::new(&scene);
    nav.set_split_ratios("ws.root", vec![0.9, 0.1]);
    nav.set_editor("main", "tables");

    assert_eq!(nav.workspace(), "investigate");
    assert!(nav.workspaces.contains_key("data"), "the inactive workspace is parked");
    assert!(!nav.workspaces.contains_key("investigate"), "the ACTIVE one lives in the flat maps");
    assert_eq!(nav.workspaces["data"].area_editor["main"], "tables", "data's own `main` authors tables");

    nav.set_workspace(&scene, "data");
    assert!(nav.workspaces.contains_key("investigate"), "investigate is parked on the way out");
    assert!((nav.workspaces["investigate"].split_ratio["ws.root"][0] - 0.9).abs() < 1e-5, "with its dragged ratio");
    assert!(!nav.workspaces.contains_key("data"));
    // The two `main` entries are distinct and neither has been qualified.
    assert_eq!(nav.editor_of("main"), Some("tables"), "data's `main`, in the flat maps");
    assert_eq!(nav.workspaces["investigate"].area_editor["main"], "tables", "investigate's `main`, parked");
    nav.set_editor("main", "graph");
    assert_eq!(nav.editor_of("main"), Some("graph"));
    assert_eq!(nav.workspaces["investigate"].area_editor["main"], "tables", "…and changing one did not touch the other");

    // Shell entries never move.
    assert!(nav.split_ratio.contains_key("shell.root"), "the shell's ratio stays in the flat maps throughout");
    assert!(!nav.workspaces["investigate"].split_ratio.contains_key("shell.root"));
}

#[test]
fn maximising_a_body_pane_keeps_the_shell_on_the_path() {
    // path_to() must cross the splice: shell root → … → shell.body → ws.root → main.
    let scene = app_scene();
    let path = scene.path_to("main").expect("a body pane has a path");
    assert_eq!(path.first().unwrap(), "shell.root");
    assert!(path.contains(&"shell.body".to_string()), "the path runs through the splice pane: {path:?}");
    assert_eq!(path.last().unwrap(), "main");

    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let before = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));
    nav.borrow_mut().set_maximized("main");
    let max = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));

    // The sibling AREA is allotted zero…
    assert_eq!(alloc(&max, "side")[2] * alloc(&max, "side")[3], 0.0, "the sibling area shrank to zero area");
    assert!(alloc(&max, "main")[2] > alloc(&before, "main")[2] + 100.0, "…and `main` took the body");
    // …but the shell rows around it are untouched, because they are on the max path.
    assert_eq!(alloc(&before, "shell.menubar"), alloc(&max, "shell.menubar"), "maximising a BODY pane does not eat the menu bar");
    assert_eq!(alloc(&before, "shell.statusbar"), alloc(&max, "shell.statusbar"));
}

// ── 3. The workspace bar is a PANE, and its tabs are clickable ──────────────────

#[test]
fn the_workspace_bar_is_a_fixed_shell_row_with_clickable_tabs() {
    let scene = app_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let st = render_app(&scene, &nav, egui::vec2(1200.0, 800.0));

    let h = alloc(&st, "shell.workspaces")[3];
    assert!((h - WORKSPACEBAR_H as f64).abs() < 0.5, "the bar is exactly WORKSPACEBAR_H tall ({h})");
    assert_eq!(st["panes"]["shell.workspaces"]["content"], "workspace_bar");
    for name in ["investigate", "data"] {
        let r = &st["workspace_tabs"][name];
        assert!(r[2].as_f64().unwrap() > 1.0 && r[3].as_f64().unwrap() > 1.0, "workspace tab `{name}` publishes a clickable rect");
    }
    // The body begins below the bar — the bar took real space, it did not overlay.
    assert!(alloc(&st, "shell.body")[1] >= alloc(&st, "shell.workspaces")[1] + h - 1.0);
    assert_eq!(scene.workspace_named("data").unwrap().tab_label(), "🧮 Data", "the icon rides in the tab label");
}

#[test]
fn clicking_a_workspace_tab_switches_the_body() {
    let scene = app_scene();
    let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
    let out = std::rc::Rc::new(std::cell::RefCell::new(Value::Null));
    let (o, n, sc) = (out.clone(), nav.clone(), scene.clone());
    let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui(move |ui| {
        *o.borrow_mut() = render_scene(&sc, &mut n.borrow_mut(), ui, &mut WsHost);
    });
    h.run();
    assert!(h.query_by_label("AREA:main:graph").is_some(), "investigate's `main` hosts Graph");

    // The prefixed AccessKit label is what keeps a workspace tab unambiguous next to a
    // pane title of the same word.
    h.get_by_label("workspace data").click();
    h.run();
    h.run();
    assert_eq!(out.borrow()["workspace"], "data", "the click switched workspace");
    assert!(h.query_by_label("AREA:main:tables").is_some(), "data's `main` hosts Tables");
    assert!(h.query_by_label("AREA:main:graph").is_none(), "…and investigate's body left the tree");
}

#[test]
fn a_menu_action_and_a_route_can_switch_workspace() {
    let scene = app_scene().route(Route::on_shortcut("Ctrl+2", Target::Workspace("data".into())));
    let mut nav = NavState::new(&scene);
    assert!(nav.fire(&scene, &Trigger::Shortcut("Ctrl+2".into())), "the route matched");
    assert_eq!(nav.workspace(), "data", "Target::Workspace switched it");
    assert!(!nav.set_workspace(&scene, "data"), "switching to the current workspace is a no-op");
    assert!(!nav.set_workspace(&scene, "nope"), "an unknown workspace is a no-op, never a panic");
    assert!(!nav.apply(&Target::Workspace("investigate".into())), "the scene-less apply() cannot switch — apply_in() does");
    assert!(nav.apply_in(&scene, &Target::Workspace("investigate".into())));
    assert_eq!(nav.workspace(), "investigate");
}

// ── 4. A workspace's own editor overrides, and RON ──────────────────────────────

#[test]
fn a_workspace_can_override_an_areas_default_editor() {
    // One archetype tree reused across workspaces with different editors mounted in it.
    let body = || Pane::split("ws.root", Axis::Horizontal, 0.5, vec![Pane::area("main", "graph"), Pane::area("side", "graph")]);
    let scene = Scene::new("app", Pane::shell("root", vec![Pane::body("hole")]))
        .workspace(Workspace::new("a", "A", body()))
        .workspace(Workspace::new("b", "B", body()).editor("main", "tables"));
    let mut nav = NavState::new(&scene);
    assert_eq!(nav.editor_of("main"), Some("graph"), "workspace a takes the pane's own default");
    nav.set_workspace(&scene, "b");
    assert_eq!(nav.editor_of("main"), Some("tables"), "workspace b's override wins");
    assert_eq!(nav.editor_of("side"), Some("graph"), "…only for the area it names");
    assert_eq!(scene.areas_in(Some("b")).into_iter().collect::<std::collections::BTreeMap<_, _>>()["main"], "tables");
}

#[test]
fn workspaces_round_trip_through_ron_and_leave_workspace_less_scenes_byte_identical() {
    let scene = app_scene();
    let ron1 = scene.to_ron();
    let back = Scene::from_ron(&ron1).expect("parses");
    assert_eq!(scene, back);
    assert_eq!(ron1, back.to_ron(), "a workspace scene's RON is byte-stable");
    assert!(ron1.contains("Body"), "the splice point is scene data");
    assert!(ron1.contains("WorkspaceBar"));

    // A scene with no workspaces never mentions the fields.
    let plain = Scene::new("plain", Pane::split("root", Axis::Horizontal, 0.5, vec![Pane::slot("l", "a"), Pane::slot("r", "b")]));
    let pr = plain.to_ron();
    assert!(!pr.contains("workspaces"), "no workspaces ⇒ the field is skipped");
    assert!(!pr.contains("active_workspace"));
    assert_eq!(pr, Scene::from_ron(&pr).unwrap().to_ron());
    plain.validate().expect("a workspace-less scene is still valid");
    assert_eq!(NavState::new(&plain).workspace(), "", "…and has no live workspace");
    assert!(matches!(plain.root.content, Content::Children(_)));
}