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

nordisk/edda

crates/nornir-testmatrix/src/atom.rs

//! # atom-layer verifier (L0) — the per-atom oracle on top of [`SurfaceKind::UiAtom`]
//!
//! The marker/metro layers above are the *declared* truth — which UI surface ↔
//! which gRPC — but they are per-button / per-tab, so sub-panel widgets (the 🧬
//! tab's CloneEvents "populate status" + Jobs panels) slip through: an
//! `is not served` error-text *leaks* untested. The atom layer closes that by
//! enumerating the surface at **atom granularity** and **verifying** each against
//! what the live UI actually rendered.
//!
//! ## Where this sits relative to [`crate::discover`]
//!
//! `discover` already owns the atom **surface kind** ([`SurfaceKind::UiAtom`]) and
//! the simple enumerator/coverage pair ([`ui_atoms`](crate::discover::ui_atoms) /
//! [`covered_atoms`](crate::discover::covered_atoms)) that the autonom path feeds
//! from a live walk. This module adds the missing piece: a UI-agnostic
//! **verifier verdict** that turns raw observations into a covered set + NAMED
//! failures, reusing the EXISTING [`SurfaceKind::UiAtom`] surface kind — there is
//! exactly ONE atom variant.
//!
//! This module owns only the **generic CONCEPT** (UI-agnostic — no egui / kittest
//! / AccessKit here):
//!
//! 1. [`AtomState`] — the state axis (`empty` vs `populated`): a surface must be
//!    correct in BOTH the clean-slate state (no error atom) and the populated
//!    state (the expected atoms present, LAW 2 RAGNARÖK).
//! 2. [`AtomSpec`] + [`atom_surface`] — the **enumerator**: from a list of
//!    expected atoms per `(tab, atom, state)`, build [`SurfaceKind::UiAtom`]
//!    [`SurfaceNode`]s that drop straight into the existing
//!    [`Surface`](crate::discover::Surface) / gate.
//! 3. [`AtomObservation`] — the UI-agnostic record of what the walk SAW for one
//!    atom in one state (present? is it an error atom?). nornir's kittest walk
//!    produces these from the real AccessKit tree; the framework never sees a
//!    widget.
//! 4. [`verify_atoms`] — the **verifier verdict**: the generic oracle — *(a)* NO
//!    error atom in the `empty` state, *(b)* the expected atom PRESENT in the
//!    `populated` state — applied to the observations to yield the **covered**
//!    key-set (which feeds the existing
//!    [`compute_gap`](crate::discover::compute_gap) / coverage gate verbatim) plus
//!    the list of [`AtomFailure`]s that explain a RED. So the atom layer reuses the
//!    whole gate machinery: green ⟺ every atom × state ran AND was correct.
//!
//! ```text
//!   walk the UI ──▶ Vec<AtomObservation>  (UI-specific, lives in nornir)
//!                          │
//!        atom_surface(specs) ──▶ Surface (UiAtom nodes)
//!                          │           │
//!                   verify_atoms(specs, &obs) ──▶ covered set + failures
//!                          │
//!         compute_gap(surface, covered, allowlist) ──▶ GREEN ⟺ every atom covered
//! ```
//!
//! ## Key convention (ONE variant, ONE shape)
//!
//! Every node this module emits is a [`SurfaceKind::UiAtom`] with [`Mode::NA`].
//! The per-state granularity is folded into the node **id** as a suffix:
//!
//! ```text
//!   ui_atom:<tab>/<atom>/<state>@na
//! ```
//!
//! This is the SAME `kind:id@mode` shape every other surface uses, and a strict
//! extension of `discover`'s state-free `ui_atom:<tab>/<label>@na` id (the live
//! atom-walk path stays as-is; the verifier path carries the extra `/<state>`
//! axis). There is no second surface variant — `SurfaceKind::Atom` does not exist.
//!
//! Pure `std` + `serde`; every type round-trips so the verdict persists to the
//! warehouse like any other coverage fact.

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};

use crate::discover::{Mode, SurfaceKind, SurfaceNode};

/// The stable atom surface key: `"ui_atom:<tab>/<atom>/<state>@na"`. The state is
/// folded into the id so the key fits the standard `kind:id@mode` shape the gate
/// joins on (the thin/fat [`Mode`] axis stays NA — an atom's axis is its state).
fn atom_key(tab: &str, atom: &str, state: AtomState) -> String {
    format!(
        "{}:{}/{}/{}@{}",
        SurfaceKind::UiAtom.label(),
        tab,
        atom,
        state.label(),
        Mode::NA.label(),
    )
}

