diff --git a/src/lib.rs b/src/lib.rs index d7738a1..1ed0488 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,3 +23,31 @@ macro_rules! prog_name { "yofi" }; } + +pub fn render_to_buffer( + config: &config::Config, + state: &mut state::State, + scale: u16, + width: i32, + height: i32, + buffer: &mut [u32], +) { + use draw::Drawable; + + let mut dt = DrawTarget::from_backing(width, height, buffer); + let mut space_left = draw::Space { + width: width as f32, + height: height as f32, + }; + let mut point = draw::Point::new(0., 0.); + + let (mut drawables, dyn_space) = draw::make_drawables(config, state, scale); + if let Some(dyn_space) = dyn_space { + space_left.height = space_left.height.min(dyn_space.height); + } + while let Some(d) = drawables.borrowed_next() { + let occupied = d.draw(&mut dt, scale, space_left, point); + point.y += occupied.height; + space_left.height -= occupied.height; + } +} diff --git a/src/mode.rs b/src/mode.rs index 6216781..8e3e4be 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -72,6 +72,10 @@ impl Mode { dialog::DialogMode::new().map(Self::Dialog) } + pub fn dialog_from_lines(lines: Vec) -> Self { + Self::Dialog(dialog::DialogMode::from_lines(lines)) + } + pub fn fork_eval(&mut self, info: EvalInfo<'_>) -> Result<()> { // Safety: // - no need for signal-safety as we single-thread everywhere; diff --git a/src/mode/dialog.rs b/src/mode/dialog.rs index 3cc67fa..5bf446f 100644 --- a/src/mode/dialog.rs +++ b/src/mode/dialog.rs @@ -15,6 +15,10 @@ impl DialogMode { .map(|lines| Self { lines }) } + pub fn from_lines(lines: Vec) -> Self { + Self { lines } + } + pub fn eval(&mut self, info: EvalInfo<'_>) -> Result { let value = info .index diff --git a/tests/fixtures/initial.png b/tests/fixtures/initial.png new file mode 100644 index 0000000..2c0e20b Binary files /dev/null and b/tests/fixtures/initial.png differ diff --git a/tests/fixtures/nav_down.png b/tests/fixtures/nav_down.png new file mode 100644 index 0000000..1054e75 Binary files /dev/null and b/tests/fixtures/nav_down.png differ diff --git a/tests/fixtures/search.png b/tests/fixtures/search.png new file mode 100644 index 0000000..b3ba196 Binary files /dev/null and b/tests/fixtures/search.png differ diff --git a/tests/fixtures/search_then_nav.png b/tests/fixtures/search_then_nav.png new file mode 100644 index 0000000..0a2e1f9 Binary files /dev/null and b/tests/fixtures/search_then_nav.png differ diff --git a/tests/regression/main.rs b/tests/regression/main.rs new file mode 100644 index 0000000..288e4bb --- /dev/null +++ b/tests/regression/main.rs @@ -0,0 +1,39 @@ +//! Regression tests for yofi's rendering pipeline. +//! +//! These tests render headlessly to a buffer and compare against stored reference snapshots. +//! With YOFI_BLESS=1 env, snapshot images are overwritten. +//! Snapshots are stored as PNG files in `tests/fixtures/` and can be viewed directly. +//! +//! On mismatch, `.new.png` and `.diff.png` are saved next to the fixture for +//! inspection. The diff highlights changed pixels in red. + +mod snap; +use snap::{run_regression, test_entries, Action}; + +#[test] +fn initial() { + run_regression("initial", test_entries(), &[]); +} + +#[test] +fn search() { + run_regression("search", test_entries(), &[Action::Type("fire")]); +} + +#[test] +fn nav_down() { + run_regression( + "nav_down", + test_entries(), + &[Action::NextItem, Action::NextItem], + ); +} + +#[test] +fn search_then_nav() { + run_regression( + "search_then_nav", + test_entries(), + &[Action::Type("te"), Action::NextItem], + ); +} diff --git a/tests/regression/snap.rs b/tests/regression/snap.rs new file mode 100644 index 0000000..735ee26 --- /dev/null +++ b/tests/regression/snap.rs @@ -0,0 +1,136 @@ +use yofi::config::Config; +use yofi::mode::Mode; +use yofi::state::State; +use yofi::window::Params; + +pub enum Action { + Type(&'static str), + NextItem, +} + +pub fn test_entries() -> Vec { + [ + "Firefox", + "Chromium", + "Terminal", + "Files", + "Settings", + "Calculator", + "Text Editor", + "Music Player", + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +fn unpremultiply_to_rgba(buffer: &[u32]) -> Vec { + let mut rgba = Vec::with_capacity(buffer.len() * 4); + for &px in buffer { + let a = (px >> 24) & 0xff; + let r = (px >> 16) & 0xff; + let g = (px >> 8) & 0xff; + let b = px & 0xff; + if a == 0 { + rgba.extend_from_slice(&[0, 0, 0, 0]); + } else { + rgba.push((r * 255 / a) as u8); + rgba.push((g * 255 / a) as u8); + rgba.push((b * 255 / a) as u8); + rgba.push(a as u8); + } + } + rgba +} + +fn save_png(path: &str, width: u32, height: u32, rgba: &[u8]) { + let file = std::fs::File::create(path).expect("failed to create PNG file"); + let mut encoder = png::Encoder::new(file, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().expect("failed to write PNG header"); + writer + .write_image_data(rgba) + .expect("failed to write PNG data"); +} + +fn make_diff_image(expected: &[u8], actual: &[u8], width: u32, height: u32) -> Vec { + let mut diff = Vec::with_capacity(expected.len()); + for (exp, act) in expected.chunks_exact(4).zip(actual.chunks_exact(4)) { + if exp == act { + // Matching pixel: dim grayscale + let gray = ((exp[0] as u16 + exp[1] as u16 + exp[2] as u16) / 3 / 3) as u8; + diff.extend_from_slice(&[gray, gray, gray, 255]); + } else { + // Differing pixel: bright red + diff.extend_from_slice(&[255, 0, 0, 255]); + } + } + // Pad if sizes differ (dimension mismatch) + let total = (width as usize) * (height as usize) * 4; + diff.resize(total, 0); + diff +} + +fn load_png_rgba(path: &str) -> (u32, u32, Vec) { + let file = std::fs::File::open(path).unwrap_or_else(|_| { + panic!("fixture not found: {path}\nRun with YOFI_BLESS=1 to generate reference snapshots") + }); + let decoder = png::Decoder::new(file); + let mut reader = decoder.read_info().unwrap(); + let mut rgba = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut rgba).unwrap(); + rgba.truncate(info.buffer_size()); + (info.width, info.height, rgba) +} + +pub fn run_regression(name: &str, entries: Vec, actions: &[Action]) { + let config = Config::default(); + + let mode = Mode::dialog_from_lines(entries); + let mut state = State::new(mode); + + for action in actions { + match action { + Action::Type(s) => state.append_to_input(s), + Action::NextItem => state.next_item(), + } + } + + let params: Params = config.param(); + let scale = params.scale.unwrap_or(1); + let w = (params.width * u32::from(scale)) as i32; + let h = (params.height * u32::from(scale)) as i32; + let mut buffer = vec![0u32; (w * h) as usize]; + yofi::render_to_buffer(&config, &mut state, scale, w, h, &mut buffer); + + let actual_rgba = unpremultiply_to_rgba(&buffer); + let w = w as u32; + let h = h as u32; + + let fixture = format!("tests/fixtures/{name}.png"); + let new_file = format!("tests/fixtures/{name}.new.png"); + let diff_file = format!("tests/fixtures/{name}.diff.png"); + + if std::env::var("YOFI_BLESS").is_ok() { + save_png(&fixture, w, h, &actual_rgba); + eprintln!("blessed: {fixture}"); + } else { + let (rw, rh, reference_rgba) = load_png_rgba(&fixture); + assert!( + rw == w && rh == h, + "dimension mismatch for '{name}': expected {rw}x{rh}, got {w}x{h}" + ); + if actual_rgba != reference_rgba { + save_png(&new_file, w, h, &actual_rgba); + let diff = make_diff_image(&reference_rgba, &actual_rgba, w, h); + save_png(&diff_file, w, h, &diff); + panic!( + "pixel mismatch for '{name}'\n \ + new: {new_file}\n \ + diff: {diff_file}\n \ + Run with YOFI_BLESS=1 to update" + ); + } + } +}