facett-core 0.1.16

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! **golden** — the shared GOLDEN-IMAGE GUARD for facett's screenshot proofs.
//!
//! A screenshot test that renders a PNG, **overwrites the committed golden** and
//! then asserts only "it is bigger than 5 KB" is not a test. It is a write-only
//! file: it can never go red, and it silently absorbs every visual regression that
//! lands on top of it. facett shipped exactly that shape until 2026-07-23 — and it
//! hid a deleted `facett-grid` toolbar (45 % of the flagship golden's pixels) for
//! three weeks.
//!
//! This module is the guard those tests were missing. [`assert_golden`] **compares**
//! the freshly rendered image against the committed PNG and does not write it:
//!
//! | situation                    | result                                                    |
//! |------------------------------|-----------------------------------------------------------|
//! | identical render             | **pass**, golden untouched (a clean `git status`)          |
//! | render differs               | **FAIL** — pixel count + %, `actual`/`diff` PNGs in `target/` |
//! | different size               | **FAIL** — expected vs actual dimensions                   |
//! | **golden missing**           | **FAIL** — never a silent pass on nothing                  |
//! | `UPDATE_SNAPSHOTS=1`         | golden (re-)written, test passes, "BLESSED" on stderr      |
//!
//! The missing-golden row is the important one. The classic way to build a guard
//! that guards nothing is to compare snapshots but treat "no snapshot yet" as
//! success (or to assert on the *error text* of the missing-file case): the suite is
//! then structurally green over an empty directory. A missing golden here is a
//! failure, full stop — the only way to create one is the explicit opt-in below.
//!
//! ## Exact match, no tolerance
//!
//! The comparison is **byte-exact per channel**. facett's `egui_kittest` + wgpu
//! renders are deterministic on a fixed host: two consecutive runs of the same
//! source produce byte-identical PNGs (verified before this guard was trusted).
//! No anti-aliasing/font-jitter tolerance is configured, because none is needed and
//! a tolerance is exactly the knob that lets a real regression through. If a future
//! host (a different GPU / driver) does jitter, add the smallest tolerance that
//! makes *that* host stable and say so at the call site — do not widen it globally.
//!
//! ## Blessing
//!
//! Opt-in, never the default:
//!
//! ```text
//! UPDATE_SNAPSHOTS=1 cargo test -p facett-grid --test snapshot
//! ```
//!
//! Accepted values: `1` / `true` / `yes` / `on` (anything else is off; an
//! unrecognised value panics rather than silently testing). Blessing a golden is a
//! reviewable act: the PNG is git-lfs, so the diff shows up as a new LFS object and
//! the commit message has to say *why* the pixels moved.

use std::path::{Path, PathBuf};

use image::RgbaImage;

/// Where the failure artifacts (`*.actual.png`, `*.diff.png`) are written, so a
/// human can look at them without dirtying the git tree. Under `target/` on
/// purpose: the golden directories are git-lfs and have been a recurring source of
/// accidental staging noise.
fn artifact_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
        return PathBuf::from(dir).join("snapshot-failures");
    }
    // `current_exe` is `<target>/<profile>/deps/<test-bin>`; walk up to `target`.
    if let Ok(exe) = std::env::current_exe() {
        for a in exe.ancestors() {
            if a.file_name().is_some_and(|n| n == "target") {
                return a.join("snapshot-failures");
            }
        }
    }
    // Fallback: this crate sits one level under the workspace root.
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .join("target")
        .join("snapshot-failures")
}

/// `<target>/snapshot-failures/<name>.<kind>.png`, with any `<name>` subdirectory
/// (the palette gallery names its shots `palette/facett_demo_*`) created.
fn artifact_path(name: &str, kind: &str) -> PathBuf {
    let p = artifact_dir().join(format!("{name}.{kind}.png"));
    if let Some(parent) = p.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    p
}

/// Is the blessing opt-in switched on? Reads `UPDATE_SNAPSHOTS`.
///
/// # Panics
/// On an unrecognised value — a typo (`UPDATE_SNAPSHOTS=yess`) must not quietly
/// leave the guard armed *or* quietly disarm it.
pub fn blessing() -> bool {
    match std::env::var("UPDATE_SNAPSHOTS") {
        Err(_) => false,
        Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
            "" | "0" | "false" | "no" | "off" => false,
            "1" | "true" | "yes" | "on" => true,
            other => panic!(
                "UPDATE_SNAPSHOTS={other:?} is not understood — use 1/true/yes/on to bless, or unset it to test"
            ),
        },
    }
}

