Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,3 +768,7 @@ impl App {
}
}
}

#[cfg(test)]
#[path = "app_event_test.rs"]
mod tests;
59 changes: 59 additions & 0 deletions src/app_event_test.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
4 changes: 4 additions & 0 deletions src/input_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,7 @@ impl App {
false
}
}

#[cfg(test)]
#[path = "input_ops_test.rs"]
mod tests;
46 changes: 46 additions & 0 deletions src/input_ops_test.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
4 changes: 4 additions & 0 deletions src/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,7 @@ impl App {
}
}
}

#[cfg(test)]
#[path = "restore_test.rs"]
mod tests;
89 changes: 89 additions & 0 deletions src/restore_test.rs
Original file line number Diff line number Diff line change
@@ -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<winit::event_loop::EventLoop<()>> {
#[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<App> {
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());
}
68 changes: 68 additions & 0 deletions src/winit_handler_test.rs
Original file line number Diff line number Diff line change
@@ -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<Instant>) -> 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);
}