use std::path::{Path, PathBuf};
use saudade::mock::{MockBackend, Snapshot};
use saudade::{Font, Widget, WindowChrome};
pub fn sans_font() -> Font {
Font::from_sans_bytes(include_bytes!("../fonts/DejaVuSans.ttf").to_vec())
.expect("bundled DejaVuSans.ttf failed to load")
}
pub fn mono_font() -> Font {
Font::from_sans_bytes(include_bytes!("../fonts/DejaVuSansMono.ttf").to_vec())
.expect("bundled DejaVuSansMono.ttf failed to load")
}
pub const SCALES: &[f32] = &[1.0, 1.25, 1.5, 2.0];
const MAX_CHANNEL_DELTA: u8 = 16;
pub fn snapshot_at_all_scales<F>(name: &str, width: i32, height: i32, mut build: F)
where
F: FnMut() -> Box<dyn Widget>,
{
for &scale in SCALES {
snapshot_one(name, width, height, scale, build());
}
}
pub fn snapshot_framed_at_all_scales<F>(
name: &str,
width: i32,
height: i32,
chrome: &WindowChrome,
mut build: F,
) where
F: FnMut() -> Box<dyn Widget>,
{
for &scale in SCALES {
let backend = MockBackend::new(width, height)
.with_scale(scale)
.with_sans_font(sans_font())
.with_mono_font(mono_font());
let snap = backend.render_framed(build().as_mut(), chrome);
compare_snapshot(name, scale, &snap);
}
}
fn snapshot_one(name: &str, width: i32, height: i32, scale: f32, mut widget: Box<dyn Widget>) {
let backend = MockBackend::new(width, height)
.with_scale(scale)
.with_sans_font(sans_font())
.with_mono_font(mono_font());
let snap = backend.render(widget.as_mut());
compare_snapshot(name, scale, &snap);
}
fn compare_snapshot(name: &str, scale: f32, snap: &Snapshot) {
let path = snapshot_path(&format!("{}_{}.snap.png", name, scale_tag(scale)));
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
std::fs::write(&path, snap.to_png())
.unwrap_or_else(|e| panic!("failed to write baseline {}: {e}", path.display()));
return;
}
let baseline = std::fs::read(&path).unwrap_or_else(|_| {
panic!(
"missing baseline {} — create it with `UPDATE_SNAPSHOTS=1 cargo test -p saudade`",
path.display()
)
});
let (bw, bh, base) = decode_rgba(&baseline, &path);
if bw as i32 != snap.width() || bh as i32 != snap.height() {
let actual = write_actual_render(snap, name, scale);
panic!(
"snapshot `{name}` @ {scale}x: size changed (baseline {bw}x{bh}, rendered {}x{}). \
Rendered frame written to {}. Run `UPDATE_SNAPSHOTS=1 cargo test -p saudade` if \
this is intended.",
snap.width(),
snap.height(),
actual.display(),
);
}
let mut offenders = 0usize;
let mut max_delta = 0u32;
let mut first_offender = None;
for (i, &px) in snap.pixels().iter().enumerate() {
let actual = [
((px >> 16) & 0xFF) as u8, ((px >> 8) & 0xFF) as u8, (px & 0xFF) as u8, ((px >> 24) & 0xFF) as u8, ];
let expected = &base[i * 4..i * 4 + 4];
let mut pixel_delta = 0u32;
for c in 0..4 {
pixel_delta = pixel_delta.max(actual[c].abs_diff(expected[c]) as u32);
}
max_delta = max_delta.max(pixel_delta);
if pixel_delta > MAX_CHANNEL_DELTA as u32 {
offenders += 1;
first_offender.get_or_insert_with(|| {
let x = i as i32 % snap.width();
let y = i as i32 / snap.width();
(x, y, pixel_delta)
});
}
}
if offenders > 0 {
let actual = write_actual_render(snap, name, scale);
panic!(
"snapshot `{name}` @ {scale}x differs from baseline: {offenders} pixel(s) off by more \
than {MAX_CHANNEL_DELTA}/255 (largest channel delta {max_delta}, first at \
{first_offender:?}). Rendered frame written to {} (uploaded as a CI artifact on \
failure). If this is an intended rendering change, regenerate with \
`UPDATE_SNAPSHOTS=1 cargo test -p saudade`; otherwise it is a regression. \
Baseline: {}",
actual.display(),
path.display(),
);
}
}
fn write_actual_render(snap: &saudade::mock::Snapshot, name: &str, scale: f32) -> PathBuf {
let path = snapshot_path(&format!("{}_{}.snap.actual.png", name, scale_tag(scale)));
let _ = std::fs::write(&path, snap.to_png());
path
}
fn snapshot_path(file: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/snapshots")
.join(file)
}
fn decode_rgba(bytes: &[u8], path: &Path) -> (u32, u32, Vec<u8>) {
let decoder = png::Decoder::new(bytes);
let mut reader = decoder
.read_info()
.unwrap_or_else(|e| panic!("failed to read baseline {}: {e}", path.display()));
let mut buf = vec![0u8; reader.output_buffer_size()];
let info = reader
.next_frame(&mut buf)
.unwrap_or_else(|e| panic!("failed to decode baseline {}: {e}", path.display()));
assert!(
info.color_type == png::ColorType::Rgba && info.bit_depth == png::BitDepth::Eight,
"baseline {} is not 8-bit RGBA (got {:?} / {:?})",
path.display(),
info.color_type,
info.bit_depth,
);
buf.truncate(info.buffer_size());
(info.width, info.height, buf)
}
fn scale_tag(scale: f32) -> String {
let scaled = (scale * 100.0).round() as i32;
format!("{}_{:02}", scaled / 100, scaled % 100)
}