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

nordisk/facett

facett-security/src/tests.rs

//! FC compliance tests for the security view: the state layer round-trips and
//! folds correctly (FC-1/FC-3), the `state_json` introspection dump exposes the
//! **Block** verdict + findings as DATA (the headless-assertion contract), and a
//! real `egui_kittest` render proves the painted verdict pill + finding rows ride
//! into the AccessKit tree as queryable, value-carrying nodes (FC-4/FC-6).

use super::*;

// ── FC-1 / FC-3: the whole observable state is serializable + round-trips ──────

#[test]
fn state_round_trips_through_serde() {
    let v = SecurityView::local();
    let json = serde_json::to_string(v.state()).expect("state serializes");
    let back: SecurityState = serde_json::from_str(&json).expect("state deserializes");
    assert_eq!(v.state(), &back, "SecurityState is a faithful round-trip (FC-3)");
}

#[test]
fn verdict_and_severity_fold_by_max() {
    // Declaration order is strictness order, so folding is `max()` — the same
    // contract a modgunn consumer relies on.
    assert!(Verdict::Pass < Verdict::Warn && Verdict::Warn < Verdict::Block);
    assert!(Severity::Info < Severity::Critical);
    let rep = SecurityView::local().report().clone();
    assert_eq!(rep.max_severity(), Some(Severity::Critical), "worst finding is Critical");
    assert_eq!(rep.findings.iter().map(|f| f.severity).max(), rep.max_severity());
}

#[test]
fn verdict_and_severity_parse_round_trip() {
    for v in [Verdict::Pass, Verdict::Warn, Verdict::Block] {
        assert_eq!(Verdict::parse(v.tag()), Some(v));
    }
    for s in [Severity::Info, Severity::Low, Severity::Medium, Severity::High, Severity::Critical] {
        assert_eq!(Severity::parse(s.tag()), Some(s));
    }
    assert_eq!(Verdict::parse("FAILED"), Some(Verdict::Block));
    assert_eq!(Severity::parse("moderate"), Some(Severity::Medium));
    assert_eq!(Verdict::parse("nonsense"), None);
}

// ── FC-2 / FC-9: mutation only via Msg/update; render produces no hidden state ──

#[test]
fn select_msg_toggles_and_filter_narrows() {
    let mut v = SecurityView::local();
    assert_eq!(v.selected(), None);
    let eff = v.update(Msg::Select(Some("CVE-2024-31337".into())));
    assert!(eff.is_empty(), "FC-8: the view emits no effects");
    assert_eq!(v.selected(), Some("CVE-2024-31337"));
    // Re-selecting the open finding clears it (toggle).
    v.update(Msg::Select(Some("CVE-2024-31337".into())));
    assert_eq!(v.selected(), None);

    // The severity floor narrows the visible set, worst-first.
    v.update(Msg::SetMinSeverity(Some(Severity::High)));
    let vis = v.state().visible_findings();
    assert_eq!(vis.len(), 2, "only Critical + High clear the High floor");
    assert_eq!(vis[0].severity, Severity::Critical, "sorted worst-first");
    assert!(vis.iter().all(|f| f.severity >= Severity::High));
}

#[test]
fn scale_is_clamped() {
    let mut v = SecurityView::local();
    v.update(Msg::SetScale(99.0));
    assert_eq!(v.scale(), 4.0, "scale clamps to the 0.25..=4.0 band");
    v.update(Msg::SetScale(0.0));
    assert_eq!(v.scale(), 0.25);
}

// ── The headless-assertion contract: state_json exposes a BLOCK verdict +
//    the findings AS DATA (not pixels). ──────────────────────────────────────────

