crates/knut-popviz/tests/robot.rs
//! **Auto robot-test** for the population preview UI — drives the load and
//! asserts on the introspection `state_json()` as the post-condition oracle,
//! with no display. The egui app (`src/main.rs`) is a thin shell over the same
//! shared `PopvizState`, so asserting its JSON is asserting what's on screen.
//!
//! Two tiers, and **both run in CI**:
//!
//! * [`robot_synthetic_load_reaches_done`] / [`robot_synthetic_load_surfaces_failure`]
//! — the **default, container-free** tier. It drives the *same* observable
//! `PopvizState` the GUI renders through a whole load's phase progression
//! (idle → loading vertices → indexing → done, and the failure path) using
//! **synthetic in-memory** progress — no FalkorDB, no Podman — and asserts the
//! `state_json` transitions. This is the tier that keeps CI exercising the UI
//! contract on every run.
//! * [`robot_load_matrix_reaches_done`] — the **live** tier: `#[ignore]`, needs a
//! FalkorDB at `$KNUT_FALKORDB_URL` (default `redis://127.0.0.1:6379`). It runs
//! the real `run_load` bulk-loader end to end. Run explicitly:
//! `cargo test -p knut-popviz --test robot -- --ignored --nocapture`
use std::sync::{Arc, Mutex};
use knut_popviz::{IngestMode, LoadParams, Phase, PopvizState, Target, run_load};
fn falkor_url() -> String {
std::env::var("KNUT_FALKORDB_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into())
}
/// Assert every done-state invariant on a load's final `state_json` (shared by
/// the synthetic + FalkorDB tiers so both hold the UI to the same bar).
fn assert_done_state(json: &serde_json::Value, ctx: &str) {
assert_eq!(json["phase"], "done", "{ctx}: load finished — {json}");
assert!(json["error"].is_null(), "{ctx}: no error — {json}");
assert_eq!(json["loaded"], json["total"], "{ctx}: every row loaded");
assert!(json["total"].as_u64().unwrap() > 0, "{ctx}: non-empty");
assert!(
json["rows_per_s"].as_f64().unwrap() > 0.0,
"{ctx}: throughput measured"
);
let labels = json["labels"].as_object().unwrap();
assert_eq!(labels.len(), 12, "{ctx}: all 12 labels present");
for (name, p) in labels {
assert_eq!(p["loaded"], p["total"], "{ctx}: {name} fully loaded");
}
// Edges: a done graph is connected — every Person->entity relationship loaded.
assert_eq!(
json["edges_loaded"], json["edges_total"],
"{ctx}: every edge loaded"
);
assert!(
json["edges_loaded"].as_u64().unwrap() > 0,
"{ctx}: the graph has relationships, not a disconnected vertex set"
);
let edges = json["edges"].as_object().unwrap();
assert_eq!(edges.len(), 11, "{ctx}: one rel per non-Person label");
for (rel, p) in edges {
assert_eq!(p["loaded"], p["total"], "{ctx}: {rel} fully loaded");
assert_eq!(p["src"], "Person", "{ctx}: {rel} sourced at Person");
}
assert!(
json["placeholders"].as_array().unwrap().is_empty(),
"{ctx}: no placeholders when done"
);
}
/// **Default (container-free) tier.** Drive the *same* observable `PopvizState`
/// the egui app renders through a full load's phase progression using synthetic
/// in-memory progress — **no FalkorDB, no Podman** — and assert the `state_json`
/// transitions at each phase. This is the tier CI runs on every push, so the
/// population-viewer UI contract is exercised without any container.
#[test]
fn robot_synthetic_load_reaches_done() {
for (i, scale) in [0.00005_f64, 0.0001].into_iter().enumerate() {
let mut s = PopvizState::new(scale);
let ctx = format!("synthetic scale {scale} (#{i})");
// ── Phase 0: idle baseline — nothing loaded, the empty-state placeholder. ──
let json = s.state_json();
assert_eq!(json["phase"], "idle", "{ctx}: starts idle");
assert_eq!(json["loaded"], 0, "{ctx}: nothing loaded yet");
assert_eq!(json["fraction"].as_f64().unwrap(), 0.0, "{ctx}: 0% at idle");
assert_eq!(
json["labels"].as_object().unwrap().len(),
12,
"{ctx}: 12 labels sized up front"
);
assert!(s.total() > 0, "{ctx}: scaled totals are non-zero");
assert!(
json["placeholders"]
.as_array()
.unwrap()
.iter()
.any(|p| p == "not started"),
"{ctx}: idle → 'not started' placeholder"
);
// ── Phase 1: loading vertices — advance the first label partway. The load
// is underway, so it's between 0% and 100% and no longer 'not started'. ──
s.phase = Phase::Vertices;
s.labels[0].loaded = s.labels[0].total / 2 + 1;
s.elapsed_s = 0.5;
let json = s.state_json();
assert_eq!(json["phase"], "loading vertices", "{ctx}: phase advanced");
let frac = json["fraction"].as_f64().unwrap();
assert!(frac > 0.0 && frac < 1.0, "{ctx}: mid-load fraction {frac}");
assert!(
json["placeholders"].as_array().unwrap().is_empty(),
"{ctx}: placeholder cleared once running"
);
// ── Phase 2: loading edges — all vertices in, relationships going in.
// The demo is a connected 12-label graph, so this phase must exist and
// report non-zero edges_loaded before indexing. ──
for l in &mut s.labels {
l.loaded = l.total;
}
s.phase = Phase::Edges;
s.rels[0].loaded = s.rels[0].total; // one rel underway
s.elapsed_s = 0.9;
let json = s.state_json();
assert_eq!(json["phase"], "loading edges", "{ctx}: edge phase");
assert!(
json["edges_loaded"].as_u64().unwrap() > 0,
"{ctx}: edges are being loaded, not skipped"
);
assert!(
json["edges_loaded"].as_u64().unwrap() < json["edges_total"].as_u64().unwrap(),
"{ctx}: mid-edge-load"
);
// ── Phase 3: indexing — every vertex AND edge in, indexes building. ──
for r in &mut s.rels {
r.loaded = r.total;
}
s.phase = Phase::Indexes;
s.elapsed_s = 1.0;
let json = s.state_json();
assert_eq!(json["phase"], "indexing", "{ctx}: indexing phase");
assert_eq!(json["loaded"], json["total"], "{ctx}: all vertices loaded");
assert_eq!(
json["fraction"].as_f64().unwrap(),
1.0,
"{ctx}: 100% loaded"
);
assert_eq!(
json["edges_loaded"], json["edges_total"],
"{ctx}: all edges loaded before indexing"
);
// ── Phase 4: done — the terminal state the GUI shows on success. ──
s.phase = Phase::Done;
s.elapsed_s = 1.5;
assert_done_state(&s.state_json(), &ctx);
}
}
/// The **failure path**: a load that errors out lands in `failed` with the error
/// surfaced in `state_json` (the red banner the GUI shows). No container needed.
#[test]
fn robot_synthetic_load_surfaces_failure() {
let mut s = PopvizState::new(0.0001);
// Simulate a mid-load connection error.
s.phase = Phase::Vertices;
s.labels[0].loaded = 10;
s.elapsed_s = 0.3;
// ... then the loader fails.
s.phase = Phase::Failed;
s.error = Some("connect: connection refused".into());
let json = s.state_json();
assert_eq!(json["phase"], "failed", "phase reflects the failure");
assert!(!json["error"].is_null(), "the error is surfaced for the UI");
assert_eq!(
json["error"], "connect: connection refused",
"the exact error text is carried"
);
// A partial load is still reflected (the progress the user saw before it broke).
assert!(
json["loaded"].as_u64().unwrap() > 0,
"partial progress kept"
);
}
/// **Default (container-free) tier — the real `run_load`.** With the `iceberg`
/// feature, the population loads into a local Iceberg warehouse (via skade) in a
/// scratch tempdir — **no FalkorDB, no Podman, no container** — yet it drives the
/// *same* `run_load` the live tier does, all the way to `done`, and is held to
/// the identical `assert_done_state` bar. This is a genuine end-to-end robot
/// drive (not synthetic field-poking) that still runs unattended in CI whenever
/// `--features iceberg` is enabled:
/// `cargo test -p knut-popviz --features iceberg --test robot`
#[cfg(feature = "iceberg")]
#[test]
fn robot_iceberg_load_reaches_done_no_db() {
let dir = tempfile::tempdir().expect("scratch warehouse dir");
let scale = 0.00005_f64;
let state = Arc::new(Mutex::new(PopvizState::new(scale)));
let params = LoadParams {
// The Iceberg target ignores `url` (it writes to the warehouse path).
url: String::new(),
graph: "robot_popviz_iceberg".into(),
scale,
// Fresh tempdir — nothing to drop; leave the guard's dir intact.
drop: false,
target: Target::Iceberg {
warehouse: dir.path().to_string_lossy().into_owned(),
},
ingest: IngestMode::Auto, // ignored by the Iceberg target
tls: None,
};
// Blocking, as in the live tier; the no-op `wake` stands in for UI repaint.
run_load(params, Arc::clone(&state), || {});
let s = state.lock().unwrap();
// Same done-state bar the synthetic + FalkorDB tiers hold the UI to.
assert_done_state(&s.state_json(), "iceberg (no DB)");
}
/// **Live tier.** The matrix: a few scales, each asserted to reach `done` with
/// every row loaded and a clean, fully-populated `state_json`.
#[test]
#[ignore]
fn robot_load_matrix_reaches_done() {
for (i, scale) in [0.00005_f64, 0.0001].into_iter().enumerate() {
let state = Arc::new(Mutex::new(PopvizState::new(scale)));
let params = LoadParams {
url: falkor_url(),
graph: format!("robot_popviz_{i}"),
scale,
drop: true,
target: Target::Falkor,
// Auto → an empty (`--drop`ped) graph exercises the bulk fast lane.
ingest: IngestMode::Auto,
tls: None,
};
// run_load is blocking; the no-op `wake` stands in for the UI repaint.
run_load(params, Arc::clone(&state), || {});
let s = state.lock().unwrap();
// Same done-state bar the synthetic tier holds the UI to.
assert_done_state(&s.state_json(), &format!("falkor scale {scale}"));
}
}
/// **End-to-end test-matrix emit proof.** The `testmatrix_wiring` test drives the
/// `functional_status` *wrapper* directly; this asserts the REAL call site —
/// `run_load`'s success arm — actually emits a GREEN row into the nornir matrix
/// during a genuine (container-free Iceberg) load. Closes the gap between "the
/// wrapper forwards" and "the loader calls it": the remote/introspection surface
/// is exercised IN CODE, unattended, so the matrix stays honest without a human
/// or a DB. Needs both features:
/// `cargo test -p knut-popviz --features "iceberg testmatrix" --test robot`
#[cfg(all(feature = "iceberg", feature = "testmatrix"))]
#[test]
fn robot_iceberg_load_emits_a_green_matrix_row() {
use nornir_testmatrix::{drain_functional_rows, status};
// Clear any rows other tests in this binary left in the process-global buffer.
let _ = drain_functional_rows();
let dir = tempfile::tempdir().expect("scratch warehouse dir");
let scale = 0.00005_f64;
let state = Arc::new(Mutex::new(PopvizState::new(scale)));
let params = LoadParams {
url: String::new(),
graph: "robot_popviz_matrix".into(),
scale,
drop: false,
target: Target::Iceberg {
warehouse: dir.path().to_string_lossy().into_owned(),
},
ingest: IngestMode::Auto, // ignored by the Iceberg target
tls: None,
};
run_load(params, Arc::clone(&state), || {});
assert_done_state(&state.lock().unwrap().state_json(), "iceberg matrix-emit");
// The load's Ok arm must have emitted exactly one GREEN load row end-to-end.
let rows = drain_functional_rows();
let load_rows: Vec<_> = rows
.iter()
.filter(|r| r.suite == "knut-popviz/run_load" && r.test_name == "population_load")
.collect();
assert!(
!load_rows.is_empty(),
"run_load emitted a population_load row: {rows:?}"
);
let green = load_rows
.iter()
.find(|r| status::is_green(&r.status))
.expect("run_load's success arm emitted a GREEN row");
assert_eq!(green.aspect, "functional");
assert_eq!(green.metric, 0.0, "green rows carry metric 0.0");
}