use ratatui::buffer::Buffer;
use team_core::supervisor::AgentState;
use teamctl_ui::app::{render_to_buffer, App, Stage};
use teamctl_ui::data::{AgentInfo, TeamSnapshot};
use teamctl_ui::triptych::Pane;
fn buffer_to_string(buf: &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
}
fn fresh_app() -> App {
std::env::set_var("NO_COLOR", "1");
std::env::set_var("TZ", "UTC");
App::new()
}
#[test]
fn splash_layout_at_120x30() {
let app = fresh_app();
assert_eq!(app.stage, Stage::Splash);
let buf = render_to_buffer(&app, 120, 30);
let mut settings = insta::Settings::clone_current();
settings.add_filter(r"v\d+\.\d+\.\d+", "v[VERSION]");
settings.bind(|| {
insta::assert_snapshot!("splash_120x30", buffer_to_string(&buf));
});
}
#[test]
fn triptych_empty_state_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("triptych_empty_120x30", buffer_to_string(&buf));
}
#[test]
fn triptych_focus_ring_follows_focused_pane() {
let mut app = fresh_app();
app.dismiss_splash();
app.cycle_focus(); assert_eq!(app.focused_pane, Pane::Detail);
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("triptych_detail_focused_120x30", buffer_to_string(&buf));
}
#[test]
fn quit_confirm_overlay_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
app.enter_quit_confirm();
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("quit_confirm_120x30", buffer_to_string(&buf));
}
#[test]
fn statusline_renders_tutorial_hint_at_right() {
let mut app = fresh_app();
app.dismiss_splash();
let buf = render_to_buffer(&app, 80, 10);
let s = buffer_to_string(&buf);
let lines: Vec<&str> = s.lines().collect();
let statusline = lines
.get(lines.len().saturating_sub(2))
.copied()
.expect("buffer not empty");
assert!(
statusline.contains("t tutorial"),
"statusline missing tutorial hint at 80 cols: {statusline:?}"
);
}
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,
}
}
fn synth_worker_reporting_to(id: &str, manager_short_name: &str, state: AgentState) -> AgentInfo {
let mut info = synth_agent(id, state, 0, 0);
info.reports_to = Some(manager_short_name.into());
info
}
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 agents_panel_renders_glyphs_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
let mut manager = synth_agent("writing:manager", AgentState::Running, 0, 0);
manager.last_activity_at = Some(-1.0);
app.replace_team(fixture_team(
"writing-team",
vec![
manager,
synth_agent("writing:worker-1", AgentState::Running, 3, 0),
synth_agent("writing:worker-2", AgentState::Running, 0, 1),
synth_agent("writing:critic", AgentState::Stopped, 0, 0),
synth_agent("writing:scout", AgentState::Unknown, 0, 0),
],
));
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("agents_panel_with_glyphs_120x30", buffer_to_string(&buf));
}
#[test]
fn agents_panel_renders_reports_to_tree_at_120x30() {
use teamctl_ui::data::into_tree_dfs_order;
let mut app = fresh_app();
app.dismiss_splash();
let mut mgr_a = synth_agent("writing:mgr-a", AgentState::Running, 0, 0);
mgr_a.is_manager = true;
let mut mgr_b = synth_agent("writing:mgr-b", AgentState::Running, 0, 0);
mgr_b.is_manager = true;
let agents = into_tree_dfs_order(vec![
mgr_a,
mgr_b,
synth_worker_reporting_to("writing:scribe", "mgr-a", AgentState::Running),
synth_worker_reporting_to("writing:critic", "mgr-a", AgentState::Stopped),
synth_worker_reporting_to("writing:scout", "mgr-a", AgentState::Unknown),
]);
app.replace_team(fixture_team("writing-team", agents));
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!(
"agents_panel_with_reports_to_tree_120x30",
buffer_to_string(&buf)
);
}
#[test]
fn detail_pane_streams_buffer_for_selected_agent() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:worker-1", AgentState::Running, 0, 0),
],
));
app.set_detail_buffer(
[
"[12:00] user: draft a release plan",
"[12:01] assistant: Sure — I'll outline the cascade.",
"[12:01] tool: teamctl validate",
]
.iter()
.map(|s| s.to_string())
.collect(),
);
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("detail_streams_120x30", buffer_to_string(&buf));
}
fn message(id: i64, sender: &str, recipient: &str, text: &str) -> teamctl_ui::mailbox::MessageRow {
teamctl_ui::mailbox::MessageRow {
id,
sender: sender.into(),
recipient: recipient.into(),
text: text.into(),
sent_at: 0.0,
}
}
#[test]
fn mailbox_pane_renders_inbox_tab_with_rows() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.mailbox.extend(
teamctl_ui::mailbox::MailboxTab::Inbox,
vec![
message(11, "writing:dev1", "writing:manager", "ready for review"),
message(12, "user:telegram", "writing:manager", "any blockers?"),
],
);
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("mailbox_inbox_120x30", buffer_to_string(&buf));
}
fn approval(id: i64, action: &str, summary: &str) -> teamctl_ui::approvals::Approval {
teamctl_ui::approvals::Approval {
id,
project_id: "writing".into(),
agent_id: "writing:manager".into(),
action: action.into(),
summary: summary.into(),
payload_json: String::new(),
}
}
#[test]
fn approvals_stripe_renders_when_pending() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.replace_approvals(vec![
approval(7, "publish", "post the morning brief"),
approval(8, "deploy", "ship docs"),
]);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let first_line = s.lines().next().expect("non-empty buffer");
assert!(
first_line.contains("approvals: 2 pending") && first_line.contains("`a` to review"),
"stripe missing or malformed: {first_line:?}"
);
insta::assert_snapshot!("approvals_stripe_120x30", s);
}
#[test]
fn approvals_modal_renders_action_summary_and_hint() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.replace_approvals(vec![approval(
7,
"publish",
"Post the morning brief to r/yourcity",
)]);
app.enter_approvals_modal();
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("approvals · 1/1"), "modal title missing");
assert!(s.contains("publish"), "action missing");
assert!(s.contains("[y] approve"), "action hint missing");
assert!(
s.contains("[Shift-N] deny"),
"deny hint must signal Shift-gate"
);
insta::assert_snapshot!("approvals_modal_120x30", s);
}
#[test]
fn approvals_modal_multi_row_cursor_advanced_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.replace_approvals(vec![
approval(7, "publish", "Post the morning brief to r/yourcity"),
approval(8, "deploy", "Ship docs site to production"),
approval(9, "merge", "Land PR #123 to main"),
]);
app.enter_approvals_modal();
app.cycle_approval_next();
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.contains("approvals · 2/3"),
"modal title must show 2/3 after one j: {s}"
);
assert!(
s.contains("Ship docs site to production"),
"focused row summary must be the second row: {s}"
);
insta::assert_snapshot!("approvals_modal_multi_row_at_2of3_120x30", s);
}
#[test]
fn compose_modal_renders_target_body_and_attach_footer() {
let mut app = fresh_app();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
app.dismiss_splash();
app.select_next(); app.enter_compose_dm_for_focused();
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let press = |code: KeyCode| KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
for c in "line one".chars() {
app.compose_editor.apply_key(press(KeyCode::Char(c)));
}
app.compose_editor.apply_key(press(KeyCode::Enter));
for c in "line two".chars() {
app.compose_editor.apply_key(press(KeyCode::Char(c)));
}
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("→ writing:dev1"), "title missing: {s}");
assert!(
s.contains("line one") && s.contains("line two"),
"body missing"
);
assert!(s.contains("Tab attach"), "footer attach hint missing: {s}");
assert!(
!s.contains("TODO #32"),
"footer should not carry the TODO once the affordance ships: {s}"
);
insta::assert_snapshot!("compose_modal_120x30", s);
}
#[test]
fn detail_pane_two_vertical_splits_renders_side_by_side() {
use teamctl_ui::app::SplitOrientation;
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
app.set_detail_buffer(["[12:00] focused".into()].to_vec());
app.detail_splits = vec![("writing:dev1".into(), SplitOrientation::Vertical)];
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("writing:manager"), "focused agent title missing");
assert!(s.contains("writing:dev1"), "split agent title missing");
insta::assert_snapshot!("detail_two_vertical_splits_120x30", s);
}
#[test]
fn detail_pane_two_horizontal_splits_stack_top_to_bottom() {
use teamctl_ui::app::SplitOrientation;
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
],
));
app.set_detail_buffer(["[12:00] focused".into()].to_vec());
app.detail_splits = vec![("writing:dev1".into(), SplitOrientation::Horizontal)];
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("writing:manager"));
assert!(s.contains("writing:dev1"));
insta::assert_snapshot!("detail_two_horizontal_splits_120x30", s);
}
#[test]
fn detail_pane_four_split_mixed_grid_renders() {
use teamctl_ui::app::SplitOrientation;
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:dev1", AgentState::Running, 0, 0),
synth_agent("writing:dev2", AgentState::Running, 0, 0),
synth_agent("writing:critic", AgentState::Running, 0, 0),
],
));
app.set_detail_buffer(["[12:00] focused".into()].to_vec());
app.detail_splits = vec![
("writing:dev1".into(), SplitOrientation::Vertical),
("writing:dev2".into(), SplitOrientation::Horizontal),
("writing:critic".into(), SplitOrientation::Vertical),
];
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
for must in [
"writing:manager",
"writing:dev1",
"writing:dev2",
"writing:critic",
] {
assert!(s.contains(must), "missing split title: {must}");
}
insta::assert_snapshot!("detail_four_split_mixed_120x30", s);
}
#[test]
fn render_at_minimum_terminal_does_not_panic() {
let mut app = fresh_app();
app.dismiss_splash();
let _ = render_to_buffer(&app, 20, 8);
}
#[test]
fn wall_layout_renders_tile_grid_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:worker-1", AgentState::Running, 0, 0),
synth_agent("writing:worker-2", AgentState::Running, 0, 0),
synth_agent("writing:critic", AgentState::Stopped, 0, 0),
],
));
app.toggle_wall_layout();
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("wall_layout_120x30", buffer_to_string(&buf));
}
fn agent_with_label(id: &str, display: &str, state: AgentState) -> AgentInfo {
let mut info = synth_agent(id, state, 0, 0);
info.display_name = Some(display.into());
info
}
#[test]
fn roster_renders_display_name_when_set() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
agent_with_label("writing:manager", "Manager (Lead)", AgentState::Running),
synth_agent("writing:worker-1", AgentState::Running, 0, 0),
],
));
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.contains("Manager (Lead)"),
"roster missing display_name `Manager (Lead)`: {s}"
);
assert!(
s.contains("worker-1"),
"roster missing short-form fallback for agent without display_name: {s}"
);
}
#[test]
fn detail_header_renders_display_name_when_set() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![agent_with_label(
"writing:manager",
"Manager (Lead)",
AgentState::Running,
)],
));
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("DETAIL"), "detail pane not rendered: {s}");
assert!(
s.contains("Manager (Lead)"),
"detail title missing display_name `Manager (Lead)`: {s}"
);
}
#[test]
fn wall_tile_title_renders_display_name_when_set() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
agent_with_label("writing:manager", "Manager (Lead)", AgentState::Running),
synth_agent("writing:worker-1", AgentState::Running, 0, 0),
synth_agent("writing:worker-2", AgentState::Running, 0, 0),
synth_agent("writing:critic", AgentState::Stopped, 0, 0),
],
));
app.toggle_wall_layout();
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.contains("Manager (Lead)"),
"wall tile title missing display_name `Manager (Lead)`: {s}"
);
assert!(
s.contains("writing:worker-1"),
"wall tile title missing canonical-id fallback for agent without display_name: {s}"
);
}
#[test]
fn mailbox_first_layout_renders_channel_focused_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![
synth_agent("writing:manager", AgentState::Running, 0, 0),
synth_agent("writing:worker-1", AgentState::Running, 0, 0),
],
));
app.toggle_mailbox_first_layout();
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("mailbox_first_layout_120x30", buffer_to_string(&buf));
}
#[test]
fn mailbox_pane_inbox_folds_inbound_channel_and_wire() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.mailbox.agent_id = "writing:manager".to_string();
app.mailbox.extend(
teamctl_ui::mailbox::MailboxTab::Inbox,
vec![message(
11,
"writing:dev1",
"writing:manager",
"ready for review",
)],
);
app.mailbox.extend(
teamctl_ui::mailbox::MailboxTab::Channel,
vec![
message(12, "writing:critic", "channel:writing:devs", "looks tight"),
message(13, "writing:manager", "channel:writing:devs", "merging now"),
],
);
app.mailbox.extend(
teamctl_ui::mailbox::MailboxTab::Wire,
vec![message(
14,
"user:cli",
"channel:writing:all",
"0.7.1 release cut · CHANGELOG updated",
)],
);
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("mailbox_inbox_folded_120x30", buffer_to_string(&buf));
}
#[test]
fn mailbox_pane_renders_sent_tab_with_rows() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.cycle_focus();
app.cycle_focus();
app.cycle_mailbox_tab(); app.mailbox.extend(
teamctl_ui::mailbox::MailboxTab::Sent,
vec![
message(
41,
"writing:manager",
"writing:dev1",
"T-079 needs the wave-3 split first",
),
message(
42,
"writing:manager",
"channel:writing:devs",
"blocking on review for #88",
),
message(
43,
"writing:manager",
"user:telegram",
"all 5 PRs merged · 0.7.1 ready",
),
],
);
let buf = render_to_buffer(&app, 120, 30);
insta::assert_snapshot!("mailbox_sent_with_rows_120x30", buffer_to_string(&buf));
}
#[test]
fn stream_keys_mode_renders_banner_and_pane_marker() {
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.cycle_focus(); app.enter_stream_keys();
assert_eq!(app.stage, Stage::StreamKeys);
let buf = render_to_buffer(&app, 120, 30);
let rendered = buffer_to_string(&buf);
assert!(
rendered.contains("STREAM-KEYS → writing:manager"),
"statusline banner missing the target id"
);
assert!(
rendered.contains("Ctrl+E to exit"),
"statusline banner missing the exit hint"
);
assert!(
rendered.contains("[STREAM-KEYS]"),
"detail pane title missing the stream-mode tag"
);
insta::assert_snapshot!("stream_keys_banner_120x30", rendered);
}
#[test]
fn status_bar_renders_path_left_and_metrics_right_at_120x30() {
let mut app = fresh_app();
app.dismiss_splash();
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
last_line.contains("/tmp/teamctl-fixture/.team"),
"status bar missing team-root path at 120 cols: {last_line:?}"
);
assert!(
last_line.contains("CPU "),
"status bar missing CPU label: {last_line:?}"
);
assert!(
last_line.contains("RAM "),
"status bar missing RAM label: {last_line:?}"
);
}
#[test]
fn status_bar_truncates_path_when_narrow_keeps_metrics_visible() {
let mut app = fresh_app();
app.dismiss_splash();
app.team.root = std::path::PathBuf::from(
"/home/operator/very/long/nested/project/path/teamctl-deep-nest/.team",
);
let buf = render_to_buffer(&app, 100, 20);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
last_line.contains(".team"),
"narrow status bar dropped the basename: {last_line:?}"
);
assert!(
last_line.contains("CPU "),
"narrow status bar dropped CPU label: {last_line:?}"
);
}
#[test]
fn status_bar_elides_metrics_when_too_narrow_for_both_slots() {
let mut app = fresh_app();
app.dismiss_splash();
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let buf = render_to_buffer(&app, 30, 20);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
last_line.contains(".team"),
"narrow status bar dropped the basename: {last_line:?}"
);
assert!(
!last_line.contains("CPU "),
"expected CPU to elide on 30-col width: {last_line:?}"
);
}
fn unix_now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
#[test]
fn status_bar_renders_rate_limit_for_focused_agent_with_active_window() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = true;
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let mut agent = synth_agent("p:a", AgentState::Running, 0, 0);
agent.rate_limit_resets_at = Some(unix_now_secs() + 3661.0); app.replace_team(fixture_team("test", vec![agent]));
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
last_line.contains("limit "),
"status bar missing rate-limit indicator: {last_line:?}"
);
}
#[test]
fn status_bar_omits_rate_limit_when_preview_flag_disabled() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = false;
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let mut agent = synth_agent("p:a", AgentState::Running, 0, 0);
agent.rate_limit_resets_at = Some(unix_now_secs() + 3661.0);
app.replace_team(fixture_team("test", vec![agent]));
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
!last_line.contains("limit "),
"preview-gated indicator rendered with flag off: {last_line:?}"
);
}
#[test]
fn status_bar_omits_rate_limit_when_focused_agent_has_no_window() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = true;
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let agent = synth_agent("p:a", AgentState::Running, 0, 0);
app.replace_team(fixture_team("test", vec![agent]));
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
!last_line.contains("limit "),
"status bar rendered indicator with no active window: {last_line:?}"
);
}
#[test]
fn status_bar_omits_rate_limit_when_focused_agent_window_is_in_the_past() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = true;
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let mut agent = synth_agent("p:a", AgentState::Running, 0, 0);
agent.rate_limit_resets_at = Some(1.0);
app.replace_team(fixture_team("test", vec![agent]));
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
!last_line.contains("limit "),
"status bar rendered indicator for expired window: {last_line:?}"
);
}
#[test]
fn status_bar_swaps_rate_limit_with_focused_agent() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = true;
app.team.root = std::path::PathBuf::from("/tmp/teamctl-fixture/.team");
let mut limited = synth_agent("p:limited", AgentState::Running, 0, 0);
limited.rate_limit_resets_at = Some(unix_now_secs() + 600.0); let calm = synth_agent("p:calm", AgentState::Running, 0, 0);
app.replace_team(fixture_team("test", vec![limited, calm]));
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.lines()
.last()
.map(|l| l.contains("limit "))
.unwrap_or(false),
"indicator missing when limited agent focused"
);
app.selected_agent = Some(1);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
!s.lines()
.last()
.map(|l| l.contains("limit "))
.unwrap_or(true),
"indicator persisted when focus moved to unlimited agent"
);
}
#[test]
fn status_bar_omits_rate_limit_indicator_when_path_crowds_center_slot() {
let mut app = fresh_app();
app.dismiss_splash();
app.rate_limit_indicator_enabled = true;
app.team.root = std::path::PathBuf::from("/operator/dev/projects/teamctl-deep-nest/.team");
let mut agent = synth_agent("p:a", AgentState::Running, 0, 0);
agent.rate_limit_resets_at = Some(unix_now_secs() + 600.0);
let mut team = fixture_team("test", vec![agent]);
team.root = app.team.root.clone();
app.replace_team(team);
app.selected_agent = Some(0);
let buf = render_to_buffer(&app, 80, 20);
let s = buffer_to_string(&buf);
let last_line = s.lines().last().expect("buffer not empty");
assert!(
last_line.contains(".team"),
"status bar dropped the basename: {last_line:?}"
);
assert!(
last_line.contains("CPU "),
"status bar dropped CPU label: {last_line:?}"
);
assert!(
!last_line.contains("limit "),
"status bar should drop indicator before metrics when path crowds center: {last_line:?}"
);
}
#[test]
fn mailbox_pane_renders_head_anchored_window_when_cursor_at_head() {
use teamctl_ui::mailbox::MailboxTab;
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
let batch: Vec<_> = (1..=30)
.map(|i| {
message(
i,
"writing:dev1",
"writing:manager",
&format!("msg #{i:02}"),
)
})
.collect();
app.mailbox.extend(MailboxTab::Inbox, batch);
app.mailbox_cursor_home(); let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.contains("msg #01"),
"head-anchored mailbox should render the first row;\nbuf:\n{s}"
);
assert!(
!s.contains("msg #30"),
"head-anchored mailbox must NOT render the last row;\nbuf:\n{s}"
);
insta::assert_snapshot!("mailbox_head_anchored_120x30", s);
}
#[test]
fn mailbox_pane_shows_filter_indicator_when_filter_set_and_input_closed() {
use teamctl_ui::mailbox::{MailboxInputKind, MailboxTab};
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.mailbox.extend(
MailboxTab::Inbox,
vec![
message(11, "writing:ada", "writing:manager", "ready for review"),
message(12, "writing:kian", "writing:manager", "release notes"),
message(13, "writing:ada", "writing:manager", "shipping the patch"),
message(14, "user:telegram", "writing:manager", "any blockers?"),
],
);
app.mailbox
.set_input(MailboxTab::Inbox, MailboxInputKind::Filter, "ada".into());
assert!(app.mailbox_input_mode.is_none());
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(
s.contains("filter: ada"),
"filter-state indicator must be visible when filter set + input closed;\nbuf:\n{s}"
);
assert!(
s.contains("ready for review") && s.contains("shipping the patch"),
"ada's rows must remain visible;\nbuf:\n{s}"
);
assert!(
!s.contains("release notes") && !s.contains("any blockers"),
"non-ada rows must be hidden by the filter;\nbuf:\n{s}"
);
insta::assert_snapshot!("mailbox_filter_indicator_120x30", s);
}
#[test]
fn mailbox_detail_modal_renders_metadata_and_body() {
use teamctl_ui::app::Stage;
use teamctl_ui::mailbox::MailboxTab;
let mut app = fresh_app();
app.dismiss_splash();
app.replace_team(fixture_team(
"writing-team",
vec![synth_agent("writing:manager", AgentState::Running, 0, 0)],
));
app.mailbox.extend(
MailboxTab::Inbox,
vec![message(
42,
"user:telegram",
"writing:manager",
"shipping the detail modal — please review when you have a moment.",
)],
);
app.cycle_focus();
app.cycle_focus();
app.open_mailbox_detail_modal();
assert_eq!(app.stage, Stage::MailboxDetailModal);
let buf = render_to_buffer(&app, 120, 30);
let s = buffer_to_string(&buf);
assert!(s.contains("MESSAGE"), "modal title missing:\n{s}");
assert!(s.contains("id 42"), "message id missing:\n{s}");
assert!(
s.contains("via telegram"),
"transport heuristic missing:\n{s}"
);
assert!(s.contains("DM"), "kind label missing:\n{s}");
assert!(
s.contains("shipping the detail modal"),
"body missing:\n{s}"
);
insta::assert_snapshot!("mailbox_detail_modal_120x30", s);
}