/// The outcome of one golden comparison.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// Every pixel matched.
    Match,
    /// The golden was (re-)written because `UPDATE_SNAPSHOTS` is set.
    Blessed,
}

/// A structured difference between two same-sized images.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diff {
    /// Number of pixels differing in any channel.
    pub pixels: u64,
    /// Total pixels compared.
    pub total: u64,
    /// The first differing pixel in raster order, `(x, y)`.
    pub first: Option<(u32, u32)>,
    /// Bounding box of all differing pixels, `(x0, y0, x1, y1)` inclusive.
    pub bbox: Option<(u32, u32, u32, u32)>,
    /// The largest absolute per-channel delta seen.
    pub max_channel_delta: u8,
}

impl Diff {
    /// Differing pixels as a percentage of the frame.
    pub fn percent(&self) -> f64 {
        if self.total == 0 { 0.0 } else { self.pixels as f64 * 100.0 / self.total as f64 }
    }
}

/// Compare two same-sized RGBA images pixel-exactly. `None` = identical.
///
/// Pure (no I/O), so it is unit-testable and reusable by any facett render proof
/// that already holds both buffers.
pub fn compare(golden: &RgbaImage, actual: &RgbaImage) -> Option<Diff> {
    debug_assert_eq!(golden.dimensions(), actual.dimensions());
    let (w, h) = golden.dimensions();
    let (g, a) = (golden.as_raw(), actual.as_raw());
    let mut d = Diff {
        pixels: 0,
        total: u64::from(w) * u64::from(h),
        first: None,
        bbox: None,
        max_channel_delta: 0,
    };
    for i in 0..(g.len() / 4) {
        let (gp, ap) = (&g[i * 4..i * 4 + 4], &a[i * 4..i * 4 + 4]);
        if gp == ap {
            continue;
        }
        let (x, y) = ((i as u32) % w, (i as u32) / w);
        d.pixels += 1;
        if d.first.is_none() {
            d.first = Some((x, y));
        }
        d.bbox = Some(match d.bbox {
            None => (x, y, x, y),
            Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),
        });
        for c in 0..4 {
            d.max_channel_delta = d.max_channel_delta.max(gp[c].abs_diff(ap[c]));
        }
    }
    (d.pixels > 0).then_some(d)
}

/// The floor a **"the picture changed"** proof has to clear.
///
/// [`compare`] answers *did anything differ*. That is the right question for a golden,
/// and the WRONG one for a before/after interaction proof — which is why every robot
/// suite that copied the reference harness wrote `assert_ne!(png_bytes_a, png_bytes_b)`
/// and got a test that cannot fail for the reason it claims.
///
/// Three separate suites were measured passing that assertion on things the click never
/// caused: **a window title**, **an ephemeral port number rendered as text**, and **a
/// text legend**. Each moves a handful of pixels in a thin strip, and "the bytes differ"
/// cannot tell that from a pane redrawing.
///
/// So a real proof needs two floors, and `percent` alone is not enough:
///
/// * **`percent`** — the share of the frame that must differ. Rules out a port number,
///   a frame counter, a few characters of text.
/// * **`extent`** — the changed region's bounding box must span at least this share of
///   the frame in **both** axes. Rules out a title bar or a status line: wide, but a
///   thin strip. A pane that actually redrew has extent in both directions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MinChange {
    /// Minimum share of differing pixels, in percent of the whole frame.
    pub percent: f64,
    /// Minimum bbox width AND height, each as a share (0.0–1.0) of the frame.
    pub extent: f64,
}

impl MinChange {
    /// A pane or panel visibly redrew — the usual "did my click reach the pixels" case.
    ///
    /// 0.5 % of a 1100×760 frame is ~4 200 pixels, and the box must span 10 % of the
    /// frame each way. A one-line text change cannot reach either number.
    pub const PANE: Self = Self { percent: 0.5, extent: 0.10 };

    /// A smaller widget changed (a toggle, a single row). Still far above text noise.
    pub const WIDGET: Self = Self { percent: 0.05, extent: 0.02 };
}

