facett-app/src/persist_tests.rs
//! **Tests for layout persistence** — the gap every comparable tool had filled and
//! facett had not.
//!
//! The load-bearing assertion is a **rect round-trip**: arrange the shell (drag a
//! divider, minimise a pane, swap an area's editor, switch workspace), write it to disk,
//! throw the state away, read it back, and render — the pane rects must be the ones you
//! left, not the authored ones. A `Serialize` derive that produced plausible JSON but
//! restored an arrangement nobody recognised would pass a JSON-equality test and fail
//! this one.
use super::*;
use crate::scene::{Axis, Content, Pane, SceneHost, render as render_scene};
use crate::workspace::Workspace;
use egui_kittest::Harness;
use serde_json::{Value, json};
struct PHost;
impl SceneHost for PHost {
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 (fixed status row + the body splice) and two workspaces, both naming their
/// panes `main` / `side` — the case a persisted layout must disambiguate.
fn app() -> Scene {
Scene::new(
"persistapp",
Pane::shell(
"shell.root",
vec![Pane::bar("shell.statusbar", "status", 22.0), Pane::body("shell.body")],
),
)
.workspace(Workspace::new(
"analysis",
"Analysis",
Pane::split("ws.root", Axis::Horizontal, 0.5, vec![Pane::area("main", "graph"), Pane::area("side", "tables")]),
))
.workspace(Workspace::new(
"infra",
"Infra",
Pane::split("ws.root", Axis::Vertical, 0.5, vec![Pane::area("main", "tables"), Pane::area("side", "graph")]),
))
}
fn render_all(scene: &Scene, nav: &std::rc::Rc<std::cell::RefCell<NavState>>) -> 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(egui::vec2(1200.0, 800.0)).build_ui(move |ui| {
*o.borrow_mut() = render_scene(&sc, &mut n.borrow_mut(), ui, &mut PHost);
});
h.run();
out.borrow().clone()
}
/// Every pane's allotted rect, as one comparable value.
fn all_rects(st: &Value) -> Vec<(String, Vec<f64>)> {
let mut out: Vec<(String, Vec<f64>)> = st["panes"]
.as_object()
.expect("panes")
.iter()
.map(|(k, v)| (k.clone(), v["alloc"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap()).collect()))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn tmpdir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("facett-layout-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
// ── THE round trip, asserted on rects ───────────────────────────────────────────
#[test]
fn a_saved_layout_restores_the_exact_rects_through_a_real_file() {
let scene = app();
let dir = tmpdir("roundtrip");
// 1. Arrange: drag the body divider, minimise a pane, swap an area's editor, and
// leave the user on the SECOND workspace.
let nav = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
render_all(&scene, &nav);
{
let mut n = nav.borrow_mut();
n.set_split_ratios("ws.root", vec![0.78, 0.22]);
n.set_minimized("side", true);
n.set_editor("main", "tables");
}
let arranged = render_all(&scene, &nav);
let arranged_rects = all_rects(&arranged);
nav.borrow_mut().set_workspace(&scene, "infra");
// 2. Save.
let path = LayoutSnapshot::of_scene(&scene, &nav.borrow()).save_to(&dir).expect("saved");
assert!(path.exists(), "the layout was written to {}", path.display());
assert!(path.ends_with("persistapp.layout.json"), "…at <app>.layout.json ({})", path.display());
// 3. Restart: nothing but the authored scene and the file on disk.
let snap = LayoutSnapshot::load_from(&dir, "persistapp").expect("the layout loads back");
let restored = std::rc::Rc::new(std::cell::RefCell::new(snap.restore(&scene)));
assert_eq!(restored.borrow().workspace(), "infra", "the ACTIVE workspace was persisted");
// 4. The user goes back to the workspace they arranged: exact rects, not defaults.
restored.borrow_mut().set_workspace(&scene, "analysis");
let after = render_all(&scene, &restored);
assert_eq!(all_rects(&after), arranged_rects, "every pane came back at its exact saved rect");
// …and each individual thing that was arranged really did survive.
assert!((after["nav"]["split_ratio"]["ws.root"][0].as_f64().unwrap() - 0.78).abs() < 1e-5, "the dragged ratio");
assert_eq!(after["nav"]["minimized"]["side"], true, "the minimise");
assert_eq!(after["nav"]["area_editor"]["main"], "tables", "the per-area editor choice");
assert_eq!(after["panes"]["main"]["editor"]["id"], "tables", "…and it is what actually renders");
assert!((after["panes"]["shell.statusbar"]["alloc"][3].as_f64().unwrap() - 22.0).abs() < 0.5, "the fixed shell row");
// A DEFAULT state renders differently — so the assertion above is testing something.
let fresh = std::rc::Rc::new(std::cell::RefCell::new(NavState::new(&scene)));
assert_ne!(all_rects(&render_all(&scene, &fresh)), arranged_rects, "the authored defaults are NOT what was restored");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn minimise_a_pane_restart_and_it_is_still_minimised() {
// The mandate's own acceptance sentence, in one test.
let scene = app();
let dir = tmpdir("minimise");
let mut nav = NavState::new(&scene);
nav.set_minimized("side", true);
LayoutSnapshot::of_scene(&scene, &nav).save_to(&dir).expect("saved");
drop(nav);
let back = LayoutSnapshot::load_from(&dir, "persistapp").expect("loads").restore(&scene);
assert!(back.is_minimized("side"), "the pane is still minimised after a restart");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_parked_workspace_body_is_persisted_under_its_own_name() {
// THE disambiguation, through a real file: both workspaces call a pane `main`, and
// each keeps its own arrangement because the inactive one is filed by workspace.
let scene = app();
let dir = tmpdir("parked");
let mut nav = NavState::new(&scene);
nav.set_split_ratios("ws.root", vec![0.9, 0.1]); // analysis' `ws.root`
nav.set_workspace(&scene, "infra");
nav.set_split_ratios("ws.root", vec![0.2, 0.8]); // infra's, same key, other workspace
nav.set_editor("main", "graph");
LayoutSnapshot::of_scene(&scene, &nav).save_to(&dir).expect("saved");
let back = LayoutSnapshot::load_from(&dir, "persistapp").expect("loads").restore(&scene);
assert_eq!(back.workspace(), "infra");
assert!((back.split_ratios("ws.root").unwrap()[0] - 0.2).abs() < 1e-5, "infra's ratio is live");
assert!(
(back.workspaces["analysis"].split_ratio["ws.root"][0] - 0.9).abs() < 1e-5,
"analysis' ratio is parked under its own name, at the SAME key"
);
assert_eq!(back.editor_of("main"), Some("graph"), "infra's `main`");
assert_eq!(back.workspaces["analysis"].area_editor["main"], "graph", "analysis' `main` — its own authored default");
// The file really does carry both, keyed by workspace, and shell keys stay flat.
let text = std::fs::read_to_string(dir.join("persistapp.layout.json")).unwrap();
assert!(text.contains("\"active_workspace\": \"infra\""));
assert!(text.contains("\"analysis\""), "the parked workspace is named in the file");
assert!(text.contains("shell.root"), "shell keys are flat, never under a workspace");
let _ = std::fs::remove_dir_all(&dir);
}
// ── A workspace-less scene persists exactly the same way ────────────────────────
#[test]
fn a_scene_with_no_workspaces_persists_through_the_same_api() {
let scene = Scene::new(
"soloapp",
Pane::split("root", Axis::Horizontal, 0.5, vec![Pane::slot("l", "a"), Pane::slot("r", "b")]),
);
let dir = tmpdir("solo");
let mut nav = NavState::new(&scene);
nav.set_split_ratios("root", vec![0.9, 0.1]);
nav.set_minimized("r", true);
LayoutSnapshot::of_scene(&scene, &nav).save_to(&dir).expect("saved");
let back = LayoutSnapshot::load_from(&dir, "soloapp").expect("loads").restore(&scene);
assert!((back.split_ratios("root").unwrap()[0] - 0.9).abs() < 1e-5);
assert!(back.is_minimized("r"));
assert_eq!(back.workspace(), "", "no workspaces ⇒ no live workspace, and nothing parked");
assert!(back.workspaces.is_empty());
// And the rects it produces really are the saved ones.
let render_solo = |nav: NavState| -> Value {
let out = std::rc::Rc::new(std::cell::RefCell::new(Value::Null));
let (o, sc) = (out.clone(), scene.clone());
let mut n = nav;
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui(move |ui| {
*o.borrow_mut() = render_scene(&sc, &mut n, ui, &mut PHost);
});
h.run();
out.borrow().clone()
};
let a = render_solo(back);
let b = render_solo(NavState::new(&scene));
assert_ne!(a["panes"]["l"]["alloc"], b["panes"]["l"]["alloc"], "the restored layout is not the authored one");
let _ = std::fs::remove_dir_all(&dir);
}
// ── Degrading safely: version, app, corruption, and a changed pane tree ─────────
#[test]
fn an_incompatible_schema_version_is_refused_whole() {
let scene = app();
let mut nav = NavState::new(&scene);
nav.set_minimized("side", true);
let mut snap = LayoutSnapshot::of_scene(&scene, &nav);
snap.version = LAYOUT_VERSION + 1;
assert!(!snap.is_current());
assert!(!snap.restore(&scene).is_minimized("side"), "a future-schema layout falls back to defaults, never half-applies");
// A v1 file — written before workspaces became a spliced body — is dropped whole
// rather than half-read into the new shape.
let mut v1 = LayoutSnapshot::of_scene(&scene, &nav);
v1.version = 1;
assert!(!v1.restore(&scene).is_minimized("side"), "the pre-splice schema is refused");
// A snapshot from a DIFFERENT app is refused too.
let other = LayoutSnapshot::of("someone-else", &nav);
assert!(!other.restore(&scene).is_minimized("side"));
}
#[test]
fn a_missing_or_corrupt_layout_opens_on_the_defaults() {
let dir = tmpdir("corrupt");
assert!(LayoutSnapshot::load_from(&dir, "nothing-here").is_none(), "a missing file is None, not a panic");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("broken.layout.json"), "{ this is not json").unwrap();
assert!(LayoutSnapshot::load_from(&dir, "broken").is_none(), "a corrupt file is None — the app still opens");
assert!(LayoutSnapshot::from_json("").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_layout_saved_against_an_older_tree_keeps_what_survived() {
// The upgrade case: v1 arranged three things; v2 renamed a body pane, removed a
// workspace and added another.
let shell = || Pane::shell("shell.root", vec![Pane::slot("keeper", "k").fixed(30.0), Pane::body("shell.body")]);
let v1 = Scene::new("app", shell())
.workspace(Workspace::new(
"w",
"W",
Pane::split("ws.root", Axis::Horizontal, 0.5, vec![Pane::area("old_name", "graph"), Pane::slot("stay", "s")]),
))
.workspace(Workspace::new("gone", "Gone", Pane::slot("only", "o")));
let mut nav = NavState::new(&v1);
nav.set_split_ratios("ws.root", vec![0.8, 0.2]);
nav.set_minimized("stay", true);
nav.set_editor("old_name", "tables");
nav.set_maximized("old_name");
let snap = LayoutSnapshot::of_scene(&v1, &nav);
let v2 = Scene::new("app", shell())
.workspace(Workspace::new(
"w",
"W",
Pane::split("ws.root", Axis::Horizontal, 0.5, vec![Pane::area("new_name", "graph"), Pane::slot("stay", "s")]),
))
.workspace(Workspace::new("added", "Added", Pane::slot("only", "o")));
let back = snap.restore(&v2);
assert!(back.is_minimized("stay"), "the body pane that survived kept its minimise");
assert!((back.split_ratios("ws.root").unwrap()[0] - 0.8).abs() < 1e-5, "…and the split it still belongs to kept its ratio");
assert_eq!(back.editor_of("old_name"), None, "the removed pane's binding is gone");
assert_eq!(back.editor_of("new_name"), Some("graph"), "the new pane is seeded from its authored default");
assert_eq!(back.maximized_pane(), None, "a maximise pointing at a removed pane is cleared, not left dangling");
assert!(!back.workspaces.contains_key("gone"), "the removed workspace's parked body is dropped");
assert!(back.workspaces.contains_key("added"), "the workspace this build added is seeded");
}
#[test]
fn a_saved_active_workspace_that_no_longer_exists_falls_back() {
let v1 = Scene::new("app", Pane::body("b")).workspace(Workspace::new("gone", "Gone", Pane::slot("a", "a")));
let nav = NavState::new(&v1);
assert_eq!(nav.workspace(), "gone");
let v2 = Scene::new("app", Pane::body("b")).workspace(Workspace::new("kept", "Kept", Pane::slot("a", "a")));
let back = LayoutSnapshot::of_scene(&v1, &nav).restore(&v2);
assert_eq!(back.workspace(), "kept", "the authored default takes over rather than leaving the body blank");
assert!(back.workspaces.is_empty(), "…and nothing stale stays parked");
}
#[test]
fn adopt_drops_a_ratio_vector_whose_child_count_changed() {
// Reapplying an old 2-child ratio to a now-3-child split would silently starve the
// third pane, so the authored ratios win instead.
let v1 = Scene::new("s", Pane::split("root", Axis::Horizontal, 0.5, vec![Pane::slot("a", "a"), Pane::slot("b", "b")]));
let mut nav = NavState::new(&v1);
nav.set_split_ratios("root", vec![0.9, 0.1]);
let v2 = Scene::new(
"s",
Pane::split_n("root", Axis::Horizontal, vec![0.4, 0.3, 0.3], vec![Pane::slot("a", "a"), Pane::slot("b", "b"), Pane::slot("c", "c")]),
);
nav.adopt(&v2);
let r = nav.split_ratios("root").unwrap();
assert_eq!(r.len(), 3, "the ratio vector matches the new child count");
assert!((r[0] - 0.4).abs() < 1e-5, "…and it is the AUTHORED apportionment, not a stretched old one");
// A same-shape split keeps the user's drag.
let mut nav3 = NavState::new(&v1);
nav3.set_split_ratios("root", vec![0.9, 0.1]);
nav3.adopt(&v1.clone());
assert!((nav3.split_ratios("root").unwrap()[0] - 0.9).abs() < 1e-5, "an unchanged split keeps the dragged ratio");
}
#[test]
fn adopt_resets_a_router_pointing_at_a_pane_that_is_gone() {
let v1 = Scene::new("s", Pane::flow("root", vec![Pane::slot("gone", "g")]));
let mut nav = NavState::new(&v1);
nav.router.navigate("gone");
let v2 = Scene::new("s", Pane::flow("root", vec![Pane::slot("here", "h")]));
nav.adopt(&v2);
assert_eq!(nav.router.current(), "root", "the route falls back to the root rather than dangling");
assert!(matches!(v2.root.content, Content::Children(_)));
}
// ── Where it is stored ──────────────────────────────────────────────────────────
#[test]
fn the_layout_directory_follows_xdg_state_with_an_explicit_override() {
// Pure resolution, so the precedence is asserted without touching the environment.
assert_eq!(resolve_dir(Some("/tmp/forced"), Some("/x/state"), Some("/home/u")), std::path::PathBuf::from("/tmp/forced"));
assert_eq!(resolve_dir(None, Some("/x/state"), Some("/home/u")), std::path::PathBuf::from("/x/state/facett"));
assert_eq!(resolve_dir(None, None, Some("/home/u")), std::path::PathBuf::from("/home/u/.local/state/facett"));
assert_eq!(resolve_dir(None, None, None), std::path::PathBuf::from(".facett"), "it never silently writes nowhere");
assert_eq!(
resolve_dir(Some(""), Some(""), Some("/home/u")),
std::path::PathBuf::from("/home/u/.local/state/facett"),
"empty vars do not count as set"
);
assert_eq!(LAYOUT_DIR_ENV, "FACETT_LAYOUT_DIR");
// The filename is one path component even for a hostile app name.
assert_eq!(layout_path("../../etc/passwd").file_name().unwrap(), "______etc_passwd.layout.json");
assert_eq!(layout_path("korp").file_name().unwrap(), "korp.layout.json");
assert_eq!(layout_path("korp").parent().unwrap(), layout_dir());
}
#[test]
fn the_snapshot_json_is_readable_and_stable() {
// wasm hosts keep this string in localStorage, so it must round-trip as text.
let scene = app();
let mut nav = NavState::new(&scene);
nav.set_editor("main", "tables");
let snap = LayoutSnapshot::of_scene(&scene, &nav);
let text = snap.to_json();
let back = LayoutSnapshot::from_json(&text).expect("round-trips as text");
assert_eq!(back.to_json(), text, "the JSON is a fixed point");
assert_eq!(back.version, LAYOUT_VERSION);
assert_eq!(back.app, "persistapp");
assert_eq!(back.nav.editor_of("main"), Some("tables"));
// The SCENE is not in the file — only the arrangement of it.
assert!(!text.contains("Slot"), "no pane tree is written into the user's layout file");
assert!(!text.contains("Content"));
}