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