/// Assert that `after` differs from `before` **substantially enough to be the change
/// you meant**, returning the [`Diff`] on success and a named reason on failure.
///
/// This is the ONE writer for before/after interaction proofs. Use it instead of
/// comparing encoded PNG bytes: those carry metadata and encoder state, so
/// `assert_ne!` on them can pass while the picture is identical, and passes trivially on
/// any incidental text.
///
/// # Errors
/// Names which floor failed and by how much, so a failure is actionable without
/// re-running under a debugger:
/// * the two frames are identical;
/// * differing pixels below `min.percent`;
/// * the changed region is a thin strip (below `min.extent` in either axis);
/// * the frames are different sizes (a resize is not a proof that a click landed).
pub fn moved(before: &RgbaImage, after: &RgbaImage, min: MinChange) -> Result<Diff, String> {
    if before.dimensions() != after.dimensions() {
        return Err(format!(
            "frames differ in SIZE ({:?} vs {:?}) — a resize is not proof that the \
             interaction reached the pixels",
            before.dimensions(),
            after.dimensions()
        ));
    }
    let (w, h) = before.dimensions();
    let Some(d) = compare(before, after) else {
        return Err(
            "the two frames are byte-identical — the interaction never reached the pixels"
                .to_owned(),
        );
    };
    if d.percent() < min.percent {
        return Err(format!(
            "only {:.4} % of the frame changed ({} of {} px), below the {:.4} % floor. \
             That is the size of a text label or a port number, not a pane redrawing",
            d.percent(),
            d.pixels,
            d.total,
            min.percent
        ));
    }
    // `compare` guarantees a bbox whenever pixels > 0.
    let (x0, y0, x1, y1) = d.bbox.expect("compare sets bbox when pixels differ");
    let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1);
    let (fw, fh) = (f64::from(bw) / f64::from(w), f64::from(bh) / f64::from(h));
    if fw < min.extent || fh < min.extent {
        return Err(format!(
            "the change is a THIN STRIP: bbox {bw}×{bh} px at ({x0},{y0}) is {:.3}×{:.3} \
             of the frame, below the {:.3} floor in {}. A window title, a status line or \
             a legend looks exactly like this",
            fw,
            fh,
            min.extent,
            if fw < min.extent { "width" } else { "height" }
        ));
    }
    Ok(d)
}

/// Paint a human-readable diff image: the golden dimmed to grayscale, with every
/// differing pixel stamped magenta. Reads at a glance as "here is what moved".
pub fn diff_image(golden: &RgbaImage, actual: &RgbaImage) -> RgbaImage {
    let (w, h) = golden.dimensions();
    let mut out = RgbaImage::new(w, h);
    let (g, a) = (golden.as_raw(), actual.as_raw());
    for (i, px) in out.pixels_mut().enumerate() {
        let (gp, ap) = (&g[i * 4..i * 4 + 4], &a[i * 4..i * 4 + 4]);
        *px = if gp == ap {
            let lum = (u16::from(gp[0]) * 30 + u16::from(gp[1]) * 59 + u16::from(gp[2]) * 11) / 100;
            let l = (lum / 3) as u8; // dimmed, so the stamps pop
            image::Rgba([l, l, l, 255])
        } else {
            image::Rgba([255, 0, 190, 255])
        };
    }
    out
}

