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(_)));
}