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

nordisk/facett

facett-graphnav/tests/robot.rs

//! **Robot scenario + render proof for the GRAPH NAVIGATOR.** Drives the navigator
//! end-to-end through its `Msg` surface (the robot-addressable channel) asserting each
//! panel's observable state, then runs a real `egui_kittest` frame so the canvas
//! painter + AccessKit nodes execute (a node rides in as a queryable Button).

use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};

use facett_core::Facet;
use facett_core::harness::render_sized;
use facett_graphnav::{ExportFormat, GraphNav, LayoutKind, Metric, Msg, Partition, StyleBy};

/// The scripted robot scenario: query → select → style → layout → search → path →
/// filter/expand → export, asserting the state after each step.
#[test]
fn robot_drives_every_panel() {
    let mut nav = GraphNav::demo();

    // 1. Query bar: run a Cypher label match.
    nav.update(Msg::SetQuery("g.V().hasLabel('Person')".into()));
    nav.update(Msg::RunQuery);
    assert_eq!(nav.state_json()["query"]["result_nodes"], 6);

    // 2. Inspector: select a node.
    nav.update(Msg::SelectNode("p1".into()));
    assert_eq!(nav.state_json()["inspector"]["caption"], "Alice");

    // 3. Styling: colour by label.
    nav.update(Msg::SetStyle(StyleBy::Label));
    assert!(nav.state_json()["style"]["categories"].as_array().unwrap().len() >= 2);

    // 4. Layout switcher.
    nav.update(Msg::SetLayout(LayoutKind::Hierarchical));
    assert_eq!(nav.state_json()["canvas"]["layout"], "hierarchical");

    // 5. Search.
    nav.update(Msg::SetSearch("globex".into()));
    assert_eq!(nav.state_json()["search"]["match_count"], 1);

    // 6. Path highlight across the two clusters.
    nav.update(Msg::SetPath(Some(("p1".into(), "p5".into()))));
    let path = nav.state_json()["path"]["nodes"].as_array().unwrap().len();
    assert!(path >= 3, "a bridged shortest path was found");

    // 7. Filter + expand neighbourhood.
    nav.update(Msg::SetFilterLabel(Some("Company".into())));
    assert_eq!(nav.state_json()["canvas"]["visible_count"], 2);
    nav.update(Msg::ExpandNeighborhood("c1".into()));
    assert!(nav.state_json()["canvas"]["visible_count"].as_u64().unwrap() > 2);

    // 8. Export.
    nav.update(Msg::Export(ExportFormat::Dot));
    assert_eq!(nav.state_json()["export"]["last"], "dot");
}

#[test]
fn navigator_paints_nodes_into_the_accesskit_tree() {
    let mut nav = GraphNav::demo();
    // Grid layout keeps the labelled node buttons well-separated on the canvas.
    nav.update(Msg::SetLayout(LayoutKind::Grid));
    let mut harness = Harness::builder().with_size(egui::vec2(560.0, 480.0)).build_ui(move |ui| {
        nav.ui(ui);
    });
    harness.run();
    // Alice rode into the tree as a queryable Button (caption · label).
    let alice = harness.get_by_label("Alice · Person").accesskit_node();
    assert_eq!(alice.role(), egui::accesskit::Role::Button, "a canvas node is a queryable Button");
    // The company node is present too.
    assert!(harness.query_by_label("Acme · Company").is_some());
}

