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_ui_kittest.rs

//! **ROBOT-UI for the REAL popviz widget tree** — headless `egui_kittest`,
//! REAL AccessKit clicks, assertions on APPLIED OUTPUT (the widget tree that
//! resulted, and the *pixels* the progress bar filled).
//!
//! ## Why this exists
//!
//! `tests/robot.rs` and `tests/robot_ui_atoms.rs` are named "robot" but drive
//! [`PopvizState`] through `update_json` and read `state_json` back. That is a
//! **state round-trip**: it asserts that a setter set a field. No widget is
//! built, no click is delivered, and no pixel is rendered — so every one of them
//! passes against a UI that draws nothing at all. Until this file, nothing in
//! `knut-popviz` had ever asserted that a click reaches the pixels.
//!
//! ## What is asserted here
//!
//! * a REAL AccessKit activation of the `Iceberg` chip, with the **widget tree
//!   before and after** as the applied output — the `warehouse:` field must be
//!   ABSENT before and PRESENT after (and gone again on the way back);
//! * the progress bar's **fill in pixels**, measured off the rendered frame at
//!   four fractions and asserted to track the fraction *proportionally*. Nothing
//!   sits on the identity value: 1.0 is only ever the denominator, and 0.25 and
//!   0.60 must land at their own share of it. A bar wired to a constant, or one
//!   that only ever draws empty-or-full, fails;
//! * the rendered frame before and after a click must **differ**.
//!
//! `--features gui` (the same feature the desktop app is built with). LAW 4:
//! wgpu, never a GL path. Run:
//!
//! ```bash
//! cargo test -p knut-popviz --features gui --test robot_ui_kittest -- --nocapture
//! ```
#![cfg(feature = "gui")]

use std::cell::RefCell;
use std::rc::Rc;

use egui_kittest::Harness;
use egui_kittest::kittest::{By, Queryable};
use knut_popviz::app::PopvizApp;
use knut_popviz::{IngestMode, LoadParams, Phase, PopvizState, Target};

/// Serialize the headless **GPU** renders across this binary: each
/// `Harness::render()` builds its own wgpu device, and several at once races
/// some drivers into a SIGSEGV (the facett robot suites hold the same lock).
fn gpu_lock() -> std::sync::MutexGuard<'static, ()> {
    static L: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
    L.get_or_init(|| std::sync::Mutex::new(()))
        .lock()
        .unwrap_or_else(|e| e.into_inner())
}

fn params() -> LoadParams {
    LoadParams {
        url: "redis://127.0.0.1:6399".into(),
        graph: "robot_ui".into(),
        scale: 0.0001,
        drop: false,
        target: Target::Falkor,
        ingest: IngestMode::Auto,
        tls: None,
    }
}

/// A fresh app, with the autostart flag and the state-file dump defused so the
/// harness never kicks off a real load or writes outside the scratch dir.
fn app() -> Rc<RefCell<PopvizApp>> {
    let mut a = PopvizApp::new(params());
    a.autostart = false;
    a.state_path = std::env::temp_dir().join("knut_popviz_robot_state.json");
    Rc::new(RefCell::new(a))
}

fn harness_for(app: Rc<RefCell<PopvizApp>>) -> Harness<'static> {
    let a = app.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(1000.0, 800.0))
        .build_ui(move |ui| a.borrow_mut().ui(ui));
    h.run();
    h.run();
    h
}

/// **A real click on the target chip, asserted on the widget tree it produced.**
///
/// The `warehouse:` field is conditional on `to_iceberg`. Its absence before the
/// click is asserted first — without that, "it is present after" would pass on a
/// UI that always showed it.
#[test]
fn robot_clicks_the_iceberg_chip_and_the_warehouse_field_appears() {
    let app = app();
    let mut h = harness_for(app.clone());

    // The chips reached the pixels as AccessKit nodes at all.
    for label in ["FalkorDB", "Iceberg", "drop first", "▶  Start load"] {
        assert!(
            h.query_all(By::new().label_contains(label)).count() >= 1,
            "`{label}` must render as an AccessKit node"
        );
    }

    // ── precondition: FalkorDB is selected, so there is NO warehouse field. ──
    assert!(
        !app.borrow().to_iceberg,
        "the app starts on the FalkorDB target"
    );
    assert_eq!(
        h.query_all(By::new().label_contains("warehouse")).count(),
        0,
        "the warehouse field must be ABSENT before the click — otherwise the \
         assertion below proves nothing"
    );

    // ── THE CLICK — a real AccessKit activation. ──
    h.get_by_label("Iceberg").click();
    h.run();
    h.run();

    // ── APPLIED OUTPUT: the widget tree grew the conditional field. ──
    assert!(app.borrow().to_iceberg, "the click flipped the target");
    assert!(
        h.query_all(By::new().label_contains("warehouse")).count() >= 1,
        "clicking Iceberg must SHOW the warehouse path field — a click that \
         changed the flag but not the tree is a click the user cannot see"
    );

    // ── and back: clicking FalkorDB must remove it again. ──
    h.get_by_label("FalkorDB").click();
    h.run();
    h.run();
    assert!(!app.borrow().to_iceberg);
    assert_eq!(
        h.query_all(By::new().label_contains("warehouse")).count(),
        0,
        "the field must disappear again — a one-way toggle would leave a stale \
         path visible against the FalkorDB target"
    );
}

