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

nordisk/facett

facett-buildthing/tests/robot_galois.rs

//! **RED-when-broken robot proof for the bidirectional feature↔dependency pane.**
//!
//! Rendered headless through a REAL `egui_kittest` AccessKit harness, driven by
//! REAL clicks, and asserted on APPLIED OUTPUT: which crates are in the build,
//! which graph edges went dashed, which node ids exist. Never `is_ok()`, never
//! "a frame was drawn".
//!
//! The fixture is not hand-written. The `CrateAnalysis` values come out of
//! `nornir-feature-graph`'s real resolver run over real manifest text, so the
//! whole chain — manifest → `F` → minimal causes → pane → pixels — is under
//! test. Hand-writing the analyses would test the pane against invented facts
//! and prove nothing about the thing that was actually wrong.
//!
//! The shape modelled is the measured one. On `nornir-auth-policy`, 2026-08-01,
//! `cargo tree -p … -e no-dev --no-default-features`:
//!
//! | features | closure | reqwest |
//! |---|---|---|
//! | (none) | 84 | no |
//! | `online` | 84 | no |
//! | `auth-oidc` | 120 | no |
//! | `auth-oidc,online` | 161 | **yes** |

use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;

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

use facett_buildthing::galois_pane::{Action, CrateClass, FeatureAxis, GaloisPane};
use nornir_feature_graph::{FeatureGalois, ResolveConfig, Universe};

const POLICY: &str = r#"
[package]
name = "auth-policy"
version = "0.1.0"
[features]
online = ["oidc?/online", "oauth2?/online"]
auth-oidc = ["dep:oidc"]
auth-oauth2 = ["dep:oauth2"]
auth-bearer = ["dep:bearer"]
[dependencies]
serde = "1"
oidc = { version = "0.1", optional = true }
oauth2 = { version = "0.1", optional = true }
bearer = { version = "0.1", optional = true }
"#;

const OIDC: &str = r#"
[package]
name = "oidc"
version = "0.1.0"
[features]
online = ["dep:reqwest"]
[dependencies]
reqwest = { version = "0.12", optional = true }
"#;

const OAUTH2: &str = r#"
[package]
name = "oauth2"
version = "0.1.0"
[features]
online = ["dep:reqwest"]
[dependencies]
reqwest = { version = "0.12", optional = true }
"#;

/// Derive the axis with the REAL resolver. Nothing below is hand-asserted data.
fn axis() -> FeatureAxis {
    let mut u = Universe::default();
    u.add_manifest_str("edda", "auth-policy", POLICY).unwrap();
    u.add_manifest_str("edda", "oidc", OIDC).unwrap();
    u.add_manifest_str("edda", "oauth2", OAUTH2).unwrap();
    for n in ["serde", "reqwest", "bearer"] {
        u.add_manifest_str("r", n, &format!("[package]\nname = \"{n}\"\nversion = \"1.0.0\"\n"))
            .unwrap();
    }

    let g = FeatureGalois::new(&u, "auth-policy", ResolveConfig::default());
    let features: Vec<String> = g.features().iter().cloned().collect();
    let everything = g.pulls(g.features());

    let mut analyses = BTreeMap::new();
    for krate in &everything.packages {
        if krate == "auth-policy" {
            continue;
        }
        analyses.insert(krate.clone(), g.analyse(krate));
    }

    FeatureAxis {
        repo: "edda".into(),
        root: "auth-policy".into(),
        features,
        analyses,
        // Stand-in for Mímir's `feature_gate_facts` counts.
        gated_items: [("online".to_string(), 12usize), ("auth-oidc".to_string(), 30)]
            .into_iter()
            .collect(),
        sizes: BTreeMap::new(),
        provenance: "derived by nornir-feature-graph from manifests (test fixture)".into(),
    }
}

fn harness_for(pane: Rc<RefCell<GaloisPane>>) -> Harness<'static> {
    let p = pane.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(1100.0, 900.0))
        .build_ui(move |ui| {
            p.borrow_mut().ui(ui);
        });
    h.run();
    h.run();
    h
}

