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/tests.rs

//! Unit + headless tests for the nav backbone — model, router, and the shell,
//! all asserted as DATA (state_json), the facett way.

use crate::*;
use facett_core::{Facet, FacetCaps, harness};

/// A minimal mountable Facet for AppShell pane tests.
struct Probe {
    title: String,
    ticks: u32,
}
impl Facet for Probe {
    fn title(&self) -> &str {
        &self.title
    }
    fn ui(&mut self, ui: &mut egui::Ui) {
        self.ticks += 1;
        ui.label(format!("probe {} ({})", self.title, self.ticks));
    }
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({ "probe": self.title, "ticks": self.ticks })
    }
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable()
    }
}

fn sample_model() -> NavModel {
    NavModel::new([
        NavItem::new("home", "Home").icon("🏠"),
        NavItem::new("data", "Data").icon("🗃").children([
            NavItem::new("data.grid", "Grid").route("grid"),
            NavItem::new("data.table", "Table").route("table").badge("3"),
        ]),
        NavItem::new("settings", "Settings").icon("⚙"),
        NavItem::new("admin", "Admin").disabled(),
    ])
}

// ── NavModel ──────────────────────────────────────────────────────────────────

#[test]
fn navmodel_flattens_finds_and_routes() {
    let m = sample_model();
    // flat = preorder over sections + leaves.
    assert_eq!(m.flat().len(), 6, "home, data, grid, table, settings, admin");
    assert_eq!(m.find("data.table").unwrap().route_or_id(), "table");
    // routes() skips disabled (admin) and is route-resolved.
    let routes = m.routes();
    assert!(routes.contains(&"home") && routes.contains(&"grid") && routes.contains(&"table"));
    assert!(!routes.contains(&"admin"), "disabled item is not a route");
    // first navigable leaf is the router start.
    assert_eq!(m.first_route(), Some("home"));
}

#[test]
fn navmodel_breadcrumb_is_the_label_path() {
    let m = sample_model();
    assert_eq!(m.breadcrumb("table"), vec!["Data".to_string(), "Table".to_string()]);
    assert_eq!(m.breadcrumb("home"), vec!["Home".to_string()]);
    assert!(m.breadcrumb("does-not-exist").is_empty());
}

#[test]
fn navmodel_breadcrumb_items_carry_routes_for_clicks() {
    let m = sample_model();
    // (label, route) per crumb so the trail is clickable.
    assert_eq!(
        m.breadcrumb_items("table"),
        vec![("Data".to_string(), "data".to_string()), ("Table".to_string(), "table".to_string())]
    );
}

// ── Dropdown ──────────────────────────────────────────────────────────────────

#[test]
fn dropdown_headless_select_and_state() {
    let mut d = Dropdown::new("theme")
        .options([("dark", "Dark"), ("light", "Light"), ("vinter", "Skade Vinter")])
        .with_selected("dark")
        .placeholder("Theme");
    assert_eq!(d.selected(), Some("dark"));
    assert_eq!(d.selected_label(), Some("Dark"));
    assert!(!d.is_open());

    assert!(d.select("vinter"), "headless select by id");
    assert_eq!(d.selected(), Some("vinter"));
    assert!(!d.select("nope"), "unknown id is a no-op");

    // open/closed is OBSERVABLE state (FC-1), not hidden in egui memory.
    d.set_open(true);
    let j = d.state_json();
    assert_eq!(j["selected"], "vinter");
    assert_eq!(j["open"], true);
    assert_eq!(j["options"].as_array().unwrap().len(), 3);
}

// ── Router ────────────────────────────────────────────────────────────────────

#[test]
fn router_history_back_forward() {
    let mut r = Router::new("home");
    assert!(!r.can_back());
    assert!(r.navigate("grid"));
    assert!(!r.navigate("grid"), "same route is a no-op");
    assert!(r.navigate("table"));
    assert_eq!(r.current(), "table");
    assert!(r.can_back());

    assert!(r.back());
    assert_eq!(r.current(), "grid");
    assert!(r.can_forward());
    assert!(r.back());
    assert_eq!(r.current(), "home");
    assert!(!r.can_back());

    assert!(r.forward());
    assert_eq!(r.current(), "grid");
    // Navigating clears the forward stack (browser semantics).
    assert!(r.navigate("settings"));
    assert!(!r.can_forward());
}

#[test]
fn router_hash_deeplink_roundtrip() {
    let mut r = Router::new("home");
    r.navigate("OSM 3D");
    assert_eq!(r.to_hash(), "#OSM%203D", "spaces are percent-encoded for the URL hash");
    assert_eq!(Router::route_from_hash("#OSM%203D"), Some("OSM 3D".to_string()));
    assert_eq!(Router::route_from_hash("#"), None);

    // Restore from a deep link.
    let mut r2 = Router::new("home");
    assert!(r2.sync_from_hash("#grid"));
    assert_eq!(r2.current(), "grid");
    assert!(!r2.sync_from_hash("#grid"), "already there");
    assert!(!r2.sync_from_hash("#"), "empty fragment ignored");
}

