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