/// The fixture itself must have the shape the test relies on. If the resolver
/// ever stops finding the conjunction, every assertion below would pass
/// vacuously against a graph with no AND-group in it.
#[test]
fn the_fixture_really_contains_a_conjunction() {
    let a = axis();
    assert!(a.analyses.contains_key("reqwest"), "reqwest must be on the axis");
    assert_eq!(
        a.class_of("reqwest"),
        CrateClass::Conjunction,
        "reqwest must be a CONJUNCTION — analyses: {:?}",
        a.analyses.get("reqwest")
    );
    assert_eq!(a.class_of("serde"), CrateClass::Unconditional, "serde is the ⊥ case");
    assert_eq!(a.class_of("bearer"), CrateClass::Single, "bearer is a one-click removal");
    let causes = &a.analyses["reqwest"].causes;
    assert_eq!(causes.len(), 2, "two conjunctions: {causes:?}");
    assert!(causes.iter().all(|c| c.len() == 2), "both are pairs: {causes:?}");
}

/// **(1) Render non-blank** and **(2) click a FEATURE off** — the first adjoint.
/// A real AccessKit click on the `online` chip must make `reqwest` leave the
/// build and its conjunction edges go dashed.
#[test]
fn robot_clicks_a_feature_off_and_the_dependency_leaves() {
    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let mut h = harness_for(pane.clone());

    // (1) the clickable features reached the pixels as AccessKit nodes.
    for label in ["online", "auth-oidc", "auth-oauth2", "auth-bearer"] {
        assert!(
            h.query_all(By::new().label_contains(label)).count() >= 1,
            "the feature chip `{label}` must render as an AccessKit node"
        );
    }

    let before = pane.borrow().state_json();
    assert!(
        before["present"].as_array().unwrap().iter().any(|c| c == "reqwest"),
        "precondition: reqwest starts IN the build — else the click below proves nothing"
    );
    let dashed_before = before["edges"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|e| e["dashed"] == true)
        .count();
    assert_eq!(dashed_before, 0, "nothing is dashed before any click");

    // (2) THE CLICK — a real AccessKit activation, not a method call.
    h.get_by_label("online").click();
    h.run();
    h.run();

    let after = pane.borrow().state_json();
    assert_eq!(
        after["last_action"]["kind"], "toggle-feature",
        "the click routed to the feature adjoint: {:?}",
        after["last_action"]
    );
    assert_eq!(after["last_action"]["now_enabled"], false);

    // APPLIED OUTPUT: reqwest left.
    let absent: Vec<&str> =
        after["absent"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
    assert!(
        absent.contains(&"reqwest"),
        "clicking `online` off must remove reqwest — absent={absent:?}"
    );
    // And the picture changed with it: the conjunction edges are now dashed.
    let dashed_after = after["edges"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|e| e["dashed"] == true)
        .count();
    assert!(
        dashed_after > 0,
        "the graph must SHOW the loss — no edge went dashed, so the picture still \
         claims reqwest is pulled"
    );
    // The consequence, not just the toggle.
    let cons = after["consequence"].as_str().unwrap();
    assert!(
        cons.contains("12 gated items"),
        "the pane must report the gated items that stop compiling: {cons}"
    );
    assert_eq!(after["gated_lost"], 12);

    // (3) click it back ON — the graph returns.
    h.get_by_label("online").click();
    h.run();
    let back = pane.borrow().state_json();
    assert!(
        back["present"].as_array().unwrap().iter().any(|c| c == "reqwest"),
        "clicking `online` back on must restore reqwest"
    );
    assert_eq!(back["last_action"]["now_enabled"], true);
}

/// **The other adjoint** — click the DEPENDENCY, and the features that pull it
/// turn off. Routed through `on_click`, the same path `draw_graph`'s
/// `clicked_node` takes.
#[test]
fn robot_clicks_a_dependency_off_and_the_features_go() {
    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let mut h = harness_for(pane.clone());

    // The crate node must exist in the model that gets painted.
    let sj = pane.borrow().state_json();
    let ids: Vec<&str> =
        sj["node_ids"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
    assert!(ids.contains(&"crate:reqwest"), "the reqwest node must be drawn: {ids:?}");

    pane.borrow_mut().on_click("crate:reqwest");
    h.run();

    let after = pane.borrow().state_json();
    assert_eq!(
        after["last_action"]["kind"], "drop-crate",
        "clicking a dependency must run the drop adjoint: {:?}",
        after["last_action"]
    );
    let disabled: Vec<&str> = after["last_action"]["disabled"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    // Both conjunctions share `online`, so ONE feature is the whole plan. A UI
    // that turned off auth-oidc AND auth-oauth2 would be overstating the cost.
    assert_eq!(disabled, vec!["online"], "the minimum hitting set is one feature");
    assert!(
        after["absent"].as_array().unwrap().iter().any(|c| c == "reqwest"),
        "and it actually left the build"
    );
    assert_eq!(after["disabled"].as_array().unwrap().len(), 1);

    // Click it again → it comes back. The connection runs both ways.
    pane.borrow_mut().on_click("crate:reqwest");
    h.run();
    let back = pane.borrow().state_json();
    assert_eq!(back["last_action"]["kind"], "restore-crate");
    assert!(back["present"].as_array().unwrap().iter().any(|c| c == "reqwest"));
}

/// **The anti-lie assertion.** Clicking ONE half of an AND-group removes the
/// crate. A UI that said "not removable" here — which is what the old
/// `features_pulling(reqwest) == []` produced — would be repeating the measured
/// bug in pictures.
#[test]
fn breaking_one_conjunct_is_enough_and_the_graph_says_so() {
    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let mut h = harness_for(pane.clone());

    let sj = pane.borrow().state_json();
    let conj: Vec<&str> = sj["conjunction_nodes"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    assert_eq!(
        conj.len(),
        2,
        "both AND-groups must be DRAWN as their own nodes — a bipartite graph without \
         them cannot distinguish 'either' from 'both': {conj:?}"
    );
    // And badged, so it reads as a conjunction without hovering.
    assert_eq!(
        sj["badges"]["crate:reqwest"], "AND 2",
        "reqwest must carry a conjunction badge: {:?}",
        sj["badges"]
    );
    assert_eq!(sj["badges"]["crate:serde"], "ALWAYS", "the unconditional crate is badged ALWAYS");
    assert_eq!(sj["classes"]["reqwest"], "conjunction");
    assert_eq!(sj["classes"]["bearer"], "single");

    let node = conj[0].to_string();
    pane.borrow_mut().on_click(&node);
    h.run();

    let after = pane.borrow().state_json();
    assert_eq!(
        after["last_action"]["kind"], "break-conjunction",
        "clicking the AND node must break it: {:?}",
        after["last_action"]
    );
    let killed = after["last_action"]["disabled"].as_str().unwrap();
    let conjuncts: Vec<&str> = after["last_action"]["conjuncts"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    assert_eq!(conjuncts.len(), 2, "it is a pair");
    assert!(conjuncts.contains(&killed), "the disabled feature is one of the pair");
    assert_eq!(
        after["disabled"].as_array().unwrap().len(),
        1,
        "exactly ONE feature was turned off — one conjunct is enough for an AND-group"
    );
    // Whether reqwest left depends on which conjunct: killing `online` kills
    // both causes, killing `auth-oidc` leaves the oauth2 cause. Either way the
    // AND-node itself must now read unsatisfied, which is the honest picture.
    let unsat_edges = after["edges"]
        .as_array()
        .unwrap()
        .iter()
        .filter(|e| e["dashed"] == true)
        .count();
    assert!(unsat_edges > 0, "breaking a conjunct must dash its edges");
}

/// **The ⊥ case must refuse, out loud.** Clicking an unconditional dependency
/// changes nothing — and the pane says why instead of swallowing the click.
#[test]
fn an_unconditional_dependency_refuses_and_explains() {
    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let mut h = harness_for(pane.clone());

    pane.borrow_mut().on_click("crate:serde");
    h.run();

    let after = pane.borrow().state_json();
    assert_eq!(
        after["last_action"]["kind"], "refused",
        "an unconditional crate must REFUSE: {:?}",
        after["last_action"]
    );
    let reason = after["last_action"]["reason"].as_str().unwrap();
    assert!(
        reason.contains("unconditionally"),
        "the refusal must say why, in words: {reason}"
    );
    assert!(
        after["disabled"].as_array().unwrap().is_empty(),
        "nothing was turned off"
    );
    assert!(
        after["present"].as_array().unwrap().iter().any(|c| c == "serde"),
        "and serde is still in the build"
    );
    // The refusal reaches the pixels, not just the JSON.
    assert!(
        h.query_all(By::new().label_contains("unconditionally")).count() >= 1,
        "the refusal must RENDER — a swallowed click teaches the user nothing"
    );
}

/// Toggling everything off and back on must return to exactly the starting
/// state. A pane that leaks disabled features would drift away from the truth
/// the resolver computed.
#[test]
fn the_pane_returns_to_where_it_started() {
    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let mut h = harness_for(pane.clone());
    let start = pane.borrow().state_json();

    let feats: Vec<String> = pane.borrow().axis().features.clone();
    for f in &feats {
        pane.borrow_mut().toggle_feature(f);
    }
    h.run();
    let all_off = pane.borrow().state_json();
    assert_ne!(all_off["present"], start["present"], "turning everything off must change something");
    // Only the ⊥ crates survive — that is the definition of ⊥, applied.
    let present: BTreeSet<&str> =
        all_off["present"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
    let expected: BTreeSet<String> = pane
        .borrow()
        .axis()
        .analyses
        .iter()
        .filter(|(_, a)| a.status == nornir_feature_graph::Reachability::Unconditional)
        .map(|(k, _)| k.clone())
        .collect();
    let present_owned: BTreeSet<String> = present.iter().map(|s| s.to_string()).collect();
    assert_eq!(present_owned, expected, "with every feature off, exactly the ⊥ set remains");
    assert!(!expected.is_empty(), "the ⊥ set must not be empty, or this asserts nothing");

    for f in &feats {
        pane.borrow_mut().toggle_feature(f);
    }
    h.run();
    let back = pane.borrow().state_json();
    assert_eq!(back["present"], start["present"], "round trip must restore the build");
    assert_eq!(back["edges"], start["edges"], "and the picture");
}

/// A click on something that is not on the axis must be REFUSED by name, never
/// silently ignored.
#[test]
fn an_unknown_target_is_named_not_swallowed() {
    let mut pane = GaloisPane::new(axis());
    assert!(pane.toggle_feature("no-such-feature").is_empty());
    match pane.last_action() {
        Some(Action::Refused { target, reason }) => {
            assert_eq!(target, "no-such-feature");
            assert!(reason.contains("not a declared feature"), "{reason}");
        }
        other => panic!("expected a named refusal, got {other:?}"),
    }
    assert!(pane.toggle_crate("no-such-crate").is_empty());
    match pane.last_action() {
        Some(Action::Refused { target, .. }) => assert_eq!(target, "no-such-crate"),
        other => panic!("expected a named refusal, got {other:?}"),
    }
}

/// **See the frame.** Renders the pane before and after a click and writes both
/// PNGs, so the change is something a human can look at rather than take on
/// trust. Skips (loudly) with no wgpu backend, like the other facett pixel legs.
///
/// This is the harness every other robot suite in the fleet was told to model on, so
/// it carries all FOUR legs of the STINKY-19 R0 pixel-proof pattern, in order:
///
/// 1. compare **raw RGBA**, never encoded PNG bytes (encoder state is not a picture);
/// 2. a **same-input determinism control** first, which measures the noise floor;
/// 3. a **percent** floor — a caption or a port number cannot reach it;
/// 4. an **extent** floor in both axes — a title bar or a status line cannot reach it.
///
/// Legs 1, 3 and 4 live in `facett_core::golden::moved`; leg 2 is
/// `facett_core::golden::stable`.
///
/// # Measured on oden, 2026-08-22 (1100×760 = 836 000 px, wgpu)
///
/// | state                                                   | changed pixels        |
/// |---------------------------------------------------------|-----------------------|
/// | two renders of the SAME state (noise floor)              | **0** — 0.0000 %      |
/// | production frozen: `toggle_feature` made a no-op         | **271** — 0.0324 %    |
/// | intact click, bbox 884×483                               | **29 535** — 3.53 %   |
///
/// floor = 0.5 %: **15× above** the frozen state, **7× below** the intact one.
///
/// The frozen row is the finding, and it was measured on THIS file rather than argued.
/// The click was dead and 271 pixels still moved — the hover highlight under the
/// cursor — so `galois_before.png` and `galois_after.png` came out at different sizes
/// with different sha256. The assertion this test used to carry,
/// `assert_ne!(fs::read(&bp), fs::read(&ap))`, was therefore GREEN over a completely
/// dead click. That is why the shape had to change and not just the threshold.
#[test]
fn galois_pane_png_blobs_before_and_after_a_click() {
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
    std::fs::create_dir_all(&dir).unwrap();

    let pane = Rc::new(RefCell::new(GaloisPane::new(axis())));
    let p = pane.clone();
    let mut h = Harness::builder()
        .with_size(egui::vec2(1100.0, 760.0))
        .build_ui(move |ui| {
            p.borrow_mut().ui(ui);
        });
    h.run_steps(3);

    let before = match h.render() {
        Ok(i) => i,
        Err(e) => {
            eprintln!("[robot] no wgpu backend ({e}) — galois PNG blobs skipped");
            return;
        }
    };
    let bp = dir.join("galois_before.png");
    before.save(&bp).expect("save galois_before.png");

    // ── The DETERMINISM CONTROL, and it runs BEFORE the click on purpose. ──────────
    //
    // Render the pane a second time with NO input in between. Two renders of the same
    // state must be byte-identical, and the number that comes out of this is the NOISE
    // FLOOR the later before/after figure has to clear. Without it, a before/after
    // pixel assertion — even a good one — cannot tell the interaction from the host:
    // driver dithering, an unpinned clock or an easing animation would be credited to
    // the click. `moved`'s floors below were chosen against THIS measurement, so the
    // measurement has to actually be taken and not assumed.
    let control = h.render().expect("a second render of the SAME state");
    let (fw, fh) = (control.width(), control.height());
    if let Err(why) = facett_core::golden::stable(&before, &control) {
        panic!("[robot] galois pane determinism control FAILED\n  {why}");
    }
    eprintln!(
        "[robot] noise floor: 0 of {} px (0.0000 % of frame) — two renders of the same state \
         are byte-identical",
        u64::from(fw) * u64::from(fh)
    );

    // The click that matters: `online` off → reqwest leaves.
    h.get_by_label("online").click();
    h.run_steps(3);
    let after = h.render().expect("second render");
    let ap = dir.join("galois_after.png");
    after.save(&ap).expect("save galois_after.png");

    let (b, a) = (
        std::fs::metadata(&bp).unwrap().len(),
        std::fs::metadata(&ap).unwrap().len(),
    );
    assert!(b > 1000 && a > 1000, "both frames are real ({b} / {a} bytes)");
    // The PIXELS must differ, and differ ENOUGH to be the pane redrawing.
    //
    // This assertion used to be `assert_ne!(fs::read(&bp), fs::read(&ap))` — a whole-file
    // comparison of the ENCODED PNGs. That is the shape every other robot suite copied
    // from this test, and it cannot fail for the reason it claims: three suites were
    // independently measured passing it on a window title, an ephemeral port number and
    // a text legend. Encoded bytes also carry metadata and encoder state, so "the bytes
    // differ" is not even "the picture differs".
    //
    // `facett_core::golden::moved` is the one writer for this question. It compares
    // DECODED pixels and names the floor a real redraw has to clear: a share of the
    // frame, plus a bounding box with extent in BOTH axes so a thin strip (a title, a
    // status line) cannot satisfy it. Its own tests assert it rejects all three of the
    // observed false passes.
    let d = facett_core::golden::moved(&before, &after, facett_core::golden::MinChange::PANE)
        .expect("the `online` click must visibly redraw the galois pane");
    eprintln!(
        "[robot] pane moved: {} px ({:.2} % of frame), bbox {:?}, max channel delta {}",
        d.pixels,
        d.percent(),
        d.bbox,
        d.max_channel_delta
    );
    // And the state really did move, so the pixel difference is the right one.
    assert!(pane.borrow().state_json()["absent"]
        .as_array()
        .unwrap()
        .iter()
        .any(|c| c == "reqwest"));
    eprintln!("[robot] wrote {} ({b} B) and {} ({a} B)", bp.display(), ap.display());
}