From 71f88cb6ea2ce145577611e3a615eed26afb655e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20W=C3=B6lfer?= Date: Sat, 24 Jan 2026 09:41:22 +0100 Subject: [PATCH] tests: improved testability --- Cargo.lock | 135 +++++++++++++++++- Cargo.toml | 3 + src/main.rs | 62 +++----- src/terminal_guard.rs | 5 +- src/todo_list.rs | 56 ++++++++ .../ui_snapshot__ui_snapshot_basic.snap | 23 +++ tests/ui_snapshot.rs | 61 ++++++++ 7 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 src/todo_list.rs create mode 100644 tests/snapshots/ui_snapshot__ui_snapshot_basic.snap create mode 100644 tests/ui_snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index 0fc8cc1..7290441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ name = "chkr" version = "0.1.0" dependencies = [ "crossterm", + "insta", "ratatui", ] @@ -135,6 +136,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -296,6 +309,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -309,7 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -331,6 +350,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -426,6 +451,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.11" @@ -584,7 +621,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -971,7 +1008,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1070,6 +1107,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.1" @@ -1137,6 +1180,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1495,6 +1551,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1504,6 +1569,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 3561d83..a578902 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,9 @@ edition = "2024" crossterm = "0.29" ratatui = { version = "0.30", features = ["crossterm"] } +[dev-dependencies] +insta = "1.33" + [lints.clippy] pedantic = "warn" unused_trait_names = "warn" diff --git a/src/main.rs b/src/main.rs index b4af9fb..e88c46c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ mod terminal_guard; +/// UI components (todo list) +mod todo_list; use core::time::Duration; use crossterm::event::{self, EnableMouseCapture, Event, KeyCode}; @@ -6,11 +8,11 @@ use crossterm::execute; use crossterm::terminal::{EnterAlternateScreen, enable_raw_mode}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use ratatui::style::Style; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; use std::io::{self, Read as _}; use terminal_guard::TerminalModeGuard; +use todo_list::TodoList; +#[allow(clippy::wildcard_enum_match_arm)] fn main() -> Result<(), Box> { let mut input = String::new(); io::stdin().read_to_string(&mut input)?; @@ -24,7 +26,7 @@ fn main() -> Result<(), Box> { .lines() .map(std::string::ToString::to_string) .collect(); - let mut marked = vec![false; lines.len()]; + let mut todo = TodoList::with_lines(lines); enable_raw_mode()?; let mut stdout = std::io::stdout(); @@ -35,33 +37,9 @@ fn main() -> Result<(), Box> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let mut state = ListState::default(); - if !lines.is_empty() { - state.select(Some(0)); - } - loop { terminal.draw(|f| { - let size = f.area(); - - let items: Vec = lines - .iter() - .zip(marked.iter()) - .map(|(text, marked)| { - let prefix = if *marked { 'x' } else { ' ' }; - ListItem::new(format!("[{prefix}] {text}")) - }) - .collect(); - - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title("Lines (space to mark, q to quit)"), - ) - .highlight_style(Style::default().bold().yellow().on_dark_gray()); - - f.render_stateful_widget(list, size, &mut state); + todo.draw(f); })?; if event::poll(Duration::from_millis(100))? { @@ -69,23 +47,23 @@ fn main() -> Result<(), Box> { Event::Key(key) => match key.code { KeyCode::Char('q') => break, KeyCode::Up => { - if let Some(i) = state.selected() { - state.select(Some(i.saturating_sub(1))); + if let Some(i) = todo.state.selected() { + todo.state.select(Some(i.saturating_sub(1))); } } KeyCode::Down => { - if let Some(i) = state.selected() { - state.select(Some((i + 1).min(lines.len() - 1))); + if let Some(i) = todo.state.selected() { + todo.state.select(Some((i + 1).min(todo.lines.len() - 1))); } } KeyCode::Char(' ') => { - if let Some(i) = state.selected() { - let Some(marked_cell) = marked.get_mut(i) else { + if let Some(i) = todo.state.selected() { + let Some(marked_cell) = todo.marked.get_mut(i) else { return Err("Index out of bounds".into()); }; if !*marked_cell { - let next = lines.len().min(i + 1); - state.select(Some(next)); + let next = todo.lines.len().min(i + 1); + todo.state.select(Some(next)); } *marked_cell = !*marked_cell; } @@ -98,10 +76,14 @@ fn main() -> Result<(), Box> { Event::Mouse(s) => { if s.kind == event::MouseEventKind::Down(event::MouseButton::Left) { let area = terminal.size()?; - if s.row > area.height && s.row < area.height + area.height { - let idx = (s.row - area.height - 1) as usize; - if idx < lines.len() { - state.select(Some(idx)); + if let Some(row_idx) = s + .row + .checked_sub(area.height + 1) + .filter(|_| s.row < area.height.saturating_add(area.height)) + { + let idx = usize::from(row_idx); + if idx < todo.lines.len() { + todo.state.select(Some(idx)); } } } diff --git a/src/terminal_guard.rs b/src/terminal_guard.rs index 0cbfb99..1a307f8 100644 --- a/src/terminal_guard.rs +++ b/src/terminal_guard.rs @@ -11,7 +11,10 @@ pub struct TerminalModeGuard; impl TerminalModeGuard {} impl Drop for TerminalModeGuard { - #[allow(clippy::let_underscore_must_use, reason = "We want to ignore errors during cleanup.")] + #[allow( + clippy::let_underscore_must_use, + reason = "We want to ignore errors during cleanup." + )] fn drop(&mut self) { let _ = disable_raw_mode(); let mut stdout = std::io::stdout(); diff --git a/src/todo_list.rs b/src/todo_list.rs new file mode 100644 index 0000000..7951350 --- /dev/null +++ b/src/todo_list.rs @@ -0,0 +1,56 @@ +use ratatui::style::Style; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; +// use Frame via the crate root type `ratatui::Frame` in the signature below + +/// A simple todo/list view that owns the lines, marked flags and selection state. +pub struct TodoList { + pub lines: Vec, + pub marked: Vec, + pub state: ListState, + pub title: String, +} + +impl TodoList { + /// Create a new `TodoList` from lines. + pub fn new(lines: Vec) -> Self { + let mut state = ListState::default(); + if !lines.is_empty() { + state.select(Some(0)); + } + + Self { + lines, + marked: vec![false; state.selected().map(|_| 0).unwrap_or(0)], + state, + title: "Lines (space to mark, q to quit)".to_string(), + } + } + + /// Convenience constructor that ensures marked has same length as lines. + pub fn with_lines(lines: Vec) -> Self { + let mut s = Self::new(lines); + s.marked = vec![false; s.lines.len()]; + s + } + + /// Draw the list into a `ratatui` frame. + pub fn draw(&mut self, f: &mut ratatui::Frame<'_>) { + let size = f.area(); + + let items: Vec = self + .lines + .iter() + .zip(self.marked.iter()) + .map(|(text, marked)| { + let prefix = if *marked { 'x' } else { ' ' }; + ListItem::new(format!("[{prefix}] {text}")) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(&*self.title)) + .highlight_style(Style::default().bold().yellow().on_dark_gray()); + + f.render_stateful_widget(list, size, &mut self.state); + } +} diff --git a/tests/snapshots/ui_snapshot__ui_snapshot_basic.snap b/tests/snapshots/ui_snapshot__ui_snapshot_basic.snap new file mode 100644 index 0000000..042af2d --- /dev/null +++ b/tests/snapshots/ui_snapshot__ui_snapshot_basic.snap @@ -0,0 +1,23 @@ +--- +source: tests/ui_snapshot.rs +assertion_line: 50 +expression: out +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 10 }, + content: [ + "┌Lines (space to mark, q to quit)──────┐", + "│[ ] first │", + "│[x] second │", + "│[ ] third │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "└──────────────────────────────────────┘", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/tests/ui_snapshot.rs b/tests/ui_snapshot.rs new file mode 100644 index 0000000..be8acdf --- /dev/null +++ b/tests/ui_snapshot.rs @@ -0,0 +1,61 @@ +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; + +fn render_list_into_buffer( + lines: &[&str], + marked: &[bool], + selected: Option, + width: u16, + height: u16, +) -> Buffer { + let backend = TestBackend::new(width.into(), height.into()); + let mut terminal = Terminal::new(backend).expect("create terminal"); + + let lines_vec: Vec = lines + .iter() + .zip(marked.iter()) + .map(|(text, marked)| { + let prefix = if *marked { 'x' } else { ' ' }; + ListItem::new(format!("[{prefix}] {text}")) + }) + .collect(); + + let mut state = ListState::default(); + state.select(selected); + + terminal + .draw(|f| { + let size = Rect::new(0, 0, width, height); + + let list = List::new(lines_vec.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title("Lines (space to mark, q to quit)"), + ) + .highlight_style(Style::default()); + + f.render_stateful_widget(list, size, &mut state); + }) + .expect("draw"); + + // Extract the underlying backend buffer (clone to own it) + let backend = terminal.backend(); + let buf = backend.buffer().clone(); + buf +} + +#[test] +fn ui_snapshot_basic() { + let lines = ["first", "second", "third"]; + let marked = [false, true, false]; + let buf = render_list_into_buffer(&lines, &marked, Some(1), 40, 10); + + // Use the buffer's debug representation for snapshotting + let out = format!("{buf:#?}"); + insta::assert_snapshot!(out); +}