/// **The guard.** Compare `actual` against the committed golden PNG at `golden_path`.
///
/// `name` is only used to label the failure artifacts and the panic message.
///
/// See the [module docs](self) for the full behaviour table. Returns the
/// [`Verdict`] on success; **panics** on any mismatch, size change, or missing
/// golden.
///
/// # Panics
/// - the golden file does not exist, or cannot be decoded (a bare git-lfs pointer
///   is the usual cause — run `git lfs pull`)
/// - the rendered size differs from the golden's
/// - any pixel differs
#[track_caller]
pub fn assert_golden(actual: &RgbaImage, golden_path: &Path, name: &str) -> Verdict {
    let (aw, ah) = actual.dimensions();

    if blessing() {
        if let Some(parent) = golden_path.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        actual.save(golden_path).unwrap_or_else(|e| panic!("[{name}] writing golden {}: {e}", golden_path.display()));
        eprintln!("BLESSED {name}{} ({aw}×{ah})", golden_path.display());
        return Verdict::Blessed;
    }

    // Dump the render next to the failure evidence and return the panic message.
    let dump = |why: String| -> ! {
        let actual_path = artifact_path(name, "actual");
        let saved = actual.save(&actual_path).is_ok();
        let where_ = if saved {
            format!("\n  actual render → {}", actual_path.display())
        } else {
            String::new()
        };
        panic!(
            "\nSNAPSHOT GUARD FAILED [{name}]\n  {why}{where_}\n  \
             bless with: UPDATE_SNAPSHOTS=1 cargo test …  (only when the change is intended)\n"
        );
    };

    if !golden_path.exists() {
        dump(format!(
            "MISSING golden {} — a snapshot test with no committed golden is not a passing test.",
            golden_path.display()
        ));
    }

    let golden = match image::open(golden_path) {
        Ok(img) => img.to_rgba8(),
        Err(e) => dump(format!(
            "could not decode golden {}: {e}\n  (a git-lfs pointer instead of a PNG? try `git lfs pull`)",
            golden_path.display()
        )),
    };

    let (gw, gh) = golden.dimensions();
    if (gw, gh) != (aw, ah) {
        dump(format!("size changed: golden is {gw}×{gh}, render is {aw}×{ah}"));
    }

    if let Some(d) = compare(&golden, actual) {
        // Write the diff image beside the actual render before panicking.
        let diff_path = artifact_path(name, "diff");
        let diff_note = if diff_image(&golden, actual).save(&diff_path).is_ok() {
            format!("\n  diff image    → {} (magenta = changed)", diff_path.display())
        } else {
            String::new()
        };
        let (x0, y0, x1, y1) = d.bbox.unwrap_or((0, 0, 0, 0));
        dump(format!(
            "{} of {} pixels differ ({:.2}%), max channel delta {}\n  \
             first differing pixel at ({}, {}); changed region x {x0}..={x1}, y {y0}..={y1}\n  \
             golden        → {}{diff_note}",
            d.pixels,
            d.total,
            d.percent(),
            d.max_channel_delta,
            d.first.map_or(0, |p| p.0),
            d.first.map_or(0, |p| p.1),
            golden_path.display(),
        ));
    }

    eprintln!("golden OK {name} ({aw}×{ah}, exact) ← {}", golden_path.display());
    Verdict::Match
}

#[cfg(test)]
mod tests {
    use super::*;

