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

nordisk/edda

crates/nornir-robotui/src/snapshot.rs

//! WebP-in-warehouse UI snapshot harness (the render-oracle reference store).
//!
//! The render half of the test matrix renders a component to an
//! [`image::RgbaImage`] and diffs it against a stored **reference** image. This
//! module owns the *toolkit-neutral* half of that: lossless-WebP **encode**,
//! WebP **decode**, and a tolerant **pixel diff** — plus the thin
//! [`SnapshotStore`] seam and the [`assert_snapshot_warehouse`] entry point.
//!
//! The reference images live in the **warehouse** (lossless WebP, gitignored),
//! NOT in git — see `.nornir/snapshot-to-warehouse.md`. To keep this crate lean
//! (no iceberg / skade / warehouse deps leaking into the shared robot kit), the
//! warehouse coupling is held behind the [`SnapshotStore`] trait: `nornir`
//! implements it with the `ui_snapshots` Iceberg table and passes it in. This
//! crate only knows "fetch reference bytes for a key" / "store reference bytes
//! for a key".
//!
//! Encoder: the `webp` crate (libwebp) for **lossless** encode — the `image`
//! 0.25 native WebP encoder is decode-biased, so we use libwebp directly.
//!
//! Available only with the `snapshot` feature (which already pulls `image`).

use image::RgbaImage;

/// Identity of one reference image in the store. The warehouse keys a row on
/// `(repo, crate, test_name, theme)` and time-travels by `git_sha`; the harness
/// fills these from the running test's context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotKey {
    /// Owning repo (e.g. `nornir`, `facett`).
    pub repo: String,
    /// Owning crate / nested snapshot dir (e.g. `facett-graphview`).
    pub krate: String,
    /// The kittest snapshot id / test name.
    pub test_name: String,
    /// Render-matrix theme axis (`light` / `dark` / a device preset).
    pub theme: String,
}

impl SnapshotKey {
    pub fn new(
        repo: impl Into<String>,
        krate: impl Into<String>,
        test_name: impl Into<String>,
        theme: impl Into<String>,
    ) -> Self {
        Self {
            repo: repo.into(),
            krate: krate.into(),
            test_name: test_name.into(),
            theme: theme.into(),
        }
    }
}

/// A stored reference image: the lossless-WebP bytes plus its dimensions and a
/// content hash (for dedup — an unchanged frame stores one blob, not N).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotRef {
    pub webp: Vec<u8>,
    pub width: u32,
    pub height: u32,
    pub content_sha: String,
}

/// The warehouse seam. `nornir` implements this over the `ui_snapshots` Iceberg
/// table; a test can implement it over an in-memory map. Kept deliberately
/// tiny so this crate needs no warehouse dependency.
pub trait SnapshotStore {
    /// Fetch the latest stored reference for `key`, or `None` if none exists.
    fn fetch_reference(&self, key: &SnapshotKey) -> Result<Option<SnapshotRef>, String>;
    /// Store `r` as the new reference for `key`.
    fn store_reference(&self, key: &SnapshotKey, r: &SnapshotRef) -> Result<(), String>;
}

/// What `assert_snapshot_warehouse` did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapResult {
    /// The rendered image matched the stored reference within threshold.
    Match { diff_px: u64 },
    /// No reference existed and we were not asked to update — recorded as the
    /// FIRST reference (so the next run can diff). Carries the content hash.
    Created { content_sha: String },
    /// `NORNIR_UPDATE_SNAPSHOTS=1`: wrote a new reference into the store.
    Updated { content_sha: String },
    /// The rendered image differed from the reference beyond threshold. The
    /// `actual_webp_path` (if writable) holds the rendered frame for inspection.
    Mismatch {
        diff_px: u64,
        threshold_px: u64,
        actual_webp_path: Option<std::path::PathBuf>,
    },
}

impl SnapResult {
    /// True when the assertion passed (match / created-first-ref / updated).
    pub fn is_ok(&self) -> bool {
        !matches!(self, SnapResult::Mismatch { .. })
    }
}