/// A real click on the `drop first` checkbox must reach `LoadParams::drop` —
/// the flag that decides whether the live load wipes the graph first.
#[test]
fn robot_clicks_drop_first_and_the_load_params_change() {
    let app = app();
    let mut h = harness_for(app.clone());

    assert!(!app.borrow().params.drop, "starts unchecked");
    h.get_by_label("drop first").click();
    h.run();
    assert!(
        app.borrow().params.drop,
        "the checkbox must reach LoadParams::drop — this is the flag that wipes \
         a live graph, so a click that did not land would silently skip the drop"
    );
    h.get_by_label("drop first").click();
    h.run();
    assert!(!app.borrow().params.drop, "and it toggles back");
}

/// Pose the shared state at `frac` of every label's total, so the overall
/// `PopvizState::fraction()` — and every bar the app draws — sits at `frac`.
fn pose(app: &Rc<RefCell<PopvizApp>>, frac: f32) {
    let mut a = app.borrow_mut();
    let mut s = a.state.lock().unwrap();
    s.phase = Phase::Vertices;
    s.elapsed_s = 1.0;
    for l in &mut s.labels {
        l.loaded = ((l.total as f64) * frac as f64).round() as usize;
    }
    for r in &mut s.rels {
        r.loaded = 0;
    }
    drop(s);
    a.running = true;
}

/// Every `y` row that carries at least one selection-fill pixel, grouped into
/// contiguous BANDS. The app paints selection fill in three places, top to
/// bottom: the selected target chip, the overall `ProgressBar`, and the
/// `facett-pop` per-label bars. Splitting them is what makes the assertions
/// below able to name WHICH bar went dead — a whole-frame pixel count cannot,
/// because either surface alone keeps the total tracking the fraction.
fn fill_bands(img: &image::RgbaImage, c: egui::Color32) -> Vec<(u32, u32)> {
    let (r, g, b) = (c.r(), c.g(), c.b());
    let hit = |x: u32, y: u32| {
        let p = img.get_pixel(x, y).0;
        p[0] == r && p[1] == g && p[2] == b
    };
    let rows: Vec<bool> = (0..img.height())
        .map(|y| (0..img.width()).any(|x| hit(x, y)))
        .collect();
    let mut bands = Vec::new();
    let mut start: Option<u32> = None;
    for (y, &on) in rows.iter().enumerate() {
        match (on, start) {
            (true, None) => start = Some(y as u32),
            (false, Some(s)) => {
                bands.push((s, y as u32 - 1));
                start = None;
            }
            _ => {}
        }
    }
    if let Some(s) = start {
        bands.push((s, img.height() - 1));
    }
    bands
}

/// The widest contiguous run of selection fill on any row inside `y0..=y1` —
/// i.e. the **fill edge**, in pixels. This is the projected quantity: a
/// `ProgressBar` renders `fraction * bar_width` of fill.
fn widest_run(img: &image::RgbaImage, c: egui::Color32, y0: u32, y1: u32) -> u32 {
    let (r, g, b) = (c.r(), c.g(), c.b());
    let mut best = 0u32;
    for y in y0..=y1.min(img.height() - 1) {
        let (mut run, mut cur) = (0u32, 0u32);
        for x in 0..img.width() {
            let p = img.get_pixel(x, y).0;
            if p[0] == r && p[1] == g && p[2] == b {
                cur += 1;
                run = run.max(cur);
            } else {
                cur = 0;
            }
        }
        best = best.max(run);
    }
    best
}

/// Selection-fill pixels inside `y0..=y1`.
fn fill_pixels_in(img: &image::RgbaImage, c: egui::Color32, y0: u32, y1: u32) -> usize {
    let (r, g, b) = (c.r(), c.g(), c.b());
    let mut n = 0;
    for y in y0..=y1.min(img.height() - 1) {
        for x in 0..img.width() {
            let p = img.get_pixel(x, y).0;
            if p[0] == r && p[1] == g && p[2] == b {
                n += 1;
            }
        }
    }
    n
}

