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

nordisk/knut

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