/// Encode an [`RgbaImage`] to **lossless** WebP bytes via libwebp (the `webp`
/// crate). Lossless so the reference round-trips pixel-exact.
pub fn encode_lossless(img: &RgbaImage) -> Result<Vec<u8>, String> {
    let (w, h) = img.dimensions();
    let encoder = webp::Encoder::from_rgba(img.as_raw(), w, h);
    let mem = encoder.encode_lossless().to_vec();
    if mem.is_empty() {
        return Err("webp lossless encode produced 0 bytes".into());
    }
    Ok(mem)
}

/// Decode WebP `bytes` back to an [`RgbaImage`].
pub fn decode(bytes: &[u8]) -> Result<RgbaImage, String> {
    let decoded = webp::Decoder::new(bytes)
        .decode()
        .ok_or_else(|| "webp decode failed (not a WebP / corrupt)".to_string())?;
    let img = decoded.to_image(); // -> image::DynamicImage
    Ok(img.to_rgba8())
}

/// Count pixels whose per-channel difference exceeds `tol` (anti-aliasing
/// tolerance). Dimension mismatch is reported as "every pixel differs"
/// (`u64::MAX`) so it always trips the threshold.
pub fn diff_pixels(a: &RgbaImage, b: &RgbaImage, tol: u8) -> u64 {
    if a.dimensions() != b.dimensions() {
        return u64::MAX;
    }
    let mut n = 0u64;
    for (pa, pb) in a.pixels().zip(b.pixels()) {
        let differs = pa
            .0
            .iter()
            .zip(pb.0.iter())
            .any(|(x, y)| x.abs_diff(*y) > tol);
        if differs {
            n += 1;
        }
    }
    n
}

/// How [`assert_snapshot_warehouse`] treats the reference.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapMode {
    /// Diff against the stored reference (record it if none exists yet).
    Check,
    /// Overwrite the stored reference with the rendered frame.
    Update,
}

impl SnapMode {
    /// `Update` when `NORNIR_UPDATE_SNAPSHOTS=1`, else `Check`. The standard way
    /// a test picks its mode (so a whole suite re-bakes refs with one env var).
    pub fn from_env() -> Self {
        if std::env::var("NORNIR_UPDATE_SNAPSHOTS").map(|v| v == "1").unwrap_or(false) {
            SnapMode::Update
        } else {
            SnapMode::Check
        }
    }
}

/// Where a mismatch's `<name>.actual.webp` is written for inspection: the
/// warehouse root's `_snap_actual` dir if `$NORNIR_WAREHOUSE_ROOT` is set, else
/// the OS temp dir. Best-effort — a write failure just yields `None`.
fn actual_dir() -> std::path::PathBuf {
    if let Ok(root) = std::env::var("NORNIR_WAREHOUSE_ROOT") {
        std::path::PathBuf::from(root).join("_snap_actual")
    } else {
        std::env::temp_dir().join("nornir_snap_actual")
    }
}

/// Per-test pixel threshold (anti-aliasing tolerant): how many pixels may
/// differ before the assertion fails. `channel_tol` is the per-channel slack.
#[derive(Debug, Clone, Copy)]
pub struct SnapThreshold {
    /// Max number of differing pixels allowed.
    pub max_diff_px: u64,
    /// Per-channel value tolerance (a pixel counts as "differing" only if any
    /// channel is off by more than this).
    pub channel_tol: u8,
}

impl Default for SnapThreshold {
    /// Exact match: zero pixels may differ, zero channel slack. Lossless WebP
    /// round-trips pixel-exact, so the default reference contract is strict.
    fn default() -> Self {
        Self { max_diff_px: 0, channel_tol: 0 }
    }
}