/// **THE PIXEL PROJECTION.** `PopvizState::fraction()` → the *width* of the
/// overall bar's filled region, and the per-label bars' fill, measured off the
/// rendered frame at four fractions.
///
/// This is the assertion `robot.rs` structurally cannot make: it reads back the
/// fraction it just wrote. A `ProgressBar::new(0.0)` satisfies every existing
/// "robot" test in this crate and fails here.
///
/// **The two surfaces are measured SEPARATELY, on purpose.** The first draft of
/// this test counted selection-fill pixels over the WHOLE frame — and stayed
/// green when the overall bar was pinned to `0.0` (the twelve `facett-pop`
/// per-label bars carried the total) *and* when the per-label bars were pinned
/// to zero (the overall bar carried it). A guard that passes as long as *some*
/// bar works cannot report either one dead. Bands fix that by construction.
///
/// Four fractions, and **1.0 is only ever the denominator**: 0.25 and 0.60 must
/// each land at their own share of the full bar, so the test never sits on the
/// identity value.
#[test]
fn robot_progress_bar_fill_tracks_the_fraction_in_pixels() {
    let _gpu = gpu_lock();
    let app = app();

    // frac → (overall-bar fill WIDTH, per-label fill pixels)
    let mut measured: Vec<(f32, u32, usize)> = Vec::new();
    let mut sel = egui::Color32::PLACEHOLDER;
    // Band layout is taken from the FULL frame, where every bar is painted; the
    // widget heights do not depend on the fraction, so it holds for all four.
    let mut layout: Option<((u32, u32), (u32, u32))> = None;

    for &f in &[1.0f32, 0.0, 0.25, 0.60] {
        pose(&app, f);
        let a = app.clone();
        let mut h = Harness::builder()
            .with_size(egui::vec2(1000.0, 800.0))
            .build_ui(move |ui| a.borrow_mut().ui(ui));
        h.run_steps(4);
        sel = h.ctx.global_style().visuals.selection.bg_fill;
        let img = h.render().unwrap_or_else(|e| {
            panic!(
                "no wgpu backend for the headless render ({e}) — this leg must \
                 not be skipped silently; run it on a host with wgpu"
            )
        });
        // Sanity: the state really is where we posed it.
        let got = app.borrow().state.lock().unwrap().fraction();
        assert!(
            (got - f).abs() < 0.02,
            "posed fraction {f}, state reports {got}"
        );

        if layout.is_none() {
            let bands = fill_bands(&img, sel);
            assert!(
                bands.len() >= 3,
                "at fraction 1.0 the frame must show at least three selection-fill \
                 bands (the target chip, the overall bar, the per-label bars); \
                 found {bands:?}. The layout assumption this test rests on is gone."
            );
            let main = bands[1];
            let labels = (bands[2].0, bands.last().unwrap().1);
            eprintln!("[robot-popviz] bands {bands:?} → main {main:?} per-label {labels:?}");
            layout = Some((main, labels));
        }
        let (main, labels) = layout.expect("layout from the full frame");
        measured.push((
            f,
            widest_run(&img, sel, main.0, main.1),
            fill_pixels_in(&img, sel, labels.0, labels.1),
        ));
    }

    eprintln!(
        "[robot-popviz] selection fill {sel:?} · (frac, main-bar fill width px, per-label fill px) {measured:?}"
    );

    // measured is in the order 1.0, 0.0, 0.25, 0.60 — index it by meaning.
    let by = |f: f32| {
        *measured
            .iter()
            .find(|(g, _, _)| (*g - f).abs() < 1e-6)
            .unwrap_or_else(|| panic!("no measurement at {f}"))
    };
    let (full, empty, quarter, mid) = (by(1.0), by(0.0), by(0.25), by(0.60));

    // ── surface 1: the OVERALL bar, by fill WIDTH in pixels. ──
    let (fw, ew, qw, mw) = (full.1, empty.1, quarter.1, mid.1);
    assert!(
        fw > 300,
        "a full overall bar must span most of a 1000 px window, got {fw} px"
    );
    assert!(
        ew < fw / 20,
        "an EMPTY overall bar must draw (almost) no fill: {ew} px against a full {fw} px"
    );
    assert!(
        ew < qw && qw < mw && mw < fw,
        "the overall bar's fill width must grow strictly with the fraction: \
         0.0→{ew}, 0.25→{qw}, 0.60→{mw}, 1.0→{fw}"
    );
    let qs = qw as f64 / fw as f64;
    let ms = mw as f64 / fw as f64;
    assert!(
        (0.18..0.33).contains(&qs),
        "fraction 0.25 must fill about a quarter of the OVERALL bar's width, got \
         {qs:.3} ({qw}/{fw} px)"
    );
    assert!(
        (0.52..0.69).contains(&ms),
        "fraction 0.60 must fill about three fifths of the OVERALL bar's width, \
         got {ms:.3} ({mw}/{fw} px)"
    );

    // ── surface 2: the twelve per-label bars, by fill AREA. ──
    let (fp, ep, qp, mp) = (full.2, empty.2, quarter.2, mid.2);
    assert!(
        fp > 1_000,
        "the per-label bars must paint a real area, got {fp} px"
    );
    assert!(
        ep * 20 < fp,
        "with nothing loaded the per-label bars must be (near) empty: {ep} against {fp}"
    );
    assert!(
        ep < qp && qp < mp && mp < fp,
        "the per-label fill must grow strictly with the fraction: \
         0.0→{ep}, 0.25→{qp}, 0.60→{mp}, 1.0→{fp}"
    );
    let qps = (qp - ep) as f64 / (fp - ep) as f64;
    let mps = (mp - ep) as f64 / (fp - ep) as f64;
    assert!(
        (0.15..0.36).contains(&qps),
        "the per-label bars at 0.25 must fill about a quarter, got {qps:.3} ({qp}/{fp} px)"
    );
    assert!(
        (0.50..0.72).contains(&mps),
        "the per-label bars at 0.60 must fill about three fifths, got {mps:.3} ({mp}/{fp} px)"
    );
}