// ── NavShell (the shell Facet) ────────────────────────────────────────────────

#[test]
fn navshell_starts_at_first_route_and_navigates() {
    let mut s = NavShell::new("nav", sample_model()).with_body("home", "welcome");
    assert_eq!(s.current_route(), "home");
    assert_eq!(s.breadcrumb(), vec!["Home".to_string()]);

    assert!(s.navigate("table"));
    assert_eq!(s.current_route(), "table");
    assert_eq!(s.breadcrumb(), vec!["Data".to_string(), "Table".to_string()]);
    assert!(s.back());
    assert_eq!(s.current_route(), "home");
}

#[test]
fn navshell_headless_render_and_state_json() {
    let mut s = NavShell::new("nav", sample_model());
    let r = harness::headless_render(&mut s);
    assert!(r.drew(), "the shell tessellates (rail + content)");
    assert_eq!(r.state["route"], "home");
    assert_eq!(r.state["router"]["can_back"], false);
    // The whole nav model is observable for robot drives.
    let routes = r.state["nav"]["routes"].as_array().unwrap();
    assert!(routes.iter().any(|v| v == "table"));
}

#[test]
fn navshell_with_route_dropdown_is_observable() {
    let dd = Dropdown::new("theme").options([("dark", "Dark"), ("light", "Light")]).with_selected("dark");
    let mut s = NavShell::new("nav", sample_model()).with_route_dropdown("settings", "Theme", dd);
    assert_eq!(s.dropdown_selection(), Some("dark"));
    // The dropdown is folded into the shell's observable state.
    let j = s.state_json();
    assert_eq!(j["dropdown"]["route"], "settings");
    assert_eq!(j["dropdown"]["state"]["selected"], "dark");
    // Still renders headlessly with the control attached.
    let r = harness::headless_render(&mut s);
    assert!(r.drew());
}

// ── AppShell (hosts real Facets per route) ────────────────────────────────────

#[test]
fn appshell_mounts_panes_and_navigates() {
    let model = NavModel::new([
        NavItem::new("home", "Home"),
        NavItem::new("data", "Data").children([NavItem::new("data.grid", "Grid").route("grid")]),
    ]);
    let mut app = AppShell::new("MyApp", model)
        .with_body("home", "welcome")
        .with_pane("grid", Box::new(Probe { title: "grid".into(), ticks: 0 }))
        .with_status("ok");

    assert_eq!(app.current_route(), "home");
    assert!(app.active_pane().is_none(), "home has a body, no pane");

    assert!(app.navigate("grid"));
    assert!(app.active_pane().is_some(), "grid mounts the Probe pane");
    assert_eq!(app.active_pane().unwrap().title(), "grid");
}

#[test]
fn appshell_state_folds_in_active_pane() {
    let model = NavModel::new([NavItem::new("grid", "Grid")]);
    let mut app = AppShell::new("MyApp", model).with_pane("grid", Box::new(Probe { title: "grid".into(), ticks: 7 }));
    let j = app.state_json();
    assert_eq!(j["route"], "grid");
    assert_eq!(j["panes"], serde_json::json!(["grid"]));
    // Whole-app introspection: the active pane's own state is observable.
    assert_eq!(j["active_pane"]["title"], "grid");
    assert_eq!(j["active_pane"]["state"]["ticks"], 7);

    // Renders headlessly with a mounted pane (drives the pane's ui).
    let r = harness::headless_render(&mut app);
    assert!(r.drew());
}

// ── NavModel → facett-menu bridge ─────────────────────────────────────────────

#[test]
fn nav_commands_bridge() {
    // The same nav model that drives the sidebar builds facett-menu commands.
    let cmds = nav_commands(&sample_model());
    // One command per enabled leaf: grid, table (under "Data"), settings. NOT the
    // disabled "admin", NOT the "data"/"home"-less sections themselves.
    let ids: Vec<&str> = cmds.iter().map(|c| c.id.as_str()).collect();
    assert!(ids.contains(&"grid") && ids.contains(&"table") && ids.contains(&"settings"));
    assert!(!ids.contains(&"admin"), "disabled leaf is excluded");
    // group = parent section label; a top-level leaf falls under "Go".
    let table = cmds.iter().find(|c| c.id == "table").unwrap();
    assert_eq!(table.group, "Data");
    let settings = cmds.iter().find(|c| c.id == "settings").unwrap();
    assert_eq!(settings.group, "Go");
    // The palette can now be driven by this owned-id command set (no &'static).
    let mut pal = facett_menu::CommandPalette::default();
    assert_eq!(pal.invoke("grid"), "grid");
    assert_eq!(pal.invoked(), Some("grid"));
}

