#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::config::NotificationConfig;
use crate::session::SessionStatus;
use crate::tui::popup::PopupSection;
use crate::tui::tabs::{StatusIndicator, TabItem, tab_display_width};
use crate::worktree::GitWorktreeStatus;
use crossterm::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use rstest::rstest;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
fn create_test_app() -> (TuiApp, mpsc::Receiver<SessionCommand>) {
let (cmd_tx, cmd_rx) = mpsc::channel(16);
let app = TuiApp::new(cmd_tx, NotificationConfig::default(), vec![]);
(app, cmd_rx)
}
fn create_test_app_with_sessions(count: usize) -> (TuiApp, mpsc::Receiver<SessionCommand>) {
let (mut app, rx) = create_test_app();
for i in 0..count {
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: format!("session-{i}"),
status: SessionStatus::Running,
branch: Some(format!("feature/{i}")),
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
}
if !app.sessions.is_empty() {
app.popup_state.session_list.select(Some(0));
}
(app, rx)
}
fn create_test_app_with_worktrees(count: usize) -> (TuiApp, mpsc::Receiver<SessionCommand>) {
let (mut app, rx) = create_test_app();
for i in 0..count {
app.worktrees.push(WorktreeItem::new(
format!("branch-{i}"),
PathBuf::from(format!("/path/worktree/{i}")),
GitWorktreeStatus::default(),
));
}
if !app.worktrees.is_empty() {
app.popup_state.worktree_list.select(Some(0));
}
(app, rx)
}
fn create_terminated_session(id: SessionId, name: &str) -> SessionSnapshot {
SessionSnapshot {
id,
name: name.to_string(),
status: SessionStatus::Terminated { exit_code: Some(0) },
branch: None,
}
}
fn create_test_hook_event(
event_type: crate::hooks::HookEventType,
requires_attention: bool,
) -> crate::hooks::HookEvent {
use serde_json::json;
let payload = if requires_attention {
json!({
"hook_event_name": "Notification",
"type": "permission_prompt",
"message": "Test notification"
})
} else {
json!({
"hook_event_name": event_type.as_hook_name()
})
};
crate::hooks::HookEvent::from_payload(SessionId::new_v4(), payload).unwrap()
}
#[test]
fn test_initial_state_normal() {
let (app, _rx) = create_test_app();
assert_eq!(app.state(), &AppState::Normal);
}
#[rstest]
#[case::ctrl_s('s', Some(AppAction::ShowWorkspacePopup))]
#[case::ctrl_q('q', Some(AppAction::ShowConfirmQuit))]
#[case::ctrl_t('t', Some(AppAction::SendInput(vec![20])))] #[case::ctrl_w('w', Some(AppAction::TerminateCurrentSession))]
#[case::ctrl_n('n', Some(AppAction::NextSession))]
#[case::ctrl_p('p', Some(AppAction::PrevSession))]
fn ctrl_key_normal_state(#[case] ch: char, #[case] expected: Option<AppAction>) {
let (mut app, _rx) = create_test_app();
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL);
assert_eq!(app.handle_key(key), expected);
}
#[test]
fn test_esc_hides_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.handle_key(key), Some(AppAction::HidePopup));
}
#[test]
fn test_session_created_event() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
assert_eq!(app.sessions.len(), 1);
assert_eq!(app.sessions[0].id, id);
assert!(app.terminal_buffers.contains_key(&id));
}
#[test]
fn test_session_output_event() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Output {
id,
data: b"Hello".to_vec(),
});
let parser = app.terminal_buffers.get(&id).unwrap();
let screen = parser.screen();
let mut content = String::new();
for col in 0..5 {
if let Some(cell) = screen.cell(0, col) {
content.push_str(&cell.contents());
}
}
assert_eq!(content, "Hello");
}
#[test]
fn test_session_title_changed_event() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::TitleChanged {
id,
title: "My Session".to_string(),
});
assert_eq!(app.sessions[0].name, "My Session");
}
#[test]
fn test_session_terminated_event() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Terminated {
id,
exit_code: Some(0),
});
assert!(matches!(
app.sessions[0].status,
SessionStatus::Terminated { exit_code: Some(0) }
));
}
#[test]
fn test_next_session_wraps() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.active_idx = 2;
app.next_session();
assert_eq!(app.active_idx, 0);
}
#[test]
fn test_prev_session_wraps() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.active_idx = 0;
app.prev_session();
assert_eq!(app.active_idx, 2);
}
#[test]
fn test_switch_session() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.switch_session(1);
assert_eq!(app.active_idx, 1);
app.switch_session(10);
assert_eq!(app.active_idx, 1);
}
#[test]
fn test_scroll() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.scroll(-5);
assert_eq!(app.scroll_offsets.get(&id), Some(&5));
app.scroll(3);
assert_eq!(app.scroll_offsets.get(&id), Some(&2));
app.scroll(10);
assert_eq!(app.scroll_offsets.get(&id), Some(&0));
}
#[test]
fn test_confirm_quit_yes() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ConfirmQuit));
}
#[test]
fn test_confirm_quit_no() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::HidePopup));
}
#[test]
fn test_workspace_popup_session_navigation() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.select_next();
assert_eq!(app.popup_state.session_list.selected(), Some(1));
app.select_prev();
assert_eq!(app.popup_state.session_list.selected(), Some(0));
app.select_prev();
assert_eq!(app.popup_state.session_list.selected(), Some(2));
}
#[tokio::test]
async fn test_dispatch_hide_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::HidePopup).await.unwrap();
assert_eq!(app.state, AppState::Normal);
}
#[tokio::test]
async fn test_dispatch_show_workspace_popup() {
let (mut app, _rx) = create_test_app();
app.dispatch(AppAction::ShowWorkspacePopup).await.unwrap();
assert_eq!(app.state, AppState::WorkspacePopup);
}
#[tokio::test]
async fn test_dispatch_confirm_quit() {
let (mut app, _rx) = create_test_app();
app.dispatch(AppAction::ConfirmQuit).await.unwrap();
assert!(app.should_quit());
}
#[tokio::test]
async fn test_dispatch_input_char() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::InputChar('f')).await.unwrap();
app.dispatch(AppAction::InputChar('o')).await.unwrap();
app.dispatch(AppAction::InputChar('o')).await.unwrap();
assert_eq!(app.popup_state.input, "foo");
assert_eq!(app.popup_state.cursor, 3);
}
#[tokio::test]
async fn test_dispatch_input_backspace() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::InputChar('a')).await.unwrap();
app.dispatch(AppAction::InputChar('b')).await.unwrap();
app.dispatch(AppAction::InputBackspace).await.unwrap();
assert_eq!(app.popup_state.input, "a");
}
#[tokio::test]
async fn test_dispatch_next_prev_popup_section() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
app.dispatch(AppAction::NextPopupSection).await.unwrap();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
app.dispatch(AppAction::NextPopupSection).await.unwrap();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
app.dispatch(AppAction::PrevPopupSection).await.unwrap();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
}
#[test]
fn test_workspace_popup_tab_switches_section() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::NextPopupSection));
}
#[test]
fn test_workspace_popup_branch_input_enter() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
app.popup_state.input = "feature/test".to_string();
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(
action,
Some(AppAction::CreateSessionWithBranch(
"feature/test".to_string()
))
);
}
#[test]
fn test_workspace_popup_branch_input_enter_empty() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_session_count() {
let (mut app, _rx) = create_test_app();
assert_eq!(app.session_count(), 0);
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
assert_eq!(app.session_count(), 1);
}
#[test]
fn test_set_size() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.set_size(50, 120);
assert_eq!(app.size, (50, 120));
}
#[test]
fn test_handle_event_key() {
let (mut app, _rx) = create_test_app();
let event = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL));
let action = app.handle_event(&event);
assert_eq!(action, Some(AppAction::ShowConfirmQuit));
}
#[test]
fn test_handle_event_mouse_scroll() {
let (mut app, _rx) = create_test_app();
let event = Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
let action = app.handle_event(&event);
assert_eq!(action, Some(AppAction::Scroll(-3)));
}
#[test]
fn test_handle_event_resize() {
let (mut app, _rx) = create_test_app();
let event = Event::Resize(100, 40);
let action = app.handle_event(&event);
assert_eq!(action, Some(AppAction::ResizeTerminal(40, 100)));
}
#[rstest]
#[case::enter(KeyCode::Enter, Some(AppAction::SendInput(vec![b'\r'])))]
#[case::backspace(KeyCode::Backspace, Some(AppAction::SendInput(vec![0x7f])))]
#[case::tab(KeyCode::Tab, Some(AppAction::SendInput(vec![b'\t'])))]
#[case::backtab(KeyCode::BackTab, Some(AppAction::SendInput(b"\x1b[Z".to_vec())))]
#[case::escape(KeyCode::Esc, Some(AppAction::SendInput(vec![0x1b])))]
#[case::up(KeyCode::Up, Some(AppAction::SendInput(b"\x1b[A".to_vec())))]
#[case::down(KeyCode::Down, Some(AppAction::SendInput(b"\x1b[B".to_vec())))]
#[case::right(KeyCode::Right, Some(AppAction::SendInput(b"\x1b[C".to_vec())))]
#[case::left(KeyCode::Left, Some(AppAction::SendInput(b"\x1b[D".to_vec())))]
#[case::home(KeyCode::Home, Some(AppAction::SendInput(b"\x1b[H".to_vec())))]
#[case::end(KeyCode::End, Some(AppAction::SendInput(b"\x1b[F".to_vec())))]
#[case::delete(KeyCode::Delete, Some(AppAction::SendInput(b"\x1b[3~".to_vec())))]
#[case::page_up(KeyCode::PageUp, Some(AppAction::Scroll(-10)))]
#[case::page_down(KeyCode::PageDown, Some(AppAction::Scroll(10)))]
fn key_forward(#[case] code: KeyCode, #[case] expected: Option<AppAction>) {
let (mut app, _rx) = create_test_app();
assert_eq!(
app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)),
expected
);
}
#[test]
fn test_mouse_click_tab_bar() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
let tab1_width = tab_display_width(&TabItem::new("session-1", StatusIndicator::Running));
assert_eq!(tab1_width, 14);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
};
let action = app.handle_mouse(mouse);
assert_eq!(action, Some(AppAction::SwitchSession(0)));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: tab1_width,
row: 0,
modifiers: KeyModifiers::NONE,
};
let action = app.handle_mouse(mouse);
assert_eq!(action, Some(AppAction::SwitchSession(1)));
}
#[test]
fn test_mouse_click_tab_bar_out_of_bounds() {
let (mut app, _rx) = create_test_app();
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
let tab_width = tab_display_width(&TabItem::new("session-1", StatusIndicator::Running));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: tab_width * 5,
row: 0,
modifiers: KeyModifiers::NONE,
};
let action = app.handle_mouse(mouse);
assert_eq!(action, None);
}
#[test]
fn test_mouse_click_tab_bar_variable_width() {
let (mut app, _rx) = create_test_app();
let id1 = SessionId::new_v4();
let id2 = SessionId::new_v4();
let id3 = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id: id1,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::TitleChanged {
id: id1,
title: "a".to_string(), });
app.handle_session_event(SessionEvent::Created {
id: id2,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::TitleChanged {
id: id2,
title: "日本語".to_string(), });
app.handle_session_event(SessionEvent::Created {
id: id3,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::TitleChanged {
id: id3,
title: "test".to_string(), });
let tab1_width = tab_display_width(&TabItem::new("a", StatusIndicator::Running));
let tab2_width = tab_display_width(&TabItem::new("日本語", StatusIndicator::Running));
let tab3_width = tab_display_width(&TabItem::new("test", StatusIndicator::Running));
assert_eq!(tab1_width, 6);
assert_eq!(tab2_width, 11);
assert_eq!(tab3_width, 9);
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(app.handle_mouse(mouse), Some(AppAction::SwitchSession(0)));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 6,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(app.handle_mouse(mouse), Some(AppAction::SwitchSession(1)));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 16,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(app.handle_mouse(mouse), Some(AppAction::SwitchSession(1)));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 17,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(app.handle_mouse(mouse), Some(AppAction::SwitchSession(2)));
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 26,
row: 0,
modifiers: KeyModifiers::NONE,
};
assert_eq!(app.handle_mouse(mouse), None);
}
#[test]
fn test_mouse_click_not_tab_bar() {
let (mut app, _rx) = create_test_app();
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
};
let action = app.handle_mouse(mouse);
assert_eq!(action, None);
}
#[test]
fn test_mouse_scroll_up_down() {
let (mut app, _rx) = create_test_app();
let action = app.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_eq!(action, Some(AppAction::Scroll(-3)));
let action = app.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_eq!(action, Some(AppAction::Scroll(3)));
}
#[test]
fn test_mouse_drag_returns_none() {
let (mut app, _rx) = create_test_app();
let action = app.handle_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_eq!(action, None);
}
#[test]
fn test_mouse_moved_returns_none() {
let (mut app, _rx) = create_test_app();
let action = app.handle_mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 10,
row: 10,
modifiers: KeyModifiers::NONE,
});
assert_eq!(action, None);
}
#[tokio::test]
async fn test_dispatch_switch_session() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.dispatch(AppAction::SwitchSession(1)).await.unwrap();
assert_eq!(app.active_idx, 1);
}
#[tokio::test]
async fn test_dispatch_next_prev_session() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.active_idx = 0;
app.dispatch(AppAction::NextSession).await.unwrap();
assert_eq!(app.active_idx, 1);
app.dispatch(AppAction::PrevSession).await.unwrap();
assert_eq!(app.active_idx, 0);
}
#[tokio::test]
async fn test_dispatch_scroll() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.dispatch(AppAction::Scroll(-5)).await.unwrap();
assert_eq!(app.scroll_offsets.get(&id), Some(&5));
}
#[tokio::test]
async fn test_dispatch_resize_terminal() {
let (mut app, mut rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.dispatch(AppAction::ResizeTerminal(50, 120))
.await
.unwrap();
assert_eq!(app.size, (50, 120));
if let Ok(SessionCommand::Resize { rows, cols, .. }) = rx.try_recv() {
assert_eq!(rows, 50);
assert_eq!(cols, 120);
}
}
#[tokio::test]
async fn test_dispatch_toggle_quit_selection() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
app.quit_selected_yes = false;
app.dispatch(AppAction::ToggleQuitSelection).await.unwrap();
assert!(app.quit_selected_yes);
app.dispatch(AppAction::ToggleQuitSelection).await.unwrap();
assert!(!app.quit_selected_yes);
}
#[tokio::test]
async fn test_dispatch_select_next_prev() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.dispatch(AppAction::SelectNext).await.unwrap();
assert_eq!(app.popup_state.session_list.selected(), Some(1));
app.dispatch(AppAction::SelectPrev).await.unwrap();
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[tokio::test]
async fn test_dispatch_confirm_selection() {
let (mut app, _rx) = create_test_app();
for _ in 0..3 {
app.handle_session_event(SessionEvent::Created {
id: SessionId::new_v4(),
branch: None,
auto_input: Vec::new(),
});
}
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(2));
app.dispatch(AppAction::ConfirmSelection).await.unwrap();
assert_eq!(app.active_idx, 2);
assert_eq!(app.state, AppState::Normal);
}
#[tokio::test]
async fn test_dispatch_send_input() {
let (mut app, mut rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.dispatch(AppAction::SendInput(b"hello".to_vec()))
.await
.unwrap();
if let Ok(SessionCommand::SendInput { data, .. }) = rx.try_recv() {
assert_eq!(data, b"hello");
}
}
#[tokio::test]
async fn test_dispatch_input_cursor_left_right() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::InputChar('a')).await.unwrap();
app.dispatch(AppAction::InputChar('b')).await.unwrap();
assert_eq!(app.popup_state.cursor, 2);
app.dispatch(AppAction::InputCursorLeft).await.unwrap();
assert_eq!(app.popup_state.cursor, 1);
app.dispatch(AppAction::InputCursorRight).await.unwrap();
assert_eq!(app.popup_state.cursor, 2);
}
#[test]
fn test_workspace_popup_backtab() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::PrevPopupSection));
}
#[test]
fn test_workspace_popup_ctrl_q_shows_confirm() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ShowConfirmQuit));
}
#[test]
fn test_workspace_popup_branch_input_keys() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
let action = app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::InputChar('x')));
let action = app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::InputBackspace));
let action = app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::InputCursorLeft));
let action = app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::InputCursorRight));
}
#[test]
fn test_workspace_popup_session_section_keys() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
let action = app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::SelectNext));
let action = app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::SelectPrev));
let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::ConfirmSelection));
}
#[test]
fn test_workspace_popup_worktree_section_keys() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
app.worktrees.push(WorktreeItem::new(
"test-branch",
PathBuf::from("/path/to/worktree"),
GitWorktreeStatus::default(),
));
app.popup_state.worktree_list.select(Some(0));
let action = app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::SelectNext));
let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
action,
Some(AppAction::AdoptWorktree {
path: PathBuf::from("/path/to/worktree")
})
);
let action = app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(
action,
Some(AppAction::DeleteWorktree {
path: PathBuf::from("/path/to/worktree")
})
);
let action = app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert_eq!(
action,
Some(AppAction::PullWorktree {
path: PathBuf::from("/path/to/worktree")
})
);
}
#[test]
fn test_workspace_popup_worktree_no_selection() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(action, None);
let action = app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(action, None);
}
#[test]
fn test_confirm_quit_toggle() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
let action = app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::ToggleQuitSelection));
let action = app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::ToggleQuitSelection));
let action = app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(action, Some(AppAction::ToggleQuitSelection));
}
#[test]
fn test_session_error_event() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Error {
id,
error: "Test error".to_string(),
});
assert_eq!(app.sessions.len(), 1);
}
#[test]
fn test_select_next_worktrees_section() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
for i in 0..3 {
app.worktrees.push(WorktreeItem::new(
format!("branch-{i}"),
PathBuf::from(format!("/path/{i}")),
GitWorktreeStatus::default(),
));
}
app.popup_state.worktree_list.select(Some(0));
app.select_next();
assert_eq!(app.popup_state.worktree_list.selected(), Some(1));
app.select_next();
assert_eq!(app.popup_state.worktree_list.selected(), Some(2));
app.select_next();
assert_eq!(app.popup_state.worktree_list.selected(), Some(0));
}
#[test]
fn test_select_prev_worktrees_section() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
for i in 0..3 {
app.worktrees.push(WorktreeItem::new(
format!("branch-{i}"),
PathBuf::from(format!("/path/{i}")),
GitWorktreeStatus::default(),
));
}
app.popup_state.worktree_list.select(Some(0));
app.select_prev();
assert_eq!(app.popup_state.worktree_list.selected(), Some(2));
app.select_prev();
assert_eq!(app.popup_state.worktree_list.selected(), Some(1));
}
#[test]
fn test_select_next_empty_worktrees() {
let (mut app, _rx) = create_test_app();
app.popup_state.section = PopupSection::Worktrees;
app.select_next();
assert_eq!(app.popup_state.worktree_list.selected(), None);
}
#[test]
fn test_select_next_branch_input_noop() {
let (mut app, _rx) = create_test_app();
app.popup_state.section = PopupSection::BranchInput;
app.select_next();
app.select_prev();
}
#[test]
fn session_nav_empty_noop() {
let (mut app, _rx) = create_test_app();
app.next_session();
assert_eq!(app.active_idx, 0);
app.prev_session();
assert_eq!(app.active_idx, 0);
}
#[test]
fn test_scroll_no_session() {
let (mut app, _rx) = create_test_app();
app.scroll(-5);
assert!(app.scroll_offsets.is_empty());
}
#[test]
fn handle_event_non_key_returns_none() {
let (mut app, _rx) = create_test_app();
assert_eq!(app.handle_event(&Event::FocusGained), None);
assert_eq!(app.handle_event(&Event::FocusLost), None);
assert_eq!(app.handle_event(&Event::Paste("test".to_string())), None);
}
#[test]
fn unhandled_keys_return_none() {
let (mut app, _rx) = create_test_app();
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::F(1), KeyModifiers::CONTROL)),
None
);
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)),
None
);
}
#[test]
fn test_key_forward_regular_char() {
let (mut app, _rx) = create_test_app();
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SendInput(b"x".to_vec())));
}
#[test]
fn unhandled_keys_in_popup_states() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)),
None
);
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
None
);
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
None
);
app.state = AppState::ConfirmQuit;
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)),
None
);
app.state = AppState::ErrorPopup {
message: "test".to_string(),
from_popup: false,
};
assert_eq!(
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
None
);
}
#[test]
fn test_error_popup_esc_dismisses() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ErrorPopup {
message: "test error".to_string(),
from_popup: false,
};
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::DismissError));
}
#[test]
fn test_error_popup_enter_dismisses() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ErrorPopup {
message: "test error".to_string(),
from_popup: false,
};
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::DismissError));
}
#[tokio::test]
async fn test_dispatch_dismiss_error_to_normal() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ErrorPopup {
message: "test error".to_string(),
from_popup: false,
};
app.dispatch(AppAction::DismissError).await.unwrap();
assert_eq!(app.state, AppState::Normal);
}
#[tokio::test]
async fn test_dispatch_dismiss_error_to_workspace_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ErrorPopup {
message: "test error".to_string(),
from_popup: true,
};
app.dispatch(AppAction::DismissError).await.unwrap();
assert_eq!(app.state, AppState::WorkspacePopup);
}
#[test]
fn test_show_error_from_normal() {
let (mut app, _rx) = create_test_app();
app.state = AppState::Normal;
app.show_error("test error".to_string());
assert!(matches!(
app.state,
AppState::ErrorPopup {
from_popup: false,
..
}
));
}
#[test]
fn test_show_error_from_workspace_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.show_error("test error".to_string());
assert!(matches!(
app.state,
AppState::ErrorPopup {
from_popup: true,
..
}
));
}
#[test]
fn test_worktree_pulled_updates_status() {
let (mut app, _rx) = create_test_app();
let path = PathBuf::from("/test/worktree");
app.worktrees.push(WorktreeItem {
display_name: "worktree".to_string(),
branch: "main".to_string(),
path: path.clone(),
status: GitWorktreeStatus::default(),
});
let new_status = GitWorktreeStatus {
dirty: true,
ahead: 2,
behind: 1,
detached: false,
no_upstream: false,
};
app.handle_session_event(SessionEvent::WorktreePulled {
path: path.clone(),
result: Ok(new_status.clone()),
});
assert_eq!(app.worktrees[0].status, new_status);
assert_eq!(app.popup_state.loading, LoadingOperation::Idle);
}
#[test]
fn test_worktree_pulled_error_shows_error() {
let (mut app, _rx) = create_test_app();
let path = PathBuf::from("/test/worktree");
app.handle_session_event(SessionEvent::WorktreePulled {
path,
result: Err("Pull failed".to_string()),
});
assert!(matches!(app.state, AppState::ErrorPopup { .. }));
assert_eq!(app.popup_state.loading, LoadingOperation::Idle);
}
#[test]
fn test_workspace_popup_ctrl_g_hides_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::HidePopup));
}
#[test]
fn test_workspace_popup_ctrl_n_cross_section_next() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CrossSectionNext));
}
#[test]
fn test_workspace_popup_ctrl_p_cross_section_prev() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CrossSectionPrev));
}
#[test]
fn test_workspace_popup_ctrl_m_confirm_in_sessions() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ConfirmSelection));
}
#[test]
fn test_workspace_popup_ctrl_m_confirm_in_branch_input_empty() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
app.popup_state.input.clear();
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_workspace_popup_ctrl_m_confirm_in_branch_input_with_text() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::BranchInput;
app.popup_state.input = "feature-branch".to_string();
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(
action,
Some(AppAction::CreateSessionWithBranch(
"feature-branch".to_string()
))
);
}
#[test]
fn test_workspace_popup_ctrl_m_confirm_in_worktrees() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
app.worktrees.push(WorktreeItem::new(
"test-branch",
PathBuf::from("/path/to/worktree"),
GitWorktreeStatus::default(),
));
app.popup_state.worktree_list.select(Some(0));
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(
action,
Some(AppAction::AdoptWorktree {
path: PathBuf::from("/path/to/worktree")
})
);
}
#[test]
fn test_cross_section_next_from_branch_input_to_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_from_branch_input_to_worktrees_no_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.clear();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
assert_eq!(app.popup_state.worktree_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_within_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-2".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(1));
}
#[test]
fn test_cross_section_next_sessions_to_worktrees() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
assert_eq!(app.popup_state.worktree_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_sessions_to_branch_input_no_worktrees() {
let (mut app, _rx) = create_test_app();
app.worktrees.clear();
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[test]
fn test_cross_section_next_worktrees_wrap_to_branch_input() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[test]
fn test_cross_section_prev_from_branch_input_to_worktrees() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
assert_eq!(app.popup_state.worktree_list.selected(), Some(0));
}
#[test]
fn test_cross_section_prev_from_branch_input_to_sessions_no_worktrees() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.worktrees.clear();
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[test]
fn test_cross_section_prev_within_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-2".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(1));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[test]
fn test_cross_section_prev_sessions_to_branch_input() {
let (mut app, _rx) = create_test_app();
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[test]
fn test_cross_section_prev_worktrees_to_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[test]
fn test_cross_section_prev_worktrees_to_branch_input_no_sessions() {
let (mut app, _rx) = create_test_app();
app.sessions.clear();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[tokio::test]
async fn test_dispatch_cross_section_next() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.popup_state.section = PopupSection::BranchInput;
app.dispatch(AppAction::CrossSectionNext).await.unwrap();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
}
#[tokio::test]
async fn test_dispatch_cross_section_prev() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::BranchInput;
app.dispatch(AppAction::CrossSectionPrev).await.unwrap();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
}
#[test]
fn test_session_terminated_popup_shows_on_active_session() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Terminated {
id,
exit_code: Some(0),
});
assert!(matches!(
app.state,
AppState::SessionTerminatedPopup {
session_id,
exit_code: Some(0)
} if session_id == id
));
}
#[test]
fn test_session_terminated_popup_not_shown_for_inactive_session() {
let (mut app, _rx) = create_test_app();
let id1 = SessionId::new_v4();
let id2 = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id: id1,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Created {
id: id2,
branch: None,
auto_input: Vec::new(),
});
app.active_idx = 0;
app.handle_session_event(SessionEvent::Terminated {
id: id2,
exit_code: Some(0),
});
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_session_terminated_popup_key_close_lowercase() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test".to_string(),
status: SessionStatus::Terminated { exit_code: Some(0) },
branch: None,
});
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CloseSession { id }));
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_session_terminated_popup_key_close_uppercase() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test".to_string(),
status: SessionStatus::Terminated { exit_code: Some(0) },
branch: None,
});
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CloseSession { id }));
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_session_terminated_popup_key_keep_uppercase() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_session_terminated_popup_unhandled_key() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
assert!(matches!(app.state, AppState::SessionTerminatedPopup { .. }));
}
#[test]
fn test_session_terminated_popup_key_keep() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_session_terminated_popup_key_esc() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_close_session_only_for_terminated() {
let (mut app, _rx) = create_test_app();
let id_running = SessionId::new_v4();
let id_terminated = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id: id_running,
name: "running".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.sessions.push(SessionSnapshot {
id: id_terminated,
name: "terminated".to_string(),
status: SessionStatus::Terminated { exit_code: Some(0) },
branch: None,
});
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
app.popup_state.session_list.select(Some(1));
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CloseSession { id: id_terminated }));
}
#[test]
fn test_session_section_unhandled_key() {
let (mut app, _rx) = create_test_app();
app.sessions.push(SessionSnapshot {
id: SessionId::new_v4(),
name: "test".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_worktree_section_up_key() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch-1",
PathBuf::from("/path/1"),
GitWorktreeStatus::default(),
));
app.worktrees.push(WorktreeItem::new(
"branch-2",
PathBuf::from("/path/2"),
GitWorktreeStatus::default(),
));
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(1));
let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectPrev));
}
#[test]
fn test_worktree_section_unhandled_key() {
let (mut app, _rx) = create_test_app();
app.worktrees.push(WorktreeItem::new(
"branch-1",
PathBuf::from("/path/1"),
GitWorktreeStatus::default(),
));
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(0));
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
mod render_snapshots {
use super::*;
use insta::assert_snapshot;
use ratatui::{Terminal, backend::TestBackend};
fn render_app_to_string(app: &mut TuiApp, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| app.render(f)).unwrap();
let buffer = terminal.backend().buffer().clone();
crate::tui::test_utils::buffer_to_snapshot(&buffer)
}
#[test]
fn render_normal_empty_sessions() {
let (mut app, _rx) = create_test_app();
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_normal_with_sessions() {
let (mut app, _rx) = create_test_app_with_sessions(3);
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_workspace_popup() {
let (mut app, _rx) = create_test_app_with_sessions(2);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_confirm_quit_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
app.quit_selected_yes = false;
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_confirm_quit_yes_selected() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmQuit;
app.quit_selected_yes = true;
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_error_popup() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ErrorPopup {
message: "Failed to create worktree: branch already exists".to_string(),
from_popup: true,
};
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_session_terminated_popup() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "feature-auth".to_string(),
status: SessionStatus::Terminated { exit_code: Some(0) },
branch: Some("feature/auth".to_string()),
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.state = AppState::SessionTerminatedPopup {
session_id: id,
exit_code: Some(0),
};
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_with_terminal_content() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.handle_session_event(SessionEvent::Created {
id,
branch: None,
auto_input: Vec::new(),
});
app.handle_session_event(SessionEvent::Output {
id,
data: b"$ ls\r\nfile1.txt\r\nfile2.txt".to_vec(),
});
let output = render_app_to_string(&mut app, 80, 24);
assert_snapshot!(output);
}
#[test]
fn render_status_bar_with_branch() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test-session".to_string(),
status: SessionStatus::Running,
branch: Some("feature/auth".to_string()),
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
let output = render_app_to_string(&mut app, 80, 24);
assert!(output.contains("[Gy]f"));
assert!(output.contains("[Gy]a"));
assert!(output.contains("[Gy]u"));
assert!(output.contains("[Gy]t"));
assert!(output.contains("[Gy]h"));
}
#[test]
fn render_notifications_badge() {
let (mut app, _rx) = create_test_app_with_sessions(1);
for i in 0..5 {
app.mark_pending(&SessionId::from_u128(i));
}
let output = render_app_to_string(&mut app, 80, 24);
assert!(output.contains('5'));
}
}
#[test]
fn test_worktrees_refreshed_updates_list() {
let (mut app, _rx) = create_test_app();
app.popup_state.loading = LoadingOperation::Fetching;
let worktrees = vec![
crate::worktree::WorktreeInfo {
branch: "main".to_string(),
path: PathBuf::from("/path/1"),
status: GitWorktreeStatus::default(),
},
crate::worktree::WorktreeInfo {
branch: "feature".to_string(),
path: PathBuf::from("/path/2"),
status: GitWorktreeStatus {
dirty: true,
..Default::default()
},
},
];
app.handle_session_event(SessionEvent::WorktreesRefreshed {
worktrees,
fetch_pending: false,
});
assert_eq!(app.worktrees.len(), 2);
assert_eq!(app.popup_state.loading, LoadingOperation::Idle);
assert_eq!(app.popup_state.worktree_list.selected(), Some(0));
}
#[test]
fn test_worktrees_refreshed_preserves_selection_when_valid() {
let (mut app, _rx) = create_test_app_with_worktrees(3);
app.popup_state.worktree_list.select(Some(1));
let worktrees = vec![
crate::worktree::WorktreeInfo {
branch: "new-1".to_string(),
path: PathBuf::from("/new/1"),
status: GitWorktreeStatus::default(),
},
crate::worktree::WorktreeInfo {
branch: "new-2".to_string(),
path: PathBuf::from("/new/2"),
status: GitWorktreeStatus::default(),
},
];
app.handle_session_event(SessionEvent::WorktreesRefreshed {
worktrees,
fetch_pending: false,
});
assert_eq!(app.popup_state.worktree_list.selected(), Some(1));
}
#[test]
fn test_worktrees_refreshed_empty_list() {
let (mut app, _rx) = create_test_app();
app.popup_state.worktree_list.select(Some(0));
app.handle_session_event(SessionEvent::WorktreesRefreshed {
worktrees: vec![],
fetch_pending: false,
});
assert!(app.worktrees.is_empty());
}
#[test]
fn test_worktree_deleted_success_removes_from_list() {
let (mut app, _rx) = create_test_app();
let path = PathBuf::from("/test/worktree");
app.worktrees.push(WorktreeItem::new(
"branch",
path.clone(),
GitWorktreeStatus::default(),
));
app.popup_state.worktree_list.select(Some(0));
app.popup_state.loading = LoadingOperation::Deleting { path: path.clone() };
app.handle_session_event(SessionEvent::WorktreeDeleted {
path,
result: Ok(()),
});
assert!(app.worktrees.is_empty());
assert_eq!(app.popup_state.loading, LoadingOperation::Idle);
assert_eq!(app.popup_state.worktree_list.selected(), None);
}
#[test]
fn test_worktree_deleted_adjusts_selection_down() {
let (mut app, _rx) = create_test_app_with_worktrees(3);
let path = app.worktrees[2].path.clone();
app.popup_state.worktree_list.select(Some(2)); app.popup_state.loading = LoadingOperation::Deleting { path: path.clone() };
app.handle_session_event(SessionEvent::WorktreeDeleted {
path,
result: Ok(()),
});
assert_eq!(app.worktrees.len(), 2);
assert_eq!(app.popup_state.worktree_list.selected(), Some(1)); }
#[test]
fn test_worktree_deleted_error_shows_error_popup() {
let (mut app, _rx) = create_test_app_with_worktrees(1);
let path = app.worktrees[0].path.clone();
app.popup_state.loading = LoadingOperation::Deleting { path: path.clone() };
app.handle_session_event(SessionEvent::WorktreeDeleted {
path,
result: Err("Worktree has uncommitted changes".to_string()),
});
assert!(matches!(app.state, AppState::ErrorPopup { .. }));
assert_eq!(app.worktrees.len(), 1); assert_eq!(app.popup_state.loading, LoadingOperation::Idle);
}
#[test]
fn test_worktree_deleted_middle_item_selection_stays() {
let (mut app, _rx) = create_test_app_with_worktrees(5);
let path = app.worktrees[2].path.clone();
app.popup_state.worktree_list.select(Some(1));
app.handle_session_event(SessionEvent::WorktreeDeleted {
path,
result: Ok(()),
});
assert_eq!(app.worktrees.len(), 4);
assert_eq!(app.popup_state.worktree_list.selected(), Some(1)); }
#[test]
fn test_hook_received_notification_marks_session_pending() {
let (mut app, _rx) = create_test_app();
assert_eq!(app.pending_count(), 0);
let event = create_test_hook_event(crate::hooks::HookEventType::Notification, true);
let session_id = event.session_id;
app.handle_session_event(SessionEvent::HookReceived { event });
assert!(app.is_pending(&session_id));
assert_eq!(app.pending_count(), 1);
}
#[test]
fn test_hook_received_non_attention_no_pending() {
let (mut app, _rx) = create_test_app();
let event = create_test_hook_event(crate::hooks::HookEventType::PreToolUse, false);
let session_id = event.session_id;
app.handle_session_event(SessionEvent::HookReceived { event });
assert!(!app.is_pending(&session_id));
assert_eq!(app.pending_count(), 0);
}
#[test]
fn test_hook_received_with_bell_enabled() {
let (cmd_tx, _rx) = mpsc::channel(16);
let config = NotificationConfig {
terminal_bell: true,
..NotificationConfig::default()
};
let mut app = TuiApp::new(cmd_tx, config, vec![]);
let event = create_test_hook_event(crate::hooks::HookEventType::Notification, true);
let session_id = event.session_id;
app.handle_session_event(SessionEvent::HookReceived { event });
assert!(app.is_pending(&session_id));
assert_eq!(app.pending_count(), 1);
}
#[test]
fn test_hook_received_multiple_events_from_different_sessions() {
let (mut app, _rx) = create_test_app();
for _ in 0..5 {
let event = create_test_hook_event(crate::hooks::HookEventType::Notification, true);
app.handle_session_event(SessionEvent::HookReceived { event });
}
assert_eq!(app.pending_count(), 5);
}
#[test]
fn test_hook_received_same_session_idempotent() {
let (mut app, _rx) = create_test_app();
let session_id = SessionId::new_v4();
for _ in 0..3 {
app.mark_pending(&session_id);
}
assert_eq!(app.pending_count(), 1);
assert!(app.is_pending(&session_id));
}
#[test]
fn test_output_does_not_clear_pending_status() {
let (mut app, _rx) = create_test_app_with_sessions(1);
let session_id = app.sessions[0].id;
app.mark_pending(&session_id);
assert!(app.is_pending(&session_id));
app.handle_session_event(SessionEvent::Output {
id: session_id,
data: b"some output".to_vec(),
});
assert!(app.is_pending(&session_id));
assert_eq!(app.pending_count(), 1);
}
#[test]
fn test_switch_session_does_not_clear_pending_status() {
let (mut app, _rx) = create_test_app_with_sessions(2);
let session_id_1 = app.sessions[1].id;
app.active_idx = 0;
app.mark_pending(&session_id_1);
assert!(app.is_pending(&session_id_1));
app.switch_session(1);
assert!(app.is_pending(&session_id_1));
assert_eq!(app.pending_count(), 1);
}
#[test]
fn test_next_session_does_not_clear_pending_status() {
let (mut app, _rx) = create_test_app_with_sessions(2);
let session_id_1 = app.sessions[1].id;
app.active_idx = 0;
app.mark_pending(&session_id_1);
app.next_session();
assert_eq!(app.active_idx, 1);
assert!(app.is_pending(&session_id_1));
}
#[test]
fn test_prev_session_does_not_clear_pending_status() {
let (mut app, _rx) = create_test_app_with_sessions(2);
let session_id_0 = app.sessions[0].id;
app.active_idx = 1;
app.mark_pending(&session_id_0);
app.prev_session();
assert_eq!(app.active_idx, 0);
assert!(app.is_pending(&session_id_0));
}
#[tokio::test]
async fn test_send_input_with_enter_clears_pending() {
let (mut app, _rx) = create_test_app_with_sessions(1);
let session_id = app.sessions[0].id;
app.mark_pending(&session_id);
assert!(app.is_pending(&session_id));
app.send_input(b"hello\r").await;
assert!(!app.is_pending(&session_id));
assert_eq!(app.pending_count(), 0);
}
#[tokio::test]
async fn test_send_input_with_newline_clears_pending() {
let (mut app, _rx) = create_test_app_with_sessions(1);
let session_id = app.sessions[0].id;
app.mark_pending(&session_id);
assert!(app.is_pending(&session_id));
app.send_input(b"hello\n").await;
assert!(!app.is_pending(&session_id));
assert_eq!(app.pending_count(), 0);
}
#[tokio::test]
async fn test_send_input_without_newline_does_not_clear_pending() {
let (mut app, _rx) = create_test_app_with_sessions(1);
let session_id = app.sessions[0].id;
app.mark_pending(&session_id);
assert!(app.is_pending(&session_id));
app.send_input(b"hello").await;
assert!(app.is_pending(&session_id));
assert_eq!(app.pending_count(), 1);
}
#[tokio::test]
async fn test_send_input_clears_pending_only_for_active_session() {
let (mut app, _rx) = create_test_app_with_sessions(2);
let session_id_0 = app.sessions[0].id;
let session_id_1 = app.sessions[1].id;
app.active_idx = 0;
app.mark_pending(&session_id_0);
app.mark_pending(&session_id_1);
assert_eq!(app.pending_count(), 2);
app.send_input(b"\r").await;
assert!(!app.is_pending(&session_id_0));
assert!(app.is_pending(&session_id_1));
assert_eq!(app.pending_count(), 1);
}
#[tokio::test]
async fn test_dispatch_delete_worktree() {
let (mut app, mut rx) = create_test_app_with_worktrees(1);
let path = app.worktrees[0].path.clone();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::DeleteWorktree { path: path.clone() })
.await
.unwrap();
assert!(matches!(
app.popup_state.loading,
LoadingOperation::Deleting { .. }
));
if let Ok(SessionCommand::DeleteWorktreeAsync { path: cmd_path }) = rx.try_recv() {
assert_eq!(cmd_path, path);
} else {
panic!("Expected DeleteWorktreeAsync command");
}
}
#[tokio::test]
async fn test_dispatch_pull_worktree() {
let (mut app, mut rx) = create_test_app_with_worktrees(1);
let path = app.worktrees[0].path.clone();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::PullWorktree { path: path.clone() })
.await
.unwrap();
assert!(matches!(
app.popup_state.loading,
LoadingOperation::Pulling { .. }
));
if let Ok(SessionCommand::PullWorktreeAsync { path: cmd_path }) = rx.try_recv() {
assert_eq!(cmd_path, path);
} else {
panic!("Expected PullWorktreeAsync command");
}
}
#[tokio::test]
async fn test_dispatch_close_session_success() {
let (mut app, mut rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions
.push(create_terminated_session(id, "terminated"));
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.scroll_offsets.insert(id, 5);
app.popup_state.session_list.select(Some(0));
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CloseSession {
id: cmd_id,
response_tx,
}) = rx.recv().await
{
assert_eq!(cmd_id, id);
let _ = response_tx.send(Ok(Some(PathBuf::from("/worktree/path"))));
}
});
app.dispatch(AppAction::CloseSession { id }).await.unwrap();
assert!(app.sessions.is_empty());
assert!(!app.terminal_buffers.contains_key(&id));
assert!(!app.scroll_offsets.contains_key(&id));
handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_close_session_clears_pending_status() {
let (mut app, mut rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions
.push(create_terminated_session(id, "terminated"));
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.pending_sessions.insert(id);
assert!(app.is_pending(&id));
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CloseSession { response_tx, .. }) = rx.recv().await {
let _ = response_tx.send(Ok(None));
}
});
app.dispatch(AppAction::CloseSession { id }).await.unwrap();
assert!(!app.is_pending(&id));
assert_eq!(app.pending_count(), 0);
handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_close_session_adjusts_active_idx() {
let (mut app, mut rx) = create_test_app();
let id1 = SessionId::new_v4();
let id2 = SessionId::new_v4();
app.sessions.push(create_terminated_session(id1, "s1"));
app.sessions.push(create_terminated_session(id2, "s2"));
app.terminal_buffers
.insert(id1, vt100::Parser::new(24, 80, 1000));
app.terminal_buffers
.insert(id2, vt100::Parser::new(24, 80, 1000));
app.active_idx = 1;
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CloseSession { response_tx, .. }) = rx.recv().await {
let _ = response_tx.send(Ok(None));
}
});
app.dispatch(AppAction::CloseSession { id: id2 })
.await
.unwrap();
assert_eq!(app.active_idx, 0); assert_eq!(app.sessions.len(), 1);
handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_close_session_error() {
let (mut app, mut rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(create_terminated_session(id, "test"));
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CloseSession { response_tx, .. }) = rx.recv().await {
let _ = response_tx.send(Err(SessionError::NotFound { id: id.to_string() }));
}
});
app.dispatch(AppAction::CloseSession { id }).await.unwrap();
assert!(matches!(app.state, AppState::ErrorPopup { .. }));
handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_close_session_adjusts_popup_selection() {
let (mut app, mut rx) = create_test_app();
for i in 0..3 {
let id = SessionId::new_v4();
app.sessions
.push(create_terminated_session(id, &format!("s{i}")));
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
}
app.popup_state.session_list.select(Some(2)); let id_to_close = app.sessions[2].id;
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CloseSession { response_tx, .. }) = rx.recv().await {
let _ = response_tx.send(Ok(None));
}
});
app.dispatch(AppAction::CloseSession { id: id_to_close })
.await
.unwrap();
assert_eq!(app.popup_state.session_list.selected(), Some(1)); handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_terminate_current_session_no_sessions() {
let (mut app, _rx) = create_test_app();
app.dispatch(AppAction::TerminateCurrentSession)
.await
.unwrap();
}
#[test]
fn test_new_stores_default_args() {
let (cmd_tx, _rx) = mpsc::channel(16);
let args = vec!["--dangerously-skip-permissions".to_string()];
let app = TuiApp::new(cmd_tx, NotificationConfig::default(), args.clone());
assert_eq!(app.default_args(), &args);
}
#[tokio::test]
async fn test_create_session_uses_default_args() {
let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
let args = vec!["--model".to_string(), "opus".to_string()];
let mut app = TuiApp::new(cmd_tx, NotificationConfig::default(), args.clone());
app.set_size(24, 80);
let handle = tokio::spawn(async move {
match cmd_rx.recv().await {
Some(SessionCommand::Create {
args, response_tx, ..
}) => {
let _ = response_tx.send(Ok(SessionId::new_v4()));
args
}
_ => vec![],
}
});
let _ = app.create_session_with_branch(None).await;
let received_args = handle.await.unwrap();
assert_eq!(received_args, args);
}
#[tokio::test]
async fn test_adopt_worktree_uses_default_args() {
let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
let args = vec!["--dangerously-skip-permissions".to_string()];
let mut app = TuiApp::new(cmd_tx, NotificationConfig::default(), args.clone());
app.set_size(24, 80);
let handle = tokio::spawn(async move {
match cmd_rx.recv().await {
Some(SessionCommand::CreateFromWorktree {
args, response_tx, ..
}) => {
let _ = response_tx.send(Ok(SessionId::new_v4()));
args
}
_ => vec![],
}
});
let _ = app.adopt_worktree(Path::new("/tmp/test")).await;
let received_args = handle.await.unwrap();
assert_eq!(received_args, args);
}
#[test]
fn test_handle_key_action_select_popup_esc() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_action_select_popup_ctrl_g() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_action_select_popup_enter_with_selection() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![
ActionChoice {
branch: "feat/test".to_string(),
action: ActionType::Implement,
prompt: "Test #42".to_string(),
},
ActionChoice {
branch: "survey/test".to_string(),
action: ActionType::Survey,
prompt: "Survey #42".to_string(),
},
];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.action_select_state.list_state.select(Some(0));
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectActionChoice { index: 0 }));
}
#[test]
fn test_handle_key_action_select_popup_enter_no_selection() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_handle_key_confirm_permissions_y() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
let key = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ConfirmDangerousPermissions));
}
#[test]
fn test_handle_key_confirm_permissions_n() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: true,
};
let key = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_confirm_permissions_esc() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: true,
};
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_confirm_permissions_ctrl_m_yes() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: true,
};
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ConfirmDangerousPermissions));
}
#[test]
fn test_handle_key_confirm_permissions_ctrl_m_no() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_issue_loading_ctrl_g() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[tokio::test]
async fn test_dispatch_toggle_permissions_choice() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
app.dispatch(AppAction::TogglePermissionsChoice)
.await
.unwrap();
if let AppState::ConfirmPermissions { selected_yes, .. } = app.state {
assert!(selected_yes);
} else {
panic!("Expected ConfirmPermissions state");
}
}
#[rstest]
#[case::from_issue_loading(AppState::IssueLoading { issue_number: 42, phase: LoadingOperation::FetchingIssue { issue_number: 42 } })]
#[case::from_action_select(AppState::ActionSelectPopup { issue_number: 42, choices: vec![] })]
#[case::from_confirm_permissions(AppState::ConfirmPermissions { branch: "feat/test".to_string(), prompt: "Test".to_string(), selected_yes: true })]
#[tokio::test]
async fn cancel_issue_flow_returns_to_workspace(#[case] initial_state: AppState) {
let (mut app, _rx) = create_test_app();
app.state = initial_state;
app.dispatch(AppAction::CancelIssueFlow).await.unwrap();
assert_eq!(app.state, AppState::WorkspacePopup);
}
#[tokio::test]
async fn test_dispatch_select_issue() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.dispatch(AppAction::SelectIssue { number: 42 })
.await
.unwrap();
assert!(matches!(
app.state,
AppState::IssueLoading {
issue_number: 42,
..
}
));
}
#[test]
fn test_app_state_issue_loading_equality() {
let state1 = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
let state2 = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
assert_eq!(state1, state2);
}
#[test]
fn test_app_state_action_select_popup_equality() {
use crate::github::{ActionChoice, ActionType};
let choices = vec![ActionChoice {
branch: "feat/test".to_string(),
action: ActionType::Implement,
prompt: "Test".to_string(),
}];
let state1 = AppState::ActionSelectPopup {
issue_number: 42,
choices: choices.clone(),
};
let state2 = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
assert_eq!(state1, state2);
}
#[tokio::test]
async fn test_dispatch_select_action_choice() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![
ActionChoice {
branch: "feat/issue-42".to_string(),
action: ActionType::Implement,
prompt: "Implement #42".to_string(),
},
ActionChoice {
branch: "survey/issue-42".to_string(),
action: ActionType::Survey,
prompt: "Survey #42".to_string(),
},
];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.dispatch(AppAction::SelectActionChoice { index: 0 })
.await
.unwrap();
if let AppState::ConfirmPermissions {
branch,
prompt,
selected_yes,
} = &app.state
{
assert_eq!(branch, "feat/issue-42");
assert_eq!(prompt, "Implement #42");
assert!(!selected_yes);
} else {
panic!("Expected ConfirmPermissions state");
}
}
#[tokio::test]
async fn test_dispatch_select_action_choice_out_of_bounds() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![ActionChoice {
branch: "feat/test".to_string(),
action: ActionType::Implement,
prompt: "Test".to_string(),
}];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.dispatch(AppAction::SelectActionChoice { index: 10 })
.await
.unwrap();
assert!(matches!(app.state, AppState::ActionSelectPopup { .. }));
}
#[tokio::test]
async fn test_dispatch_confirm_dangerous_permissions() {
let (mut app, mut rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/issue-42".to_string(),
prompt: "Implement #42".to_string(),
selected_yes: true,
};
let handle = tokio::spawn(async move {
if let Some(SessionCommand::CreateWithAutoInput {
branch_name,
auto_input,
response_tx,
..
}) = rx.recv().await
{
assert_eq!(branch_name, Some("feat/issue-42".to_string()));
assert!(!auto_input.is_empty());
let _ = response_tx.send(Ok(SessionId::new_v4()));
}
});
app.dispatch(AppAction::ConfirmDangerousPermissions)
.await
.unwrap();
assert_eq!(app.state, AppState::Normal);
handle.await.unwrap();
}
#[tokio::test]
async fn test_dispatch_action_select_navigation() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![
ActionChoice {
branch: "feat/1".to_string(),
action: ActionType::Implement,
prompt: "1".to_string(),
},
ActionChoice {
branch: "feat/2".to_string(),
action: ActionType::Implement,
prompt: "2".to_string(),
},
ActionChoice {
branch: "feat/3".to_string(),
action: ActionType::Implement,
prompt: "3".to_string(),
},
];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.action_select_state.list_state.select(Some(0));
app.dispatch(AppAction::SelectNext).await.unwrap();
assert_eq!(app.action_select_state.selected(), Some(1));
app.dispatch(AppAction::SelectPrev).await.unwrap();
assert_eq!(app.action_select_state.selected(), Some(0));
}
#[allow(clippy::cast_possible_truncation)]
fn create_test_app_with_issues(count: usize) -> (TuiApp, mpsc::Receiver<SessionCommand>) {
let (mut app, rx) = create_test_app();
for i in 0..count {
app.issues.push(IssueItem::new(
(i + 1) as u32,
format!("Issue {}", i + 1),
"",
));
}
if !app.issues.is_empty() {
app.popup_state.issue_list.select(Some(0));
}
(app, rx)
}
#[test]
fn test_handle_key_issue_section_down() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectNext));
}
#[test]
fn test_handle_key_issue_section_up() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(2));
let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectPrev));
}
#[test]
fn test_handle_key_issue_section_enter_with_selection() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(1));
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectIssue { number: 2 }));
}
#[test]
fn test_handle_key_issue_section_enter_no_selection() {
let (mut app, _rx) = create_test_app();
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_handle_key_issue_section_unhandled() {
let (mut app, _rx) = create_test_app_with_issues(1);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_select_next_issues_section() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.select_next();
assert_eq!(app.popup_state.issue_list.selected(), Some(1));
app.select_next();
assert_eq!(app.popup_state.issue_list.selected(), Some(2));
app.select_next();
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}
#[test]
fn test_select_prev_issues_section() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.select_prev();
assert_eq!(app.popup_state.issue_list.selected(), Some(2));
app.select_prev();
assert_eq!(app.popup_state.issue_list.selected(), Some(1));
}
#[test]
fn issues_nav_empty_noop() {
let (mut app, _rx) = create_test_app();
app.popup_state.section = PopupSection::Issues;
app.select_next();
assert_eq!(app.popup_state.issue_list.selected(), None);
app.select_prev();
assert_eq!(app.popup_state.issue_list.selected(), None);
}
#[test]
fn test_issues_fetched_success() {
use crate::github::{GitHubIssue, IssueState};
let (mut app, _rx) = create_test_app();
let issues = vec![
GitHubIssue {
number: 1,
title: "First issue".to_string(),
body: String::new(),
labels: vec![],
state: IssueState::Open,
},
GitHubIssue {
number: 2,
title: "Second issue".to_string(),
body: "Body".to_string(),
labels: vec![],
state: IssueState::Open,
},
];
app.handle_session_event(SessionEvent::IssuesFetched { result: Ok(issues) });
assert_eq!(app.issues.len(), 2);
assert_eq!(app.issues[0].number, 1);
assert_eq!(app.issues[1].number, 2);
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}
#[test]
fn test_issues_fetched_empty() {
let (mut app, _rx) = create_test_app();
app.handle_session_event(SessionEvent::IssuesFetched { result: Ok(vec![]) });
assert!(app.issues.is_empty());
assert_eq!(app.popup_state.issue_list.selected(), None);
}
#[test]
fn test_issues_fetched_error() {
let (mut app, _rx) = create_test_app();
app.handle_session_event(SessionEvent::IssuesFetched {
result: Err("Network error".to_string()),
});
assert!(app.issues.is_empty());
}
#[test]
fn test_issue_fetched_transitions_to_generating() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
app.handle_session_event(SessionEvent::IssueFetched { issue_number: 42 });
assert!(matches!(
app.state,
AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::GeneratingActions { .. }
}
));
}
#[test]
fn test_issue_fetched_wrong_issue_number_ignored() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
app.handle_session_event(SessionEvent::IssueFetched { issue_number: 99 });
assert!(matches!(
app.state,
AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { .. }
}
));
}
#[test]
fn test_issue_fetched_not_in_loading_state() {
let (mut app, _rx) = create_test_app();
app.state = AppState::Normal;
app.handle_session_event(SessionEvent::IssueFetched { issue_number: 42 });
assert_eq!(app.state, AppState::Normal);
}
#[test]
fn test_issue_actions_fetched_success() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::GeneratingActions { issue_number: 42 },
};
let choices = vec![
ActionChoice {
branch: "feat/issue-42".to_string(),
action: ActionType::Implement,
prompt: "Implement #42".to_string(),
},
ActionChoice {
branch: "survey/issue-42".to_string(),
action: ActionType::Survey,
prompt: "Survey #42".to_string(),
},
];
app.handle_session_event(SessionEvent::IssueActionsFetched {
issue_number: 42,
result: Ok(choices),
});
if let AppState::ActionSelectPopup {
issue_number,
choices,
} = &app.state
{
assert_eq!(*issue_number, 42);
assert_eq!(choices.len(), 2);
} else {
panic!("Expected ActionSelectPopup state");
}
assert_eq!(app.action_select_state.selected(), Some(0));
}
#[test]
fn test_issue_actions_fetched_empty() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::GeneratingActions { issue_number: 42 },
};
app.handle_session_event(SessionEvent::IssueActionsFetched {
issue_number: 42,
result: Ok(vec![]),
});
assert!(matches!(
app.state,
AppState::ErrorPopup {
from_popup: true,
..
}
));
}
#[test]
fn test_issue_actions_fetched_error() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::GeneratingActions { issue_number: 42 },
};
app.handle_session_event(SessionEvent::IssueActionsFetched {
issue_number: 42,
result: Err("Claude API error".to_string()),
});
assert!(matches!(
app.state,
AppState::ErrorPopup {
from_popup: true,
..
}
));
}
#[test]
fn test_cross_section_next_from_branch_input_to_issues_only() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.sessions.clear();
app.worktrees.clear();
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_from_sessions_to_issues_no_worktrees() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.worktrees.clear();
app.popup_state.section = PopupSection::Sessions;
app.popup_state.session_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_from_worktrees_to_issues() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.worktrees.push(WorktreeItem::new(
"branch",
PathBuf::from("/path"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Worktrees;
app.popup_state.worktree_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}
#[test]
fn test_cross_section_next_within_issues() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(1));
}
#[test]
fn test_cross_section_next_issues_wrap_to_branch_input() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(1));
app.cross_section_next();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[test]
fn test_cross_section_prev_from_branch_input_to_issues() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.popup_state.section = PopupSection::BranchInput;
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(2)); }
#[test]
fn test_cross_section_prev_within_issues() {
let (mut app, _rx) = create_test_app_with_issues(3);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(2));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Issues);
assert_eq!(app.popup_state.issue_list.selected(), Some(1));
}
#[test]
fn test_cross_section_prev_issues_to_worktrees() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.worktrees.push(WorktreeItem::new(
"branch-1",
PathBuf::from("/path/1"),
GitWorktreeStatus::default(),
));
app.worktrees.push(WorktreeItem::new(
"branch-2",
PathBuf::from("/path/2"),
GitWorktreeStatus::default(),
));
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Worktrees);
assert_eq!(app.popup_state.worktree_list.selected(), Some(1)); }
#[test]
fn test_cross_section_prev_issues_to_sessions_no_worktrees() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.sessions.push(SessionSnapshot {
id: uuid::Uuid::new_v4(),
name: "session-1".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.worktrees.clear();
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::Sessions);
assert_eq!(app.popup_state.session_list.selected(), Some(0));
}
#[test]
fn test_cross_section_prev_issues_to_branch_input_no_worktrees_no_sessions() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.sessions.clear();
app.worktrees.clear();
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
app.cross_section_prev();
assert_eq!(app.popup_state.section, PopupSection::BranchInput);
}
#[test]
fn test_handle_enter_for_section_issues() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(0));
let action = app.handle_enter_for_section();
assert_eq!(action, Some(AppAction::SelectIssue { number: 1 }));
}
#[test]
fn test_handle_enter_for_section_issues_no_selection() {
let (mut app, _rx) = create_test_app();
app.popup_state.section = PopupSection::Issues;
let action = app.handle_enter_for_section();
assert_eq!(action, None);
}
#[test]
fn test_workspace_popup_ctrl_m_confirm_in_issues() {
let (mut app, _rx) = create_test_app_with_issues(2);
app.state = AppState::WorkspacePopup;
app.popup_state.section = PopupSection::Issues;
app.popup_state.issue_list.select(Some(1));
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectIssue { number: 2 }));
}
#[test]
fn test_handle_key_issue_loading_esc() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_handle_key_issue_loading_unhandled() {
let (mut app, _rx) = create_test_app();
app.state = AppState::IssueLoading {
issue_number: 42,
phase: LoadingOperation::FetchingIssue { issue_number: 42 },
};
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_handle_key_action_select_popup_ctrl_m() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![ActionChoice {
branch: "feat/issue-42".to_string(),
action: ActionType::Implement,
prompt: "Implement #42".to_string(),
}];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.action_select_state.list_state.select(Some(0));
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectActionChoice { index: 0 }));
}
#[test]
fn test_handle_key_action_select_popup_ctrl_m_no_selection() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_handle_key_action_select_popup_unhandled_char() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[rstest]
#[case::tab(KeyCode::Tab)]
#[case::left(KeyCode::Left)]
#[case::right(KeyCode::Right)]
fn test_handle_key_confirm_permissions_toggle_keys(#[case] key_code: KeyCode) {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
let key = KeyEvent::new(key_code, KeyModifiers::NONE);
assert_eq!(
app.handle_key(key),
Some(AppAction::TogglePermissionsChoice)
);
}
#[test]
fn test_handle_key_confirm_permissions_unhandled_char() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[rstest]
#[case::up_arrow(KeyCode::Up, KeyModifiers::NONE, AppAction::SelectPrev)]
#[case::down_arrow(KeyCode::Down, KeyModifiers::NONE, AppAction::SelectNext)]
#[case::vim_j(KeyCode::Char('j'), KeyModifiers::NONE, AppAction::SelectNext)]
#[case::vim_k(KeyCode::Char('k'), KeyModifiers::NONE, AppAction::SelectPrev)]
#[case::emacs_ctrl_n(KeyCode::Char('n'), KeyModifiers::CONTROL, AppAction::SelectNext)]
#[case::emacs_ctrl_p(KeyCode::Char('p'), KeyModifiers::CONTROL, AppAction::SelectPrev)]
fn test_handle_key_action_select_popup_navigation(
#[case] key_code: KeyCode,
#[case] modifiers: KeyModifiers,
#[case] expected: AppAction,
) {
let (mut app, _rx) = create_test_app();
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices: vec![],
};
let key = KeyEvent::new(key_code, modifiers);
assert_eq!(app.handle_key(key), Some(expected));
}
#[test]
fn test_handle_key_action_select_enter_with_selection() {
use crate::github::{ActionChoice, ActionType};
let (mut app, _rx) = create_test_app();
let choices = vec![
ActionChoice {
branch: "feat/issue-1".to_string(),
action: ActionType::Implement,
prompt: "First".to_string(),
},
ActionChoice {
branch: "feat/issue-2".to_string(),
action: ActionType::Survey,
prompt: "Second".to_string(),
},
];
app.state = AppState::ActionSelectPopup {
issue_number: 42,
choices,
};
app.action_select_state.list_state.select(Some(1));
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::SelectActionChoice { index: 1 }));
}
#[test]
fn test_confirm_permissions_ctrl_enter_when_yes() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: true,
};
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::ConfirmDangerousPermissions));
}
#[test]
fn test_confirm_permissions_ctrl_enter_when_no() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: false,
};
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
let action = app.handle_key(key);
assert_eq!(action, Some(AppAction::CancelIssueFlow));
}
#[test]
fn test_confirm_permissions_plain_enter_unhandled() {
let (mut app, _rx) = create_test_app();
app.state = AppState::ConfirmPermissions {
branch: "feat/test".to_string(),
prompt: "Test".to_string(),
selected_yes: true,
};
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let action = app.handle_key(key);
assert_eq!(action, None);
}
#[test]
fn test_scroll_offset_preserved_on_output() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.active_idx = 0;
app.scroll_offsets.insert(id, 5);
app.handle_session_event(SessionEvent::Output {
id,
data: b"new line\n".to_vec(),
});
assert_eq!(app.scroll_offsets.get(&id).copied(), Some(5));
}
#[test]
fn test_scroll_follow_mode_default() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.active_idx = 0;
app.handle_session_event(SessionEvent::Output {
id,
data: b"output\n".to_vec(),
});
assert!(app.scroll_offsets.get(&id).copied().unwrap_or(0) == 0);
}
#[test]
fn test_scroll_up_down() {
let (mut app, _rx) = create_test_app();
let id = SessionId::new_v4();
app.sessions.push(SessionSnapshot {
id,
name: "test".to_string(),
status: SessionStatus::Running,
branch: None,
});
app.terminal_buffers
.insert(id, vt100::Parser::new(24, 80, 1000));
app.active_idx = 0;
app.scroll(-3);
assert_eq!(app.scroll_offsets.get(&id).copied(), Some(3));
app.scroll(2);
assert_eq!(app.scroll_offsets.get(&id).copied(), Some(1));
app.scroll(10);
assert_eq!(app.scroll_offsets.get(&id).copied(), Some(0));
}
#[tokio::test]
async fn test_dispatch_show_workspace_selects_cached_issues() {
let (mut app, _rx) = create_test_app();
for i in 1..=3_u32 {
app.issues.push(IssueItem::new(i, format!("Issue {i}"), ""));
}
app.dispatch(AppAction::ShowWorkspacePopup).await.unwrap();
assert_eq!(app.state, AppState::WorkspacePopup);
assert_eq!(app.popup_state.issue_list.selected(), Some(0));
}