use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use team_core::supervisor::AgentState;
use teamctl_ui::app::{self, render_to_buffer, App, Stage};
use teamctl_ui::approvals::test_support::MockApprovalDecider;
use teamctl_ui::approvals::{Approval, Decision};
use teamctl_ui::compose::test_support::MockMessageSender;
use teamctl_ui::data::{AgentInfo, TeamSnapshot};
use teamctl_ui::keysender::test_support::MockKeySender;
use teamctl_ui::keysender::ScrollDirection;
use teamctl_ui::mailbox::test_support::MockMailboxSource;
use teamctl_ui::triptych::{MainLayout, Pane};
pub struct Harness {
pub app: App,
pub sender: MockMessageSender,
pub decider: MockApprovalDecider,
pub mailbox: MockMailboxSource,
pub key_sender: MockKeySender,
}
impl Harness {
pub fn new() -> Self {
std::env::set_var("NO_COLOR", "1");
Self {
app: App::new(),
sender: MockMessageSender::default(),
decider: MockApprovalDecider::default(),
mailbox: MockMailboxSource::default(),
key_sender: MockKeySender::default(),
}
}
pub fn dispatch_key(&mut self, code: KeyCode) {
self.dispatch_key_mods(code, KeyModifiers::NONE);
}
pub fn dispatch_key_mods(&mut self, code: KeyCode, modifiers: KeyModifiers) {
let ev = Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
});
app::handle_event(
&mut self.app,
ev,
&self.decider,
&self.sender,
&self.mailbox,
&self.key_sender,
);
}
pub fn dispatch_mouse(&mut self, kind: crossterm::event::MouseEventKind) {
let ev = Event::Mouse(crossterm::event::MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
app::handle_event(
&mut self.app,
ev,
&self.decider,
&self.sender,
&self.mailbox,
&self.key_sender,
);
}
}
impl Default for Harness {
fn default() -> Self {
Self::new()
}
}
pub fn synth_agent(id: &str, state: AgentState, unread: u32, pending: u32) -> AgentInfo {
let (project, agent) = id.split_once(':').unwrap_or(("p", id));
AgentInfo {
id: id.into(),
agent: agent.into(),
project: project.into(),
tmux_session: format!("t-{}-{}", project, agent),
state,
unread_mail: unread,
pending_approvals: pending,
is_manager: false,
display_name: None,
rate_limit_resets_at: None,
last_activity_at: None,
reports_to: None,
}
}
pub fn fixture_team(team_name: &str, agents: Vec<AgentInfo>) -> TeamSnapshot {
TeamSnapshot {
root: std::path::PathBuf::from("/fixture"),
team_name: team_name.into(),
agents,
channels: Vec::new(),
}
}
#[test]
fn splash_dismisses_on_any_key() {
let mut h = Harness::new();
assert_eq!(h.app.stage, Stage::Splash);
h.dispatch_key(KeyCode::Char(' '));
assert_eq!(h.app.stage, Stage::Triptych);
}
#[test]
fn tab_cycles_focus_through_panes() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert_eq!(h.app.focused_pane, Pane::Roster);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Detail);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
h.dispatch_key(KeyCode::Tab);
assert_eq!(
h.app.focused_pane,
Pane::Roster,
"Tab from Mailbox wraps back to Agents"
);
}
#[test]
fn compose_modal_opens_via_at_key_without_sending() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
h.app.select_next();
h.dispatch_key(KeyCode::Char('@'));
assert_eq!(h.app.stage, Stage::ComposeModal);
assert!(
h.app.compose_target.is_some(),
"compose_target seeded by `@`"
);
assert!(
h.sender.dm_calls.lock().unwrap().is_empty(),
"opening the modal must not trigger a send"
);
assert!(
h.sender.broadcast_calls.lock().unwrap().is_empty(),
"opening the modal must not trigger a broadcast"
);
}
fn type_body(h: &mut Harness, body: &str) {
for c in body.chars() {
h.dispatch_key(KeyCode::Char(c));
}
}
fn dm_compose_setup() -> Harness {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
h.app.select_next(); h
}
#[test]
fn dm_compose_sends_via_send_chord_with_focused_target_and_typed_body() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
assert_eq!(h.app.stage, Stage::ComposeModal);
type_body(&mut h, "ready for review");
h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);
let calls = h.sender.dm_calls.lock().unwrap();
assert_eq!(calls.len(), 1, "exactly one DM should fire");
assert_eq!(calls[0].0, "writing:dev1", "DM target is the focused agent");
assert_eq!(
calls[0].1, "ready for review",
"body matches what was typed"
);
assert_eq!(
h.app.stage,
Stage::Triptych,
"successful send closes the modal"
);
assert!(
h.app.compose_target.is_none(),
"compose target cleared on close"
);
}
#[test]
fn dm_compose_blank_body_surfaces_error_and_does_not_send() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);
assert!(
h.sender.dm_calls.lock().unwrap().is_empty(),
"blank body must not reach the sender"
);
assert_eq!(
h.app.stage,
Stage::ComposeModal,
"modal stays open on blank-body error"
);
assert!(
h.app
.compose_error
.as_deref()
.is_some_and(|e| e.contains("empty")),
"compose_error should explain the no-send: got {:?}",
h.app.compose_error
);
}
#[test]
fn dm_compose_esc_esc_cancels_without_sending() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "draft");
h.dispatch_key(KeyCode::Esc);
h.dispatch_key(KeyCode::Esc);
assert!(
h.sender.dm_calls.lock().unwrap().is_empty(),
"Esc-Esc must not fire the sender"
);
assert_eq!(h.app.stage, Stage::Triptych, "Esc-Esc closes the modal");
assert!(
h.app.compose_target.is_none(),
"compose target cleared on cancel"
);
}
#[test]
fn dm_compose_multi_line_body_is_sent_with_embedded_newline() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "line one");
h.dispatch_key(KeyCode::Enter); type_body(&mut h, "line two");
h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);
let calls = h.sender.dm_calls.lock().unwrap();
assert_eq!(calls.len(), 1, "multi-line send fires exactly once");
assert_eq!(
calls[0].1, "line one\nline two",
"newline preserved in body"
);
}
#[test]
fn dm_compose_target_follows_roster_selection_after_cancel() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
h.dispatch_key(KeyCode::Esc);
h.dispatch_key(KeyCode::Esc);
assert_eq!(h.app.stage, Stage::Triptych);
h.app.select_next();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "ping");
h.dispatch_key_mods(KeyCode::Enter, KeyModifiers::ALT);
let calls = h.sender.dm_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].0, "writing:manager",
"DM follows the new roster selection, not the prior cancelled target"
);
}
fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
let area = buf.area();
let mut out = String::with_capacity((area.width as usize + 1) * area.height as usize);
for y in 0..area.height {
for x in 0..area.width {
let cell = &buf[(area.x + x, area.y + y)];
out.push_str(cell.symbol());
}
out.push('\n');
}
out
}
#[test]
fn ctrl_w_switches_triptych_to_wall() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert_eq!(h.app.layout, MainLayout::Triptych);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
}
#[test]
fn ctrl_w_returns_wall_to_triptych() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Triptych);
}
#[test]
fn ctrl_m_switches_triptych_to_mailbox_first() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert_eq!(h.app.layout, MainLayout::Triptych);
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}
#[test]
fn ctrl_m_returns_mailbox_first_to_triptych() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Triptych);
}
#[test]
fn ctrl_m_from_wall_switches_to_mailbox_first() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}
#[test]
fn ctrl_w_from_mailbox_first_switches_to_wall() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
}
#[test]
fn ctrl_w_with_mailbox_pane_focused_still_switches_layout() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
}
#[test]
fn compose_modal_blocks_layout_switch() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Char('@'));
assert_eq!(h.app.stage, Stage::ComposeModal);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(
h.app.layout,
MainLayout::Triptych,
"compose modal owns input — layout must not flip underneath"
);
}
#[test]
fn quit_confirm_overlay_blocks_layout_switch() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Char('q'));
assert_eq!(h.app.stage, Stage::QuitConfirm);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(
h.app.layout,
MainLayout::Triptych,
"quit-confirm overlay must not be bypassed by layout chord"
);
}
#[test]
fn rendered_buffer_reflects_wall_after_ctrl_w() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::Wall);
let s = buffer_to_string(&render_to_buffer(&h.app, 120, 30));
assert!(
!s.contains("AGENTS"),
"Wall buffer must not render the Triptych AGENTS pane title:\n{s}"
);
assert!(
!s.contains("MAILBOX"),
"Wall buffer must not render the Triptych MAILBOX pane title:\n{s}"
);
}
#[test]
fn rendered_buffer_reflects_mailbox_first_after_ctrl_m() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('m'), KeyModifiers::CONTROL);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
let s = buffer_to_string(&render_to_buffer(&h.app, 120, 30));
assert!(
!s.contains("AGENTS"),
"MailboxFirst buffer must not render the Triptych AGENTS pane title:\n{s}"
);
assert!(
!s.contains("DETAIL"),
"MailboxFirst buffer must not render the Triptych DETAIL pane title:\n{s}"
);
}
#[test]
fn ctrl_shift_w_still_toggles_wall_layout() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(
KeyCode::Char('W'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.layout, MainLayout::Wall);
}
#[test]
fn ctrl_shift_m_still_toggles_mailbox_first_layout() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key_mods(
KeyCode::Char('M'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.layout, MainLayout::MailboxFirst);
}
#[test]
fn ctrl_w_with_detail_split_open_arms_chord_not_layout() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.dispatch_key_mods(KeyCode::Char('|'), KeyModifiers::CONTROL);
assert_eq!(h.app.detail_splits.len(), 1);
h.dispatch_key_mods(KeyCode::Char('w'), KeyModifiers::CONTROL);
assert_eq!(
h.app.pending_chord,
Some(KeyCode::Char('w')),
"Ctrl+W with splits arms the chord prefix"
);
assert_eq!(
h.app.layout,
MainLayout::Triptych,
"Ctrl+W with splits must not flip layout (chord wins)"
);
}
fn approval(id: i64, action: &str, summary: &str) -> Approval {
Approval {
id,
project_id: "writing".into(),
agent_id: "writing:manager".into(),
action: action.into(),
summary: summary.into(),
payload_json: String::new(),
}
}
fn approvals_setup(approvals: Vec<Approval>) -> Harness {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.app.replace_approvals(approvals);
h
}
#[test]
fn a_key_enters_approvals_modal_when_pending_non_empty() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
assert_eq!(h.app.stage, Stage::ApprovalsModal);
assert_eq!(h.app.selected_approval, 0);
}
#[test]
fn a_key_is_no_op_when_no_pending_approvals() {
let mut h = approvals_setup(vec![]);
h.dispatch_key(KeyCode::Char('a'));
assert_eq!(h.app.stage, Stage::Triptych);
}
#[test]
fn y_approves_focused_row_via_decider() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Char('y'));
let calls = h.decider.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], (7, Decision::Approve, String::new()));
assert_eq!(
h.app.stage,
Stage::Triptych,
"queue emptied — modal auto-closes"
);
}
#[test]
fn capital_y_also_approves_focused_row() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key_mods(KeyCode::Char('Y'), KeyModifiers::SHIFT);
let calls = h.decider.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, Decision::Approve);
}
#[test]
fn shift_n_denies_focused_row_via_decider() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key_mods(KeyCode::Char('N'), KeyModifiers::SHIFT);
let calls = h.decider.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0], (7, Decision::Deny, String::new()));
}
#[test]
fn lowercase_n_does_not_deny_destructive_chord_is_shift_gated() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Char('n'));
assert!(
h.decider.calls.lock().unwrap().is_empty(),
"lowercase n must not reach the decider"
);
assert_eq!(
h.app.stage,
Stage::ApprovalsModal,
"modal stays open on no-op key"
);
}
#[test]
fn j_advances_focused_approval_and_k_retreats() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
approval(9, "merge", "third"),
]);
h.dispatch_key(KeyCode::Char('a'));
assert_eq!(h.app.selected_approval, 0);
h.dispatch_key(KeyCode::Char('j'));
assert_eq!(h.app.selected_approval, 1);
h.dispatch_key(KeyCode::Char('j'));
assert_eq!(h.app.selected_approval, 2);
h.dispatch_key(KeyCode::Char('k'));
assert_eq!(h.app.selected_approval, 1);
}
#[test]
fn down_and_up_arrows_navigate_like_j_and_k() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Down);
assert_eq!(h.app.selected_approval, 1);
h.dispatch_key(KeyCode::Up);
assert_eq!(h.app.selected_approval, 0);
}
#[test]
fn j_wraps_at_end_of_queue() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Char('j'));
assert_eq!(h.app.selected_approval, 1);
h.dispatch_key(KeyCode::Char('j'));
assert_eq!(
h.app.selected_approval, 0,
"j from last row wraps back to first"
);
}
#[test]
fn k_wraps_at_start_of_queue() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
]);
h.dispatch_key(KeyCode::Char('a'));
assert_eq!(h.app.selected_approval, 0);
h.dispatch_key(KeyCode::Char('k'));
assert_eq!(h.app.selected_approval, 1, "k from first row wraps to last");
}
#[test]
fn esc_closes_approvals_modal_without_decision() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Esc);
assert_eq!(h.app.stage, Stage::Triptych);
assert!(h.decider.calls.lock().unwrap().is_empty());
assert_eq!(h.app.pending_approvals.len(), 1, "queue intact after Esc");
}
#[test]
fn q_closes_approvals_modal_without_decision() {
let mut h = approvals_setup(vec![approval(7, "publish", "post the brief")]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Char('q'));
assert_eq!(h.app.stage, Stage::Triptych);
assert!(h.decider.calls.lock().unwrap().is_empty());
}
#[test]
fn navigate_then_approve_routes_correct_row_id_to_decider() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
approval(9, "merge", "third"),
]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key(KeyCode::Char('j'));
assert_eq!(h.app.selected_approval, 1);
h.dispatch_key(KeyCode::Char('y'));
let calls = h.decider.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(
calls[0].0, 8,
"decider received the focused-row id (middle), not the head"
);
assert_eq!(calls[0].1, Decision::Approve);
assert_eq!(
h.app.pending_approvals.len(),
2,
"approved row removed from local queue optimistically"
);
assert_eq!(
h.app.stage,
Stage::ApprovalsModal,
"modal stays open while queue still has rows"
);
}
#[test]
fn deny_followed_by_approve_routes_each_to_distinct_rows() {
let mut h = approvals_setup(vec![
approval(7, "publish", "first"),
approval(8, "deploy", "second"),
]);
h.dispatch_key(KeyCode::Char('a'));
h.dispatch_key_mods(KeyCode::Char('N'), KeyModifiers::SHIFT);
h.dispatch_key(KeyCode::Char('y'));
let calls = h.decider.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], (7, Decision::Deny, String::new()));
assert_eq!(calls[1], (8, Decision::Approve, String::new()));
assert_eq!(
h.app.stage,
Stage::Triptych,
"queue empty after both decisions"
);
}
use teamctl_ui::app::refresh_mailbox;
use teamctl_ui::mailbox::{MailboxTab, MessageRow};
fn mb_row(id: i64, sender: &str, recipient: &str, text: &str) -> MessageRow {
MessageRow {
id,
sender: sender.into(),
recipient: recipient.into(),
text: text.into(),
sent_at: 0.0,
}
}
#[test]
fn arrow_right_cycles_mailbox_tabs_forward_when_mailbox_focused() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
assert_eq!(h.app.mailbox_tab, MailboxTab::Inbox);
h.dispatch_key(KeyCode::Right);
assert_eq!(h.app.mailbox_tab, MailboxTab::Sent);
h.dispatch_key(KeyCode::Right);
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"`→` from Sent wraps to Inbox"
);
}
#[test]
fn arrow_left_cycles_mailbox_tabs_backward_when_mailbox_focused() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
h.dispatch_key(KeyCode::Left);
assert_eq!(h.app.mailbox_tab, MailboxTab::Sent);
h.dispatch_key(KeyCode::Left);
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"`←` from Sent wraps back to Inbox"
);
}
#[test]
fn arrow_keys_are_no_op_when_mailbox_not_focused() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert_eq!(h.app.focused_pane, Pane::Roster);
h.dispatch_key(KeyCode::Right);
h.dispatch_key(KeyCode::Left);
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"←/→ must not cycle tabs from Roster focus"
);
}
#[test]
fn brackets_no_longer_cycle_mailbox_tabs_after_t124() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
let initial = h.app.mailbox_tab;
h.dispatch_key(KeyCode::Char(']'));
h.dispatch_key(KeyCode::Char('['));
assert_eq!(
h.app.mailbox_tab, initial,
"`[` / `]` must no longer cycle mailbox tabs (T-124 hard-swap)",
);
}
#[test]
fn tab_into_mailbox_preserves_active_tab() {
let mut h = Harness::new();
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Right);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
assert_eq!(h.app.mailbox_tab, MailboxTab::Sent);
h.dispatch_key(KeyCode::Tab); assert_eq!(h.app.focused_pane, Pane::Roster);
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Tab);
assert_eq!(h.app.focused_pane, Pane::Mailbox);
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Sent,
"active mailbox tab survives the Tab-walk away and back"
);
}
#[test]
fn switching_focused_agent_resets_mailbox_buffers() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
h.app.mailbox.extend(
MailboxTab::Inbox,
vec![mb_row(7, "writing:dev1", "writing:manager", "ping")],
);
assert_eq!(h.app.mailbox.inbox.len(), 1);
assert_eq!(h.app.mailbox.inbox_after, 7);
h.app.select_next();
assert!(
h.app.mailbox.inbox.is_empty(),
"mailbox buffer cleared on agent switch"
);
assert_eq!(
h.app.mailbox.inbox_after, 0,
"inbox cursor reset to 0 on agent switch"
);
}
#[test]
fn refresh_mailbox_fans_out_to_three_filters_with_recorded_args() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
refresh_mailbox(&mut h.app, &h.mailbox);
assert_eq!(
*h.mailbox.inbox_calls.lock().unwrap(),
vec![("writing:manager".into(), 0_i64)],
"inbox queried for the focused agent's id"
);
assert_eq!(
*h.mailbox.channel_calls.lock().unwrap(),
vec![("writing:manager".into(), 0_i64)],
"channel_feed queried for the focused agent's id (membership-scoped server-side)"
);
assert_eq!(
*h.mailbox.wire_calls.lock().unwrap(),
vec![("writing".into(), 0_i64)],
"wire queried for the project id, NOT the agent id"
);
}
#[test]
fn refresh_mailbox_with_seeded_rows_extends_buffers_and_advances_cursors() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.mailbox.inbox_rows = vec![
mb_row(11, "writing:dev1", "writing:manager", "ready"),
mb_row(12, "writing:dev2", "writing:manager", "merged"),
];
h.mailbox.wire_rows = vec![mb_row(20, "user:cli", "channel:writing:all", "release cut")];
refresh_mailbox(&mut h.app, &h.mailbox);
assert_eq!(h.app.mailbox.inbox.len(), 2);
assert_eq!(h.app.mailbox.inbox_after, 12);
assert_eq!(h.app.mailbox.wire.len(), 1);
assert_eq!(h.app.mailbox.wire_after, 20);
assert!(h.app.mailbox.channel.is_empty());
assert_eq!(h.app.mailbox.channel_after, 0);
}
#[test]
fn refresh_mailbox_no_op_when_no_agent_focused() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert!(h.app.selected_agent.is_none());
refresh_mailbox(&mut h.app, &h.mailbox);
assert!(h.mailbox.inbox_calls.lock().unwrap().is_empty());
assert!(h.mailbox.channel_calls.lock().unwrap().is_empty());
assert!(h.mailbox.wire_calls.lock().unwrap().is_empty());
}
fn harness_with_two_splits() -> Harness {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"writing",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
h.app.add_detail_split_vertical();
h.app.add_detail_split_vertical();
assert_eq!(h.app.detail_splits.len(), 2);
h
}
#[test]
fn ctrl_h_lowercase_cycles_split_prev() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(KeyCode::Char('h'), KeyModifiers::CONTROL);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_shift_h_cycles_split_prev() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(
KeyCode::Char('H'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_k_lowercase_cycles_split_prev() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(KeyCode::Char('k'), KeyModifiers::CONTROL);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_shift_k_cycles_split_prev() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(
KeyCode::Char('K'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_l_lowercase_cycles_split_next() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(KeyCode::Char('l'), KeyModifiers::CONTROL);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_shift_l_cycles_split_next() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(
KeyCode::Char('L'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_j_lowercase_cycles_split_next() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(KeyCode::Char('j'), KeyModifiers::CONTROL);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_shift_j_cycles_split_next() {
let mut h = harness_with_two_splits();
h.app.selected_split = 0;
h.dispatch_key_mods(
KeyCode::Char('J'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.selected_split, 1);
}
#[test]
fn ctrl_q_uppercase_closes_focused_split() {
let mut h = harness_with_two_splits();
h.dispatch_key_mods(
KeyCode::Char('Q'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert_eq!(h.app.detail_splits.len(), 1);
}
#[test]
fn ctrl_q_lowercase_closes_focused_split() {
let mut h = harness_with_two_splits();
h.dispatch_key_mods(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert_eq!(h.app.detail_splits.len(), 1);
}
#[test]
fn tab_in_compose_modal_opens_attach_input_overlay() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@')); type_body(&mut h, "before-tab");
h.dispatch_key(KeyCode::Tab);
assert!(
h.app.compose_attach_input_open,
"Tab opens the attach-input overlay"
);
assert!(
h.app.compose_attach_buffer.is_empty(),
"buffer starts empty"
);
assert_eq!(
h.app.compose_editor.body(),
"before-tab",
"Tab does not reach the editor"
);
}
#[test]
fn typing_in_attach_overlay_fills_buffer_not_editor() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "draft body");
h.dispatch_key(KeyCode::Tab);
for c in "/tmp/file.md".chars() {
h.dispatch_key(KeyCode::Char(c));
}
assert_eq!(h.app.compose_attach_buffer, "/tmp/file.md");
assert_eq!(h.app.compose_editor.body(), "draft body");
}
#[test]
fn enter_in_attach_overlay_appends_marker_and_closes_overlay() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "see attached");
h.dispatch_key(KeyCode::Tab);
for c in "/home/alice/notes.md".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Enter);
assert!(!h.app.compose_attach_input_open, "overlay closes on Enter");
assert_eq!(h.app.compose_attach_buffer, "");
let body = h.app.compose_editor.body();
assert!(
body.contains("see attached"),
"original body preserved: {body}"
);
assert!(
body.contains("📎 attachment: /home/alice/notes.md"),
"marker appended: {body}"
);
assert!(
body.lines()
.any(|l| l.trim() == "📎 attachment: /home/alice/notes.md"),
"marker on its own line: {body:?}"
);
}
#[test]
fn esc_in_attach_overlay_cancels_back_to_editor() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "draft");
h.dispatch_key(KeyCode::Tab);
for c in "/tmp/x".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Esc);
assert!(!h.app.compose_attach_input_open, "overlay dismissed");
assert_eq!(h.app.compose_attach_buffer, "", "buffer cleared on cancel");
assert_eq!(h.app.stage, Stage::ComposeModal, "compose modal still open");
assert!(
!h.app.compose_editor.body().contains("📎"),
"no marker appended on cancel"
);
}
#[test]
fn enter_with_empty_buffer_closes_overlay_without_appending() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "body");
h.dispatch_key(KeyCode::Tab);
h.dispatch_key(KeyCode::Enter);
assert!(!h.app.compose_attach_input_open);
assert_eq!(h.app.compose_editor.body(), "body");
assert!(
!h.app.compose_editor.body().contains("📎"),
"no marker on empty confirm"
);
}
#[test]
fn backspace_in_attach_overlay_pops_buffer() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
h.dispatch_key(KeyCode::Tab);
for c in "/tmp/abc".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Backspace);
h.dispatch_key(KeyCode::Backspace);
assert_eq!(h.app.compose_attach_buffer, "/tmp/a");
}
#[test]
fn multiple_attach_confirms_append_separate_marker_lines() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
type_body(&mut h, "two files");
h.dispatch_key(KeyCode::Tab);
for c in "/home/alice/a.md".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Enter);
h.dispatch_key(KeyCode::Tab);
for c in "/home/alice/b.md".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Enter);
let body = h.app.compose_editor.body();
let marker_lines: Vec<&str> = body
.lines()
.filter(|l| l.trim_start().starts_with("📎 attachment: "))
.collect();
assert_eq!(
marker_lines.len(),
2,
"two confirms produce two markers: {body:?}"
);
assert!(
marker_lines.iter().any(|l| l.contains("a.md")),
"first marker present: {body:?}"
);
assert!(
marker_lines.iter().any(|l| l.contains("b.md")),
"second marker present: {body:?}"
);
}
#[test]
fn closing_compose_modal_clears_attach_overlay_state() {
let mut h = dm_compose_setup();
h.dispatch_key(KeyCode::Char('@'));
h.dispatch_key(KeyCode::Tab);
for c in "/tmp/leak".chars() {
h.dispatch_key(KeyCode::Char(c));
}
h.dispatch_key(KeyCode::Esc);
h.dispatch_key(KeyCode::Esc);
h.dispatch_key(KeyCode::Esc);
assert_ne!(h.app.stage, Stage::ComposeModal, "compose modal closed");
h.app.select_next();
h.dispatch_key(KeyCode::Char('@'));
assert!(!h.app.compose_attach_input_open);
assert_eq!(h.app.compose_attach_buffer, "");
}
#[test]
fn mouse_wheel_in_detail_pane_forwards_scroll_to_focused_agent_session() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"t",
vec![synth_agent("t:m", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab); assert_eq!(h.app.focused_pane, Pane::Detail);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
let calls = h.key_sender.scroll_calls.lock().unwrap();
assert_eq!(calls.len(), 3, "every wheel tick forwards exactly once");
let session = "t-t-m"; assert_eq!(
*calls,
vec![
(session.into(), ScrollDirection::Up),
(session.into(), ScrollDirection::Up),
(session.into(), ScrollDirection::Down),
]
);
assert!(h.key_sender.calls.lock().unwrap().is_empty());
}
#[test]
fn mouse_wheel_in_detail_pane_is_silent_when_no_agent_selected() {
let mut h = Harness::new();
h.app.dismiss_splash();
assert_eq!(h.app.focused_pane, Pane::Roster);
h.dispatch_key(KeyCode::Tab); assert!(h.app.selected_agent_id().is_none());
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
}
#[test]
fn mouse_wheel_in_roster_steps_agent_selection() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"t",
vec![
synth_agent("t:a", AgentState::Running, 0, 0),
synth_agent("t:b", AgentState::Running, 0, 0),
synth_agent("t:c", AgentState::Running, 0, 0),
],
));
h.app.dismiss_splash();
assert_eq!(h.app.focused_pane, Pane::Roster);
assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:a"));
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:b"));
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:c"));
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
assert_eq!(h.app.selected_agent_id().as_deref(), Some("t:b"));
assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
assert!(h.key_sender.calls.lock().unwrap().is_empty());
}
#[test]
fn mouse_wheel_in_mailbox_is_a_noop_for_v1() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"t",
vec![synth_agent("t:m", AgentState::Running, 0, 0)],
));
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Tab); h.dispatch_key(KeyCode::Tab); assert_eq!(h.app.focused_pane, Pane::Mailbox);
let tab_before = h.app.mailbox_tab;
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
assert_eq!(
h.app.mailbox_tab, tab_before,
"mailbox wheel must not cycle tabs in v1 — arrows own that"
);
assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
assert!(h.key_sender.calls.lock().unwrap().is_empty());
}
#[test]
fn mouse_wheel_in_non_triptych_stages_is_silent() {
let mut h = Harness::new();
h.app.replace_team(fixture_team(
"t",
vec![synth_agent("t:m", AgentState::Running, 0, 0)],
));
assert_eq!(h.app.stage, Stage::Splash);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollUp);
assert_eq!(
h.app.stage,
Stage::Splash,
"wheel must not dismiss the splash stage"
);
assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
h.app.dismiss_splash();
h.dispatch_key(KeyCode::Char('@'));
assert_eq!(h.app.stage, Stage::ComposeModal);
h.dispatch_mouse(crossterm::event::MouseEventKind::ScrollDown);
assert_eq!(h.app.stage, Stage::ComposeModal, "modal stays open");
assert!(h.key_sender.scroll_calls.lock().unwrap().is_empty());
}