diff --git a/src/app_event.rs b/src/app_event.rs index ed8d987..dc2d6ad 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -768,3 +768,7 @@ impl App { } } } + +#[cfg(test)] +#[path = "app_event_test.rs"] +mod tests; diff --git a/src/app_event_test.rs b/src/app_event_test.rs new file mode 100644 index 0000000..bdf74f9 --- /dev/null +++ b/src/app_event_test.rs @@ -0,0 +1,59 @@ +use crate::app_state::AppState; +use crate::config::Config; +use crate::theme::default_theme; + +fn make_state() -> AppState { + AppState::new(Config::default(), default_theme()) +} + +#[test] +fn push_search_history_ignores_empty_query() { + let mut s = make_state(); + s.push_search_history(String::new()); + assert!(s.search_history.is_empty()); +} + +#[test] +fn push_search_history_appends_in_order() { + let mut s = make_state(); + s.push_search_history("foo".into()); + s.push_search_history("bar".into()); + assert_eq!(s.search_history, vec!["foo".to_string(), "bar".to_string()]); +} + +#[test] +fn push_search_history_dedupes_moving_existing_to_end() { + let mut s = make_state(); + s.push_search_history("foo".into()); + s.push_search_history("bar".into()); + s.push_search_history("foo".into()); + // "foo" must not appear twice; the re-search moves it to the most-recent slot. + assert_eq!(s.search_history, vec!["bar".to_string(), "foo".to_string()]); +} + +#[test] +fn push_search_history_caps_at_50_entries_dropping_oldest() { + let mut s = make_state(); + for i in 0..60 { + s.push_search_history(format!("q{i}")); + } + assert_eq!( + s.search_history.len(), + 50, + "history is capped at 50 entries" + ); + // The 10 oldest (q0..q9) were dropped; q10 is now the oldest, q59 the newest. + assert_eq!(s.search_history.first().unwrap(), "q10"); + assert_eq!(s.search_history.last().unwrap(), "q59"); +} + +#[test] +fn push_search_history_clears_pending_before_buffer() { + let mut s = make_state(); + s.search_before_history = "draft".into(); + s.push_search_history("committed".into()); + assert!( + s.search_before_history.is_empty(), + "committing a search must clear the saved in-progress query" + ); +} diff --git a/src/input_ops.rs b/src/input_ops.rs index 878bf57..6bd8f67 100644 --- a/src/input_ops.rs +++ b/src/input_ops.rs @@ -244,3 +244,7 @@ impl App { false } } + +#[cfg(test)] +#[path = "input_ops_test.rs"] +mod tests; diff --git a/src/input_ops_test.rs b/src/input_ops_test.rs new file mode 100644 index 0000000..4ef5a77 --- /dev/null +++ b/src/input_ops_test.rs @@ -0,0 +1,46 @@ +use super::bracketed_paste_encode; + +const PASTE_START: &[u8] = b"\x1b[200~"; +const PASTE_END: &[u8] = b"\x1b[201~"; + +#[test] +fn bracketed_paste_wraps_text_in_markers() { + let out = bracketed_paste_encode("hi", true); + let mut expected = Vec::new(); + expected.extend_from_slice(PASTE_START); + expected.extend_from_slice(b"hi"); + expected.extend_from_slice(PASTE_END); + assert_eq!(out, expected); +} + +#[test] +fn non_bracketed_paste_passes_text_through_unchanged() { + let out = bracketed_paste_encode("hi", false); + assert_eq!(out, b"hi"); +} + +#[test] +fn bracketed_paste_empty_text_still_emits_markers() { + // An empty paste in bracketed mode is start+end with nothing between, so a + // program in bracketed-paste mode still sees a (zero-length) paste event. + let out = bracketed_paste_encode("", true); + let mut expected = Vec::new(); + expected.extend_from_slice(PASTE_START); + expected.extend_from_slice(PASTE_END); + assert_eq!(out, expected); +} + +#[test] +fn non_bracketed_empty_text_is_empty() { + assert!(bracketed_paste_encode("", false).is_empty()); +} + +#[test] +fn bracketed_paste_preserves_inner_bytes_including_newlines() { + let out = bracketed_paste_encode("a\nb", true); + // The payload between the markers must be byte-for-byte the original text. + assert_eq!( + &out[PASTE_START.len()..out.len() - PASTE_END.len()], + b"a\nb" + ); +} diff --git a/src/restore.rs b/src/restore.rs index c36b6af..acc25a0 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -176,3 +176,7 @@ impl App { } } } + +#[cfg(test)] +#[path = "restore_test.rs"] +mod tests; diff --git a/src/restore_test.rs b/src/restore_test.rs new file mode 100644 index 0000000..2801939 --- /dev/null +++ b/src/restore_test.rs @@ -0,0 +1,89 @@ +use crate::config::Config; +use crate::session::{SavedNode, SavedSession, SavedTab}; + +use super::App; + +/// Builds an EventLoop that works from any thread (needed for tests). +/// Returns `None` when no display is available (headless CI) so callers skip. +/// Mirrors the helper in `pane_ops_test.rs` / `main_test.rs`. +fn make_event_loop() -> Option> { + #[cfg(target_os = "linux")] + { + use winit::event_loop::EventLoopBuilder; + use winit::platform::x11::EventLoopBuilderExtX11; + EventLoopBuilder::new().with_any_thread(true).build().ok() + } + #[cfg(not(target_os = "linux"))] + { + winit::event_loop::EventLoop::new().ok() + } +} + +/// Headless App with no window; `None` if no display is available. +fn make_app() -> Option { + let el = make_event_loop()?; + let proxy = el.create_proxy(); + std::mem::forget(el); + Some(App::new(Config::default(), proxy, None)) +} + +fn one_pane_tab(cwd: std::path::PathBuf) -> SavedTab { + SavedTab { + name: Some("t".into()), + active_pane: 0, + pane_cwds: vec![cwd], + layout: SavedNode::Leaf { slot: 0 }, + } +} + +#[test] +fn restore_session_falls_back_to_home_for_missing_cwd() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + let missing = std::path::PathBuf::from("/nonexistent/path/should/not/exist"); + let saved = SavedSession { + active_tab: 0, + tabs: vec![one_pane_tab(missing)], + theme: None, + }; + // A non-existent CWD must not abort the restore; the pane spawns in $HOME. + let ok = app.restore_session(saved, 800, 600); + assert!(ok, "restore should succeed despite the missing CWD"); + assert_eq!(app.state.tabs.len(), 1, "the saved tab was restored"); + assert_eq!( + app.state.tabs[0].panes.len(), + 1, + "the pane spawned via the $HOME fallback" + ); +} + +#[test] +fn restore_session_handles_empty_cwd_as_home() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + // An empty CWD string is the documented "fall back to $HOME" sentinel. + let saved = SavedSession { + active_tab: 0, + tabs: vec![one_pane_tab(std::path::PathBuf::new())], + theme: None, + }; + assert!(app.restore_session(saved, 800, 600)); + assert_eq!(app.state.tabs[0].panes.len(), 1); +} + +#[test] +fn restore_session_empty_tabs_is_noop() { + let Some(mut app) = make_app() else { + return; // no display — skip + }; + let saved = SavedSession { + active_tab: 0, + tabs: vec![], + theme: None, + }; + // Nothing to restore: returns false and leaves the app with no tabs. + assert!(!app.restore_session(saved, 800, 600)); + assert!(app.state.tabs.is_empty()); +} diff --git a/src/winit_handler_test.rs b/src/winit_handler_test.rs new file mode 100644 index 0000000..5787add --- /dev/null +++ b/src/winit_handler_test.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use super::next_bell_wakeup; +use crate::app_state::TabState; +use crate::ui::layout::Layout; + +/// Builds a bare tab whose only meaningful field for these tests is +/// `bell_flash_until`. No panes, no PTYs — `next_bell_wakeup` only reads the +/// bell expiry. +fn tab_with_bell(bell_until: Option) -> TabState { + TabState { + panes: HashMap::new(), + layout: Layout::new(0, 800, 600), + active: 0, + name: None, + zoomed: false, + has_activity: false, + bell_flash_start: None, + bell_flash_until: bell_until, + bell_cooldown_until: None, + passthrough: false, + } +} + +#[test] +fn next_bell_wakeup_empty_tabs_returns_default() { + let default = Instant::now() + Duration::from_secs(10); + assert_eq!(next_bell_wakeup(&[], default), default); +} + +#[test] +fn next_bell_wakeup_ignores_tabs_without_bell() { + let default = Instant::now() + Duration::from_secs(10); + let tabs = [tab_with_bell(None), tab_with_bell(None)]; + assert_eq!(next_bell_wakeup(&tabs, default), default); +} + +#[test] +fn next_bell_wakeup_ignores_expired_bell() { + let default = Instant::now() + Duration::from_secs(10); + // A bell that already expired must not pull the wakeup into the past. + let past = Instant::now() - Duration::from_millis(1); + let tabs = [tab_with_bell(Some(past))]; + assert_eq!(next_bell_wakeup(&tabs, default), default); +} + +#[test] +fn next_bell_wakeup_picks_earliest_future_bell() { + let default = Instant::now() + Duration::from_secs(10); + let soon = Instant::now() + Duration::from_millis(200); + let later = Instant::now() + Duration::from_secs(5); + let tabs = [tab_with_bell(Some(later)), tab_with_bell(Some(soon))]; + assert_eq!( + next_bell_wakeup(&tabs, default), + soon, + "must wake at the soonest pending bell, earlier than the default" + ); +} + +#[test] +fn next_bell_wakeup_future_bell_after_default_is_ignored() { + // A bell later than the default deadline must not delay the wakeup. + let default = Instant::now() + Duration::from_millis(100); + let far = Instant::now() + Duration::from_secs(30); + let tabs = [tab_with_bell(Some(far))]; + assert_eq!(next_bell_wakeup(&tabs, default), default); +}