/// The **camera** scenario: pan, scroll-zoom, fit, and zoom-to-selection all move the
/// serialised transform (the pan/zoom the canvas paints with survives frames).
#[test]
fn robot_drives_the_camera() {
    let mut nav = GraphNav::demo();
    // A fresh camera sits at pan (0,0) zoom 1.
    assert_eq!(nav.state_json()["camera"]["zoom"], 1.0);

    // Scroll-zoom in, then pan.
    nav.update(Msg::CameraZoom(1.5));
    assert!(nav.state_json()["camera"]["zoom"].as_f64().unwrap() > 1.0);
    nav.update(Msg::CameraPan([0.2, -0.1]));
    let pan = nav.state_json()["camera"]["pan"].as_array().unwrap().clone();
    assert!((pan[0].as_f64().unwrap() - 0.2).abs() < 1e-5);

    // Fit resets pan+zoom.
    nav.update(Msg::FitView);
    assert_eq!(nav.state_json()["camera"]["zoom"], 1.0);
    assert_eq!(nav.state_json()["camera"]["pan"][0], 0.0);

    // Zoom-to-selection needs a selection: no-op then centres the node.
    assert!(!nav.update(Msg::ZoomToSelection));
    nav.update(Msg::SelectNode("p3".into()));
    assert!(nav.update(Msg::ZoomToSelection));
    let cam = nav.state_json()["camera"].clone();
    assert!((cam["zoom"].as_f64().unwrap() - 2.6).abs() < 1e-6, "snaps to the focus zoom");
    // The pan now offsets the node toward centre (non-zero unless the node is at origin).
    assert!(cam["pan"][0].as_f64().unwrap().abs() + cam["pan"][1].as_f64().unwrap().abs() > 0.0);
}

