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