Introduces a multi-layered testing strategy for the libcosmic widget toolkit: - Visual snapshot testing: Renders widgets via tiny-skia software renderer and compares pixel output against reference PNGs. Generates diff images on failure. Covers containers, dividers, text styles, progress bars, and layouts. - Widget unit tests: Thorough behavioral tests for segmented button model (selection, ordering, data storage, enable/disable, closable, indent), calendar model (month navigation, year wrapping, date selection, grid layout), spin button (clamping, increment/decrement, float support), and color picker. - Property-based tests (proptest): Randomized input testing for spin button bounds invariants, calendar first-day-of-week correctness, segmented model length/clear/toggle invariants, and theme spacing monotonicity. - Theme consistency tests: Validates all four theme variants (dark, light, high-contrast dark/light) have valid spacing, corner radii ordering, non-zero-alpha colors, visible text-on-background, and RON round-trip serialization. - Expanded inline tests: 16 new spin_button unit tests covering increment, decrement, clamping, float types, and constructor value bounds. - CI integration: New visual-tests job runs snapshot, theme, property, and widget tests with artifact upload on failure for snapshot diffs. https://claude.ai/code/session_01P3WLXMif9wmNXbWDo5FhRQ
355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
// Copyright 2024 System76 <info@system76.com>
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
//! Snapshot testing harness for COSMIC widgets.
|
|
//!
|
|
//! Renders widgets to pixel buffers using the tiny-skia software renderer
|
|
//! and compares against stored reference images. Provides deterministic,
|
|
//! headless visual regression testing without requiring a display server.
|
|
|
|
use cosmic::iced_core::{self, Rectangle, Size, layout, mouse, renderer};
|
|
use cosmic::iced_core::widget::Tree;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Maximum per-pixel color channel difference allowed before a mismatch
|
|
/// is flagged. Accounts for minor anti-aliasing and rounding differences.
|
|
const PIXEL_TOLERANCE: u8 = 2;
|
|
|
|
/// Maximum fraction of pixels allowed to differ before the snapshot is
|
|
/// considered a failure (0.01 = 1%).
|
|
const MISMATCH_THRESHOLD: f64 = 0.01;
|
|
|
|
/// Test harness for rendering COSMIC widgets to pixel buffers.
|
|
pub struct SnapshotHarness {
|
|
width: u32,
|
|
height: u32,
|
|
}
|
|
|
|
impl SnapshotHarness {
|
|
/// Create a new harness with the given pixel dimensions.
|
|
pub fn new(width: u32, height: u32) -> Self {
|
|
Self { width, height }
|
|
}
|
|
|
|
/// Render an element and compare it against a stored reference snapshot.
|
|
///
|
|
/// If no reference exists, the rendered image is saved as the new reference.
|
|
/// If a reference exists, the rendered image is compared pixel-by-pixel.
|
|
pub fn snapshot<'a>(
|
|
&self,
|
|
name: &str,
|
|
element: cosmic::Element<'a, ()>,
|
|
) {
|
|
let pixels = self.render_element(element);
|
|
let snapshot_dir = snapshot_dir();
|
|
|
|
std::fs::create_dir_all(&snapshot_dir)
|
|
.expect("Failed to create snapshots directory");
|
|
|
|
let reference_path = snapshot_dir.join(format!("{name}.png"));
|
|
let actual_path = snapshot_dir.join(format!("{name}.actual.png"));
|
|
let diff_path = snapshot_dir.join(format!("{name}.diff.png"));
|
|
|
|
// Save the current render
|
|
save_rgba_png(&actual_path, &pixels, self.width, self.height);
|
|
|
|
if reference_path.exists() {
|
|
// Compare against reference
|
|
let reference = load_png_rgba(&reference_path);
|
|
let result = compare_images(
|
|
&reference.data,
|
|
reference.width,
|
|
reference.height,
|
|
&pixels,
|
|
self.width,
|
|
self.height,
|
|
);
|
|
|
|
match result {
|
|
CompareResult::Match => {
|
|
// Clean up actual file on success
|
|
let _ = std::fs::remove_file(&actual_path);
|
|
let _ = std::fs::remove_file(&diff_path);
|
|
}
|
|
CompareResult::SizeMismatch {
|
|
ref_w,
|
|
ref_h,
|
|
act_w,
|
|
act_h,
|
|
} => {
|
|
panic!(
|
|
"Snapshot '{name}' size mismatch: reference is {ref_w}x{ref_h}, \
|
|
actual is {act_w}x{act_h}. \
|
|
Actual saved to: {}\n\
|
|
To update, delete the reference and re-run.",
|
|
actual_path.display()
|
|
);
|
|
}
|
|
CompareResult::PixelMismatch {
|
|
mismatch_count,
|
|
total_pixels,
|
|
diff_image,
|
|
} => {
|
|
let pct = (mismatch_count as f64 / total_pixels as f64) * 100.0;
|
|
save_rgba_png(&diff_path, &diff_image, self.width, self.height);
|
|
panic!(
|
|
"Snapshot '{name}' has {mismatch_count}/{total_pixels} pixels \
|
|
different ({pct:.2}%). Threshold: {:.2}%\n\
|
|
Actual: {}\n\
|
|
Diff: {}\n\
|
|
To update, delete the reference and re-run.",
|
|
MISMATCH_THRESHOLD * 100.0,
|
|
actual_path.display(),
|
|
diff_path.display(),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// No reference exists - promote actual to reference
|
|
std::fs::rename(&actual_path, &reference_path).expect("Failed to save reference");
|
|
eprintln!(
|
|
"Created new snapshot reference: {}",
|
|
reference_path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Render a COSMIC element to an RGBA pixel buffer.
|
|
fn render_element<'a>(&self, mut element: cosmic::Element<'a, ()>) -> Vec<u8> {
|
|
let w = self.width as f32;
|
|
let h = self.height as f32;
|
|
|
|
// Build the widget tree
|
|
let mut tree = Tree::new(element.as_widget());
|
|
|
|
// Create a tiny-skia renderer
|
|
let mut renderer = iced_tiny_skia::Renderer::new(
|
|
iced_core::Font::DEFAULT,
|
|
iced_core::Pixels(14.0),
|
|
);
|
|
|
|
// Compute layout
|
|
let limits = layout::Limits::new(Size::ZERO, Size::new(w, h));
|
|
let node = element.as_widget_mut().layout(&mut tree, &renderer, &limits);
|
|
let layout_obj = layout::Layout::new(&node);
|
|
|
|
// Get theme
|
|
let cosmic_theme = cosmic::theme::active();
|
|
|
|
// Draw widget
|
|
let viewport = Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: w,
|
|
height: h,
|
|
};
|
|
|
|
let style = renderer::Style {
|
|
text_color: iced_core::Color::WHITE,
|
|
icon_color: iced_core::Color::WHITE,
|
|
scale_factor: 1.0,
|
|
};
|
|
|
|
element.as_widget().draw(
|
|
&tree,
|
|
&mut renderer,
|
|
&cosmic_theme,
|
|
&style,
|
|
layout_obj,
|
|
mouse::Cursor::Unavailable,
|
|
&viewport,
|
|
);
|
|
|
|
// Render to pixel buffer
|
|
let phys_w = self.width;
|
|
let phys_h = self.height;
|
|
let mut pixmap = tiny_skia::Pixmap::new(phys_w, phys_h)
|
|
.expect("Failed to create pixmap");
|
|
|
|
let viewport_obj = iced_tiny_skia::graphics::Viewport::with_logical_size(
|
|
Size::new(w, h),
|
|
1.0,
|
|
);
|
|
|
|
let mut mask = tiny_skia::Mask::new(phys_w, phys_h)
|
|
.expect("Failed to create mask");
|
|
|
|
let damage = vec![Rectangle {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: w,
|
|
height: h,
|
|
}];
|
|
|
|
let bg_color = iced_core::Color::from_rgb(0.15, 0.15, 0.15);
|
|
let overlay: &[String] = &[];
|
|
|
|
renderer.draw(
|
|
&mut pixmap.as_mut(),
|
|
&mut mask,
|
|
&viewport_obj,
|
|
&damage,
|
|
bg_color,
|
|
overlay,
|
|
);
|
|
|
|
pixmap.data().to_vec()
|
|
}
|
|
}
|
|
|
|
/// Directory where snapshot reference images are stored.
|
|
fn snapshot_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("snapshots")
|
|
}
|
|
|
|
/// Save RGBA pixel data as a PNG file.
|
|
fn save_rgba_png(path: &Path, data: &[u8], width: u32, height: u32) {
|
|
let img = image::RgbaImage::from_raw(width, height, data.to_vec())
|
|
.expect("Invalid image dimensions");
|
|
img.save(path)
|
|
.unwrap_or_else(|e| panic!("Failed to save PNG to {}: {e}", path.display()));
|
|
}
|
|
|
|
/// Loaded PNG image data.
|
|
struct PngImage {
|
|
data: Vec<u8>,
|
|
width: u32,
|
|
height: u32,
|
|
}
|
|
|
|
/// Load a PNG file as RGBA pixel data.
|
|
fn load_png_rgba(path: &Path) -> PngImage {
|
|
let img = image::open(path)
|
|
.unwrap_or_else(|e| panic!("Failed to load PNG from {}: {e}", path.display()))
|
|
.to_rgba8();
|
|
let width = img.width();
|
|
let height = img.height();
|
|
PngImage {
|
|
data: img.into_raw(),
|
|
width,
|
|
height,
|
|
}
|
|
}
|
|
|
|
/// Result of comparing two images.
|
|
enum CompareResult {
|
|
Match,
|
|
SizeMismatch {
|
|
ref_w: u32,
|
|
ref_h: u32,
|
|
act_w: u32,
|
|
act_h: u32,
|
|
},
|
|
PixelMismatch {
|
|
mismatch_count: usize,
|
|
total_pixels: usize,
|
|
diff_image: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
/// Compare two RGBA images pixel-by-pixel.
|
|
fn compare_images(
|
|
reference: &[u8],
|
|
ref_w: u32,
|
|
ref_h: u32,
|
|
actual: &[u8],
|
|
act_w: u32,
|
|
act_h: u32,
|
|
) -> CompareResult {
|
|
if ref_w != act_w || ref_h != act_h {
|
|
return CompareResult::SizeMismatch {
|
|
ref_w,
|
|
ref_h,
|
|
act_w,
|
|
act_h,
|
|
};
|
|
}
|
|
|
|
let total_pixels = (ref_w * ref_h) as usize;
|
|
let mut mismatch_count = 0;
|
|
let mut diff_image = vec![0u8; reference.len()];
|
|
|
|
for i in 0..total_pixels {
|
|
let offset = i * 4;
|
|
let r_diff = (reference[offset] as i16 - actual[offset] as i16).unsigned_abs() as u8;
|
|
let g_diff =
|
|
(reference[offset + 1] as i16 - actual[offset + 1] as i16).unsigned_abs() as u8;
|
|
let b_diff =
|
|
(reference[offset + 2] as i16 - actual[offset + 2] as i16).unsigned_abs() as u8;
|
|
let a_diff =
|
|
(reference[offset + 3] as i16 - actual[offset + 3] as i16).unsigned_abs() as u8;
|
|
|
|
if r_diff > PIXEL_TOLERANCE
|
|
|| g_diff > PIXEL_TOLERANCE
|
|
|| b_diff > PIXEL_TOLERANCE
|
|
|| a_diff > PIXEL_TOLERANCE
|
|
{
|
|
mismatch_count += 1;
|
|
// Highlight differences in red
|
|
diff_image[offset] = 255;
|
|
diff_image[offset + 1] = 0;
|
|
diff_image[offset + 2] = 0;
|
|
diff_image[offset + 3] = 255;
|
|
} else {
|
|
// Dim matching pixels
|
|
diff_image[offset] = actual[offset] / 3;
|
|
diff_image[offset + 1] = actual[offset + 1] / 3;
|
|
diff_image[offset + 2] = actual[offset + 2] / 3;
|
|
diff_image[offset + 3] = 255;
|
|
}
|
|
}
|
|
|
|
let mismatch_fraction = mismatch_count as f64 / total_pixels as f64;
|
|
if mismatch_fraction <= MISMATCH_THRESHOLD {
|
|
CompareResult::Match
|
|
} else {
|
|
CompareResult::PixelMismatch {
|
|
mismatch_count,
|
|
total_pixels,
|
|
diff_image,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn compare_identical_images() {
|
|
let data = vec![128u8; 4 * 10 * 10];
|
|
let result = compare_images(&data, 10, 10, &data, 10, 10);
|
|
assert!(matches!(result, CompareResult::Match));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_different_sizes() {
|
|
let small = vec![0u8; 4 * 5 * 5];
|
|
let big = vec![0u8; 4 * 10 * 10];
|
|
let result = compare_images(&small, 5, 5, &big, 10, 10);
|
|
assert!(matches!(result, CompareResult::SizeMismatch { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_within_tolerance() {
|
|
let a = vec![100u8; 4 * 4 * 4];
|
|
let mut b = a.clone();
|
|
// Change one channel by 1 (within tolerance of 2)
|
|
b[0] = 101;
|
|
let result = compare_images(&a, 4, 4, &b, 4, 4);
|
|
assert!(matches!(result, CompareResult::Match));
|
|
}
|
|
|
|
#[test]
|
|
fn compare_exceeds_tolerance() {
|
|
let a = vec![100u8; 4 * 2 * 2]; // 4 pixels
|
|
let mut b = a.clone();
|
|
// Make all pixels differ significantly
|
|
for i in (0..b.len()).step_by(4) {
|
|
b[i] = 200;
|
|
}
|
|
let result = compare_images(&a, 2, 2, &b, 2, 2);
|
|
assert!(matches!(result, CompareResult::PixelMismatch { .. }));
|
|
}
|
|
}
|