/// The harness entry point. Encodes `img` to lossless WebP, fetches the
/// reference for `key` from `store`, pixel-diffs within `threshold`, and:
///   * on match → [`SnapResult::Match`],
///   * on no reference → records the FIRST reference and returns
///     [`SnapResult::Created`] (so the next run has something to diff),
///   * on `mode = Update` (`NORNIR_UPDATE_SNAPSHOTS=1` via [`SnapMode::from_env`])
///     → overwrites the reference, returns [`SnapResult::Updated`],
///   * on mismatch → writes `<test_name>.actual.webp` for inspection and
///     returns [`SnapResult::Mismatch`].
pub fn assert_snapshot_warehouse(
    key: &SnapshotKey,
    img: &RgbaImage,
    mode: SnapMode,
    threshold: SnapThreshold,
    store: &dyn SnapshotStore,
) -> Result<SnapResult, String> {
    let (w, h) = img.dimensions();
    let webp = encode_lossless(img)?;
    let content_sha = nornir_hash::sha256_hex(&webp);

    let existing = store.fetch_reference(key)?;

    // Update mode: overwrite (or create) the reference unconditionally.
    if mode == SnapMode::Update {
        let r = SnapshotRef { webp, width: w, height: h, content_sha: content_sha.clone() };
        store.store_reference(key, &r)?;
        return Ok(SnapResult::Updated { content_sha });
    }

    let Some(reference) = existing else {
        // No reference yet: record this frame as the first reference so the
        // next run can diff against it. (Empty/missing handling.)
        let r = SnapshotRef { webp, width: w, height: h, content_sha: content_sha.clone() };
        store.store_reference(key, &r)?;
        return Ok(SnapResult::Created { content_sha });
    };

    // Fast path: identical content hash ⇒ byte-identical ⇒ zero diff.
    if reference.content_sha == content_sha {
        return Ok(SnapResult::Match { diff_px: 0 });
    }

    // Decode the reference and pixel-diff.
    let ref_img = decode(&reference.webp)?;
    let diff_px = diff_pixels(img, &ref_img, threshold.channel_tol);
    if diff_px <= threshold.max_diff_px {
        return Ok(SnapResult::Match { diff_px });
    }

    // Mismatch: write the rendered frame for inspection (best-effort).
    let actual_webp_path = (|| {
        let dir = actual_dir();
        std::fs::create_dir_all(&dir).ok()?;
        let path = dir.join(format!("{}.actual.webp", sanitize(&key.test_name)));
        std::fs::write(&path, &webp).ok()?;
        Some(path)
    })();

    Ok(SnapResult::Mismatch {
        diff_px,
        threshold_px: threshold.max_diff_px,
        actual_webp_path,
    })
}