/// The state-bearing node id (`"<tab>/<atom>/<state>"`).
fn atom_id(tab: &str, atom: &str, state: AtomState) -> String {
    format!("{}/{}/{}", tab, atom, state.label())
}

/// The **state axis** of an atom surface. Every atom must be verified in both
/// states: the clean-slate `Empty` state (where an error atom like `is not served`
/// must NOT appear) and the `Populated` state (where the expected data atom MUST
/// appear — LAW 2 RAGNARÖK, no silently-blank pane).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AtomState {
    /// Clean-slate / empty registry — the state that leaks `is not served`,
    /// `Failed to load`, `unavailable` error atoms when a surface is unguarded.
    Empty,
    /// Populated — the surface has data; its expected atoms must be present.
    Populated,
}

impl AtomState {
    /// The stable tag used in the atom node's id slot / warehouse rows.
    pub fn label(self) -> &'static str {
        match self {
            AtomState::Empty => "empty",
            AtomState::Populated => "populated",
        }
    }

    pub fn parse(s: &str) -> Option<AtomState> {
        match s {
            "empty" => Some(AtomState::Empty),
            "populated" => Some(AtomState::Populated),
            _ => None,
        }
    }
}

/// One expected **atom** the gate enumerates: a widget (`atom`) that must behave
/// correctly inside `tab` in a given [`AtomState`]. The `expected_present` flag
/// encodes the oracle's polarity for this `(tab, atom, state)`:
///
/// - `expected_present == true`  → the atom MUST be present (the `Populated`-state
///   data atom; LAW 2 — a blank pane is a RED).
/// - `expected_present == false` → the atom must NOT be present (the `Empty`-state
///   error atom; an `is not served` leak is a RED).
///
/// This is the UI-agnostic declaration; nornir lists its atoms here, the kittest
/// walk produces the matching [`AtomObservation`]s, and [`verify_atoms`] joins them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtomSpec {
    /// The tab / panel this atom lives in (`"nornir"`, `"clone_events"`).
    pub tab: String,
    /// The atom's stable label — the AccessKit label the walk addresses it by
    /// (`"populate_status_panel"`, `"is not served"`).
    pub atom: String,
    /// Which state this expectation is asserted in.
    pub state: AtomState,
    /// Oracle polarity: `true` ⇒ the atom must be present; `false` ⇒ it must be
    /// absent (an error-atom ban).
    pub expected_present: bool,
}

impl AtomSpec {
    /// An atom that MUST be present in `state` (a populated-state data atom).
    pub fn present(tab: impl Into<String>, atom: impl Into<String>, state: AtomState) -> Self {
        AtomSpec { tab: tab.into(), atom: atom.into(), state, expected_present: true }
    }

    /// An atom that must be ABSENT in `state` (an error-atom ban — typically the
    /// empty-state `is not served` leak).
    pub fn absent(tab: impl Into<String>, atom: impl Into<String>, state: AtomState) -> Self {
        AtomSpec { tab: tab.into(), atom: atom.into(), state, expected_present: false }
    }

    /// The human atom id (`"<tab>/<atom>"`) — what the display shows, state-free.
    pub fn id(&self) -> String {
        format!("{}/{}", self.tab, self.atom)
    }

    /// The full surface key this spec maps to (`"ui_atom:<tab>/<atom>/<state>@na"`),
    /// the join key shared with the observation + the covered set + the node.
    pub fn key_str(&self) -> String {
        atom_key(&self.tab, &self.atom, self.state)
    }

    /// The [`SurfaceNode`] for this atom — a [`SurfaceKind::UiAtom`] node with the
    /// state folded into the id so the node's own `key_str` equals
    /// [`Self::key_str`] (the gate keys on `kind:id@mode`).
    fn surface_node(&self) -> SurfaceNode {
        SurfaceNode::ui_atom(atom_id(&self.tab, &self.atom, self.state), self.id())
    }
}

/// Build the **atom surface**: one [`SurfaceKind::UiAtom`] [`SurfaceNode`] per
/// [`AtomSpec`] (`tab × atom × state`). Drops straight into a
/// [`Surface`](crate::discover::Surface) via `surface.extend(atom_surface(&specs))`,
/// so the atom layer is gated by the SAME
/// [`compute_gap`](crate::discover::compute_gap) machinery as every other surface
/// kind. Deduped + deterministic.
pub fn atom_surface(specs: &[AtomSpec]) -> Vec<SurfaceNode> {
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for spec in specs {
        if seen.insert(spec.key_str()) {
            out.push(spec.surface_node());
        }
    }
    out
}

