Skip to content

Commit f386f36

Browse files
committed
Add test suite for matching rendered imgs
1 parent b31f775 commit f386f36

9 files changed

Lines changed: 211 additions & 0 deletions

File tree

‎src/lib.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,31 @@ macro_rules! prog_name {
2323
"yofi"
2424
};
2525
}
26+
27+
pub fn render_to_buffer(
28+
config: &config::Config,
29+
state: &mut state::State,
30+
scale: u16,
31+
width: i32,
32+
height: i32,
33+
buffer: &mut [u32],
34+
) {
35+
use draw::Drawable;
36+
37+
let mut dt = DrawTarget::from_backing(width, height, buffer);
38+
let mut space_left = draw::Space {
39+
width: width as f32,
40+
height: height as f32,
41+
};
42+
let mut point = draw::Point::new(0., 0.);
43+
44+
let (mut drawables, dyn_space) = draw::make_drawables(config, state, scale);
45+
if let Some(dyn_space) = dyn_space {
46+
space_left.height = space_left.height.min(dyn_space.height);
47+
}
48+
while let Some(d) = drawables.borrowed_next() {
49+
let occupied = d.draw(&mut dt, scale, space_left, point);
50+
point.y += occupied.height;
51+
space_left.height -= occupied.height;
52+
}
53+
}

‎src/mode.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ impl Mode {
7272
dialog::DialogMode::new().map(Self::Dialog)
7373
}
7474

