Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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