facett-demo/tests/navgraph_demo.rs
//! **The navigation graph on the real demo** (navgraph phase 1, `.nornir/navgraph-design.md` §6).
//!
//! `facett-core`'s unit tests prove the numbering, the splines and the JSON on a graph
//! built by hand. This binary proves them on the demo as it really draws
//! (`DemoState::draw_app`, the path the native binary and the wasm bundle share):
//!
//! 1. **the declared-order law** — the demo declares its order (`demo_nav_order`), so no
//! node of the spine (app, controls, deck, tab strip, tabs, active pane) shows `~` or
//! `?`. Fields INSIDE a pane belong to their component and are phase 3; their count is
//! reported, not hidden.
//! 2. **the chord** — `Ctrl+G 1 . 2 . 2 . 3 Enter` focuses the third tab (1.2.1 is the group row), and `Ctrl+G 1 . 2
//! Enter` (a node that is not a widget) focuses its first focusable descendant.
//! 3. **the AccessKit label** — `nav: 1.2.2.3 tabs.<title> [focused]` is in the tree.
//! 4. **the JSON snapshot** — the spine of `navgraph::to_json` against
//! `tests/snapshots/navgraph_demo.json` (`UPDATE_SNAPSHOTS=1` re-blesses it).
//!
//! ```bash
//! cargo test -p facett-demo --test navgraph_demo
//! ```
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{By, Queryable};
use facett_core::navgraph::{self, NavKind, NavMode, NAV_ATOM};
use facett_demo::DemoState;
fn harness() -> Harness<'static, DemoState> {
// The navgraph shares the dev-id gate; pin it open so the proof holds in release too.
facett_core::devid::force_gate(true);
let mut demo = DemoState::new();
demo.attract_enabled = false;
demo.show_about = false;
let mut h = Harness::builder()
.with_size(egui::vec2(1280.0, 1000.0))
.build_ui_state(|ui, demo: &mut DemoState| demo.draw_app(ui), demo);
navgraph::set_mode(&h.ctx, NavMode::Labels);
h.run_steps(3);
h
}
/// Is `id` inside a pane (component-owned, phase 3)?
fn in_pane(g: &navgraph::NavGraph, i: usize) -> bool {
let mut at = g.nodes()[i].parent;
while let Some(p) = at {
let n = &g.nodes()[p as usize];
if n.kind == NavKind::Pane {
return true;
}
at = n.parent;
}
false
}
#[test]
fn the_declared_spine_has_no_guessed_or_undeclared_step() {
let h = harness();
navgraph::with(&h.ctx, |g| {
assert!(g.len() > 10, "the demo registered almost nothing: {} nodes", g.len());
let app = g.find_id("facett-demo").expect("the app node");
assert_eq!(app.number, "1");
assert_eq!(g.find_id("facett-demo.controls").unwrap().number, "1.1");
assert_eq!(g.find_id("facett-demo.deck").unwrap().number, "1.2");
// The two-level bar (2026-10-01): the group row is 1.2.1, the active group's
// tab strip 1.2.2, the active pane takes the wildcard slot after them.
assert_eq!(g.find_id("facett-demo.deck.groups").unwrap().number, "1.2.1");
assert_eq!(g.find_id("facett-demo.deck.tabs").unwrap().number, "1.2.2");
let pane = g
.nodes()
.iter()
.find(|n| n.kind == NavKind::Pane)
.expect("the active facet registered as a pane");
assert_eq!(pane.number, "1.2.3", "the active pane takes the deck's wildcard slot");
let groups = g.nodes().iter().filter(|n| n.id.starts_with("facett-demo.deck.groups.")).count();
assert_eq!(groups, facett_demo::TAB_GROUPS.len(), "every group is a field of the group row");
let spine_bad: Vec<String> = g
.nodes()
.iter()
.enumerate()
.filter(|(i, n)| !n.step.is_declared() && !n.harvested && !in_pane(g, *i))
.map(|(_, n)| format!("{} = {}", n.id, n.number))
.collect();
assert!(spine_bad.is_empty(), "the declared spine shows ~ or ?: {spine_bad:?}");
let tabs = g.nodes().iter().filter(|n| n.id.starts_with("facett-demo.deck.tabs.")).count();
assert!(tabs > 20, "the graph group's tabs are fields of the strip: {tabs}");
let pane_owned = g.not_declared().filter(|n| !n.harvested).count();
let harvested = g.nodes().iter().filter(|n| n.harvested).count();
assert!(harvested > 5, "the control strip's plain egui widgets are harvested: {harvested}");
eprintln!(
"navgraph: {} nodes, {tabs} tabs, {pane_owned} component-owned atoms still ~ (phase 3), {harvested} harvested plain-egui nodes (~ until declared)",
g.len()
);
});
}
fn chord(h: &mut Harness<'_, DemoState>, number: &str) {
h.key_press_modifiers(Modifiers::CTRL, Key::G);
h.step();
for c in number.chars() {
let k = match c {
'.' => Key::Period,
d => Key::from_name(&d.to_string()).expect("digit key"),
};
h.key_press(k);
h.step();
}
h.key_press(Key::Enter);
h.run_steps(3);
}
#[test]
fn the_chord_focuses_the_numbered_node_and_the_tree_says_so() {
let mut h = harness();
let (want_id, want_widget, title) = navgraph::with(&h.ctx, |g| {
let n = g.find_number("1.2.2.3").expect("a third tab");
(n.id.clone(), n.widget.expect("a tab is a widget"), n.local().to_string())
});
chord(&mut h, "1.2.2.3");
let focused = h.ctx.memory(|m| m.focused());
assert_eq!(focused, Some(want_widget), "Ctrl+G 1.2.2.3 did not focus {want_id}");
navgraph::with(&h.ctx, |g| {
assert_eq!(g.selected(), want_id);
assert!(g.find_id(&want_id).unwrap().focused, "the graph marks the focused node");
});
let label = format!("{NAV_ATOM} 1.2.2.3 tabs.{title} {}", navgraph::FOCUSED_MARK);
assert!(
h.query_all(By::new().label(&label)).next().is_some(),
"the AccessKit tree does not say `{label}`"
);
// A node that is not a widget focuses its first focusable descendant.
chord(&mut h, "1.2");
let first_tab = navgraph::with(&h.ctx, |g| g.find_number("1.2.1.1").and_then(|n| n.widget));
assert_eq!(h.ctx.memory(|m| m.focused()), first_tab, "Ctrl+G 1.2 → the deck's first focusable node");
navgraph::with(&h.ctx, |g| assert_eq!(g.selected(), "facett-demo.deck"));
// The group row is addressable too: 1.2.1.2 is the second group.
let (gid, gwidget) = navgraph::with(&h.ctx, |g| {
let n = g.find_number("1.2.1.2").expect("a second group");
(n.id.clone(), n.widget.expect("a group label is a widget"))
});
assert_eq!(gid, format!("facett-demo.deck.groups.{}", facett_demo::TAB_GROUPS[1].0));
chord(&mut h, "1.2.1.2");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(gwidget), "Ctrl+G 1.2.1.2 focuses the second group");
}
#[test]
fn the_spine_json_matches_its_snapshot() {
let h = harness();
let j = navgraph::to_json(&h.ctx);
// The spine: every node that is not component-owned and not one of the (many, and
// growing) tab fields. Ids, numbers, kinds and steps; rects are left out because
// the font atlas decides them to the pixel.
let spine: Vec<serde_json::Value> = j["nodes"]
.as_array()
.unwrap()
.iter()
.filter(|n| n["depth"].as_u64().unwrap() <= 3 && n["harvested"] != true)
.map(|n| {
serde_json::json!({
"id": n["id"], "number": n["number"], "kind": n["kind"], "step": n["step"],
})
})
.collect();
let got = serde_json::to_string_pretty(&serde_json::json!({ "mode": j["mode"], "spine": spine })).unwrap();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/navgraph_demo.json");
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
std::fs::write(&path, format!("{got}\n")).unwrap();
return;
}
let want = std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("missing snapshot {} — bless it with UPDATE_SNAPSHOTS=1", path.display()));
assert_eq!(got.trim(), want.trim(), "the navgraph spine changed; re-bless with UPDATE_SNAPSHOTS=1 if intended");
// The edges are there too.
assert!(!j["edges"]["contains"].as_array().unwrap().is_empty());
assert!(!j["edges"]["next"].as_array().unwrap().is_empty());
}
/// Off means off: no chip atoms, and nothing collected.
#[test]
fn with_the_overlay_off_nothing_is_painted_or_collected() {
let mut h = harness();
navgraph::set_mode(&h.ctx, NavMode::Off);
h.run_steps(2);
assert_eq!(h.query_all(By::new().label_contains(NAV_ATOM)).count(), 0);
assert_eq!(navgraph::with(&h.ctx, |g| g.len()), 0);
// Ctrl+Shift+G turns it back on.
h.key_press_modifiers(Modifiers::CTRL | Modifiers::SHIFT, Key::G);
h.run_steps(3);
assert_eq!(navgraph::mode(&h.ctx), NavMode::Labels);
assert!(h.query_all(By::new().label_contains(NAV_ATOM)).count() > 3);
}
/// **The component-owned `~`/`?` count over EVERY deck tab** (navgraph-design §9 "Left":
/// atoms inside a pane number in draw order until the component declares them). Each tab
/// is made active and drawn; the undeclared, non-harvested nodes inside its pane are
/// counted. The total may only go down: `CEILING` is the last measured value.
#[test]
fn every_tab_reports_its_undeclared_atoms_and_the_total_only_shrinks() {
/// Measured total (lower it when a component declares its atoms; never raise it).
// 198 of 210 on 2026-09-29 before components declared their atoms (`2450648`+lane A);
// 8 after `declare_own_draw_order` at 24 view entry points (22 files).
// 0 on 2026-09-29 once scene slots, the scene-in-a-pane, widgets, GFX v2 and map3d declared too.
const CEILING: usize = 0;
let mut h = harness();
let titles: Vec<String> = h.state().deck.titles().into_iter().map(str::to_string).collect();
let mut rows: Vec<(usize, String, usize)> = Vec::new();
for t in &titles {
h.state_mut().deck.set_active_by_title(t);
h.run_steps(3);
let (atoms, bad) = navgraph::with(&h.ctx, |g| {
let in_pane: Vec<usize> = (0..g.len()).filter(|&i| in_pane(g, i) && !g.nodes()[i].harvested).collect();
for &i in in_pane.iter().filter(|&&i| !g.nodes()[i].step.is_declared()) {
let n = &g.nodes()[i];
let p = n.parent.map(|p| g.nodes()[p as usize].id.as_str()).unwrap_or("");
eprintln!(" undeclared in {t}: {} = {} (parent {p})", n.id, n.number);
}
(in_pane.len(), in_pane.iter().filter(|&&i| !g.nodes()[i].step.is_declared()).count())
});
rows.push((bad, t.clone(), atoms));
}
let total: usize = rows.iter().map(|r| r.0).sum();
let atoms: usize = rows.iter().map(|r| r.2).sum();
rows.sort_by_key(|r| std::cmp::Reverse(r.0));
for (bad, t, n) in rows.iter().take(15) {
eprintln!(" {bad:4} undeclared of {n:4} atoms {t}");
}
eprintln!("navgraph sweep: {} tabs, {atoms} component-owned atoms, {total} undeclared (~/?)", titles.len());
// The ratchet is at its floor: with CEILING = 0, "may only go down" means "stays 0"
// (`total <= 0` on a usize is `total == 0`).
assert_eq!(total, CEILING, "the undeclared-atom count grew: {total} > {CEILING}");
}