#[test]
fn state_json_exposes_block_verdict_and_findings_as_data() {
    let v = SecurityView::local();
    let js = v.state_json();

    assert_eq!(js["verdict"], "BLOCK", "the demo report is a Block");
    assert_eq!(js["max_severity"], "CRIT");
    assert_eq!(js["kev_count"], 1, "one KEV-listed finding");
    assert_eq!(js["findings_total"], 4);
    assert_eq!(js["findings_visible"], 4, "no filter → all visible");
    assert_eq!(js["severity_counts"]["critical"], 1);
    assert_eq!(js["severity_counts"]["high"], 1);

    // The findings ride into state_json as structured rows, worst-first.
    let findings = js["findings"].as_array().expect("findings array");
    assert_eq!(findings.len(), 4);
    let crit = &findings[0];
    assert_eq!(crit["id"], "CVE-2024-31337");
    assert_eq!(crit["severity"], "CRIT");
    assert_eq!(crit["severity_rank"], 4);
    assert_eq!(crit["package"], "openssl-sys@0.9.90");
    assert_eq!(crit["fix"], "0.9.92");
    assert_eq!(crit["kev"], true);
    assert!((crit["epss"].as_f64().unwrap() - 0.94).abs() < 1e-9);
    // Worst-first ordering holds across the whole list.
    let ranks: Vec<u64> = findings.iter().map(|f| f["severity_rank"].as_u64().unwrap()).collect();
    assert!(ranks.windows(2).all(|w| w[0] >= w[1]), "findings are sorted worst-first: {ranks:?}");
}

#[test]
fn selection_json_reflects_drilled_in_finding() {
    let mut v = SecurityView::local();
    assert_eq!(v.selection_json(), serde_json::Value::Null, "nothing selected → Null");
    v.update(Msg::Select(Some("RUSTSEC-2023-0071".into())));
    let sel = v.selection_json();
    assert_eq!(sel["id"], "RUSTSEC-2023-0071");
    assert_eq!(sel["severity"], "HIGH");
    assert_eq!(sel["package"], "rsa@0.9.2");
}

// ── The canonical `Elm` trait + headless harness (facett-core), proven against
//    THIS reference component: apply a Vec<Msg>, snapshot state() — no egui/GPU. ──

#[test]
fn elm_harness_drives_msgs_and_snapshots_state() {
    use facett_core::harness;

    // The security view drives through the shared `Elm` trait exactly as it does
    // through its inherent methods (the trait just delegates). Feed inputs, read
    // state — the FC-2 → FC-3 property, headless.
    let mut v = SecurityView::local();
    let snap = harness::snapshot(
        &mut v,
        [
            Msg::SetMinSeverity(Some(Severity::High)),
            Msg::Select(Some("CVE-2024-31337".into())),
            Msg::SetScale(2.0),
        ],
    );
    assert_eq!(snap.min_severity, Some(Severity::High));
    assert_eq!(snap.selected.as_deref(), Some("CVE-2024-31337"));
    assert_eq!(snap.scale, 2.0);
    // Only Critical + High clear the High floor (state is the source of truth).
    assert_eq!(snap.visible_findings().len(), 2);

    // `snapshot_json` is the same state, serialized (FC-3) — no render in the loop.
    let mut v2 = SecurityView::local();
    let js = harness::snapshot_json(&mut v2, [Msg::SetMinSeverity(Some(Severity::Critical))]);
    assert_eq!(js["min_severity"], "critical");
}

#[test]
fn elm_harness_proptest_style_over_msg_sequences() {
    use facett_core::harness;

    // proptest-style (dependency-free deterministic LCG): generate many Msg
    // sequences, drive the reference component headlessly, and assert the FC
    // invariants hold — no panic, state stays well-formed, serde round-trips, and
    // the selection-toggle / clamp laws match an independent reference. This is
    // contract §5.1 (property layer over `update`), driven via the shared harness.
    let ids = ["CVE-2024-31337", "RUSTSEC-2023-0071", "GHSA-wxr5-93ch-7f92", "RUSTSEC-2024-0006"];
    let sevs = [Severity::Info, Severity::Low, Severity::Medium, Severity::High, Severity::Critical];
    let mut rng: u64 = 0xD1B54A32D192ED03;
    let mut next = || {
        rng ^= rng << 13;
        rng ^= rng >> 7;
        rng ^= rng << 17;
        rng
    };

    for _ in 0..300 {
        let mut v = SecurityView::local();
        let mut expect_sel: Option<String> = None;
        let len = (next() % 16) as usize;
        let msgs: Vec<Msg> = (0..len)
            .map(|_| match next() % 3 {
                0 => {
                    let id = ids[(next() % ids.len() as u64) as usize].to_string();
                    let new = Some(id.clone());
                    // Reference toggle law: re-selecting the open id clears it.
                    expect_sel = if expect_sel == new { None } else { new };
                    Msg::Select(Some(id))
                }
                1 => {
                    let choice = next() % (sevs.len() as u64 + 1);
                    Msg::SetMinSeverity((choice < sevs.len() as u64).then(|| sevs[choice as usize]))
                }
                _ => Msg::SetScale((next() % 1000) as f32 / 100.0),
            })
            .collect();

        let snap = harness::snapshot(&mut v, msgs);
        // Invariant 1: selection tracks the independent toggle reference.
        assert_eq!(snap.selected, expect_sel);
        // Invariant 2: scale stays inside the clamp band whatever was fed.
        assert!((0.25..=4.0).contains(&snap.scale), "scale escaped clamp: {}", snap.scale);
        // Invariant 3: the filter never invents findings; visible ⊆ all, worst-first.
        let vis = snap.visible_findings();
        assert!(vis.len() <= snap.report.findings.len());
        assert!(vis.windows(2).all(|w| w[0].severity >= w[1].severity), "visible not worst-first");
        // Invariant 4: state round-trips through serde (FC-3).
        let json = serde_json::to_string(&snap).unwrap();
        let back: SecurityState = serde_json::from_str(&json).unwrap();
        assert_eq!(back, snap);
    }
}

