diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 660aa5e..2a35363 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -55,7 +55,18 @@ jobs: uses: taiki-e/install-action@cargo-llvm-cov - name: Generate lcov coverage - run: cargo llvm-cov --workspace --lcov --output-path lcov.info + # Ratchet, not the house 100% target. The uncovered remainder is the + # code that only runs against a real TTY: terminal.rs's crossterm + # impl, runtime.rs's run_interactive wiring, and lib.rs's call into + # it. None of it can execute under `cargo test`, so the gate holds the + # line where it actually is instead of asserting a number the suite + # cannot reach. Measured 96.40% lines / 83.24% functions, and stable + # to the digit across repeat runs, so these floors trip on a real + # regression rather than on noise. Raise them when coverage rises; + # never lower them. + run: >- + cargo llvm-cov --workspace --lcov --output-path lcov.info + --fail-under-lines 96 --fail-under-functions 83 - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 diff --git a/src/cli.rs b/src/cli.rs index cb04485..079c86a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,12 +38,24 @@ pub enum CliCommand { } /// Parse CLI arguments from an iterator (including argv0 as first item). +/// +/// This is a thin generic shim so callers can pass `std::env::Args`, a +/// `Vec`, or a literal array. All parsing lives in the non-generic +/// `parse_argv`: a generic body is monomorphized once per argument type, and +/// every copy carries its own counters, so the error paths that a given +/// call site never takes would each report as uncovered even though the +/// source line is exercised elsewhere. Keeping the body non-generic means the +/// logic is compiled, and counted, exactly once. pub fn parse_args(args: I) -> Result where I: IntoIterator, I::Item: Into, { - let mut args: Vec = args.into_iter().map(Into::into).collect(); + parse_argv(args.into_iter().map(Into::into).collect()) +} + +/// Parse an owned argv vector (including argv0 as first item). +fn parse_argv(mut args: Vec) -> Result { if !args.is_empty() { args.remove(0); } diff --git a/src/lib.rs b/src/lib.rs index d7c2528..2772d78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod model; pub mod process; pub mod runtime; pub mod signal; +pub mod terminal; pub mod tree; pub mod ui; diff --git a/src/runtime.rs b/src/runtime.rs index 1586b39..d59811c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -16,22 +16,18 @@ //! Input mapping and runtime action application for the TUI loop. -use std::{io, time::Duration}; +use std::time::Duration; use anyhow::Result; +use crossterm::event; use crossterm::event::{Event, KeyCode, KeyEventKind}; -use crossterm::{ - event, execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; use nix::sys::signal::Signal; -use ratatui::{Terminal, prelude::CrosstermBackend}; use sysinfo::System; use crate::{ app::{self, App}, model::ProcRow, - process, signal, ui, + process, signal, terminal, ui, }; /// Number of rows moved by page navigation actions. @@ -417,7 +413,7 @@ pub fn run_interactive( compiled_filter: Option, user_only: bool, ) -> Result<()> { - let mut terminal = setup_terminal()?; + let mut terminal = terminal::setup()?; let mut sys = System::new_all(); let mut draw = |app: &mut App| -> Result<()> { @@ -443,7 +439,7 @@ pub fn run_interactive( &mut sender, &mut (signal::wait_for_pid_gone_default as fn(i32)), ); - restore_terminal(terminal); + terminal::restore(terminal); result } @@ -469,21 +465,6 @@ fn run_with_runtime( ) } -/// Configure terminal raw mode and alternate screen for TUI rendering. -fn setup_terminal() -> Result>> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - Ok(Terminal::new(CrosstermBackend::new(stdout))?) -} - -/// Restore terminal state after TUI execution, ignoring restoration failures. -fn restore_terminal(mut terminal: Terminal>) { - let _ = disable_raw_mode(); - let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); - let _ = terminal.show_cursor(); -} - /// Refresh rows while keeping selection bounded to the previous index. fn refresh_with_selection_preserved( app: &mut App, @@ -494,1070 +475,5 @@ fn refresh_with_selection_preserved( } #[cfg(test)] -mod tests { - use super::{ - Action, ActionResult, apply_action, map_key_event_to_action, run_event_loop, - run_with_runtime, - }; - use crate::{ - app::{self, App}, - model::ProcRow, - }; - use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; - use nix::sys::signal::Signal; - use std::sync::Arc; - use std::time::Duration; - use sysinfo::ProcessStatus; - - /// Stand-in for the production `await_pid_gone` callback. Defined as a - /// real `fn` so the body is covered by `noop_await_runs` and every other - /// test can reference it without instantiating its own closure. - fn noop_await(_: i32) {} - - #[test] - fn noop_await_runs() { - noop_await(0); - } - - /// Panicking `await_pid_gone` stand-in for negative tests that assert the - /// callback is never invoked. Covered by `must_not_run_panics_when_called`. - fn must_not_run(_: i32) { - panic!("await_pid_gone must not be called when sender fails"); - } - - #[test] - #[should_panic(expected = "await_pid_gone must not be called when sender fails")] - fn must_not_run_panics_when_called() { - must_not_run(0); - } - - fn row(pid: i32, name: &str) -> ProcRow { - ProcRow { - pid, - start_time: 0, - ppid: None, - ancestor_chain: Vec::new(), - user: Arc::from("u"), - status: ProcessStatus::Run, - cpu_usage_tenths: 0, - memory_bytes: 0, - name: name.to_string(), - cmd: format!("/bin/{name}"), - } - } - - #[test] - fn map_key_event_to_action_maps_regular_actions() { - assert_eq!( - map_key_event_to_action(KeyCode::Char('q'), false, false), - Action::Quit - ); - assert_eq!( - map_key_event_to_action(KeyCode::Char('r'), false, false), - Action::Refresh - ); - assert_eq!( - map_key_event_to_action(KeyCode::Up, false, false), - Action::MoveUp - ); - assert_eq!( - map_key_event_to_action(KeyCode::Down, false, false), - Action::MoveDown - ); - assert_eq!( - map_key_event_to_action(KeyCode::PageUp, false, false), - Action::PageUp - ); - assert_eq!( - map_key_event_to_action(KeyCode::PageDown, false, false), - Action::PageDown - ); - assert_eq!( - map_key_event_to_action(KeyCode::Left, false, false), - Action::CollapseTree - ); - assert_eq!( - map_key_event_to_action(KeyCode::Right, false, false), - Action::ExpandTree - ); - assert_eq!( - map_key_event_to_action(KeyCode::Char('1'), false, false), - Action::BeginSignalConfirmation(1) - ); - assert_eq!( - map_key_event_to_action(KeyCode::Char('0'), false, false), - Action::Noop - ); - } - - #[test] - fn map_key_event_to_action_maps_pending_confirmation_actions() { - assert_eq!( - map_key_event_to_action(KeyCode::Enter, true, false), - Action::ConfirmPendingSignal - ); - assert_eq!( - map_key_event_to_action(KeyCode::Char('Y'), true, false), - Action::ConfirmPendingSignal - ); - assert_eq!( - map_key_event_to_action(KeyCode::Esc, true, false), - Action::CancelPendingSignal - ); - assert_eq!( - map_key_event_to_action(KeyCode::Up, true, false), - Action::Noop - ); - } - - #[test] - fn apply_action_confirm_pending_signal_refreshes_and_sends() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.begin_signal_confirmation(1); - let mut refresh_calls = 0; - let mut refresh = |_: Option<&crate::process::FilterSpec>| { - refresh_calls += 1; - vec![row(11, "foo")] - }; - let mut sent = false; - let mut sender = |pid: i32, signal: Signal| { - sent = true; - assert_eq!(pid, 11); - assert_eq!(signal, Signal::SIGHUP); - Ok(()) - }; - - assert_eq!( - apply_action( - &mut app, - Action::ConfirmPendingSignal, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(sent); - assert_eq!(refresh_calls, 2); - assert!(app.pending_confirmation.is_none()); - } - - #[test] - fn apply_action_confirm_pending_signal_invokes_await_pid_gone_with_signaled_pid() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.begin_signal_confirmation(1); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - let mut awaited: Option = None; - let mut await_pid_gone = |pid: i32| { - awaited = Some(pid); - }; - - apply_action( - &mut app, - Action::ConfirmPendingSignal, - &mut refresh, - &mut sender, - &mut await_pid_gone, - ); - - assert_eq!(awaited, Some(11)); - } - - #[test] - fn apply_action_confirm_pending_signal_skips_await_when_sender_fails() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.begin_signal_confirmation(1); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Err("denied".to_string()); - - // `must_not_run` panics if invoked; reaching the assert means the await - // hook was correctly skipped on the sender-failure path. - apply_action( - &mut app, - Action::ConfirmPendingSignal, - &mut refresh, - &mut sender, - &mut (must_not_run as fn(i32)), - ); - - assert!(app.status.contains("failed")); - } - - #[test] - fn apply_action_cancel_pending_signal_clears_confirmation() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.begin_signal_confirmation(1); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::CancelPendingSignal, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(app.pending_confirmation.is_none()); - } - - #[test] - fn apply_action_quit_returns_true() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - assert_eq!( - apply_action( - &mut app, - Action::Quit, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: true, - needs_redraw: false - } - ); - } - - #[test] - fn apply_action_refresh_reloads_rows() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - assert_eq!( - apply_action( - &mut app, - Action::Refresh, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert_eq!(app.rows[0].pid, 22); - } - - #[test] - fn apply_action_move_actions_change_selection() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]); - let rows = vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]; - let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); - let mut sender = |_: i32, _: Signal| Ok(()); - // Exercise the refresh closure so its body is covered. - apply_action( - &mut app, - Action::Refresh, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - - assert_eq!( - apply_action( - &mut app, - Action::MoveDown, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert_eq!(app.table_state.selected(), Some(1)); - - assert_eq!( - apply_action( - &mut app, - Action::MoveUp, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert_eq!(app.table_state.selected(), Some(0)); - } - - #[test] - fn apply_action_page_actions_change_selection() { - let rows: Vec = (0..25).map(|i| row(i + 1, "p")).collect(); - let mut app = App::with_rows(None, rows.clone()); - let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::PageDown, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert_eq!(app.table_state.selected(), Some(10)); - - assert_eq!( - apply_action( - &mut app, - Action::PageUp, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert_eq!(app.table_state.selected(), Some(0)); - } - - #[test] - fn apply_action_tree_actions_toggle_collapsed_state() { - let rows = vec![ - ProcRow { - pid: 2, - start_time: 0, - ppid: Some(1), - ancestor_chain: vec![1], - user: Arc::from("u"), - status: ProcessStatus::Run, - cpu_usage_tenths: 0, - memory_bytes: 0, - name: "service".to_string(), - cmd: "/bin/service".to_string(), - }, - ProcRow { - pid: 3, - start_time: 0, - ppid: Some(2), - ancestor_chain: vec![2, 1], - user: Arc::from("u"), - status: ProcessStatus::Run, - cpu_usage_tenths: 0, - memory_bytes: 0, - name: "worker".to_string(), - cmd: "/bin/worker".to_string(), - }, - ]; - let mut app = App::with_rows(None, rows.clone()); - let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::CollapseTree, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(app.collapsed_pids.contains(&2)); - - assert_eq!( - apply_action( - &mut app, - Action::ExpandTree, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(!app.collapsed_pids.contains(&2)); - } - - #[test] - fn apply_action_begin_signal_confirmation_sets_pending() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::BeginSignalConfirmation(1), - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(app.pending_confirmation.is_some()); - } - - #[test] - fn apply_action_confirm_pending_signal_aborts_on_target_change() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.begin_signal_confirmation(1); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::ConfirmPendingSignal, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(app.status.contains("aborted")); - } - - #[test] - fn apply_action_noop_is_noop() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - let selected = app.table_state.selected(); - assert_eq!( - apply_action( - &mut app, - Action::Noop, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)) - ), - ActionResult { - should_quit: false, - needs_redraw: false - } - ); - assert_eq!(app.table_state.selected(), selected); - } - - #[test] - fn run_event_loop_redraws_on_resize_and_exits_on_q() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut draw_calls = 0; - let mut draw = |_: &mut App| -> anyhow::Result<()> { - draw_calls += 1; - Ok(()) - }; - - let mut events = vec![ - Event::Resize(100, 20), - Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), - ] - .into_iter(); - let mut next_event = - |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ) - .expect("loop should terminate cleanly"); - - assert!(draw_calls >= 2); - } - - #[test] - fn run_event_loop_updates_redraw_state_for_non_quit_key_action() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(12, "bar")]); - let mut draw_calls = 0; - let mut draw = |_: &mut App| -> anyhow::Result<()> { - draw_calls += 1; - Ok(()) - }; - - let rows = vec![row(11, "foo"), row(12, "bar")]; - let mut events = vec![ - // 'r' ensures the refresh closure body is executed. - Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), - Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), - ] - .into_iter(); - let mut next_event = - |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); - let mut sender = |_: i32, _: Signal| Ok(()); - - run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ) - .expect("loop should terminate cleanly"); - - assert_eq!(app.table_state.selected(), Some(1)); - assert!(draw_calls >= 2); - } - - #[test] - fn run_event_loop_ignores_non_press_key_events() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(12, "bar")]); - let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; - let rows = vec![row(11, "foo"), row(12, "bar")]; - let release = - KeyEvent::new_with_kind(KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Release); - let mut events = vec![ - // 'r' ensures the refresh closure body is executed. - Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), - Event::Key(release), - Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), - ] - .into_iter(); - let mut next_event = - |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); - let mut sender = |_: i32, _: Signal| Ok(()); - - run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ) - .expect("loop should terminate cleanly"); - - assert_eq!(app.table_state.selected(), Some(0)); - } - - #[test] - fn run_event_loop_ignores_non_key_events() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; - let mut events = vec![ - Event::FocusGained, - Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), - ] - .into_iter(); - let mut next_event = - |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ) - .expect("loop should terminate cleanly"); - } - - #[test] - fn run_event_loop_propagates_draw_errors() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut draw = |_: &mut App| -> anyhow::Result<()> { Err(anyhow::anyhow!("draw failed")) }; - let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(None) }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(result.is_err()); - } - - #[test] - fn run_event_loop_propagates_event_errors() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; - let mut next_event = |_timeout: Duration| -> anyhow::Result> { - Err(anyhow::anyhow!("event failed")) - }; - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = run_event_loop( - &mut app, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(result.is_err()); - } - - #[test] - fn run_with_runtime_initializes_rows_and_runs_loop() { - let mut draw_calls = 0; - let mut draw = |_: &mut App| -> anyhow::Result<()> { - draw_calls += 1; - Ok(()) - }; - let mut events = vec![Event::Key(KeyEvent::new( - KeyCode::Char('q'), - KeyModifiers::NONE, - ))] - .into_iter(); - let mut next_event = - |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; - let mut refresh_calls = 0; - let mut refresh = |_: Option<&crate::process::FilterSpec>| { - refresh_calls += 1; - vec![row(11, "foo")] - }; - let mut sender = |_: i32, _: Signal| Ok(()); - - run_with_runtime( - Some("foo".to_string()), - None, - &mut draw, - &mut next_event, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ) - .expect("runtime should terminate cleanly"); - - assert_eq!(refresh_calls, 1); - assert!(draw_calls >= 1); - } - - #[test] - fn map_key_event_to_action_maps_filter_mode_actions() { - assert_eq!( - map_key_event_to_action(KeyCode::Char('/'), false, false), - Action::BeginInteractiveFilter - ); - assert_eq!( - map_key_event_to_action(KeyCode::Char('a'), false, true), - Action::FilterInputChar('a') - ); - assert_eq!( - map_key_event_to_action(KeyCode::Backspace, false, true), - Action::FilterInputBackspace - ); - assert_eq!( - map_key_event_to_action(KeyCode::Enter, false, true), - Action::FilterConfirm - ); - assert_eq!( - map_key_event_to_action(KeyCode::Esc, false, true), - Action::FilterCancel - ); - // Normal keys are noop in filter mode. - assert_eq!( - map_key_event_to_action(KeyCode::Char('q'), false, true), - Action::FilterInputChar('q') - ); - } - - #[test] - fn apply_action_begin_interactive_filter_opens_prompt() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - assert_eq!( - apply_action( - &mut app, - Action::BeginInteractiveFilter, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ), - ActionResult { - should_quit: false, - needs_redraw: true - } - ); - assert!(app.filter_input.is_some()); - assert_eq!(app.filter_input.as_ref().unwrap().text, ""); - } - - #[test] - fn apply_action_filter_input_char_appends_and_refilters() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: String::new(), - compiled: None, - }); - let mut refresh = - |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterInputChar('f'), - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - let fi = app.filter_input.as_ref().unwrap(); - assert_eq!(fi.text, "f"); - assert!(fi.compiled.is_some()); - } - - #[test] - fn apply_action_filter_input_backspace_removes_char() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.filter_input = Some(app::FilterInput { - text: "fo".to_string(), - compiled: None, - }); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterInputBackspace, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert_eq!(app.filter_input.as_ref().unwrap().text, "f"); - } - - #[test] - fn apply_action_filter_confirm_commits_filter() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: "foo".to_string(), - compiled: None, - }); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterConfirm, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(app.filter_input.is_none()); - assert_eq!(app.filter.as_deref(), Some("foo")); - assert!(app.compiled_filter.is_some()); - } - - #[test] - fn apply_action_filter_cancel_restores_state() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.filter_input = Some(app::FilterInput { - text: "bar".to_string(), - compiled: None, - }); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = apply_action( - &mut app, - Action::FilterCancel, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(app.filter_input.is_none()); - assert!(result.needs_redraw); - } - - #[test] - fn map_key_event_to_action_filter_mode_allows_navigation_keys() { - assert_eq!( - map_key_event_to_action(KeyCode::Up, false, true), - Action::MoveUp - ); - assert_eq!( - map_key_event_to_action(KeyCode::Down, false, true), - Action::MoveDown - ); - assert_eq!( - map_key_event_to_action(KeyCode::PageUp, false, true), - Action::PageUp - ); - assert_eq!( - map_key_event_to_action(KeyCode::PageDown, false, true), - Action::PageDown - ); - assert_eq!( - map_key_event_to_action(KeyCode::Left, false, true), - Action::CollapseTree - ); - assert_eq!( - map_key_event_to_action(KeyCode::Right, false, true), - Action::ExpandTree - ); - } - - #[test] - fn map_key_event_to_action_filter_mode_noop_for_unknown_key() { - assert_eq!( - map_key_event_to_action(KeyCode::F(1), false, true), - Action::Noop - ); - } - - #[test] - fn map_key_event_to_action_noop_for_unknown_key_in_normal_mode() { - assert_eq!( - map_key_event_to_action(KeyCode::F(1), false, false), - Action::Noop - ); - } - - #[test] - fn apply_action_begin_interactive_filter_prefills_existing_substring() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) - .ok() - .flatten(); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::BeginInteractiveFilter, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - let fi = app.filter_input.as_ref().unwrap(); - assert_eq!(fi.text, "foo"); - assert!(fi.compiled.is_some()); - } - - #[test] - fn apply_action_filter_input_char_noop_when_not_in_filter_mode() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = apply_action( - &mut app, - Action::FilterInputChar('x'), - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(!result.needs_redraw); - // Rows must not change since filter mode is not active. - assert_eq!(app.rows[0].pid, 11); - } - - #[test] - fn apply_action_filter_input_backspace_noop_when_not_in_filter_mode() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = apply_action( - &mut app, - Action::FilterInputBackspace, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(!result.needs_redraw); - assert_eq!(app.rows[0].pid, 11); - } - - #[test] - fn apply_action_filter_input_backspace_clears_compiled_when_text_becomes_empty() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: "f".to_string(), - compiled: crate::process::compile_filter(Some("f".to_string()), false) - .ok() - .flatten(), - }); - let mut refresh = - |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterInputBackspace, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - let fi = app.filter_input.as_ref().unwrap(); - assert_eq!(fi.text, ""); - assert!(fi.compiled.is_none()); - } - - #[test] - fn apply_action_filter_confirm_noop_when_not_in_filter_mode() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterConfirm, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - // filter_input was None, compiled_filter stays None, rows refresh with None filter. - assert!(app.filter_input.is_none()); - assert!(app.compiled_filter.is_none()); - } - - #[test] - fn apply_action_filter_confirm_with_empty_text_clears_filter() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: String::new(), - compiled: None, - }); - let mut refresh = - |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterConfirm, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(app.filter_input.is_none()); - assert!(app.filter.is_none()); - assert!(app.compiled_filter.is_none()); - } - - #[test] - fn apply_action_filter_cancel_noop_when_not_active() { - let mut app = App::with_rows(None, vec![row(11, "foo")]); - let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - let result = apply_action( - &mut app, - Action::FilterCancel, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert!(!result.needs_redraw); - // Rows must not change since there was nothing to cancel. - assert_eq!(app.rows[0].pid, 11); - } - - #[test] - fn apply_action_begin_interactive_filter_applies_prefill_immediately() { - // Start with two rows; refresh returns only foo when given a substring filter. - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) - .ok() - .flatten(); - // Simulate the live process list: only foo matches the pre-filled filter. - let mut refresh = |f: Option<&crate::process::FilterSpec>| { - if f.is_some() { - vec![row(11, "foo")] - } else { - vec![row(11, "foo"), row(22, "bar")] - } - }; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::BeginInteractiveFilter, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - // The row list must already reflect the pre-filled filter. - assert_eq!(app.rows.len(), 1); - assert_eq!(app.rows[0].pid, 11); - } - - #[test] - fn apply_action_filter_cancel_resets_selection_to_first() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: "x".to_string(), - compiled: None, - }); - app.table_state.select(Some(1)); // selection somewhere other than first - let mut refresh = - |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterCancel, - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert_eq!(app.table_state.selected(), Some(0)); - } - - #[test] - fn apply_action_filter_input_char_resets_selection_to_first() { - let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); - app.filter_input = Some(app::FilterInput { - text: String::new(), - compiled: None, - }); - app.table_state.select(Some(1)); // pre-select last row - let mut refresh = - |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; - let mut sender = |_: i32, _: Signal| Ok(()); - - apply_action( - &mut app, - Action::FilterInputChar('f'), - &mut refresh, - &mut sender, - &mut (noop_await as fn(i32)), - ); - assert_eq!(app.table_state.selected(), Some(0)); - } -} +#[path = "runtime_tests/mod.rs"] +mod tests; diff --git a/src/runtime_tests/actions.rs b/src/runtime_tests/actions.rs new file mode 100644 index 0000000..cbdced1 --- /dev/null +++ b/src/runtime_tests/actions.rs @@ -0,0 +1,649 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Key mapping, signal actions, navigation, and the event loop. +//! +//! Everything except interactive filter mode, which lives in `filter.rs`. + +use super::{must_not_run, noop_await, row}; +use crate::runtime::{ + Action, ActionResult, apply_action, map_key_event_to_action, run_event_loop, run_with_runtime, +}; +use crate::{app::App, model::ProcRow}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use nix::sys::signal::Signal; +use std::sync::Arc; +use std::time::Duration; +use sysinfo::ProcessStatus; + +#[test] +fn map_key_event_to_action_maps_regular_actions() { + assert_eq!( + map_key_event_to_action(KeyCode::Char('q'), false, false), + Action::Quit + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('r'), false, false), + Action::Refresh + ); + assert_eq!( + map_key_event_to_action(KeyCode::Up, false, false), + Action::MoveUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::Down, false, false), + Action::MoveDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageUp, false, false), + Action::PageUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageDown, false, false), + Action::PageDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::Left, false, false), + Action::CollapseTree + ); + assert_eq!( + map_key_event_to_action(KeyCode::Right, false, false), + Action::ExpandTree + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('1'), false, false), + Action::BeginSignalConfirmation(1) + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('0'), false, false), + Action::Noop + ); +} + +#[test] +fn map_key_event_to_action_maps_pending_confirmation_actions() { + assert_eq!( + map_key_event_to_action(KeyCode::Enter, true, false), + Action::ConfirmPendingSignal + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('Y'), true, false), + Action::ConfirmPendingSignal + ); + assert_eq!( + map_key_event_to_action(KeyCode::Esc, true, false), + Action::CancelPendingSignal + ); + assert_eq!( + map_key_event_to_action(KeyCode::Up, true, false), + Action::Noop + ); +} + +#[test] +fn apply_action_confirm_pending_signal_refreshes_and_sends() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.begin_signal_confirmation(1); + let mut refresh_calls = 0; + let mut refresh = |_: Option<&crate::process::FilterSpec>| { + refresh_calls += 1; + vec![row(11, "foo")] + }; + let mut sent = false; + let mut sender = |pid: i32, signal: Signal| { + sent = true; + assert_eq!(pid, 11); + assert_eq!(signal, Signal::SIGHUP); + Ok(()) + }; + + assert_eq!( + apply_action( + &mut app, + Action::ConfirmPendingSignal, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(sent); + assert_eq!(refresh_calls, 2); + assert!(app.pending_confirmation.is_none()); +} + +#[test] +fn apply_action_confirm_pending_signal_invokes_await_pid_gone_with_signaled_pid() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.begin_signal_confirmation(1); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + let mut awaited: Option = None; + let mut await_pid_gone = |pid: i32| { + awaited = Some(pid); + }; + + apply_action( + &mut app, + Action::ConfirmPendingSignal, + &mut refresh, + &mut sender, + &mut await_pid_gone, + ); + + assert_eq!(awaited, Some(11)); +} + +#[test] +fn apply_action_confirm_pending_signal_skips_await_when_sender_fails() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.begin_signal_confirmation(1); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Err("denied".to_string()); + + // `must_not_run` panics if invoked; reaching the assert means the await + // hook was correctly skipped on the sender-failure path. + apply_action( + &mut app, + Action::ConfirmPendingSignal, + &mut refresh, + &mut sender, + &mut (must_not_run as fn(i32)), + ); + + assert!(app.status.contains("failed")); +} + +#[test] +fn apply_action_cancel_pending_signal_clears_confirmation() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.begin_signal_confirmation(1); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::CancelPendingSignal, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.pending_confirmation.is_none()); +} + +#[test] +fn apply_action_quit_returns_true() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + assert_eq!( + apply_action( + &mut app, + Action::Quit, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: true, + needs_redraw: false + } + ); +} + +#[test] +fn apply_action_refresh_reloads_rows() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + assert_eq!( + apply_action( + &mut app, + Action::Refresh, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert_eq!(app.rows[0].pid, 22); +} + +#[test] +fn apply_action_move_actions_change_selection() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]); + let rows = vec![row(11, "foo"), row(22, "bar"), row(33, "baz")]; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); + let mut sender = |_: i32, _: Signal| Ok(()); + // Exercise the refresh closure so its body is covered. + apply_action( + &mut app, + Action::Refresh, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + + assert_eq!( + apply_action( + &mut app, + Action::MoveDown, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert_eq!(app.table_state.selected(), Some(1)); + + assert_eq!( + apply_action( + &mut app, + Action::MoveUp, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert_eq!(app.table_state.selected(), Some(0)); +} + +#[test] +fn apply_action_page_actions_change_selection() { + let rows: Vec = (0..25).map(|i| row(i + 1, "p")).collect(); + let mut app = App::with_rows(None, rows.clone()); + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::PageDown, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert_eq!(app.table_state.selected(), Some(10)); + + assert_eq!( + apply_action( + &mut app, + Action::PageUp, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert_eq!(app.table_state.selected(), Some(0)); +} + +#[test] +fn apply_action_tree_actions_toggle_collapsed_state() { + let rows = vec![ + ProcRow { + pid: 2, + start_time: 0, + ppid: Some(1), + ancestor_chain: vec![1], + user: Arc::from("u"), + status: ProcessStatus::Run, + cpu_usage_tenths: 0, + memory_bytes: 0, + name: "service".to_string(), + cmd: "/bin/service".to_string(), + }, + ProcRow { + pid: 3, + start_time: 0, + ppid: Some(2), + ancestor_chain: vec![2, 1], + user: Arc::from("u"), + status: ProcessStatus::Run, + cpu_usage_tenths: 0, + memory_bytes: 0, + name: "worker".to_string(), + cmd: "/bin/worker".to_string(), + }, + ]; + let mut app = App::with_rows(None, rows.clone()); + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::CollapseTree, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.collapsed_pids.contains(&2)); + + assert_eq!( + apply_action( + &mut app, + Action::ExpandTree, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(!app.collapsed_pids.contains(&2)); +} + +#[test] +fn apply_action_begin_signal_confirmation_sets_pending() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::BeginSignalConfirmation(1), + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.pending_confirmation.is_some()); +} + +#[test] +fn apply_action_confirm_pending_signal_aborts_on_target_change() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.begin_signal_confirmation(1); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::ConfirmPendingSignal, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.status.contains("aborted")); +} + +#[test] +fn apply_action_noop_is_noop() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + let selected = app.table_state.selected(); + assert_eq!( + apply_action( + &mut app, + Action::Noop, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)) + ), + ActionResult { + should_quit: false, + needs_redraw: false + } + ); + assert_eq!(app.table_state.selected(), selected); +} + +#[test] +fn run_event_loop_redraws_on_resize_and_exits_on_q() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut draw_calls = 0; + let mut draw = |_: &mut App| -> anyhow::Result<()> { + draw_calls += 1; + Ok(()) + }; + + let mut events = vec![ + Event::Resize(100, 20), + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ] + .into_iter(); + let mut next_event = + |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ) + .expect("loop should terminate cleanly"); + + assert!(draw_calls >= 2); +} + +#[test] +fn run_event_loop_updates_redraw_state_for_non_quit_key_action() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(12, "bar")]); + let mut draw_calls = 0; + let mut draw = |_: &mut App| -> anyhow::Result<()> { + draw_calls += 1; + Ok(()) + }; + + let rows = vec![row(11, "foo"), row(12, "bar")]; + let mut events = vec![ + // 'r' ensures the refresh closure body is executed. + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ] + .into_iter(); + let mut next_event = + |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); + let mut sender = |_: i32, _: Signal| Ok(()); + + run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ) + .expect("loop should terminate cleanly"); + + assert_eq!(app.table_state.selected(), Some(1)); + assert!(draw_calls >= 2); +} + +#[test] +fn run_event_loop_ignores_non_press_key_events() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(12, "bar")]); + let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; + let rows = vec![row(11, "foo"), row(12, "bar")]; + let release = KeyEvent::new_with_kind(KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Release); + let mut events = vec![ + // 'r' ensures the refresh closure body is executed. + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Event::Key(release), + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ] + .into_iter(); + let mut next_event = + |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| rows.clone(); + let mut sender = |_: i32, _: Signal| Ok(()); + + run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ) + .expect("loop should terminate cleanly"); + + assert_eq!(app.table_state.selected(), Some(0)); +} + +#[test] +fn run_event_loop_ignores_non_key_events() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; + let mut events = vec![ + Event::FocusGained, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ] + .into_iter(); + let mut next_event = + |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ) + .expect("loop should terminate cleanly"); +} + +#[test] +fn run_event_loop_propagates_draw_errors() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut draw = |_: &mut App| -> anyhow::Result<()> { Err(anyhow::anyhow!("draw failed")) }; + let mut next_event = |_timeout: Duration| -> anyhow::Result> { Ok(None) }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(result.is_err()); +} + +#[test] +fn run_event_loop_propagates_event_errors() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut draw = |_: &mut App| -> anyhow::Result<()> { Ok(()) }; + let mut next_event = |_timeout: Duration| -> anyhow::Result> { + Err(anyhow::anyhow!("event failed")) + }; + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = run_event_loop( + &mut app, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(result.is_err()); +} + +#[test] +fn run_with_runtime_initializes_rows_and_runs_loop() { + let mut draw_calls = 0; + let mut draw = |_: &mut App| -> anyhow::Result<()> { + draw_calls += 1; + Ok(()) + }; + let mut events = vec![Event::Key(KeyEvent::new( + KeyCode::Char('q'), + KeyModifiers::NONE, + ))] + .into_iter(); + let mut next_event = + |_timeout: Duration| -> anyhow::Result> { Ok(events.next()) }; + let mut refresh_calls = 0; + let mut refresh = |_: Option<&crate::process::FilterSpec>| { + refresh_calls += 1; + vec![row(11, "foo")] + }; + let mut sender = |_: i32, _: Signal| Ok(()); + + run_with_runtime( + Some("foo".to_string()), + None, + &mut draw, + &mut next_event, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ) + .expect("runtime should terminate cleanly"); + + assert_eq!(refresh_calls, 1); + assert!(draw_calls >= 1); +} diff --git a/src/runtime_tests/filter.rs b/src/runtime_tests/filter.rs new file mode 100644 index 0000000..3dd8001 --- /dev/null +++ b/src/runtime_tests/filter.rs @@ -0,0 +1,415 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Interactive `/` filter mode. +//! +//! Opening, typing, backspacing, confirming and cancelling the prompt. + +use super::{noop_await, row}; +use crate::app::{self, App}; +use crate::runtime::{Action, ActionResult, apply_action, map_key_event_to_action}; +use crossterm::event::KeyCode; +use nix::sys::signal::Signal; + +#[test] +fn map_key_event_to_action_maps_filter_mode_actions() { + assert_eq!( + map_key_event_to_action(KeyCode::Char('/'), false, false), + Action::BeginInteractiveFilter + ); + assert_eq!( + map_key_event_to_action(KeyCode::Char('a'), false, true), + Action::FilterInputChar('a') + ); + assert_eq!( + map_key_event_to_action(KeyCode::Backspace, false, true), + Action::FilterInputBackspace + ); + assert_eq!( + map_key_event_to_action(KeyCode::Enter, false, true), + Action::FilterConfirm + ); + assert_eq!( + map_key_event_to_action(KeyCode::Esc, false, true), + Action::FilterCancel + ); + // Normal keys are noop in filter mode. + assert_eq!( + map_key_event_to_action(KeyCode::Char('q'), false, true), + Action::FilterInputChar('q') + ); +} + +#[test] +fn apply_action_begin_interactive_filter_opens_prompt() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + assert_eq!( + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ), + ActionResult { + should_quit: false, + needs_redraw: true + } + ); + assert!(app.filter_input.is_some()); + assert_eq!(app.filter_input.as_ref().unwrap().text, ""); +} + +#[test] +fn apply_action_filter_input_char_appends_and_refilters() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputChar('f'), + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, "f"); + assert!(fi.compiled.is_some()); +} + +#[test] +fn apply_action_filter_input_backspace_removes_char() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.filter_input = Some(app::FilterInput { + text: "fo".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert_eq!(app.filter_input.as_ref().unwrap().text, "f"); +} + +#[test] +fn apply_action_filter_confirm_commits_filter() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "foo".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterConfirm, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(app.filter_input.is_none()); + assert_eq!(app.filter.as_deref(), Some("foo")); + assert!(app.compiled_filter.is_some()); +} + +#[test] +fn apply_action_filter_cancel_restores_state() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.filter_input = Some(app::FilterInput { + text: "bar".to_string(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterCancel, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(app.filter_input.is_none()); + assert!(result.needs_redraw); +} + +#[test] +fn map_key_event_to_action_filter_mode_allows_navigation_keys() { + assert_eq!( + map_key_event_to_action(KeyCode::Up, false, true), + Action::MoveUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::Down, false, true), + Action::MoveDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageUp, false, true), + Action::PageUp + ); + assert_eq!( + map_key_event_to_action(KeyCode::PageDown, false, true), + Action::PageDown + ); + assert_eq!( + map_key_event_to_action(KeyCode::Left, false, true), + Action::CollapseTree + ); + assert_eq!( + map_key_event_to_action(KeyCode::Right, false, true), + Action::ExpandTree + ); +} + +#[test] +fn map_key_event_to_action_filter_mode_noop_for_unknown_key() { + assert_eq!( + map_key_event_to_action(KeyCode::F(1), false, true), + Action::Noop + ); +} + +#[test] +fn map_key_event_to_action_noop_for_unknown_key_in_normal_mode() { + assert_eq!( + map_key_event_to_action(KeyCode::F(1), false, false), + Action::Noop + ); +} + +#[test] +fn apply_action_begin_interactive_filter_prefills_existing_substring() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, "foo"); + assert!(fi.compiled.is_some()); +} + +#[test] +fn apply_action_filter_input_char_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterInputChar('x'), + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(!result.needs_redraw); + // Rows must not change since filter mode is not active. + assert_eq!(app.rows[0].pid, 11); +} + +#[test] +fn apply_action_filter_input_backspace_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(!result.needs_redraw); + assert_eq!(app.rows[0].pid, 11); +} + +#[test] +fn apply_action_filter_input_backspace_clears_compiled_when_text_becomes_empty() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "f".to_string(), + compiled: crate::process::compile_filter(Some("f".to_string()), false) + .ok() + .flatten(), + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputBackspace, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + let fi = app.filter_input.as_ref().unwrap(); + assert_eq!(fi.text, ""); + assert!(fi.compiled.is_none()); +} + +#[test] +fn apply_action_filter_confirm_noop_when_not_in_filter_mode() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterConfirm, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + // filter_input was None, compiled_filter stays None, rows refresh with None filter. + assert!(app.filter_input.is_none()); + assert!(app.compiled_filter.is_none()); +} + +#[test] +fn apply_action_filter_confirm_with_empty_text_clears_filter() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterConfirm, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(app.filter_input.is_none()); + assert!(app.filter.is_none()); + assert!(app.compiled_filter.is_none()); +} + +#[test] +fn apply_action_filter_cancel_noop_when_not_active() { + let mut app = App::with_rows(None, vec![row(11, "foo")]); + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + let result = apply_action( + &mut app, + Action::FilterCancel, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert!(!result.needs_redraw); + // Rows must not change since there was nothing to cancel. + assert_eq!(app.rows[0].pid, 11); +} + +#[test] +fn apply_action_begin_interactive_filter_applies_prefill_immediately() { + // Start with two rows; refresh returns only foo when given a substring filter. + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.compiled_filter = crate::process::compile_filter(Some("foo".to_string()), false) + .ok() + .flatten(); + // Simulate the live process list: only foo matches the pre-filled filter. + let mut refresh = |f: Option<&crate::process::FilterSpec>| { + if f.is_some() { + vec![row(11, "foo")] + } else { + vec![row(11, "foo"), row(22, "bar")] + } + }; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::BeginInteractiveFilter, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + // The row list must already reflect the pre-filled filter. + assert_eq!(app.rows.len(), 1); + assert_eq!(app.rows[0].pid, 11); +} + +#[test] +fn apply_action_filter_cancel_resets_selection_to_first() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: "x".to_string(), + compiled: None, + }); + app.table_state.select(Some(1)); // selection somewhere other than first + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterCancel, + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert_eq!(app.table_state.selected(), Some(0)); +} + +#[test] +fn apply_action_filter_input_char_resets_selection_to_first() { + let mut app = App::with_rows(None, vec![row(11, "foo"), row(22, "bar")]); + app.filter_input = Some(app::FilterInput { + text: String::new(), + compiled: None, + }); + app.table_state.select(Some(1)); // pre-select last row + let mut refresh = |_: Option<&crate::process::FilterSpec>| vec![row(11, "foo"), row(22, "bar")]; + let mut sender = |_: i32, _: Signal| Ok(()); + + apply_action( + &mut app, + Action::FilterInputChar('f'), + &mut refresh, + &mut sender, + &mut (noop_await as fn(i32)), + ); + assert_eq!(app.table_state.selected(), Some(0)); +} diff --git a/src/runtime_tests/mod.rs b/src/runtime_tests/mod.rs new file mode 100644 index 0000000..535770d --- /dev/null +++ b/src/runtime_tests/mod.rs @@ -0,0 +1,68 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Unit tests for [`crate::runtime`], split by concern. +//! +//! Included into `runtime.rs` via `#[path]` rather than living inside it, so +//! these modules keep private-item access while every source file stays inside +//! the house 500-1000 LOC band. `runtime.rs` was 1563 lines, 1068 of them this +//! test module. +//! +//! Shared fixtures live here; the tests live in [`actions`] and [`filter`]. + +mod actions; +mod filter; + +use crate::model::ProcRow; +use std::sync::Arc; +use sysinfo::ProcessStatus; + +/// Stand-in for the production `await_pid_gone` callback. Defined as a +/// real `fn` so the body is covered by `noop_await_runs` and every other +/// test can reference it without instantiating its own closure. +pub(super) fn noop_await(_: i32) {} + +#[test] +fn noop_await_runs() { + noop_await(0); +} + +/// Panicking `await_pid_gone` stand-in for negative tests that assert the +/// callback is never invoked. Covered by `must_not_run_panics_when_called`. +pub(super) fn must_not_run(_: i32) { + panic!("await_pid_gone must not be called when sender fails"); +} + +#[test] +#[should_panic(expected = "await_pid_gone must not be called when sender fails")] +fn must_not_run_panics_when_called() { + must_not_run(0); +} + +pub(super) fn row(pid: i32, name: &str) -> ProcRow { + ProcRow { + pid, + start_time: 0, + ppid: None, + ancestor_chain: Vec::new(), + user: Arc::from("u"), + status: ProcessStatus::Run, + cpu_usage_tenths: 0, + memory_bytes: 0, + name: name.to_string(), + cmd: format!("/bin/{name}"), + } +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..5ec7798 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,264 @@ +/* + Copyright (C) 2026 l5yth + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +//! Terminal lifecycle: entering and leaving TUI mode. +//! +//! Split from `runtime.rs` so the *ordering* and *error policy* of the +//! lifecycle can be tested without a TTY. The policy lives in [`enter`] and +//! [`leave`], which operate on the [`TerminalOps`] trait; the only code that +//! touches a real terminal is [`CrosstermOps`] and the two thin wrappers +//! [`setup`] and [`restore`]. + +use std::io; + +use anyhow::Result; +use crossterm::{ + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, prelude::CrosstermBackend}; + +/// The concrete terminal the TUI draws into. +pub type Tui = Terminal>; + +/// The terminal mode changes the TUI lifecycle performs. +/// +/// Exists so [`enter`] and [`leave`] can be exercised against a fake. Each +/// method maps to one crossterm call; implementations are expected to be thin. +pub trait TerminalOps { + /// Put the terminal into raw mode, so keys arrive unbuffered and unechoed. + fn enable_raw(&mut self) -> Result<()>; + /// Return the terminal to cooked mode. + fn disable_raw(&mut self) -> Result<()>; + /// Switch to the alternate screen, preserving the user's scrollback. + fn enter_alternate(&mut self) -> Result<()>; + /// Switch back to the primary screen. + fn leave_alternate(&mut self) -> Result<()>; + /// Make the cursor visible again. + fn show_cursor(&mut self) -> Result<()>; +} + +/// Enter TUI mode: raw mode first, then the alternate screen. +/// +/// The order matters on the failure path. Raw mode is enabled first because it +/// is the change that makes a terminal unusable if it is left behind; if +/// switching to the alternate screen then fails, this undoes raw mode before +/// returning, so a failed startup never strands the user's shell in raw mode. +/// The undo is best-effort: the original error is what the caller needs to +/// see, so a failure to restore is deliberately discarded. +pub fn enter(ops: &mut dyn TerminalOps) -> Result<()> { + ops.enable_raw()?; + if let Err(err) = ops.enter_alternate() { + let _ = ops.disable_raw(); + return Err(err); + } + Ok(()) +} + +/// Leave TUI mode, undoing [`enter`] in reverse and ignoring failures. +/// +/// Every step is attempted even if an earlier one fails: this runs while the +/// program is already on its way out, often on an error path, and a terminal +/// left in raw mode is worse than any error this could report. There is +/// nothing useful a caller could do with a failure here, so none is returned. +pub fn leave(ops: &mut dyn TerminalOps) { + let _ = ops.disable_raw(); + let _ = ops.leave_alternate(); + let _ = ops.show_cursor(); +} + +/// [`TerminalOps`] backed by the real terminal on stdout. +pub struct CrosstermOps<'a> { + terminal: &'a mut Tui, +} + +impl TerminalOps for CrosstermOps<'_> { + fn enable_raw(&mut self) -> Result<()> { + enable_raw_mode()?; + Ok(()) + } + + fn disable_raw(&mut self) -> Result<()> { + disable_raw_mode()?; + Ok(()) + } + + fn enter_alternate(&mut self) -> Result<()> { + execute!(self.terminal.backend_mut(), EnterAlternateScreen)?; + Ok(()) + } + + fn leave_alternate(&mut self) -> Result<()> { + execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + Ok(()) + } + + fn show_cursor(&mut self) -> Result<()> { + self.terminal.show_cursor()?; + Ok(()) + } +} + +/// Build a live terminal and put it into TUI mode. +pub fn setup() -> Result { + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + enter(&mut CrosstermOps { + terminal: &mut terminal, + })?; + Ok(terminal) +} + +/// Take a live terminal out of TUI mode and drop it. +pub fn restore(mut terminal: Tui) { + leave(&mut CrosstermOps { + terminal: &mut terminal, + }); +} + +#[cfg(test)] +mod tests { + use super::{TerminalOps, enter, leave}; + use anyhow::{Result, anyhow}; + + /// Records the calls made against it and fails the steps it is told to. + #[derive(Default)] + struct FakeOps { + calls: Vec<&'static str>, + fail_enable_raw: bool, + fail_enter_alternate: bool, + fail_disable_raw: bool, + fail_leave_alternate: bool, + fail_show_cursor: bool, + } + + impl FakeOps { + fn step(&mut self, name: &'static str, fail: bool) -> Result<()> { + self.calls.push(name); + if fail { + return Err(anyhow!("{name} failed")); + } + Ok(()) + } + } + + impl TerminalOps for FakeOps { + fn enable_raw(&mut self) -> Result<()> { + self.step("enable_raw", self.fail_enable_raw) + } + fn disable_raw(&mut self) -> Result<()> { + self.step("disable_raw", self.fail_disable_raw) + } + fn enter_alternate(&mut self) -> Result<()> { + self.step("enter_alternate", self.fail_enter_alternate) + } + fn leave_alternate(&mut self) -> Result<()> { + self.step("leave_alternate", self.fail_leave_alternate) + } + fn show_cursor(&mut self) -> Result<()> { + self.step("show_cursor", self.fail_show_cursor) + } + } + + #[test] + fn enter_enables_raw_mode_before_the_alternate_screen() { + let mut ops = FakeOps::default(); + + assert!(enter(&mut ops).is_ok()); + + assert_eq!(ops.calls, vec!["enable_raw", "enter_alternate"]); + } + + #[test] + fn enter_propagates_a_raw_mode_failure_without_touching_the_screen() { + let mut ops = FakeOps { + fail_enable_raw: true, + ..FakeOps::default() + }; + + let err = enter(&mut ops).expect_err("raw mode failure must propagate"); + + assert!(err.to_string().contains("enable_raw")); + assert_eq!(ops.calls, vec!["enable_raw"]); + } + + #[test] + fn enter_undoes_raw_mode_when_the_alternate_screen_fails() { + // The point of the rollback: a half-entered TUI must not leave the + // user's shell in raw mode after psn exits with an error. + let mut ops = FakeOps { + fail_enter_alternate: true, + ..FakeOps::default() + }; + + let err = enter(&mut ops).expect_err("alternate screen failure must propagate"); + + assert!(err.to_string().contains("enter_alternate")); + assert_eq!( + ops.calls, + vec!["enable_raw", "enter_alternate", "disable_raw"] + ); + } + + #[test] + fn enter_reports_the_original_error_even_if_the_rollback_also_fails() { + let mut ops = FakeOps { + fail_enter_alternate: true, + fail_disable_raw: true, + ..FakeOps::default() + }; + + let err = enter(&mut ops).expect_err("alternate screen failure must propagate"); + + // The rollback failure is swallowed; the caller sees the real cause. + assert!(err.to_string().contains("enter_alternate")); + assert_eq!( + ops.calls, + vec!["enable_raw", "enter_alternate", "disable_raw"] + ); + } + + #[test] + fn leave_undoes_the_lifecycle_in_reverse() { + let mut ops = FakeOps::default(); + + leave(&mut ops); + + assert_eq!( + ops.calls, + vec!["disable_raw", "leave_alternate", "show_cursor"] + ); + } + + #[test] + fn leave_attempts_every_step_even_when_all_of_them_fail() { + // Teardown runs while the program is already exiting, so one failing + // step must not skip the rest: a terminal stuck in raw mode is worse + // than any error this could report. + let mut ops = FakeOps { + fail_disable_raw: true, + fail_leave_alternate: true, + fail_show_cursor: true, + ..FakeOps::default() + }; + + leave(&mut ops); + + assert_eq!( + ops.calls, + vec!["disable_raw", "leave_alternate", "show_cursor"] + ); + } +} diff --git a/src/tree.rs b/src/tree.rs index 12cc85d..2331e3e 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -273,6 +273,35 @@ mod tests { ); } + #[test] + fn display_order_with_prefix_draws_branch_and_continuation_connectors() { + // A root with two children, each carrying one child of its own. This is + // the shallowest shape that exercises every connector case: a non-last + // child (`├─`), a last child (`└─`), a grandchild whose ancestor still + // has a sibling below it (continuation `│ `), and a grandchild whose + // ancestor was last (blank ` `). + let rows = vec![ + row(2, None, Vec::new(), "root"), + row(3, Some(2), vec![2], "first"), + row(4, Some(3), vec![3, 2], "under_first"), + row(5, Some(2), vec![2], "last"), + row(6, Some(5), vec![5, 2], "under_last"), + ]; + + let order = display_order_with_prefix(&rows, &HashSet::new()); + + assert_eq!( + order, + vec![ + (0, "".to_string()), + (1, "├─".to_string()), + (2, "│ └─".to_string()), + (3, "└─".to_string()), + (4, " └─".to_string()), + ] + ); + } + #[test] fn display_rows_hide_collapsed_descendants_and_mark_root() { let rows = vec![ diff --git a/tests/app_tests.rs b/tests/app_tests.rs index 60d453b..d3a11fc 100644 --- a/tests/app_tests.rs +++ b/tests/app_tests.rs @@ -807,3 +807,64 @@ fn select_first_selects_none_when_no_rows() { app.select_first(); assert_eq!(app.table_state.selected(), None); } + +#[test] +fn page_down_without_selection_and_without_rows_leaves_selection_unset() { + // Guards the empty-list branch: with nothing selected and nothing visible + // there is no index to land on, so page_down must return without touching + // the table state rather than selecting `step - 1` out of an empty list. + let mut app = App::with_rows(None, vec![]); + assert_eq!(app.table_state.selected(), None); + + app.page_down(10); + + assert_eq!(app.table_state.selected(), None); +} + +#[test] +fn page_down_without_selection_selects_within_visible_rows() { + // The sibling branch: no selection but rows exist, so the step is clamped + // to the last visible row instead of running past the end. + let mut app = App::with_rows(None, vec![row(1), row(2)]); + app.table_state.select(None); + + app.page_down(10); + + assert_eq!(app.table_state.selected(), Some(1)); +} + +#[test] +fn collapse_selected_returns_false_without_a_selected_row() { + let mut app = App::with_rows(None, vec![]); + + assert!(!app.collapse_selected()); + assert!(app.collapsed_pids.is_empty()); +} + +#[test] +fn expand_selected_returns_false_without_a_selected_row() { + let mut app = App::with_rows(None, vec![]); + + assert!(!app.expand_selected()); +} + +#[test] +fn expand_selected_returns_false_when_row_is_already_expanded() { + // A row that is not collapsed has nothing to expand, so the call is a + // no-op and must report that it changed nothing. + let mut app = App::with_rows(None, vec![row(1)]); + assert!(app.collapsed_pids.is_empty()); + + assert!(!app.expand_selected()); +} + +#[test] +fn begin_signal_confirmation_is_ignored_without_a_selected_row() { + // No selection means no target, so no confirmation prompt may open. If it + // did, confirming it would signal whatever row later landed at that index. + let mut app = App::with_rows(None, vec![]); + + app.begin_signal_confirmation(9); + + assert!(app.pending_confirmation.is_none()); +}