// ── NavPalette ────────────────────────────────────────────────────────────────

#[test]
fn navpalette_open_invoke_state() {
    let mut p = NavPalette::new();
    assert!(!p.is_open());
    assert_eq!(p.state_json()["open"], false);
    p.open();
    assert!(p.is_open());
    // Headless invoke (CLI/robot parity).
    assert_eq!(p.invoke("grid"), "grid");
    assert!(!p.is_open(), "invoking closes the palette");
    assert_eq!(p.take_invoked(), Some("grid".to_string()));
    assert!(p.take_invoked().is_none(), "consumed");
}

#[test]
fn navpalette_renders_over_a_model() {
    // Drives the fuzzy hit list + overlay headlessly.
    struct P(NavPalette, NavModel);
    impl facett_core::Facet for P {
        fn title(&self) -> &str {
            "p"
        }
        fn ui(&mut self, ui: &mut egui::Ui) {
            self.0.show(ui, &self.1);
        }
        fn state_json(&self) -> serde_json::Value {
            self.0.state_json()
        }
    }
    let mut pal = NavPalette::new();
    pal.open();
    let mut probe = P(pal, sample_model());
    assert!(harness::headless_render(&mut probe).drew());
}

// ── ColorPicker ───────────────────────────────────────────────────────────────

#[test]
fn colorpicker_value_and_hex() {
    let mut c = ColorPicker::new([255, 128, 0, 255]).swatches([[0, 0, 0, 255]]);
    assert_eq!(c.color(), [255, 128, 0, 255]);
    assert_eq!(c.hex(), "#FF8000FF");
    c.set_color([0, 255, 0, 255]);
    assert_eq!(c.state_json()["hex"], "#00FF00FF");
    assert_eq!(c.state_json()["rgba"], serde_json::json!([0, 255, 0, 255]));
}

// ── TabView ───────────────────────────────────────────────────────────────────

#[test]
fn tabview_select_and_state() {
    let mut t = TabView::new([("a", "Alpha"), ("b", "Beta"), ("c", "Gamma")]);
    assert_eq!(t.active(), "a");
    assert!(t.select("c"));
    assert_eq!(t.active(), "c");
    assert!(!t.select("c"), "already active");
    assert!(!t.select("nope"), "unknown");
    let j = t.state_json();
    assert_eq!(j["active"], "c");
    assert_eq!(j["switches"], 1);
    assert_eq!(j["tabs"], serde_json::json!(["a", "b", "c"]));
}

// ── Tree ──────────────────────────────────────────────────────────────────────

#[test]
fn tree_select_and_expand_state() {
    let mut tr = Tree::new();
    tr.select("data.table");
    assert_eq!(tr.selected(), Some("data.table"));
    tr.set_expanded("data", true);
    assert!(tr.is_expanded("data"));
    let j = tr.state_json();
    assert_eq!(j["selected"], "data.table");
    assert!(j["expanded"].as_array().unwrap().iter().any(|v| v == "data"));

    // Renders headlessly over a model (drives the recursive rows).
    struct T(Tree, NavModel);
    impl facett_core::Facet for T {
        fn title(&self) -> &str {
            "t"
        }
        fn ui(&mut self, ui: &mut egui::Ui) {
            self.0.show(ui, &self.1);
        }
        fn state_json(&self) -> serde_json::Value {
            self.0.state_json()
        }
    }
    let mut probe = T(Tree::new(), sample_model());
    assert!(harness::headless_render(&mut probe).drew());
}

// ── DatePicker ────────────────────────────────────────────────────────────────

#[test]
fn datepicker_math_and_state() {
    // 2026-07-01 is a Wednesday (day_of_week 3).
    let mut dp = DatePicker::new(2026, 7).with_selected(2026, 7, 1);
    assert_eq!(dp.selected(), Some((2026, 7, 1)));
    assert_eq!(dp.state_json()["selected"], "2026-07-01");
    assert_eq!(dp.state_json()["view"], "2026-07");

    // Month wrap.
    dp.prev_month();
    assert_eq!(dp.state_json()["view"], "2026-06");
    let mut jan = DatePicker::new(2026, 1);
    jan.prev_month();
    assert_eq!(jan.state_json()["view"], "2025-12", "January ← wraps year");

    // Leap-year clamp: Feb 2024 has 29 days; selecting the 31st clamps to 29.
    let mut feb = DatePicker::new(2024, 2);
    feb.select(2024, 2, 31);
    assert_eq!(feb.selected(), Some((2024, 2, 29)));
}

// ── SplitPane ─────────────────────────────────────────────────────────────────