/// What the UI walk OBSERVED for one atom in one state. nornir's kittest walk
/// produces these from the real AccessKit tree (`Queryable::query_all` +
/// `error_atoms()`); the framework joins them against the [`AtomSpec`]s. The
/// `present` flag is the raw fact (did the walk find a node with this label?); the
/// verdict (is that correct?) is computed by [`verify_atoms`] against the spec's
/// `expected_present`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtomObservation {
    pub tab: String,
    pub atom: String,
    pub state: AtomState,
    /// Did the walk find a node with this atom's label in this tab+state?
    pub present: bool,
}

impl AtomObservation {
    pub fn new(tab: impl Into<String>, atom: impl Into<String>, state: AtomState, present: bool) -> Self {
        AtomObservation { tab: tab.into(), atom: atom.into(), state, present }
    }

    /// The surface key this observation pertains to — joins to [`AtomSpec::key_str`].
    pub fn key_str(&self) -> String {
        atom_key(&self.tab, &self.atom, self.state)
    }
}

/// One atom-verdict FAILURE — why the gate went RED for a specific atom. Carried
/// so the gate message can NAME the offending atom (e.g. the CloneEvents
/// `is not served` leak), never a bare count.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtomFailure {
    /// The surface key (`"ui_atom:<tab>/<atom>/<state>@na"`) that failed.
    pub key: String,
    /// Why: `error_atom_leaked` (banned atom present in empty state),
    /// `expected_atom_missing` (data atom absent when populated), or
    /// `not_observed` (the walk never visited this atom — a coverage hole).
    pub reason: AtomFailReason,
}

/// Why an atom failed verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AtomFailReason {
    /// An atom that must be ABSENT was found present (the `is not served` leak).
    ErrorAtomLeaked,
    /// An atom that must be PRESENT was absent (a blank pane — LAW 2 violated).
    ExpectedAtomMissing,
    /// No observation for this spec — the walk never reached it (coverage hole).
    NotObserved,
}

impl AtomFailReason {
    pub fn label(self) -> &'static str {
        match self {
            AtomFailReason::ErrorAtomLeaked => "error_atom_leaked",
            AtomFailReason::ExpectedAtomMissing => "expected_atom_missing",
            AtomFailReason::NotObserved => "not_observed",
        }
    }
}

/// The result of running the atom verifier over the walk's observations.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtomVerdict {
    /// Surface keys that PASSED — feed this straight into the gate's `covered` set
    /// ([`compute_gap`](crate::discover::compute_gap) / coverage rows). Only an atom
    /// that was BOTH observed AND correct lands here.
    pub covered: BTreeSet<String>,
    /// The failures, sorted by key — what makes the gate RED, with a named reason.
    pub failures: Vec<AtomFailure>,
}

impl AtomVerdict {
    /// GREEN ⟺ no failures (every declared atom was observed and correct).
    pub fn is_green(&self) -> bool {
        self.failures.is_empty()
    }

    /// One-line human summary naming the failing atoms (never a bare count).
    pub fn summary(&self) -> String {
        if self.is_green() {
            return format!("{} atom(s) covered — GREEN", self.covered.len());
        }
        let named: Vec<String> = self
            .failures
            .iter()
            .map(|f| format!("{} ({})", f.key, f.reason.label()))
            .collect();
        format!(
            "{} atom(s) covered · {} FAILING: {} — RED",
            self.covered.len(),
            self.failures.len(),
            named.join(", "),
        )
    }
}

