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::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,
}
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(),
}
}
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,
);
}
}
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,
}
}
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 Roster"
);
}
#[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("ROSTER"),
"Wall buffer must not render the Triptych ROSTER 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("ROSTER"),
"MailboxFirst buffer must not render the Triptych ROSTER 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 bracket_chord_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::Char(']'));
assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);
h.dispatch_key(KeyCode::Char(']'));
assert_eq!(h.app.mailbox_tab, MailboxTab::Wire);
h.dispatch_key(KeyCode::Char(']'));
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"`]` from Wire wraps to Inbox"
);
}
#[test]
fn bracket_chord_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::Char('['));
assert_eq!(h.app.mailbox_tab, MailboxTab::Wire);
h.dispatch_key(KeyCode::Char('['));
assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);
h.dispatch_key(KeyCode::Char('['));
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"`[` from Channel wraps back to Inbox"
);
}
#[test]
fn bracket_chord_is_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::Char(']'));
h.dispatch_key(KeyCode::Char('['));
assert_eq!(
h.app.mailbox_tab,
MailboxTab::Inbox,
"bracket chords must not cycle tabs from Roster focus"
);
}
#[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::Char(']'));
assert_eq!(h.app.focused_pane, Pane::Mailbox);
assert_eq!(h.app.mailbox_tab, MailboxTab::Channel);
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::Channel,
"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);
}