// ── FC-4 / FC-6: a real headless render — the painted verdict pill + finding
//    rows must be queryable, labelled, value-carrying AccessKit nodes. ───────────

#[test]
fn robot_sees_block_verdict_and_finding_rows_as_nodes() {
    use egui::accesskit::Role;
    use egui_kittest::Harness;
    use egui_kittest::kittest::{NodeT, Queryable};

    let mut view = SecurityView::local();
    let mut harness = Harness::new_ui(move |ui| {
        view.ui(ui);
    });
    harness.run();

    // The painter-drawn verdict pill is now a queryable node carrying the verdict
    // tag as its label and the finding COUNT (4) as numeric_value (FC-4).
    let pill = harness.get_by_label("verdict: BLOCK").accesskit_node();
    assert_eq!(pill.role(), Role::Label, "the verdict pill exposes a Label node");
    assert_eq!(pill.numeric_value(), Some(4.0), "pill carries the finding count as value");

    // The Critical finding row is a selectable node whose numeric_value is the
    // severity rank (4 = Critical) — a robot reads the severity back as a number.
    let crit = harness.get_by_label("CVE-2024-31337: CRIT").accesskit_node();
    assert_eq!(crit.role(), Role::Button, "selectable finding row is a Button-role node");
    assert_eq!(crit.numeric_value(), Some(4.0), "row carries the severity rank");

    // The severity histogram (facett-stat sparkline lane) entered the tree too,
    // as an Image-role node whose value is the total finding count.
    let hist = harness.get_by_label("severity histogram · 4 findings · modal critical").accesskit_node();
    assert_eq!(hist.role(), Role::Image, "the histogram is an Image-role node");
    assert_eq!(hist.numeric_value(), Some(4.0), "histogram carries the total finding count");
}

/// **RED-when-broken: the uniform scale knob reaches the PIXELS.**
///
/// Everything else here is a state round-trip — set a value, read it back, see it in
/// `state_json`. Measured: deleting the scale application from the painter outright
/// left this whole crate's suite green, and `RenderReport::drew()` cannot see it
/// either (a glyph is four vertices at 6pt and at 60pt). So this asserts APPLIED
/// geometry: the area of the glyph triangles the painter really emitted, which must
/// grow roughly 4x when the scale doubles.
#[test]
fn scale_is_applied_to_the_drawn_text_not_just_stored() {
    use facett_core::harness;
    let ink = |s: f32| {
        let mut v = SecurityView::local();
        <_ as facett_core::Facet>::set_scale(&mut v, s);
        harness::headless_render(&mut v).textured_ink_area
    };
    let (small, big) = (ink(1.0), ink(2.0));
    assert!(small > 0.0, "the facet draws text at 1.0x ({small})");
    assert!(
        big > small * 2.0,
        "doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
         {big:.0} pt^2 ({:.2}x). A ratio near 1.0 means the knob is stored, clamped and \
         serialised but never applied to anything drawn.",
        big / small,
    );
}