Skip to content

Commit 17ac2ae

Browse files
spiraliDJMcNab
andauthored
Initial version of snapshot testing (#173)
Hi, This PR contains an initial version of snapshot testing. By design it contains only a minimal API and usage inspired by vello snapshot tests. The ambition of this PR is not to do any test coverage, it contains only two tests for demonstration purposes of the testing environment. All the bells and whistles (html report, image diffs, not loading fonts in every test) are now missing to make the code more straightforward. # Usage ```bash $ cargo test ``` If a test fails, you can compare images in `parley/tests/current` (images created by the current test) and `parley/tests/snapshots` (the accepted versions). If you think that everything is ok, you can start tests as follows: ```bash $ PARLEY_TEST="accept" cargo test ``` It will update snapshots of the failed tests. # How it works It reuses `tiny-skia-renderer` and renders the testing layout into an image and compares it pixel by pixel. This PR also adds fonts into the tests, to have tests independent on the system fonts. I have chosen DejaVu fonts, but it was a random pick. --------- Co-authored-by: Daniel McNab <[email protected]>
1 parent 45d8239 commit 17ac2ae

19 files changed

+743
-0
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

+5
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,9 @@ Licensed under either of
107107

108108
at your option.
109109

110+
Some files used for tests are under different licenses:
111+
112+
- The font file `Roboto-Regular.ttf` in `/parley/tests/assets/roboto_fonts/` is licensed solely as documented in that folder (and is licensed under the Apache License, Version 2.0).
113+
114+
110115
[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct

parley/Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ edition.workspace = true
88
rust-version.workspace = true
99
license.workspace = true
1010
repository.workspace = true
11+
exclude = ["/tests"]
1112

1213
[package.metadata.docs.rs]
1314
all-features = true
@@ -31,3 +32,6 @@ fontique = { workspace = true }
3132
core_maths = { version = "0.1.0", optional = true }
3233
accesskit = { workspace = true, optional = true }
3334
hashbrown = { workspace = true, optional = true }
35+
36+
[dev-dependencies]
37+
tiny-skia = "0.11.4"

parley/README.md

+4
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,8 @@ Licensed under either of
5252

5353
at your option.
5454

55+
Some files used for tests are under different licenses:
56+
57+
- The font file `Roboto-Regular.ttf` in `/parley/tests/assets/roboto_fonts/` is licensed solely as documented in that folder (and is licensed under the Apache License, Version 2.0).
58+
5559
[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct

parley/src/lib.rs

+3
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ mod util;
118118
pub mod layout;
119119
pub mod style;
120120

121+
#[cfg(test)]
122+
mod tests;
123+
121124
pub use peniko::kurbo::Rect;
122125
pub use peniko::Font;
123126

parley/src/tests/mod.rs

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright 2024 the Parley Authors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
mod test_basic;
5+
mod utils;

parley/src/tests/test_basic.rs

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2024 the Parley Authors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
use crate::{testenv, Alignment, InlineBox};
5+
6+
#[test]
7+
fn plain_multiline_text() {
8+
let mut env = testenv!();
9+
10+
let text = "Hello world!\nLine 2\nLine 4";
11+
let mut builder = env.builder(text);
12+
let mut layout = builder.build(text);
13+
layout.break_all_lines(None);
14+
layout.align(None, Alignment::Start);
15+
16+
env.check_snapshot(&layout);
17+
}
18+
19+
#[test]
20+
fn placing_inboxes() {
21+
let mut env = testenv!();
22+
23+
for (position, test_case_name) in [
24+
(0, "start"),
25+
(3, "in_word"),
26+
(12, "end_nl"),
27+
(13, "start_nl"),
28+
] {
29+
let text = "Hello world!\nLine 2\nLine 4";
30+
let mut builder = env.builder(text);
31+
builder.push_inline_box(InlineBox {
32+
id: 0,
33+
index: position,
34+
width: 10.0,
35+
height: 10.0,
36+
});
37+
let mut layout = builder.build(text);
38+
layout.break_all_lines(None);
39+
layout.align(None, Alignment::Start);
40+
env.check_snapshot_with_name(test_case_name, &layout);
41+
}
42+
}

parley/src/tests/utils/env.rs

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright 2024 the Parley Authors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
use crate::tests::utils::renderer::render_layout;
5+
use crate::{
6+
FontContext, FontFamily, FontStack, Layout, LayoutContext, RangedBuilder, StyleProperty,
7+
};
8+
use fontique::{Collection, CollectionOptions};
9+
use peniko::Color;
10+
use std::path::{Path, PathBuf};
11+
use tiny_skia::Pixmap;
12+
13+
// Creates a new instance of TestEnv and put current function name in constructor
14+
#[macro_export]
15+
macro_rules! testenv {
16+
() => {{
17+
// Get name of the current function
18+
fn f() {}
19+
fn type_name_of<T>(_: T) -> &'static str {
20+
std::any::type_name::<T>()
21+
}
22+
let name = type_name_of(f);
23+
let name = &name[..name.len() - 3];
24+
let name = &name[name.rfind(':').map(|x| x + 1).unwrap_or(0)..];
25+
26+
// Create test env
27+
$crate::tests::utils::TestEnv::new(name)
28+
}};
29+
}
30+
31+
fn current_imgs_dir() -> PathBuf {
32+
Path::new(env!("CARGO_MANIFEST_DIR"))
33+
.join("tests")
34+
.join("current")
35+
}
36+
37+
fn snapshot_dir() -> PathBuf {
38+
Path::new(env!("CARGO_MANIFEST_DIR"))
39+
.join("tests")
40+
.join("snapshots")
41+
}
42+
43+
fn font_dir() -> PathBuf {
44+
Path::new(env!("CARGO_MANIFEST_DIR"))
45+
.join("tests")
46+
.join("assets")
47+
.join("roboto_fonts")
48+
}
49+
50+
const DEFAULT_FONT_NAME: &str = "Roboto";
51+
52+
pub(crate) struct TestEnv {
53+
test_name: String,
54+
check_counter: u32,
55+
font_cx: FontContext,
56+
layout_cx: LayoutContext<Color>,
57+
foreground_color: Color,
58+
background_color: Color,
59+
tolerance: f32,
60+
errors: Vec<(PathBuf, String)>,
61+
}
62+
63+
fn is_accept_mode() -> bool {
64+
std::env::var("PARLEY_TEST")
65+
.map(|x| x.to_ascii_lowercase() == "accept")
66+
.unwrap_or(false)
67+
}
68+
69+
pub(crate) fn load_fonts_dir(collection: &mut Collection, path: &Path) -> std::io::Result<()> {
70+
let paths = std::fs::read_dir(path)?;
71+
for entry in paths {
72+
let entry = entry?;
73+
if !entry.metadata()?.is_file() {
74+
continue;
75+
}
76+
let path = entry.path();
77+
if path
78+
.extension()
79+
.and_then(|ext| ext.to_str())
80+
.map(|ext| !["ttf", "otf", "ttc", "otc"].contains(&ext))
81+
.unwrap_or(true)
82+
{
83+
continue;
84+
}
85+
let font_data = std::fs::read(&path)?;
86+
collection.register_fonts(font_data);
87+
}
88+
Ok(())
89+
}
90+
91+
impl TestEnv {
92+
pub(crate) fn new(test_name: &str) -> Self {
93+
let file_prefix = format!("{}-", test_name);
94+
let entries = std::fs::read_dir(current_imgs_dir()).unwrap();
95+
for entry in entries.flatten() {
96+
let path = entry.path();
97+
if path
98+
.file_name()
99+
.and_then(|name| name.to_str())
100+
.map(|name| name.starts_with(&file_prefix) && name.ends_with(".png"))
101+
.unwrap_or(false)
102+
{
103+
std::fs::remove_file(&path).unwrap();
104+
}
105+
}
106+
107+
let mut collection = Collection::new(CollectionOptions {
108+
shared: false,
109+
system_fonts: false,
110+
});
111+
load_fonts_dir(&mut collection, &font_dir()).unwrap();
112+
collection
113+
.family_id(DEFAULT_FONT_NAME)
114+
.unwrap_or_else(|| panic!("{} font not found", DEFAULT_FONT_NAME));
115+
TestEnv {
116+
test_name: test_name.to_string(),
117+
check_counter: 0,
118+
font_cx: FontContext {
119+
collection,
120+
source_cache: Default::default(),
121+
},
122+
tolerance: 0.0,
123+
layout_cx: LayoutContext::new(),
124+
foreground_color: Color::rgb8(0, 0, 0),
125+
background_color: Color::rgb8(255, 255, 255),
126+
errors: Vec::new(),
127+
}
128+
}
129+
130+
pub(crate) fn builder<'a>(&'a mut self, text: &'a str) -> RangedBuilder<'a, Color> {
131+
let mut builder = self.layout_cx.ranged_builder(&mut self.font_cx, text, 1.0);
132+
builder.push_default(StyleProperty::Brush(self.foreground_color));
133+
builder.push_default(StyleProperty::FontStack(FontStack::Single(
134+
FontFamily::Named(DEFAULT_FONT_NAME.into()),
135+
)));
136+
builder
137+
}
138+
139+
fn image_name(&mut self, test_case_name: &str) -> String {
140+
if test_case_name.is_empty() {
141+
let name = format!("{}-{}.png", self.test_name, self.check_counter);
142+
self.check_counter += 1;
143+
name
144+
} else {
145+
assert!(test_case_name
146+
.chars()
147+
.all(|c| c == '_' || char::is_alphanumeric(c)));
148+
format!("{}-{}.png", self.test_name, test_case_name)
149+
}
150+
}
151+
152+
fn check_images(&self, current_img: &Pixmap, snapshot_path: &Path) -> Result<(), String> {
153+
if !snapshot_path.is_file() {
154+
return Err(format!("Cannot find snapshot {}", snapshot_path.display()));
155+
}
156+
let snapshot_img = Pixmap::load_png(snapshot_path)
157+
.map_err(|_| format!("Loading snapshot {} failed", snapshot_path.display()))?;
158+
if snapshot_img.width() != current_img.width()
159+
|| snapshot_img.height() != current_img.height()
160+
{
161+
return Err(format!(
162+
"Snapshot has different size: snapshot {}x{}; generated image: {}x{}",
163+
snapshot_img.width(),
164+
snapshot_img.height(),
165+
current_img.width(),
166+
current_img.height()
167+
));
168+
}
169+
170+
let mut n_different_pixels = 0;
171+
let mut color_cumulative_difference = 0.0;
172+
for (pixel1, pixel2) in snapshot_img.pixels().iter().zip(current_img.pixels()) {
173+
if pixel1 != pixel2 {
174+
n_different_pixels += 1;
175+
}
176+
let diff_r = (pixel1.red() as f32 - pixel2.red() as f32).abs();
177+
let diff_g = (pixel1.green() as f32 - pixel2.green() as f32).abs();
178+
let diff_b = (pixel1.blue() as f32 - pixel2.blue() as f32).abs();
179+
color_cumulative_difference += diff_r.max(diff_g).max(diff_b);
180+
}
181+
if color_cumulative_difference > self.tolerance {
182+
return Err(format!(
183+
"Testing image differs in {n_different_pixels} pixels (color difference = {color_cumulative_difference})",
184+
));
185+
}
186+
Ok(())
187+
}
188+
189+
pub(crate) fn check_snapshot_with_name(
190+
&mut self,
191+
test_case_name: &str,
192+
layout: &Layout<Color>,
193+
) {
194+
let current_img = render_layout(layout, self.background_color, self.foreground_color);
195+
let image_name = self.image_name(test_case_name);
196+
197+
let snapshot_path = snapshot_dir().join(&image_name);
198+
let comparison_path = current_imgs_dir().join(&image_name);
199+
200+
if let Err(e) = self.check_images(&current_img, &snapshot_path) {
201+
if is_accept_mode() {
202+
current_img.save_png(&snapshot_path).unwrap();
203+
} else {
204+
current_img.save_png(&comparison_path).unwrap();
205+
self.errors.push((comparison_path, e));
206+
}
207+
}
208+
}
209+
210+
pub(crate) fn check_snapshot(&mut self, layout: &Layout<Color>) {
211+
self.check_snapshot_with_name("", layout);
212+
}
213+
}
214+
215+
impl Drop for TestEnv {
216+
// Dropping of TestEnv cause panic (if there is not already one)
217+
// We do not panic immediately when error is detected because we want to
218+
// generate all images in the test and do visual confirmation of the whole
219+
// set and not stop at the first error.
220+
fn drop(&mut self) {
221+
if !self.errors.is_empty() && !std::thread::panicking() {
222+
use std::fmt::Write;
223+
let mut panic_msg = String::new();
224+
for (path, msg) in &self.errors {
225+
write!(
226+
&mut panic_msg,
227+
"{}\nImage written into: {}\n",
228+
msg,
229+
path.display()
230+
)
231+
.unwrap();
232+
}
233+
panic!("{}", &panic_msg);
234+
}
235+
}
236+
}

parley/src/tests/utils/mod.rs

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Copyright 2024 the Parley Authors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
mod env;
5+
mod renderer;
6+
7+
pub(crate) use env::TestEnv;

0 commit comments

Comments
 (0)