/// The **appearance** scenario: size nodes by a metric, colour by a continuous ramp,
/// then by a partition — asserting the encoding + that the bridge node grows/pops.
#[test]
fn robot_drives_appearance_ranking_and_partition() {
    let mut nav = GraphNav::demo();
    // Size by degree: the bridge node p3 ends up larger than a company leaf c1.
    nav.update(Msg::SetSizeBy(Some(Metric::Degree)));
    let st = nav.state_json();
    assert_eq!(st["appearance"]["size_by"], "degree");
    let node = |st: &serde_json::Value, id: &str| -> serde_json::Value {
        st["canvas"]["nodes"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
    };
    let r_p3 = node(&st, "p3")["radius"].as_f64().unwrap();
    let r_c1 = node(&st, "c1")["radius"].as_f64().unwrap();
    assert!(r_p3 > r_c1, "degree-sized bridge node is bigger: {r_p3} vs {r_c1}");

    // Continuous colour ramp by pagerank.
    nav.update(Msg::SetColorMetric(Some(Metric::PageRank)));
    assert_eq!(nav.state_json()["appearance"]["color_mode"], "metric:pagerank");

    // Partition colouring takes precedence + reports a class count.
    nav.update(Msg::SetPartition(Some(Partition::Louvain)));
    let st = nav.state_json();
    assert_eq!(st["appearance"]["partition"], "louvain");
    assert_eq!(st["appearance"]["color_metric"], serde_json::Value::Null, "partition clears the ramp");
    assert!(st["appearance"]["partition_count"].as_u64().unwrap() >= 2);
    assert!(st["appearance"]["color_mode"].as_str().unwrap().starts_with("partition:"));

    // Back to a categorical style — appearance falls through to StyleBy.
    nav.update(Msg::SetPartition(None));
    nav.update(Msg::SetColorMetric(None));
    nav.update(Msg::SetStyle(StyleBy::Label));
    assert_eq!(nav.state_json()["appearance"]["color_mode"], "style:label");
}

/// The **data-laboratory table view**: the navigator's node/edge RecordBatches feed a
/// real `facett_grid::Grid`, and grid row ⇄ canvas selection sync round-trips.
#[test]
fn table_view_feeds_facett_grid_and_syncs_selection() {
    let nav = GraphNav::demo();

    // The node batch becomes a real Arrow-backed Grid with a row per node.
    let nb = nav.node_batch();
    let node_grid = facett_grid::Grid::from_record_batch("nodes", &nb);
    assert_eq!(node_grid.data().nrows(), nav.state_json()["canvas"]["total_nodes"].as_u64().unwrap() as usize);
    let eb = nav.edge_batch();
    let edge_grid = facett_grid::Grid::from_record_batch("edges", &eb);
    assert_eq!(edge_grid.data().nrows(), nav.state_json()["canvas"]["total_edges"].as_u64().unwrap() as usize);

    // The schema surfaces the computed metric columns.
    let cols = nav.state_json()["table"]["node_columns"].as_array().unwrap().clone();
    for want in ["id", "label", "degree", "pagerank", "betweenness", "community"] {
        assert!(cols.iter().any(|c| c == want), "node table exposes {want}");
    }

    // grid → canvas: selecting node-table row 2 selects that node.
    let mut nav = nav;
    let row = 2usize;
    let msg = nav.select_node_row(row).expect("row in range");
    nav.update(msg);
    // canvas → grid: the selection maps back to the same table row.
    assert_eq!(nav.selected_node_row(), Some(row));
    assert_eq!(nav.state_json()["table"]["selected_node_row"], row as u64);
}

/// **GEXF / GraphML export + import** — the navigator serialises to Gephi's formats
/// (with computed metrics as attributes) and re-imports them into an equivalent graph.
#[test]
fn gexf_and_graphml_export_and_import_round_trip() {
    let nav = GraphNav::demo();

    // GEXF export carries the graph + the computed-metric attributes.
    let gexf = nav.export(ExportFormat::Gexf);
    assert!(gexf.contains("<gexf"));
    assert!(gexf.contains("title=\"pagerank\""), "computed metrics ride as GEXF attributes");
    let re = GraphNav::import("reimport", ExportFormat::Gexf, &gexf).expect("re-import GEXF");
    assert_eq!(re.state_json()["canvas"]["total_nodes"], nav.state_json()["canvas"]["total_nodes"]);
    assert_eq!(re.state_json()["canvas"]["total_edges"], nav.state_json()["canvas"]["total_edges"]);

    // GraphML likewise.
    let gml = nav.export(ExportFormat::GraphMl);
    assert!(gml.contains("<graphml"));
    let re2 = GraphNav::import("reimport", ExportFormat::GraphMl, &gml).expect("re-import GraphML");
    assert_eq!(re2.state_json()["canvas"]["total_nodes"], nav.state_json()["canvas"]["total_nodes"]);

    // The export panel advertises all five formats now.
    assert_eq!(nav.state_json()["export"]["formats"].as_array().unwrap().len(), 5);

    // Importing a write-only format errors rather than panicking.
    assert!(GraphNav::import("x", ExportFormat::Csv, "whatever").is_err());
}

/// The **stats panel** — whole-graph scalars, including the two metrics newly added to
/// facett-graphview::analysis (clustering coefficient + diameter / avg path length).
#[test]
fn stats_panel_reports_whole_graph_scalars() {
    let nav = GraphNav::demo();
    let s = nav.state_json()["stats"].clone();
    assert_eq!(s["nodes"], 8);
    assert_eq!(s["edges"], nav.state_json()["canvas"]["total_edges"]);
    assert_eq!(s["components"], 1, "the demo graph is one connected component");
    // The two KNOWS-triangles give a non-trivial clustering coefficient.
    assert!(s["avg_clustering"].as_f64().unwrap() > 0.0, "triangles cluster");
    // The bridged two-cluster graph has a finite diameter ≥ 2.
    assert!(s["diameter"].as_u64().unwrap() >= 2);
    assert!(s["avg_path_length"].as_f64().unwrap() > 1.0);
    // Louvain modularity is surfaced (the two clusters partition well).
    assert!(s["modularity"].as_f64().unwrap() > 0.0);
    assert!(s["density"].as_f64().unwrap() > 0.0 && s["density"].as_f64().unwrap() < 1.0);
}

#[test]
fn state_is_deterministic() {
    let a = GraphNav::demo().state_json();
    let b = GraphNav::demo().state_json();
    assert_eq!(a, b, "same graph ⇒ byte-identical state");
}

/// The id-sorted on-canvas node positions of a rendered frame — a comparable
/// fingerprint of *where the nodes sit on screen* (world x/y ×1000 → integers).
fn node_xy(state: &serde_json::Value) -> Vec<(String, i64, i64)> {
    let mut v: Vec<(String, i64, i64)> = state["canvas"]["nodes"]
        .as_array()
        .unwrap()
        .iter()
        .map(|n| {
            (
                n["id"].as_str().unwrap().to_string(),
                (n["x"].as_f64().unwrap() * 1000.0) as i64,
                (n["y"].as_f64().unwrap() * 1000.0) as i64,
            )
        })
        .collect();
    v.sort();
    v
}

/// **The self-driving robot-UI scenario for V.G** — the harness drives the navigator
/// through a scripted exploration, rendering a REAL egui frame after every step (the
/// self-driving Elm loop: drive a `Msg`, paint, observe), and asserting each frame both
/// **paints non-blank** (LAW #4) *and* the surface **visibly changes on screen** between
/// steps: the nodes relocate on a layout switch, the camera transform moves on focus,
/// and the query / inspector / cycle / similarity highlights light up. Every driven
/// frame emits one **testmatrix uiplane cell** (green iff it drew vertices over real
/// geometry) under `--features testmatrix`.
#[test]
fn robot_self_drives_vg_visibly_moving() {
    let size = (760.0, 600.0);
    let mut nav = GraphNav::demo();
    // (step-label, tessellated vertices, geometry drawn) per driven frame.
    let mut frames: Vec<(&str, usize, usize)> = Vec::new();

    // Frame 0 — the navigator opens on the force layout; the canvas is not blank.
    let f0 = render_sized(&mut nav, size);
    assert!(f0.vertices > 0, "the opening canvas is not blank");
    let pos_force = node_xy(&f0.state);
    frames.push(("open", f0.vertices, f0.state["canvas"]["visible_count"].as_u64().unwrap() as usize));

    // Step 1 — run a query: the Person result set lights up (state changes, still paints).
    nav.update(Msg::SetQuery("g.V().hasLabel('Person')".into()));
    nav.update(Msg::RunQuery);
    let f1 = render_sized(&mut nav, size);
    assert_eq!(f1.state["query"]["result_nodes"], 6);
    assert!(f1.vertices > 0);
    frames.push(("query", f1.vertices, 6));

    // Step 2 — select the bridge node: the inspector populates on screen.
    nav.update(Msg::SelectNode("p3".into()));
    let f2 = render_sized(&mut nav, size);
    assert_eq!(f2.state["inspector"]["caption"], "Carol");
    assert!(f2.vertices > 0);
    frames.push(("select", f2.vertices, 1));

    // Step 3 — switch layout: the nodes VISIBLY MOVE (their screen positions change).
    nav.update(Msg::SetLayout(LayoutKind::Radial));
    let f3 = render_sized(&mut nav, size);
    let pos_radial = node_xy(&f3.state);
    assert_ne!(pos_force, pos_radial, "a layout switch relocates the nodes on screen");
    assert!(f3.vertices > 0);
    frames.push(("layout", f3.vertices, f3.state["canvas"]["visible_count"].as_u64().unwrap() as usize));

    // Step 4 — focus the selection: the camera transform moves the viewport.
    nav.update(Msg::ZoomToSelection);
    let f4 = render_sized(&mut nav, size);
    assert!(f4.state["camera"]["zoom"].as_f64().unwrap() > 1.0, "focus zooms the camera in");
    let pan_moved = f4.state["camera"]["pan"][0].as_f64().unwrap().abs()
        + f4.state["camera"]["pan"][1].as_f64().unwrap().abs();
    assert!(pan_moved > 0.0, "focus pans the camera to centre the node");
    assert!(f4.vertices > 0);
    frames.push(("focus", f4.vertices, 1));

    // Step 5 — highlight a directed cycle: the amber cycle ring lights a triangle.
    nav.update(Msg::FitView);
    nav.update(Msg::HighlightCycle(Some(0)));
    let f5 = render_sized(&mut nav, size);
    let on_cycle =
        f5.state["canvas"]["nodes"].as_array().unwrap().iter().filter(|n| n["on_cycle"] == true).count();
    assert_eq!(on_cycle, 3, "the highlighted 3-cycle rings three nodes");
    assert!(f5.vertices > 0);
    frames.push(("cycle", f5.vertices, on_cycle));

    // Step 6 — find the nodes similar to the selection: the teal similarity rings appear.
    nav.update(Msg::FindSimilar(5));
    let f6 = render_sized(&mut nav, size);
    let sim = f6.state["similarity"]["count"].as_u64().unwrap();
    assert!(sim >= 1, "the bridge node has neighbourhood-overlap peers");
    assert!(f6.vertices > 0);
    frames.push(("similar", f6.vertices, sim as usize));

    // Every driven frame drew real geometry over real graph content → the surface is
    // alive at each step, never a black pane.
    assert!(frames.iter().all(|&(_, verts, geom)| verts > 0 && geom > 0));

    // testmatrix UIPLANE cells: one functional row per driven step (green iff the frame
    // drew vertices over non-zero geometry — emit_render's contract), plus a headline
    // row for the whole self-driving scenario.
    #[cfg(feature = "testmatrix")]
    {
        for (step, verts, geom) in &frames {
            facett_core::testmatrix::emit_render(&format!("facett-graphnav.vg.{step}"), *verts, *geom);
        }
        facett_core::testmatrix::emit(
            "facett-graphnav.vg",
            "self_drives_visibly",
            frames.iter().all(|&(_, v, g)| v > 0 && g > 0) && pos_force != pos_radial,
            &format!("steps={} moved_on_layout_switch={}", frames.len(), pos_force != pos_radial),
        );
    }
}

/// The **analysis toolbar** (cycle browser + similar-to-selection) actually paints into
/// the AccessKit tree — the on-screen controls the self-driving scenario drives are
/// really there (a queryable render proof, not a silent headless assert).
#[test]
fn analysis_toolbar_paints_into_the_accesskit_tree() {
    let mut nav = GraphNav::demo();
    nav.update(Msg::SelectNode("p1".into()));
    nav.update(Msg::HighlightCycle(Some(0)));
    nav.update(Msg::FindSimilar(5));
    // The analysis controls now live behind the ⚙ settings fold — UNFOLD it so they lay out.
    nav.update(Msg::ToggleSettings);
    assert!(nav.state_json()["chrome"]["settings_open"].as_bool().unwrap(), "settings unfolded");
    let mut harness = Harness::builder().with_size(egui::vec2(700.0, 560.0)).build_ui(move |ui| {
        nav.ui(ui);
    });
    harness.run();
    // The similar-to-selection control rode into the tree (the analysis toolbar painted).
    assert!(harness.query_by_label("≈ top-5").is_some(), "the similar-node control is present");
    // And the similar-node clear control is present (both analysis controls painted).
    assert!(harness.query_by_label("clear").is_some(), "the similar-node clear control is present");
}

/// **Chrome fold — defaults to FOLDED and toggles.** The ⚙ "modes & look" settings surface
/// starts collapsed (so the graph is the main attraction on open) and `Msg::ToggleSettings`
/// opens/closes it. Observable via `state_json["chrome"]["settings_open"]`.
#[test]
fn chrome_settings_fold_defaults_folded_and_toggles() {
    let mut nav = GraphNav::demo();
    // Default: FOLDED — the graph dominates on open, controls tucked behind the small icon.
    assert_eq!(nav.state_json()["chrome"]["settings_open"], false, "settings default FOLDED");
    // Toggle opens it.
    assert!(nav.update(Msg::ToggleSettings));
    assert_eq!(nav.state_json()["chrome"]["settings_open"], true, "toggle unfolds the settings");
    // Toggle again folds it back.
    assert!(nav.update(Msg::ToggleSettings));
    assert_eq!(nav.state_json()["chrome"]["settings_open"], false, "toggle re-folds");
}

/// **Folded ⚙ does NOT lay out the control rows.** With settings folded (the default) the
/// layout/colour/size/ramp/partition + analysis controls are NOT rendered into the tree —
/// they collapse to the single ⚙ header. Unfolding surfaces them. A render proof that the
/// fold really removes the stacked rows (not merely hides a flag).
#[test]
fn folded_chrome_omits_control_rows_unfold_reveals_them() {
    // Folded (default): the layout-switcher label + the similar control are absent from the tree.
    let mut folded = GraphNav::demo();
    folded.update(Msg::SelectNode("p1".into()));
    let mut h_folded = Harness::builder().with_size(egui::vec2(760.0, 600.0)).build_ui(move |ui| {
        folded.ui(ui);
    });
    h_folded.run();
    assert!(h_folded.query_by_label("hierarchical").is_none(), "folded: layout controls not laid out");
    assert!(h_folded.query_by_label("≈ top-5").is_none(), "folded: analysis controls not laid out");
    // But the ⚙ settings header IS present (the small fold affordance).
    assert!(h_folded.query_by_label("⚙ modes & look").is_some(), "folded: the ⚙ settings icon is present");

    // Unfolded: the very same controls now lay out.
    let mut open = GraphNav::demo();
    open.update(Msg::SelectNode("p1".into()));
    open.update(Msg::ToggleSettings);
    let mut h_open = Harness::builder().with_size(egui::vec2(760.0, 600.0)).build_ui(move |ui| {
        open.ui(ui);
    });
    h_open.run();
    assert!(h_open.query_by_label("hierarchical").is_some(), "unfolded: layout controls laid out");
    assert!(h_open.query_by_label("≈ top-5").is_some(), "unfolded: analysis controls laid out");
}

/// **RED-when-broken canvas-dominance proof.** The graph canvas is the MAIN attraction: with
/// the chrome folded (default) it must claim the DOMINANT fraction of the viewport — it is
/// never squeezed to the bottom by the toolbar (the exact bug being fixed). `render` records
/// the canvas rect vs the viewport into `state_json["chrome"]`; here we render a real frame
/// and assert the canvas owns ≥ 70 % of the view height, sits near the top, and is far taller
/// than the chrome above it. If the old toolbar-stacked layout ever returns (canvas appended
/// after unbounded control rows), the canvas fraction collapses and this test goes RED.
#[test]
fn canvas_claims_the_dominant_area_when_folded() {
    let view_h = 600.0_f64;
    let mut nav = GraphNav::demo();
    // Fold is the default; render one real, sized frame so the chrome telemetry is recorded.
    let f = render_sized(&mut nav, (760.0, view_h as f32));
    let chrome = &f.state["chrome"];
    assert_eq!(chrome["settings_open"], false, "default is folded");

    let canvas_h = chrome["canvas"]["h"].as_f64().unwrap();
    let canvas_y = chrome["canvas"]["y"].as_f64().unwrap();
    let vh = chrome["view"]["h"].as_f64().unwrap();
    let frac = chrome["dominant_frac"].as_f64().unwrap();

    // The canvas is real (not clipped to zero/negative height).
    assert!(canvas_h > 0.0, "canvas has positive height: {canvas_h}");
    // It DOMINATES the viewport — the graph, not the chrome, owns the space.
    assert!(frac >= 0.70, "canvas claims the dominant area: {frac:.3} of {vh:.0}px (h={canvas_h:.0})");
    // The chrome above the canvas is small — the canvas top sits high, not pushed far down Y.
    assert!(canvas_y <= vh * 0.30, "canvas is not pushed down the page: top y={canvas_y:.0} of {vh:.0}");
    // And the canvas is far taller than the chrome band above it.
    assert!(canvas_h > canvas_y * 2.0, "canvas dwarfs the chrome band: canvas_h={canvas_h:.0} chrome≈{canvas_y:.0}");

    // Unfolding trades some canvas for controls — but the canvas stays present + positive.
    nav.update(Msg::ToggleSettings);
    let f2 = render_sized(&mut nav, (760.0, view_h as f32));
    let frac_open = f2.state["chrome"]["dominant_frac"].as_f64().unwrap();
    assert!(f2.state["chrome"]["canvas"]["h"].as_f64().unwrap() > 0.0, "canvas survives unfolding");
    assert!(frac_open < frac, "unfolding the chrome takes area from the canvas ({frac_open:.3} < {frac:.3})");
}