/// **See the frame.** Render the app before and after a real click and write both
/// PNGs, then assert the BYTES differ. Two identical frames would mean the click
/// changed the state and not the picture — exactly what a `state_json` assertion
/// cannot see.
#[test]
fn robot_png_blobs_before_and_after_the_target_click_differ() {
    let _gpu = gpu_lock();
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
    std::fs::create_dir_all(&dir).expect("snapshot dir");

    let app = app();
    pose(&app, 0.42);
    let a = app.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(1000.0, 800.0))
        .build_ui(move |ui| a.borrow_mut().ui(ui));
    h.run_steps(4);

    let before = h.render().expect("headless wgpu render");
    let bp = dir.join("popviz_before.png");
    before.save(&bp).expect("save popviz_before.png");

    h.get_by_label("Iceberg").click();
    h.run_steps(4);
    let after = h.render().expect("second render");
    let ap = dir.join("popviz_after.png");
    after.save(&ap).expect("save popviz_after.png");

    let (b, a_len) = (
        std::fs::metadata(&bp).unwrap().len(),
        std::fs::metadata(&ap).unwrap().len(),
    );
    assert!(
        b > 1000 && a_len > 1000,
        "both frames are real ({b} / {a_len} bytes)"
    );
    assert_ne!(
        std::fs::read(&bp).unwrap(),
        std::fs::read(&ap).unwrap(),
        "the rendered frame did not change — the click never reached the pixels"
    );
    // And the state moved with it, so the pixel difference is the right one.
    assert!(app.borrow().to_iceberg);
    eprintln!(
        "[robot-popviz] wrote {} ({b} B) and {} ({a_len} B)",
        bp.display(),
        ap.display()
    );
}

/// The three "robot" suites must agree about the same app. This drives the app
/// through a real render and then asserts the introspection JSON the *other*
/// suites rely on describes the app that was actually drawn — closing the gap
/// where `state_json` could drift away from the widget tree unnoticed.
#[test]
fn robot_state_json_describes_the_app_that_was_rendered() {
    let app = app();
    let mut h = harness_for(app.clone());

    h.get_by_label("Iceberg").click();
    h.run();
    h.run();

    // The click set the UI toggle. `state_json`'s `target`, however, is written
    // by `start_load`, not by the chip — so it still reports the ORIGINAL target.
    // Asserting that honestly is the point: a test that expected them to agree
    // would be asserting a coupling the app does not have.
    let json = app.borrow().state.lock().unwrap().state_json();
    assert_eq!(
        json["target"], "falkor",
        "state_json's target reflects the LOAD, not the chip — the chip only \
         takes effect at start_load. If this ever flips, the two surfaces have \
         been coupled and the robot suites must be told."
    );
    assert!(
        app.borrow().to_iceberg,
        "…while the UI toggle really did move"
    );

    // Fields the other suites assert on must be present in the rendered app.
    assert_eq!(json["labels"].as_object().unwrap().len(), 12);
    assert_eq!(json["phase"], "idle");
    let _ = PopvizState::new(0.0001); // the type the harness and the suites share
}