75+
pub fn dialog_from_lines(lines: Vec<String>) -> Self {
76+
Self::Dialog(dialog::DialogMode::from_lines(lines))
77+
}
78+
7579
pub fn fork_eval(&mut self, info: EvalInfo<'_>) -> Result<()> {
7680
// Safety:
7781
// - no need for signal-safety as we single-thread everywhere;

‎src/mode/dialog.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ impl DialogMode {
1515
.map(|lines| Self { lines })
1616
}
1717

18+
pub fn from_lines(lines: Vec<String>) -> Self {
19+
Self { lines }
20+
}
21+
1822
pub fn eval(&mut self, info: EvalInfo<'_>) -> Result<std::convert::Infallible> {
1923
let value = info
2024
.index

‎tests/fixtures/initial.png‎

48.8 KB
Loading

‎tests/fixtures/nav_down.png‎

48.6 KB
Loading

‎tests/fixtures/search.png‎

14.1 KB
Loading

‎tests/fixtures/search_then_nav.png‎

19.6 KB
Loading

‎tests/regression/main.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//! Regression tests for yofi's rendering pipeline.
2+
//!
3+
//! These tests render headlessly to a buffer and compare against stored reference snapshots.
4+
//! With YOFI_BLESS=1 env, snapshot images are overwritten.
5+
//! Snapshots are stored as PNG files in `tests/fixtures/` and can be viewed directly.
6+
//!
7+
//! On mismatch, `<name>.new.png` and `<name>.diff.png` are saved next to the fixture for
8+
//! inspection. The diff highlights changed pixels in red.
9+
10+
mod snap;
11+
use snap::{run_regression, test_entries, Action};
12+
13+
#[test]
14+
fn initial() {
15+
run_regression("initial", test_entries(), &[]);
16+
}
17+
18+
#[test]
19+
fn search() {
20+
run_regression("search", test_entries(), &[Action::Type("fire")]);
21+
}
22+
23+
#[test]
24+
fn nav_down() {
25+
run_regression(
26+
"nav_down",
27+
test_entries(),
28+
&[Action::NextItem, Action::NextItem],
29+
);
30+
}
31+
32+
#[test]
33+
fn search_then_nav() {
34+
run_regression(
35+
"search_then_nav",
36+
test_entries(),
37+
&[Action::Type("te"), Action::NextItem],
38+
);
39+
}

‎tests/regression/snap.rs‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
use yofi::config::Config;
2+
use yofi::mode::Mode;
3+
use yofi::state::State;
4+
use yofi::window::Params;
5+
6+
pub enum Action {
7+
Type(&'static str),
8+
NextItem,
9+
}
10+
11+
pub fn test_entries() -> Vec<String> {
12+
[
13+
"Firefox",
14+
"Chromium",
15+
"Terminal",
16+
"Files",
17+
"Settings",
18+
"Calculator",
19+
"Text Editor",
20+
"Music Player",
21+
]
22+
.iter()
23+
.map(|s| s.to_string())
24+
.collect()
25+
}
26+
27+
fn unpremultiply_to_rgba(buffer: &[u32]) -> Vec<u8> {
28+
let mut rgba = Vec::with_capacity(buffer.len() * 4);
29+
for &px in buffer {
30+
let a = (px >> 24) & 0xff;
31+
let r = (px >> 16) & 0xff;
32+
let g = (px >> 8) & 0xff;
33+
let b = px & 0xff;
34+
if a == 0 {
35+
rgba.extend_from_slice(&[0, 0, 0, 0]);
36+
} else {
37+
rgba.push((r * 255 / a) as u8);
38+
rgba.push((g * 255 / a) as u8);
39+
rgba.push((b * 255 / a) as u8);
40+
rgba.push(a as u8);
41+
}
42+
}
43+
rgba
44+
}
45+
46+
fn save_png(path: &str, width: u32, height: u32, rgba: &[u8]) {
47+
let file = std::fs::File::create(path).expect("failed to create PNG file");
48+
let mut encoder = png::Encoder::new(file, width, height);
49+
encoder.set_color(png::ColorType::Rgba);
50+
encoder.set_depth(png::BitDepth::Eight);
51+
let mut writer = encoder.write_header().expect("failed to write PNG header");
52+
writer
53+
.write_image_data(rgba)
54+
.expect("failed to write PNG data");
55+
}
56+
57+
fn make_diff_image(expected: &[u8], actual: &[u8], width: u32, height: u32) -> Vec<u8> {
58+
let mut diff = Vec::with_capacity(expected.len());
59+
for (exp, act) in expected.chunks_exact(4).zip(actual.chunks_exact(4)) {
60+
if exp == act {
61+
// Matching pixel: dim grayscale
62+
let gray = ((exp[0] as u16 + exp[1] as u16 + exp[2] as u16) / 3 / 3) as u8;
63+
diff.extend_from_slice(&[gray, gray, gray, 255]);
64+
} else {
65+
// Differing pixel: bright red
66+
diff.extend_from_slice(&[255, 0, 0, 255]);
67+
}
68+
}
69+
// Pad if sizes differ (dimension mismatch)
70+
let total = (width as usize) * (height as usize) * 4;
71+
diff.resize(total, 0);
72+
diff
73+
}
74+
75+
fn load_png_rgba(path: &str) -> (u32, u32, Vec<u8>) {
76+
let file = std::fs::File::open(path).unwrap_or_else(|_| {
77+
panic!("fixture not found: {path}\nRun with YOFI_BLESS=1 to generate reference snapshots")
78+
});
79+
let decoder = png::Decoder::new(file);
80+
let mut reader = decoder.read_info().unwrap();
81+
let mut rgba = vec![0u8; reader.output_buffer_size()];
82+
let info = reader.next_frame(&mut rgba).unwrap();
83+
rgba.truncate(info.buffer_size());
84+
(info.width, info.height, rgba)
85+
}
86+
87+
pub fn run_regression(name: &str, entries: Vec<String>, actions: &[Action]) {
88+
let config = Config::default();
89+
90+
let mode = Mode::dialog_from_lines(entries);
91+
let mut state = State::new(mode);
92+
93+
for action in actions {
94+
match action {
95+
Action::Type(s) => state.append_to_input(s),
96+
Action::NextItem => state.next_item(),
97+
}
98+
}
99+
100+
let params: Params = config.param();
101+
let scale = params.scale.unwrap_or(1);
102+
let w = (params.width * u32::from(scale)) as i32;
103+
let h = (params.height * u32::from(scale)) as i32;
104+
let mut buffer = vec![0u32; (w * h) as usize];
105+
yofi::render_to_buffer(&config, &mut state, scale, w, h, &mut buffer);
106+
107+
let actual_rgba = unpremultiply_to_rgba(&buffer);
108+
let w = w as u32;
109+
let h = h as u32;
110+
111+
let fixture = format!("tests/fixtures/{name}.png");
112+
let new_file = format!("tests/fixtures/{name}.new.png");
113+
let diff_file = format!("tests/fixtures/{name}.diff.png");
114+
115+
if std::env::var("YOFI_BLESS").is_ok() {
116+
save_png(&fixture, w, h, &actual_rgba);
117+
eprintln!("blessed: {fixture}");
118+
} else {
119+
let (rw, rh, reference_rgba) = load_png_rgba(&fixture);
120+
assert!(
121+
rw == w && rh == h,
122+
"dimension mismatch for '{name}': expected {rw}x{rh}, got {w}x{h}"
123+
);
124+
if actual_rgba != reference_rgba {
125+
save_png(&new_file, w, h, &actual_rgba);
126+
let diff = make_diff_image(&reference_rgba, &actual_rgba, w, h);
127+
save_png(&diff_file, w, h, &diff);
128+
panic!(
129+
"pixel mismatch for '{name}'\n \
130+
new: {new_file}\n \
131+
diff: {diff_file}\n \
132+
Run with YOFI_BLESS=1 to update"
133+
);
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)