/// Make a test name filesystem-safe for the `.actual.webp` dump.
fn sanitize(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use image::Rgba;
    use std::cell::RefCell;
    use std::collections::HashMap;

    /// A simple deterministic test image: a `w×h` gradient.
    fn gradient(w: u32, h: u32) -> RgbaImage {
        RgbaImage::from_fn(w, h, |x, y| {
            Rgba([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8, 255])
        })
    }

    /// LAW round-trip: encode a KNOWN image → lossless WebP → decode → assert
    /// the dimensions and EVERY pixel match exactly (lossless ⇒ zero tolerance).
    #[test]
    fn webp_lossless_round_trip_is_pixel_exact() {
        let img = gradient(40, 24);
        let bytes = encode_lossless(&img).unwrap();
        assert!(!bytes.is_empty(), "lossless encode produced bytes");
        // RIFF/WEBP magic.
        assert_eq!(&bytes[0..4], b"RIFF");
        assert_eq!(&bytes[8..12], b"WEBP");

        let back = decode(&bytes).unwrap();
        assert_eq!(back.dimensions(), (40, 24), "dimensions survive the round trip");
        assert_eq!(diff_pixels(&img, &back, 0), 0, "lossless ⇒ pixel-exact");
    }

    /// A one-pixel change is detected by the diff (inject-assert, not no-crash).
    #[test]
    fn diff_counts_changed_pixels() {
        let a = gradient(10, 10);
        let mut b = a.clone();
        b.put_pixel(3, 3, Rgba([0, 0, 0, 255]));
        assert_eq!(diff_pixels(&a, &b, 0), 1, "exactly one pixel changed");
        // Dimension mismatch ⇒ MAX (always trips a threshold).
        let c = gradient(11, 10);
        assert_eq!(diff_pixels(&a, &c, 0), u64::MAX);
    }

    /// In-memory store so the harness is testable without the warehouse.
    #[derive(Default)]
    struct MemStore {
        map: RefCell<HashMap<String, SnapshotRef>>,
    }
    impl MemStore {
        fn k(key: &SnapshotKey) -> String {
            format!("{}/{}/{}/{}", key.repo, key.krate, key.test_name, key.theme)
        }
    }
    impl SnapshotStore for MemStore {
        fn fetch_reference(&self, key: &SnapshotKey) -> Result<Option<SnapshotRef>, String> {
            Ok(self.map.borrow().get(&Self::k(key)).cloned())
        }
        fn store_reference(&self, key: &SnapshotKey, r: &SnapshotRef) -> Result<(), String> {
            self.map.borrow_mut().insert(Self::k(key), r.clone());
            Ok(())
        }
    }

    /// Full harness flow against an in-memory store: missing→Created,
    /// same→Match, changed→Mismatch (+ writes the .actual.webp).
    #[test]
    fn assert_warehouse_created_match_mismatch() {
        let store = MemStore::default();
        let key = SnapshotKey::new("nornir", "nornir-robotui", "demo_tab", "light");
        let img = gradient(32, 18);
        let thr = SnapThreshold::default();

        // 1) No reference → Created (first ref recorded).
        let r = assert_snapshot_warehouse(&key, &img, SnapMode::Check, thr, &store).unwrap();
        assert!(matches!(r, SnapResult::Created { .. }), "first run records the reference");
        assert!(r.is_ok());
        assert!(store.fetch_reference(&key).unwrap().is_some(), "reference now stored");

        // 2) Same image → Match.
        let r = assert_snapshot_warehouse(&key, &img, SnapMode::Check, thr, &store).unwrap();
        assert!(matches!(r, SnapResult::Match { diff_px: 0 }), "identical frame matches");

        // 3) Changed image → Mismatch + an .actual.webp on disk.
        let mut changed = img.clone();
        for x in 0..32 {
            changed.put_pixel(x, 9, Rgba([255, 0, 0, 255]));
        }
        let r = assert_snapshot_warehouse(&key, &changed, SnapMode::Check, thr, &store).unwrap();
        match r {
            SnapResult::Mismatch { diff_px, threshold_px, actual_webp_path } => {
                assert_eq!(diff_px, 32, "the full changed row differs");
                assert_eq!(threshold_px, 0);
                let p = actual_webp_path.expect("actual.webp written for inspection");
                assert!(p.exists(), ".actual.webp exists on disk: {}", p.display());
                // The dumped bytes decode back to the changed frame.
                let dumped = decode(&std::fs::read(&p).unwrap()).unwrap();
                assert_eq!(diff_pixels(&changed, &dumped, 0), 0);
                let _ = std::fs::remove_file(&p);
            }
            other => panic!("expected Mismatch, got {other:?}"),
        }
    }

    /// `SnapMode::Update` overwrites the reference and reports Updated — the
    /// `NORNIR_UPDATE_SNAPSHOTS=1` re-bake path (via `SnapMode::from_env`).
    #[test]
    fn update_mode_overwrites_reference() {
        let store = MemStore::default();
        let key = SnapshotKey::new("nornir", "nornir-robotui", "upd", "dark");
        let a = gradient(16, 16);
        let b = {
            let mut b = a.clone();
            b.put_pixel(0, 0, Rgba([1, 2, 3, 255]));
            b
        };
        let thr = SnapThreshold::default();

        // Seed a reference with `a`.
        assert_snapshot_warehouse(&key, &a, SnapMode::Check, thr, &store).unwrap();
        // In Update mode, `b` overwrites it.
        let r = assert_snapshot_warehouse(&key, &b, SnapMode::Update, thr, &store).unwrap();
        assert!(matches!(r, SnapResult::Updated { .. }), "update mode writes a new ref");

        // The stored reference is now `b` (next Check run matches `b`, not `a`).
        let r = assert_snapshot_warehouse(&key, &b, SnapMode::Check, thr, &store).unwrap();
        assert!(matches!(r, SnapResult::Match { .. }), "updated ref now matches b");
        // …and the OLD frame `a` now MISMATCHES the (b) reference.
        let r = assert_snapshot_warehouse(&key, &a, SnapMode::Check, thr, &store).unwrap();
        assert!(matches!(r, SnapResult::Mismatch { .. }), "old frame no longer matches");
    }
}