/// The **verifier verdict** — the generic L0 oracle.
///
/// For every declared [`AtomSpec`], join the matching [`AtomObservation`] and apply
/// the oracle:
///
/// - **no observation** → [`AtomFailReason::NotObserved`] (the walk never reached
///   it — a coverage hole, RED; "autonom only FINDS, never marks" — an unwalked
///   atom is uncovered, not silently green).
/// - **`expected_present` but absent** → [`AtomFailReason::ExpectedAtomMissing`]
///   (a blank pane — LAW 2 RAGNARÖK, RED).
/// - **must be absent but present** → [`AtomFailReason::ErrorAtomLeaked`] (the
///   `is not served` leak, RED).
/// - otherwise → the atom is **covered** (observed AND correct).
///
/// The returned `covered` set drops directly into the existing gate, so the atom
/// layer needs no new gate code — `green ⟺ every atom × state ran AND was correct`
/// reuses [`compute_gap`](crate::discover::compute_gap) verbatim.
pub fn verify_atoms(specs: &[AtomSpec], observations: &[AtomObservation]) -> AtomVerdict {
    use std::collections::BTreeMap;
    let obs_by_key: BTreeMap<String, &AtomObservation> =
        observations.iter().map(|o| (o.key_str(), o)).collect();

    let mut covered = BTreeSet::new();
    let mut failures = Vec::new();
    for spec in specs {
        let key = spec.key_str();
        match obs_by_key.get(&key) {
            None => failures.push(AtomFailure { key, reason: AtomFailReason::NotObserved }),
            Some(obs) => {
                let correct = obs.present == spec.expected_present;
                if correct {
                    covered.insert(key);
                } else if spec.expected_present {
                    // expected present, observed absent → blank pane.
                    failures.push(AtomFailure { key, reason: AtomFailReason::ExpectedAtomMissing });
                } else {
                    // expected absent, observed present → error atom leaked.
                    failures.push(AtomFailure { key, reason: AtomFailReason::ErrorAtomLeaked });
                }
            }
        }
    }
    failures.sort_by(|a, b| a.key.cmp(&b.key));
    AtomVerdict { covered, failures }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::coverage::{Allowlist, GateReport};
    use crate::discover::{compute_gap, Surface};

    /// The three nornir atoms: the 🧬 tab's CloneEvents "populate status" + Jobs
    /// panels (must be present when populated) and the `is not served` error atom
    /// (must be absent when empty).
    fn nornir_atom_specs() -> Vec<AtomSpec> {
        vec![
            AtomSpec::absent("clone_events", "is not served", AtomState::Empty),
            AtomSpec::present("clone_events", "populate_status_panel", AtomState::Populated),
            AtomSpec::present("clone_events", "jobs_panel", AtomState::Populated),
        ]
    }

    #[test]
    fn atom_surface_enumerates_one_uiatom_node_per_tab_atom_state() {
        let specs = nornir_atom_specs();
        let nodes = atom_surface(&specs);
        assert_eq!(nodes.len(), 3, "one atom node per (tab, atom, state)");
        // ONE variant: every node is a UiAtom (there is no SurfaceKind::Atom).
        assert!(nodes.iter().all(|n| n.kind == SurfaceKind::UiAtom));
        // The empty-state error-atom node and the populated data-atom nodes are
        // DISTINCT surfaces (the state axis).
        let keys: BTreeSet<String> = nodes.iter().map(|n| n.key_str()).collect();
        assert!(keys.contains("ui_atom:clone_events/is not served/empty@na"));
        assert!(keys.contains("ui_atom:clone_events/populate_status_panel/populated@na"));
        assert!(keys.contains("ui_atom:clone_events/jobs_panel/populated@na"));
        // Every spec's key matches its node's key (the join key is consistent).
        for spec in &specs {
            assert!(keys.contains(&spec.key_str()), "{} has a node", spec.key_str());
        }
        // Atoms are the UI layer.
        assert_eq!(SurfaceKind::UiAtom.layer(), "ui");
    }

    /// The flagship RED-when-uncovered / GREEN-when-covered proof, driven through
    /// the REAL gate ([`compute_gap`] / [`GateReport`]) — the atom layer reuses the
    /// whole completeness-gate machinery.
    #[test]
    fn gate_red_when_atom_leaks_or_blank_green_when_all_atoms_correct() {
        let specs = nornir_atom_specs();
        let mut surface = Surface::new();
        surface.extend(atom_surface(&specs));
        assert_eq!(surface.len(), 3);

        // ── BROKEN UI: the empty CloneEvents pane LEAKS `is not served`, and the
        // Jobs panel never rendered (blank) when populated. populate_status DID
        // render correctly.
        let broken = vec![
            AtomObservation::new("clone_events", "is not served", AtomState::Empty, true), // leaked!
            AtomObservation::new("clone_events", "populate_status_panel", AtomState::Populated, true),
            AtomObservation::new("clone_events", "jobs_panel", AtomState::Populated, false), // blank!
        ];
        let verdict = verify_atoms(&specs, &broken);
        assert!(!verdict.is_green(), "a leak + a blank pane → RED verdict");
        assert_eq!(verdict.failures.len(), 2);
        // The failures NAME the offending atoms (never a bare count).
        let leaked = verdict
            .failures
            .iter()
            .find(|f| f.reason == AtomFailReason::ErrorAtomLeaked)
            .expect("the is-not-served leak is reported");
        assert_eq!(leaked.key, "ui_atom:clone_events/is not served/empty@na");
        let blank = verdict
            .failures
            .iter()
            .find(|f| f.reason == AtomFailReason::ExpectedAtomMissing)
            .expect("the blank Jobs panel is reported");
        assert_eq!(blank.key, "ui_atom:clone_events/jobs_panel/populated@na");
        // Only the one correct atom is covered.
        assert_eq!(verdict.covered.len(), 1);
        assert!(verdict.covered.contains("ui_atom:clone_events/populate_status_panel/populated@na"));

        // Feed the covered set into the REAL gate → RED, and the gate's missing
        // set names the two uncovered atoms.
        let report = GateReport::compute("r1", "nornir", &surface, &verdict.covered, &Allowlist::new());
        assert!(!report.is_green(), "uncovered atoms make the completeness gate RED");
        let missing: BTreeSet<String> =
            report.gap.missing.iter().map(|n| n.key_str()).collect();
        assert_eq!(missing.len(), 2);
        assert!(missing.contains("ui_atom:clone_events/is not served/empty@na"));
        assert!(missing.contains("ui_atom:clone_events/jobs_panel/populated@na"));
        assert!(report.summary().contains("RED"));

        // ── FIXED UI (the empty-workspace guard landed): no leak, both panels
        // render when populated.
        let fixed = vec![
            AtomObservation::new("clone_events", "is not served", AtomState::Empty, false), // guarded
            AtomObservation::new("clone_events", "populate_status_panel", AtomState::Populated, true),
            AtomObservation::new("clone_events", "jobs_panel", AtomState::Populated, true),
        ];
        let verdict2 = verify_atoms(&specs, &fixed);
        assert!(verdict2.is_green(), "every atom observed + correct → GREEN");
        assert_eq!(verdict2.covered.len(), 3);
        assert!(verdict2.failures.is_empty());
        assert!(verdict2.summary().contains("GREEN"));

        // The gate is now GREEN — every atom surface covered.
        let report2 = GateReport::compute("r2", "nornir", &surface, &verdict2.covered, &Allowlist::new());
        assert!(report2.is_green(), "all atoms covered → Gap == ∅ → GREEN");
        assert_eq!(report2.gap.covered, 3);
        assert!(report2.gap.missing.is_empty());

        // Cross-check against the raw gap op too.
        let gap = compute_gap(&surface, &verdict2.covered, &BTreeSet::new());
        assert!(gap.is_clean());
    }

    /// "autonom only FINDS, never marks": a declared atom the walk NEVER VISITED
    /// is a coverage hole (RED), NOT silently green. This is the property that
    /// makes the atom layer trustworthy — an un-walked sub-panel can't pass.
    #[test]
    fn unobserved_atom_is_a_coverage_hole_not_silently_green() {
        let specs = nornir_atom_specs();
        // The walk only visited ONE of the three declared atoms.
        let partial = vec![AtomObservation::new(
            "clone_events",
            "is not served",
            AtomState::Empty,
            false,
        )];
        let verdict = verify_atoms(&specs, &partial);
        assert!(!verdict.is_green(), "two unvisited atoms → RED, never vacuous green");
        assert_eq!(verdict.covered.len(), 1, "only the observed-correct atom is covered");
        let holes: Vec<&AtomFailure> = verdict
            .failures
            .iter()
            .filter(|f| f.reason == AtomFailReason::NotObserved)
            .collect();
        assert_eq!(holes.len(), 2, "the two unvisited atoms are NotObserved holes");
        assert!(holes.iter().all(|f| f.key.contains("/populated@na")));
    }

    #[test]
    fn observation_and_spec_keys_join_and_round_trip_through_serde() {
        let spec = AtomSpec::absent("clone_events", "is not served", AtomState::Empty);
        let obs = AtomObservation::new("clone_events", "is not served", AtomState::Empty, false);
        assert_eq!(spec.key_str(), obs.key_str(), "spec and observation join on the same key");
        assert_eq!(spec.key_str(), "ui_atom:clone_events/is not served/empty@na");

        // Every type round-trips (the warehouse row shape).
        let verdict = verify_atoms(&[spec.clone()], &[obs.clone()]);
        let json = serde_json::to_string(&verdict).unwrap();
        let back: AtomVerdict = serde_json::from_str(&json).unwrap();
        assert_eq!(back, verdict);

        let sj = serde_json::to_string(&spec).unwrap();
        let spec_back: AtomSpec = serde_json::from_str(&sj).unwrap();
        assert_eq!(spec_back, spec);
        assert_eq!(AtomState::parse("empty"), Some(AtomState::Empty));
        assert_eq!(AtomState::parse("populated"), Some(AtomState::Populated));
        assert_eq!(AtomState::parse("nope"), None);
    }
}