#[test]
fn splitpane_hosts_two_facets_and_folds_state() {
    let mut sp = SplitPane::new(
        "compare",
        Box::new(Probe { title: "left".into(), ticks: 1 }),
        Box::new(Probe { title: "right".into(), ticks: 2 }),
    )
    .ratio(0.3);
    let j = sp.state_json();
    assert_eq!(j["first"]["title"], "left");
    assert_eq!(j["second"]["title"], "right");
    assert_eq!(j["first"]["state"]["ticks"], 1);
    assert!((j["ratio"].as_f64().unwrap() - 0.3).abs() < 1e-6, "ratio ≈ 0.3, got {}", j["ratio"]);
    // Renders headlessly (drives both children).
    let r = harness::headless_render(&mut sp);
    assert!(r.drew());
}

// ── Popover ───────────────────────────────────────────────────────────────────

#[test]
fn popover_open_state() {
    let mut p = Popover::new("info");
    assert!(!p.is_open());
    p.toggle();
    assert!(p.is_open());
    assert_eq!(p.state_json()["open"], true);
    p.close();
    assert!(!p.is_open());
}

// ── Modal ─────────────────────────────────────────────────────────────────────

#[test]
fn modal_open_action_and_state() {
    let mut m = Modal::new("confirm", "Delete?").body("This cannot be undone.").confirm_cancel();
    assert!(!m.is_open());
    assert_eq!(m.state_json()["buttons"], serde_json::json!(["cancel", "ok"]));

    m.open();
    assert!(m.is_open());
    assert_eq!(m.state_json()["open"], true);

    // Headless: simulate the action layer (a button press records last_action).
    // (UI-driven path covered by the demo; here we assert the observable contract.)
    m.close();
    assert!(!m.is_open());
}

// ── Toasts ────────────────────────────────────────────────────────────────────

#[test]
fn toasts_push_dismiss_and_expire() {
    let mut t = Toasts::new();
    let a = t.push(Level::Info, "saved");
    let _b = t.push(Level::Error, "boom");
    let c = t.push_sticky(Level::Warn, "stays");
    assert_eq!(t.len(), 3);

    // Manual dismiss by id.
    assert!(t.dismiss(a));
    assert!(!t.dismiss(a), "already gone");
    assert_eq!(t.len(), 2);

    // Expiry: tick past the default TTL — the timed one (b) drops, sticky (c) stays.
    t.tick(crate::toast::DEFAULT_TTL + 0.1);
    assert_eq!(t.len(), 1, "only the sticky toast remains");
    let j = t.state_json();
    assert_eq!(j["count"], 1);
    assert_eq!(j["items"][0]["id"], c);
    assert_eq!(j["items"][0]["sticky"], true);
    assert_eq!(j["items"][0]["level"], "warn");
}

// ── Fas 4 widgets ─────────────────────────────────────────────────────────────

#[test]
fn accordion_open_state_and_single_open() {
    let mut a = Accordion::new();
    assert!(!a.is_open("s1"));
    a.set_open("s1", true);
    a.set_open("s2", true);
    assert!(a.is_open("s1") && a.is_open("s2"));
    a.toggle("s1");
    assert!(!a.is_open("s1"));

    let mut ex = Accordion::new().single_open(true);
    ex.set_open("a", true);
    ex.set_open("b", true); // opening b closes a
    assert!(ex.is_open("b") && !ex.is_open("a"));
}

#[test]
fn stepper_clamps_to_range() {
    let mut s = Stepper::new(5).range(0, 10).step(3);
    assert_eq!(s.value(), 5);
    s.set_value(100);
    assert_eq!(s.value(), 10, "clamped to max");
    s.set_value(-5);
    assert_eq!(s.value(), 0, "clamped to min");
    assert_eq!(s.state_json()["step"], 3);
}

#[test]
fn pagination_pages_and_range() {
    let mut p = Pagination::new(95, 10);
    assert_eq!(p.page_count(), 10, "95/10 → 10 pages");
    assert_eq!(p.range(), (0, 10));
    assert!(p.next_page());
    assert_eq!(p.range(), (10, 20));
    p.set_page(9);
    assert_eq!(p.range(), (90, 95), "last page is partial");
    assert!(!p.next_page(), "no page after the last");
    p.set_page(999);
    assert_eq!(p.page(), 9, "set_page clamps");
    assert_eq!(p.state_json()["page_count"], 10);
}

#[test]
fn navshell_state_reflects_navigation() {
    let mut s = NavShell::new("nav", sample_model());
    s.navigate("grid");
    s.navigate("table");
    let j = s.state_json();
    assert_eq!(j["route"], "table");
    assert_eq!(j["router"]["back_depth"], 2);
    assert_eq!(j["breadcrumb"], serde_json::json!(["Data", "Table"]));
}