    fn img(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> RgbaImage {
        RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y)))
    }

    /// **`moved` must REJECT the three shapes that were measured passing
    /// `assert_ne!(png_a, png_b)` in three independent robot suites.**
    ///
    /// This is the point of the whole predicate, so it is asserted directly rather than
    /// left to a comment. Each case below is a real observed false pass.
    #[test]
    fn moved_rejects_the_incidental_changes_that_fooled_assert_ne() {
        let base = img(1100, 760, |_, _| [30, 30, 40, 255]);

        // 1. A WINDOW TITLE / status line: wide, but ~18 px tall.
        let title = img(1100, 760, |_, y| {
            if y < 18 { [200, 200, 210, 255] } else { [30, 30, 40, 255] }
        });
        let e = moved(&base, &title, MinChange::PANE).expect_err("a title strip is not a redraw");
        assert!(e.contains("THIN STRIP"), "must name the strip shape, got: {e}");

        // 2. An EPHEMERAL PORT rendered as text: a few dozen pixels somewhere.
        let port = img(1100, 760, |x, y| {
            if (40..96).contains(&x) && (700..712).contains(&y) {
                [255, 255, 255, 255]
            } else {
                [30, 30, 40, 255]
            }
        });
        let e = moved(&base, &port, MinChange::PANE).expect_err("a port number is not a redraw");
        assert!(e.contains("below the"), "must name the floor it missed, got: {e}");

        // 3. A LEGEND: a small block, still nothing like a pane.
        let legend = img(1100, 760, |x, y| {
            if (900..1000).contains(&x) && (20..44).contains(&y) {
                [180, 180, 90, 255]
            } else {
                [30, 30, 40, 255]
            }
        });
        assert!(moved(&base, &legend, MinChange::PANE).is_err(), "a legend is not a redraw");

        // And it must ACCEPT a pane actually redrawing — otherwise the guard is simply
        // "always red", which is no more useful than "always green".
        let pane = img(1100, 760, |x, y| {
            if (100..700).contains(&x) && (100..600).contains(&y) {
                [90, 140, 200, 255]
            } else {
                [30, 30, 40, 255]
            }
        });
        let d = moved(&base, &pane, MinChange::PANE).expect("a redrawn pane must pass");
        assert!(d.percent() > 30.0, "the accepted case really is large: {:.2} %", d.percent());
    }

    /// Identical frames are the original failure the `assert_ne!` was reaching for, and
    /// the message has to say so rather than reporting a floor.
    #[test]
    fn moved_rejects_identical_frames_by_name() {
        let a = img(64, 64, |_, _| [1, 2, 3, 255]);
        let e = moved(&a, &a.clone(), MinChange::WIDGET).expect_err("identical must fail");
        assert!(e.contains("byte-identical"), "got: {e}");
    }

    /// A resize is not proof that an interaction landed, and must not be silently
    /// compared (the old code would have compared different-length byte vectors and
    /// happily reported "they differ").
    #[test]
    fn moved_rejects_a_size_change_instead_of_comparing_it() {
        let a = img(32, 32, |_, _| [0, 0, 0, 255]);
        let b = img(64, 32, |_, _| [0, 0, 0, 255]);
        let e = moved(&a, &b, MinChange::WIDGET).expect_err("a resize must not be a pass");
        assert!(e.contains("SIZE"), "got: {e}");
    }

    #[test]
    fn identical_images_compare_equal() {
        let a = img(8, 4, |x, y| [x as u8, y as u8, 7, 255]);
        let b = a.clone();
        assert_eq!(compare(&a, &b), None);
    }

    #[test]
    fn one_changed_pixel_is_located_and_counted() {
        let a = img(8, 4, |_, _| [10, 10, 10, 255]);
        let mut b = a.clone();
        b.put_pixel(5, 2, image::Rgba([10, 10, 13, 255]));
        let d = compare(&a, &b).expect("a one-pixel change is a difference");
        assert_eq!(d.pixels, 1);
        assert_eq!(d.total, 32);
        assert_eq!(d.first, Some((5, 2)));
        assert_eq!(d.bbox, Some((5, 2, 5, 2)));
        assert_eq!(d.max_channel_delta, 3);
        assert!((d.percent() - 3.125).abs() < 1e-9);
    }

    /// The guard is EXACT: a single least-significant-bit change must not be
    /// forgiven. This is the assertion a tolerance would break.
    #[test]
    fn a_one_bit_change_is_not_forgiven() {
        let a = img(4, 4, |_, _| [128, 128, 128, 255]);
        let mut b = a.clone();
        b.put_pixel(0, 0, image::Rgba([129, 128, 128, 255]));
        assert!(compare(&a, &b).is_some());
    }

    #[test]
    fn diff_image_stamps_only_the_changed_pixels() {
        let a = img(4, 2, |_, _| [200, 200, 200, 255]);
        let mut b = a.clone();
        b.put_pixel(3, 1, image::Rgba([0, 0, 0, 255]));
        let d = diff_image(&a, &b);
        assert_eq!(*d.get_pixel(3, 1), image::Rgba([255, 0, 190, 255]));
        assert_ne!(*d.get_pixel(0, 0), image::Rgba([255, 0, 190, 255]));
    }

    #[test]
    fn a_missing_golden_fails() {
        if blessing() {
            return; // under an explicit bless run, "missing" is legitimately created
        }
        let dir = std::env::temp_dir().join("facett-golden-missing-test");
        std::fs::create_dir_all(&dir).ok();
        let path = dir.join("definitely-not-here.png");
        std::fs::remove_file(&path).ok();
        let a = img(4, 4, |_, _| [1, 2, 3, 255]);
        let err = std::panic::catch_unwind(|| assert_golden(&a, &path, "missing_probe"))
            .expect_err("a missing golden must FAIL, not pass");
        let msg = err.downcast_ref::<String>().map(String::as_str).unwrap_or_default();
        assert!(msg.contains("MISSING golden"), "unexpected panic: {msg}");
        // …and it must not have created the golden as a side effect.
        assert!(!path.exists(), "the guard must never write the golden outside a blessing");
    }
}