use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::banner::{
GLYPH_ACTIVE, GLYPH_UNREACHABLE, HELP_HINT, ProbeOutcome, USER_PALACE, compose_banner,
ensure_user_palace_at, probe_memory, probe_search,
};
use super::dispatch::{Dispatch, route};
use super::events::{SlashCommand, handle_key, is_quit, parse_slash};
use super::layout::output_tail;
use super::layout::{
CATALOG_STALE_HINT, CONTROLLER_STATUS, DAEMON_REACHABLE, DAEMON_UNREACHABLE, INPUT_PROMPT,
STATUS_BAR_HINT, catalog_indicator, daemon_indicator, input_line, status_bar_text,
};
use super::nav::{ListNav, MIN_VISIBLE_ROWS, PAGE_JUMP};
use super::poll::{coord_poll_daemon, coord_session_to_entry};
use super::render::render_result;
use super::rows::{
COLUMN_SEPARATOR, CONTROLLER_GLYPH, SESSION_GLYPH, active_row_two_col, controller_bullet_row,
numbered_session_row, session_row, session_short_id,
};
use super::state::{CoordinatorState, INPUT_HISTORY_LIMIT, OUTPUT_LOG_LIMIT, SessionEntry};
use crate::client::result::ManagedSessionView;
use crate::client::{CommandResult, CoordinatorSession, DaemonClient, TrustyCommand};
fn line_text(line: &ratatui::text::Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn controller_bullet_row_renders_glyph_label_and_status() {
let line = controller_bullet_row("idle · ready", false);
let text = line_text(&line);
assert!(
text.starts_with(CONTROLLER_GLYPH),
"expected leading bullet: {text}"
);
assert!(text.contains("controller"), "missing label: {text}");
assert!(text.contains("idle · ready"), "missing status: {text}");
}
#[test]
fn controller_bullet_row_selected_is_still_well_formed() {
let plain = line_text(&controller_bullet_row("ready", false));
let selected = line_text(&controller_bullet_row("ready", true));
assert_eq!(plain, selected);
}
#[test]
fn session_row_renders_bullet_and_prefix() {
let text = line_text(&session_row("4f9ca1b2", "aipowerranking", "Active"));
assert!(
text.starts_with(SESSION_GLYPH),
"expected hollow bullet: {text}"
);
assert!(text.contains("4f9ca1b2"), "missing short id: {text}");
assert!(text.contains("aipowerranking"), "missing prefix: {text}");
assert!(text.contains("Active"), "missing status: {text}");
}
#[test]
fn active_row_two_col_splits_on_bar() {
let text = line_text(&active_row_two_col("4f9ca1b2", "Running tests"));
assert!(
text.contains(COLUMN_SEPARATOR),
"missing column separator: {text}"
);
let (left, right) = text
.split_once(COLUMN_SEPARATOR)
.expect("a bar separator must be present");
assert!(left.contains("4f9ca1b2"), "id not in left column: {left}");
assert!(
right.contains("Running tests"),
"summary not in right column: {right}"
);
}
#[test]
fn numbered_session_row_prefixes_number() {
let text = line_text(&numbered_session_row(
3,
"4f9ca1b2",
"aipowerranking",
"Active",
));
assert!(
text.trim_start().starts_with("3."),
"expected a leading list number `3.`: {text}"
);
assert!(text.contains(SESSION_GLYPH), "missing bullet: {text}");
assert!(text.contains("4f9ca1b2"), "missing short id: {text}");
assert!(text.contains("aipowerranking"), "missing prefix: {text}");
assert!(text.contains("Active"), "missing status: {text}");
}
#[test]
fn session_short_id_truncates() {
assert_eq!(session_short_id("4f9ca1b2deadbeef"), "4f9ca1b2");
assert_eq!(session_short_id("abc"), "abc");
assert_eq!(session_short_id(""), "");
}
#[test]
fn min_visible_rows_is_five() {
assert_eq!(MIN_VISIBLE_ROWS, 5);
assert_eq!(PAGE_JUMP, MIN_VISIBLE_ROWS);
}
#[test]
fn nav_default_selects_controller_row() {
let nav = ListNav::default();
assert_eq!(nav.selected(), 0);
assert_eq!(nav.offset(), 0);
}
#[test]
fn nav_up_down_saturate() {
let mut nav = ListNav::default();
nav.sync_len(4);
nav.up(); assert_eq!(nav.selected(), 0, "up saturates at the top");
nav.down();
nav.down();
nav.down();
assert_eq!(nav.selected(), 3, "stepped to the last row");
nav.down(); assert_eq!(nav.selected(), 3, "down saturates at the last row");
nav.up();
assert_eq!(nav.selected(), 2);
}
#[test]
fn nav_select_clamps_into_bounds() {
let mut nav = ListNav::default();
nav.sync_len(3); nav.select(99);
assert_eq!(nav.selected(), 2, "select clamps to the last row");
nav.select(1);
assert_eq!(nav.selected(), 1, "an in-bounds select is honoured");
}
#[test]
fn page_down_jumps_by_page_size() {
let mut nav = ListNav::default();
nav.sync_len(20);
nav.page_down();
assert_eq!(nav.selected(), PAGE_JUMP, "page down jumps by a page");
}
#[test]
fn page_up_jumps_by_page_size() {
let mut nav = ListNav::default();
nav.sync_len(20);
nav.select(2 * PAGE_JUMP);
nav.page_up();
assert_eq!(nav.selected(), PAGE_JUMP, "page up jumps back by a page");
}
#[test]
fn page_down_saturates_at_bottom() {
let mut nav = ListNav::default();
nav.sync_len(3); nav.page_down();
assert_eq!(nav.selected(), 2, "page down saturates at the last row");
}
#[test]
fn page_up_saturates_at_top() {
let mut nav = ListNav::default();
nav.sync_len(20);
nav.select(2); nav.page_up();
assert_eq!(nav.selected(), 0, "page up saturates at the top");
}
#[test]
fn nav_sync_len_preserves_selection_and_offset() {
let mut nav = ListNav::default();
nav.sync_len(20);
nav.select(12);
*nav.state_mut().offset_mut() = 8;
assert_eq!(nav.selected(), 12);
assert_eq!(nav.offset(), 8);
nav.sync_len(25);
assert_eq!(nav.selected(), 12, "grown list preserves the selection");
assert_eq!(nav.offset(), 8, "grown list preserves the scroll offset");
nav.sync_len(25);
assert_eq!(nav.selected(), 12);
assert_eq!(nav.offset(), 8);
}
#[test]
fn nav_sync_len_clamps_on_shrink() {
let mut nav = ListNav::default();
nav.sync_len(20);
nav.select(18);
*nav.state_mut().offset_mut() = 15;
nav.sync_len(5);
assert_eq!(
nav.selected(),
4,
"selection clamps to the last row on shrink"
);
assert!(
nav.offset() <= 4,
"offset clamps within the shrunk list: {}",
nav.offset()
);
nav.sync_len(1);
assert_eq!(nav.selected(), 0);
assert_eq!(nav.offset(), 0);
}
#[test]
fn nav_sync_len_floors_at_one_row() {
let mut nav = ListNav::default();
nav.sync_len(0);
assert_eq!(
nav.selected(),
0,
"an empty list still has the controller row"
);
}
#[test]
fn clamp_selection_empty_list() {
let mut state = CoordinatorState::default();
assert_eq!(state.row_count(), 1);
state.select_row(9);
state.clamp_selection();
assert_eq!(state.selected(), 0, "clamp to the sole controller row");
}
#[test]
fn clamp_selection_single_row() {
let mut state = CoordinatorState::default();
state
.sessions
.push(SessionEntry::new("id", "p", "Active", "s"));
assert_eq!(state.row_count(), 2);
state.select_row(1);
state.clamp_selection();
assert_eq!(state.selected(), 1, "valid selection is preserved");
state.select_row(5);
state.clamp_selection();
assert_eq!(state.selected(), 1, "past-end clamps to last session");
}
#[test]
fn clamp_selection_past_end() {
let mut state = CoordinatorState::skeleton(); assert_eq!(state.row_count(), 3);
state.select_row(99);
state.clamp_selection();
assert_eq!(state.selected(), 2, "clamps to the last valid row index");
}
#[test]
fn selection_movement_saturates() {
let mut state = CoordinatorState::skeleton(); state.select_up(); assert_eq!(state.selected(), 0);
state.select_down();
assert_eq!(state.selected(), 1);
state.select_down();
assert_eq!(state.selected(), 2);
state.select_down(); assert_eq!(state.selected(), 2);
state.select_up();
assert_eq!(state.selected(), 1);
}
#[test]
fn select_row_clamps_into_bounds() {
let mut state = CoordinatorState::skeleton(); state.select_row(1);
assert_eq!(state.selected(), 1, "an in-bounds row is honoured");
state.select_row(99);
assert_eq!(
state.selected(),
2,
"past-end select clamps to the last row"
);
}
#[test]
fn clamp_selection_preserves_valid_selection() {
let mut state = CoordinatorState::skeleton(); state.select_row(2);
state.clamp_selection();
assert_eq!(
state.selected(),
2,
"a still-valid selection survives the refresh"
);
state
.sessions
.push(SessionEntry::new("new", "p", "Active", "s"));
state.clamp_selection();
assert_eq!(state.selected(), 2, "grown list keeps the cursor in place");
}
#[test]
fn page_scroll_moves_by_page_and_saturates() {
let mut state = CoordinatorState::live();
for i in 0..20 {
state
.sessions
.push(SessionEntry::new(format!("id{i}"), "p", "Active", "s"));
}
state.page_down();
assert_eq!(state.selected(), PAGE_JUMP, "page down jumps by a page");
state.page_down();
assert_eq!(
state.selected(),
2 * PAGE_JUMP,
"second page down jumps again"
);
state.page_up();
assert_eq!(state.selected(), PAGE_JUMP, "page up jumps back by a page");
state.select_row(2);
state.page_up();
assert_eq!(
state.selected(),
0,
"page up saturates at the controller row"
);
}
#[test]
fn request_repoll_sets_and_take_clears() {
let mut state = CoordinatorState::live();
assert!(!state.take_repoll(), "no re-poll requested initially");
state.request_repoll();
assert!(state.take_repoll(), "the requested re-poll fires once");
assert!(
!state.take_repoll(),
"a consumed re-poll does not re-fire on the next tick"
);
}
#[test]
fn controller_is_selected_at_row_zero() {
let mut state = CoordinatorState::skeleton();
assert!(state.controller_selected());
state.select_row(1);
assert!(!state.controller_selected());
}
#[test]
fn selected_session_offsets_by_controller_row() {
let mut state = CoordinatorState::skeleton();
assert!(
state.selected_session().is_none(),
"row 0 is the controller"
);
state.select_row(1);
assert_eq!(state.selected_session().unwrap().prefix, "aipowerranking");
state.select_row(2);
assert_eq!(state.selected_session().unwrap().prefix, "genealogy");
}
#[test]
fn default_state_has_placeholder_rows() {
let state = CoordinatorState::skeleton();
assert_eq!(state.sessions.len(), 2);
assert_eq!(state.selected(), 0);
}
#[test]
fn input_edits_buffer() {
let mut state = CoordinatorState::default();
state.push_char('h');
state.push_char('i');
assert_eq!(state.input, "hi");
state.backspace();
assert_eq!(state.input, "h");
state.clear_input();
assert!(state.input.is_empty());
}
#[test]
fn submit_records_history() {
let mut state = CoordinatorState::default();
state.input = " status please ".to_string();
let submitted = state.submit_input();
assert_eq!(
submitted.as_deref(),
Some("status please"),
"trims and returns"
);
assert_eq!(state.history, vec!["status please".to_string()]);
assert!(state.input.is_empty(), "buffer cleared after submit");
}
#[test]
fn submit_empty_input_is_noop() {
let mut state = CoordinatorState::default();
state.input = " ".to_string();
assert!(state.submit_input().is_none());
assert!(state.history.is_empty());
}
#[test]
fn history_ring_is_bounded() {
let mut state = CoordinatorState::default();
for i in 0..(INPUT_HISTORY_LIMIT + 5) {
state.input = format!("msg{i}");
let _ = state.submit_input();
}
assert_eq!(state.history.len(), INPUT_HISTORY_LIMIT, "ring is capped");
assert_eq!(
state.history.last().unwrap(),
&format!("msg{}", INPUT_HISTORY_LIMIT + 4)
);
assert_eq!(state.history.first().unwrap(), "msg5", "oldest dropped");
}
#[test]
fn history_recall_walks_entries() {
let mut state = CoordinatorState::default();
for m in ["one", "two", "three"] {
state.input = m.to_string();
let _ = state.submit_input();
}
state.history_prev();
assert_eq!(state.input, "three");
state.history_prev();
assert_eq!(state.input, "two");
state.history_next();
assert_eq!(state.input, "three");
state.history_next();
assert!(state.input.is_empty(), "stepping past newest clears buffer");
}
#[test]
fn parse_slash_recognises_known_commands() {
assert_eq!(parse_slash("/help"), Some(SlashCommand::Help));
assert_eq!(parse_slash("/new repo=x"), Some(SlashCommand::New));
assert_eq!(parse_slash("/sessions"), Some(SlashCommand::Sessions));
assert_eq!(parse_slash("/attach"), Some(SlashCommand::Attach));
assert_eq!(parse_slash("/stop"), Some(SlashCommand::Stop));
assert_eq!(parse_slash("/resume"), Some(SlashCommand::Resume));
assert_eq!(parse_slash("/kill"), Some(SlashCommand::Kill));
assert_eq!(parse_slash("/health"), Some(SlashCommand::Health));
assert_eq!(parse_slash("/healthcheck"), Some(SlashCommand::Health));
assert_eq!(parse_slash("/ping"), Some(SlashCommand::Health));
assert_eq!(parse_slash("/HELP"), Some(SlashCommand::Help));
}
#[test]
fn parse_slash_classifies_unknown() {
assert_eq!(
parse_slash("/frobnicate now"),
Some(SlashCommand::Unknown("frobnicate".to_string()))
);
}
#[test]
fn parse_slash_ignores_plain_text() {
assert_eq!(parse_slash("hello there"), None);
assert_eq!(parse_slash("@aipowerranking: run tests"), None);
assert_eq!(parse_slash(""), None);
}
#[test]
fn mutating_commands_are_classified() {
assert!(SlashCommand::New.is_mutating());
assert!(SlashCommand::Kill.is_mutating());
assert!(SlashCommand::Stop.is_mutating());
assert!(SlashCommand::Resume.is_mutating());
assert!(!SlashCommand::Help.is_mutating());
assert!(!SlashCommand::Sessions.is_mutating());
assert!(!SlashCommand::Attach.is_mutating());
assert!(!SlashCommand::Unknown("frob".to_string()).is_mutating());
}
#[test]
fn parse_slash_bare_slash_is_not_a_command() {
assert_eq!(parse_slash("/"), None);
assert_eq!(parse_slash("/ "), None);
assert_eq!(parse_slash("/\t"), None);
}
#[test]
fn quit_on_q_when_empty() {
assert!(is_quit(&key(KeyCode::Char('q')), true));
let mut state = CoordinatorState::skeleton();
handle_key(&mut state, key(KeyCode::Char('q')));
assert!(state.should_exit);
}
#[test]
fn quit_on_ctrl_c() {
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(is_quit(&ctrl_c, true));
assert!(is_quit(&ctrl_c, false), "Ctrl-C quits even mid-message");
let mut state = CoordinatorState::default();
state.input = "typing".to_string();
handle_key(&mut state, ctrl_c);
assert!(state.should_exit);
}
#[test]
fn q_in_message_is_not_quit() {
assert!(!is_quit(&key(KeyCode::Char('q')), false));
let mut state = CoordinatorState::default();
state.input = "fi".to_string();
handle_key(&mut state, key(KeyCode::Char('q')));
assert!(!state.should_exit, "q is editing text, not quitting");
assert_eq!(state.input, "fiq");
}
#[test]
fn handle_key_moves_selection_when_input_empty() {
let mut state = CoordinatorState::skeleton();
handle_key(&mut state, key(KeyCode::Down));
assert_eq!(state.selected(), 1);
handle_key(&mut state, key(KeyCode::Char('j')));
assert_eq!(state.selected(), 2);
handle_key(&mut state, key(KeyCode::Char('k')));
assert_eq!(state.selected(), 1);
handle_key(&mut state, key(KeyCode::Up));
assert_eq!(state.selected(), 0);
}
#[test]
fn handle_key_enter_submits_and_returns_line() {
let mut state = CoordinatorState::default();
state.input = "spin up a session".to_string();
let submitted = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(submitted.as_deref(), Some("spin up a session"));
assert_eq!(state.history.len(), 1);
}
#[test]
fn handle_key_page_keys_scroll_regardless_of_buffer() {
let mut state = CoordinatorState::live();
for i in 0..20 {
state
.sessions
.push(SessionEntry::new(format!("id{i}"), "p", "Active", "s"));
}
state.input = "half-typed".to_string(); handle_key(&mut state, key(KeyCode::PageDown));
assert_eq!(
state.selected(),
PAGE_JUMP,
"page down scrolls even mid-message"
);
handle_key(&mut state, key(KeyCode::PageUp));
assert_eq!(state.selected(), 0, "page up scrolls back even mid-message");
assert_eq!(state.input, "half-typed", "page keys never edit the buffer");
}
#[test]
fn handle_key_enter_mutating_command_requests_repoll() {
let mut state = CoordinatorState::live();
state.input = "/new repo=x".to_string();
let submitted = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(submitted.as_deref(), Some("/new repo=x"));
assert!(
state.take_repoll(),
"a mutating slash command requests a re-poll"
);
state.input = "/help".to_string();
handle_key(&mut state, key(KeyCode::Enter));
assert!(
!state.take_repoll(),
"a read-only command must not force a refresh"
);
state.input = "just chatting".to_string();
handle_key(&mut state, key(KeyCode::Enter));
assert!(!state.take_repoll(), "plain chat must not force a refresh");
}
#[test]
fn handle_key_up_recalls_history_when_input_nonempty() {
let mut state = CoordinatorState::default();
state.input = "earlier".to_string();
let _ = state.submit_input();
state.push_char('x');
handle_key(&mut state, key(KeyCode::Up));
assert_eq!(state.input, "earlier");
}
#[test]
fn input_line_shows_prompt_and_cursor() {
let mut state = CoordinatorState::default();
state.input = "hi".to_string();
let line = input_line(&state);
assert!(line.starts_with(INPUT_PROMPT), "missing prompt: {line}");
assert!(line.ends_with('_'), "missing cursor glyph: {line}");
assert!(line.contains("hi"));
}
#[test]
fn status_bar_lists_keys() {
let bar = status_bar_text();
assert_eq!(bar, STATUS_BAR_HINT);
assert!(bar.contains("/help"));
assert!(bar.contains("quit"));
}
#[test]
fn controller_status_is_synthetic() {
assert!(CONTROLLER_STATUS.contains("ready"));
}
fn coord_session(id: &str, prefix: &str, status: &str, recent: &[&str]) -> CoordinatorSession {
CoordinatorSession {
id: id.to_string(),
name: format!("tmpm-{prefix}"),
prefix: prefix.to_string(),
workdir: "/tmp".to_string(),
status: status.to_string(),
active_delegations: 0,
recent_output: recent.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn coord_session_to_entry_summarizes_last_output() {
let s = coord_session(
"4f9ca1b2deadbeef", "aipowerranking",
"Active",
&["", "Running tests"],
);
let entry = coord_session_to_entry(s);
assert_eq!(entry.summary, "Running tests");
assert_eq!(entry.short_id, "4f9ca1b2");
assert_eq!(entry.prefix, "aipowerranking");
assert_eq!(entry.status, "Active");
}
#[test]
fn coord_session_to_entry_falls_back_to_status() {
let s = coord_session("7b2ec0d1cafef00d", "genealogy", "Paused", &[]);
let entry = coord_session_to_entry(s);
assert_eq!(entry.summary, "Paused", "empty pane → status word");
let blank = coord_session("abc", "p", "Idle", &[" ", "\t"]);
assert_eq!(coord_session_to_entry(blank).summary, "Idle");
}
#[test]
fn coord_session_to_entry_derives_short_id() {
let long = coord_session("0123456789abcdef0123", "p", "Active", &["x"]);
assert_eq!(coord_session_to_entry(long).short_id, "01234567");
let short = coord_session("abc", "p", "Active", &["x"]);
assert_eq!(coord_session_to_entry(short).short_id, "abc");
}
#[test]
fn live_state_starts_empty_and_unreachable() {
let state = CoordinatorState::live();
assert!(
state.sessions.is_empty(),
"no placeholder rows in live mode"
);
assert!(
!state.daemon_reachable,
"daemon presumed down until first poll"
);
assert_eq!(state.selected(), 0, "controller selected at start");
}
#[test]
fn daemon_indicator_reflects_reachability() {
let mut state = CoordinatorState::live();
assert_eq!(daemon_indicator(&state), DAEMON_UNREACHABLE);
state.daemon_reachable = true;
assert_eq!(daemon_indicator(&state), DAEMON_REACHABLE);
assert_ne!(DAEMON_REACHABLE, DAEMON_UNREACHABLE);
}
#[test]
fn catalog_indicator_reflects_staleness() {
let mut state = CoordinatorState::live();
assert_eq!(catalog_indicator(&state), None);
state.catalog_stale = true;
assert_eq!(catalog_indicator(&state), None);
state.daemon_reachable = true;
assert_eq!(catalog_indicator(&state), Some(CATALOG_STALE_HINT));
state.catalog_stale = false;
assert_eq!(catalog_indicator(&state), None);
assert!(CATALOG_STALE_HINT.contains("tm catalog apply"));
}
use crate::test_support::dead_loopback_url;
#[tokio::test]
#[serial_test::serial]
async fn poll_marks_unreachable_clears_sessions() {
let discovered = crate::core::resolve_daemon_url(None);
let probe = DaemonClient::new(discovered.clone());
if probe.is_healthy().await {
eprintln!(
"skipping daemon-down assertions: a reachable daemon was discovered at {discovered}"
);
return;
}
let mut state = CoordinatorState::live();
state.daemon_reachable = true; state.sessions = vec![
SessionEntry::new("4f9ca1b2", "aipowerranking", "Active", "Running tests"),
SessionEntry::new("7b2ec0d1", "genealogy", "Idle", "Idle"),
];
state.select_row(2);
let mut client = DaemonClient::new(dead_loopback_url());
coord_poll_daemon(&mut state, &mut client).await;
assert!(
!state.daemon_reachable,
"an unreachable daemon must flip daemon_reachable to false"
);
assert!(
state.sessions.is_empty(),
"a daemon-down poll must clear stale session rows"
);
assert_eq!(
state.selected(),
0,
"clearing the list clamps the selection back to the controller row"
);
}
#[tokio::test]
async fn coord_poll_keeps_explicit_url() {
let bound = tokio::net::TcpSocket::new_v4()
.and_then(|s| s.bind("127.0.0.1:0".parse().expect("addr")).map(|()| s))
.expect("bind loopback socket");
let dead = format!("http://{}", bound.local_addr().expect("bound addr"));
let mut client = DaemonClient::new(dead.clone()).with_pinned_base_url(true);
let mut state = CoordinatorState::live();
state.daemon_reachable = true;
coord_poll_daemon(&mut state, &mut client).await;
assert!(
!state.daemon_reachable,
"a dead explicit URL reports unreachable"
);
assert_eq!(client.base_url(), dead, "the explicit URL is kept");
}
#[test]
fn compose_banner_includes_name_and_version() {
let banner = compose_banner(
env!("CARGO_PKG_VERSION"),
&ProbeOutcome::active(None),
&ProbeOutcome::active(None),
Some(3),
);
let expected = format!("trusty-mpm sessions v{}", env!("CARGO_PKG_VERSION"));
assert!(
banner.lines().next() == Some(expected.as_str()),
"banner must lead with the name + version line, got:\n{banner}"
);
}
#[test]
fn compose_banner_active_shows_glyphs() {
let banner = compose_banner(
"1.2.3",
&ProbeOutcome::active(None),
&ProbeOutcome::active(None),
Some(2),
);
assert!(
banner.contains(&format!("memory {GLYPH_ACTIVE} active")),
"active memory must show `● active`, got:\n{banner}"
);
assert!(
banner.contains(&format!("search {GLYPH_ACTIVE} active")),
"active search must show `● active`, got:\n{banner}"
);
}
#[test]
fn compose_banner_unreachable_shows_glyphs() {
let banner = compose_banner(
"1.2.3",
&ProbeOutcome::unreachable(None),
&ProbeOutcome::unreachable(None),
Some(0),
);
assert!(
banner.contains(&format!("memory {GLYPH_UNREACHABLE} unreachable")),
"down memory must show `○ unreachable`, got:\n{banner}"
);
assert!(
banner.contains(&format!("search {GLYPH_UNREACHABLE} unreachable")),
"down search must show `○ unreachable`, got:\n{banner}"
);
}
#[test]
fn compose_banner_active_shows_palace_note() {
let banner = compose_banner(
"0.1.0",
&ProbeOutcome::active(Some(format!("palace: {USER_PALACE}"))),
&ProbeOutcome::active(None),
Some(1),
);
assert!(
banner.contains(&format!("(palace: {USER_PALACE})")),
"memory line must note the `user` palace per D4, got:\n{banner}"
);
}
#[test]
fn compose_banner_appends_help_hint() {
let banner = compose_banner(
"0.1.0",
&ProbeOutcome::active(None),
&ProbeOutcome::active(None),
Some(4),
);
let last = banner.lines().last().unwrap_or_default();
assert!(
last.ends_with(HELP_HINT),
"startup line must end with the /help hint, got: {last:?}"
);
assert!(
last.starts_with("4 active sessions"),
"startup line must lead with the active-session count, got: {last:?}"
);
}
#[test]
fn compose_banner_pluralizes_count() {
let line_for = |n: usize| {
let banner = compose_banner(
"0.1.0",
&ProbeOutcome::active(None),
&ProbeOutcome::active(None),
Some(n),
);
banner.lines().last().unwrap_or_default().to_string()
};
assert!(line_for(0).starts_with("0 active sessions"));
assert!(line_for(1).starts_with("1 active session ·"));
assert!(line_for(5).starts_with("5 active sessions"));
}
#[test]
fn compose_banner_handles_unknown_count() {
let banner = compose_banner(
"0.1.0",
&ProbeOutcome::unreachable(None),
&ProbeOutcome::unreachable(None),
None,
);
let last = banner.lines().last().unwrap_or_default();
assert!(
last.starts_with("daemon unreachable"),
"unknown count must read `daemon unreachable`, got: {last:?}"
);
assert!(
last.ends_with(HELP_HINT),
"the /help hint must remain even when the count is unknown, got: {last:?}"
);
}
#[tokio::test]
async fn probe_unreachable_search_is_inactive() {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = probe_search(Some(&dir.path().join("missing.sock"))).await;
assert!(
!outcome.active,
"an unreachable search daemon must probe inactive"
);
}
#[tokio::test]
async fn probe_search_over_the_socket_is_active() {
let daemon = crate::uds_mock::spawn(crate::uds_mock::always(
serde_json::json!({ "version": "9.9.9" }),
))
.await;
let outcome = probe_search(Some(daemon.socket())).await;
assert!(outcome.active, "a live search socket must probe active");
}
#[tokio::test]
async fn probe_search_on_a_refusing_daemon_is_inactive() {
use std::sync::{Arc, Mutex};
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let daemon = crate::uds_mock::spawn(move |method: &str, _params: serde_json::Value| {
recorder.lock().expect("recorder").push(method.to_string());
Box::pin(async { Err(crate::uds_mock::RpcError::internal("refused")) })
})
.await;
let outcome = probe_search(Some(daemon.socket())).await;
assert!(!outcome.active, "a refused health probe must be inactive");
assert_eq!(
*seen.lock().expect("recorder"),
vec!["search.health".to_string()]
);
}
#[tokio::test]
async fn probe_unreachable_memory_is_inactive() {
let outcome = probe_memory(Some(std::path::Path::new(
"/nonexistent/trusty-memory.sock",
)))
.await;
assert!(
!outcome.active,
"an unreachable memory daemon must probe inactive"
);
assert!(
outcome.note.is_some(),
"a degraded memory probe should carry a reason note"
);
}
#[test]
fn compose_banner_has_single_trailing_newline() {
let banner = compose_banner(
"0.1.0",
&ProbeOutcome::active(None),
&ProbeOutcome::active(None),
Some(2),
);
assert!(
banner.ends_with('\n'),
"composed banner must end with a newline, got:\n{banner:?}"
);
assert!(
!banner.ends_with("\n\n"),
"composed banner must NOT end with a double newline, got:\n{banner:?}"
);
}
async fn spawn_palace_mock(
code: i64,
) -> (
crate::uds_mock::MockUdsDaemon,
tokio::sync::watch::Receiver<bool>,
) {
use crate::uds_mock::{self, RpcError};
let (tx, rx) = tokio::sync::watch::channel(false);
let daemon = uds_mock::spawn(move |method: &str, _params: serde_json::Value| {
let tx = tx.clone();
let method = method.to_string();
Box::pin(async move {
match method.as_str() {
"memory.palace_get" => Err(RpcError::new(code, "stubbed refusal".to_string())),
"palace_create" => {
let _ = tx.send(true);
Ok(serde_json::json!({"ok": true}))
}
other => Err(RpcError::method_not_found(other, &[])),
}
})
})
.await;
(daemon, rx)
}
#[tokio::test]
async fn ensure_user_palace_does_not_create_on_other_errors() {
let (daemon, create_seen) = spawn_palace_mock(-32603).await;
let created = ensure_user_palace_at(daemon.socket()).await;
assert!(
!created,
"an internal error from palace_get must NOT confirm the palace as active"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
!*create_seen.borrow(),
"a non-not-found refusal must NOT trigger a create"
);
}
#[tokio::test]
async fn ensure_user_palace_creates_only_on_not_found() {
let (daemon, create_seen) = spawn_palace_mock(trusty_common::memory_rpc::CODE_NOT_FOUND).await;
let created = ensure_user_palace_at(daemon.socket()).await;
assert!(
created,
"a not-found followed by a successful create must confirm the palace"
);
assert!(
*create_seen.borrow(),
"a not-found must trigger exactly the create"
);
}
#[test]
fn parse_slash_recognises_aliases() {
assert_eq!(parse_slash("/spawn"), Some(SlashCommand::New));
assert_eq!(parse_slash("/list"), Some(SlashCommand::Sessions));
assert_eq!(parse_slash("/managed-list"), Some(SlashCommand::Sessions));
assert_eq!(parse_slash("/managed-stop x"), Some(SlashCommand::Stop));
assert_eq!(parse_slash("/decommission x"), Some(SlashCommand::Kill));
assert_eq!(
parse_slash("/managed-decommission x"),
Some(SlashCommand::Kill)
);
assert_eq!(parse_slash("/approve x"), Some(SlashCommand::Answer));
assert_eq!(parse_slash("/status x"), Some(SlashCommand::Get));
assert_eq!(parse_slash("/managed-send x hi"), Some(SlashCommand::Send));
assert_eq!(
parse_slash("/managed-activity x"),
Some(SlashCommand::Activity)
);
}
#[test]
fn route_slash_maps_to_command() {
assert_eq!(
route("/sessions", None),
Dispatch::Command(TrustyCommand::ManagedList)
);
assert_eq!(route("/help", None), Dispatch::Command(TrustyCommand::Help));
assert_eq!(
route("/health", None),
Dispatch::Command(TrustyCommand::Health)
);
assert_eq!(
route("/stop red-owl", None),
Dispatch::Command(TrustyCommand::ManagedRuntimeStop {
target: "red-owl".to_string()
})
);
assert_eq!(
route("/kill red-owl", None),
Dispatch::Command(TrustyCommand::ManagedDecommission {
target: "red-owl".to_string()
})
);
assert_eq!(
route("/resume red-owl", None),
Dispatch::Command(TrustyCommand::ManagedResume {
target: "red-owl".to_string()
})
);
assert_eq!(
route("/attach red-owl", None),
Dispatch::Command(TrustyCommand::ManagedAttachCmd {
target: "red-owl".to_string()
})
);
assert_eq!(
route("/get red-owl", None),
Dispatch::Command(TrustyCommand::ManagedGet {
target: "red-owl".to_string()
})
);
assert_eq!(
route("/activity red-owl", None),
Dispatch::Command(TrustyCommand::ManagedActivity {
target: "red-owl".to_string()
})
);
}
#[test]
fn route_targetless_verb_borrows_focus() {
assert_eq!(
route("/stop", Some("focus-id")),
Dispatch::Command(TrustyCommand::ManagedRuntimeStop {
target: "focus-id".to_string()
})
);
}
#[test]
fn route_slash_explicit_target_overrides_focus() {
assert_eq!(
route("/kill other", Some("focus-id")),
Dispatch::Command(TrustyCommand::ManagedDecommission {
target: "other".to_string()
})
);
}
#[test]
fn route_missing_target_hints() {
match route("/stop", None) {
Dispatch::Hint(msg) => assert!(msg.contains("no session selected"), "{msg}"),
other => panic!("expected hint, got {other:?}"),
}
}
#[test]
fn route_unknown_command_hints() {
match route("/frobnicate now", None) {
Dispatch::Hint(msg) => assert!(msg.contains("unknown command"), "{msg}"),
other => panic!("expected hint, got {other:?}"),
}
}
#[test]
fn route_new_requires_repo_ref_task() {
match route("/new just-a-repo", None) {
Dispatch::Hint(msg) => assert!(msg.contains("usage"), "{msg}"),
other => panic!("expected usage hint, got {other:?}"),
}
}
#[test]
fn route_new_builds_managed_new() {
assert_eq!(
route("/new https://x/r.git main do the thing", None),
Dispatch::Command(TrustyCommand::ManagedNew {
repo_url: "https://x/r.git".to_string(),
git_ref: "main".to_string(),
task: "do the thing".to_string(),
name_hint: None,
runtime: None,
inject_task: None,
deliverable_id: None,
force_new: false,
})
);
}
#[test]
fn route_send_uses_focus_for_target() {
assert_eq!(
route("/send run the tests", Some("focus-id")),
Dispatch::Command(TrustyCommand::ManagedSend {
target: "focus-id".to_string(),
text: "run the tests".to_string()
})
);
}
#[test]
fn route_send_explicit_target() {
assert_eq!(
route("/send red-owl run the tests", None),
Dispatch::Command(TrustyCommand::ManagedSend {
target: "red-owl".to_string(),
text: "run the tests".to_string()
})
);
}
#[test]
fn route_send_missing_text_hints() {
match route("/send", Some("focus-id")) {
Dispatch::Hint(msg) => assert!(msg.contains("text is required"), "{msg}"),
other => panic!("expected hint, got {other:?}"),
}
}
#[test]
fn route_answer_routes_to_managed_answer() {
assert_eq!(
route("/answer yes proceed", Some("focus-id")),
Dispatch::Command(TrustyCommand::ManagedAnswer {
target: "focus-id".to_string(),
answer: "yes proceed".to_string()
})
);
}
#[test]
fn route_free_text_sends_to_focus() {
assert_eq!(
route("run the integration suite", Some("focus-id")),
Dispatch::Command(TrustyCommand::ManagedSend {
target: "focus-id".to_string(),
text: "run the integration suite".to_string()
})
);
}
#[test]
fn route_free_text_no_focus_echoes() {
assert_eq!(route("just chatting", None), Dispatch::Echo);
assert_eq!(route(" ", Some("focus-id")), Dispatch::Echo);
}
#[test]
fn focused_target_is_none_on_controller() {
let mut state = CoordinatorState::live();
state.sessions.push(SessionEntry::with_id(
"full-id-1",
"fullid",
"p",
"Active",
"s",
));
assert_eq!(state.selected(), 0);
assert_eq!(state.focused_target(), None);
}
#[test]
fn focused_target_returns_selected_session_id() {
let mut state = CoordinatorState::live();
state.sessions.push(SessionEntry::with_id(
"full-id-1",
"fullid",
"p",
"Active",
"s",
));
state.select_row(1); assert_eq!(state.focused_target(), Some("full-id-1"));
}
#[test]
fn focused_session_target_prefers_full_id() {
let entry = SessionEntry::with_id("0123456789ab", "01234567", "p", "Active", "s");
assert_eq!(entry.id, "0123456789ab");
assert_eq!(entry.short_id, "01234567");
}
#[test]
fn output_log_records_and_is_bounded() {
let mut state = CoordinatorState::live();
state.push_output(ratatui::text::Line::from("first"));
state.push_output_lines(vec![
ratatui::text::Line::from("a"),
ratatui::text::Line::from("b"),
]);
assert_eq!(state.output.len(), 3);
for i in 0..OUTPUT_LOG_LIMIT + 10 {
state.push_output(ratatui::text::Line::from(format!("line {i}")));
}
assert_eq!(state.output.len(), OUTPUT_LOG_LIMIT);
}
#[test]
fn output_tail_keeps_most_recent() {
let mut state = CoordinatorState::live();
for i in 0..10 {
state.push_output(ratatui::text::Line::from(format!("line {i}")));
}
let tail = output_tail(&state, 3);
assert_eq!(tail.len(), 3);
assert_eq!(line_text(&tail[2]), "line 9", "last line is the newest");
assert_eq!(line_text(&tail[0]), "line 7");
assert_eq!(output_tail(&state, 100).len(), 10);
}
fn managed_view(id: &str, name: &str, state: &str) -> ManagedSessionView {
ManagedSessionView {
id: id.to_string(),
name: name.to_string(),
state: state.to_string(),
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
slot: 0,
deleted: false,
}
}
#[test]
fn render_error_is_red() {
let lines = render_result(&CommandResult::Error("daemon unreachable".to_string()));
assert_eq!(lines.len(), 1);
let text = line_text(&lines[0]);
assert!(text.contains("daemon unreachable"), "{text}");
assert!(text.starts_with('✗'), "{text}");
}
#[test]
fn render_result_lists_managed_sessions() {
let views = vec![
managed_view("aaaa00001111", "tmpm-red", "Running"),
managed_view("bbbb22223333", "tmpm-owl", "Stopped"),
];
let lines = render_result(&CommandResult::ManagedSessions(views));
let head = line_text(&lines[0]);
assert!(head.contains("managed sessions (2)"), "{head}");
let row = line_text(&lines[1]);
assert!(row.contains("aaaa0000"), "short id: {row}");
assert!(row.contains("tmpm-red"), "name: {row}");
assert!(row.contains("Running"), "state: {row}");
}
#[test]
fn render_result_empty_sessions_shows_none() {
let lines = render_result(&CommandResult::ManagedSessions(vec![]));
assert!(line_text(&lines[0]).contains("(0)"));
assert!(line_text(&lines[1]).contains("none"));
}
#[test]
fn render_result_renders_lifecycle() {
let lines = render_result(&CommandResult::ManagedLifecycle {
id: "0123456789ab".to_string(),
name: "tmpm-red".to_string(),
state: "Stopped".to_string(),
action: "stopped".to_string(),
workspace_removed: None,
});
let text = line_text(&lines[0]);
assert!(text.starts_with('✓'), "{text}");
assert!(text.contains("stopped"), "{text}");
assert!(text.contains("tmpm-red"), "{text}");
assert!(text.contains("01234567"), "short id only: {text}");
}
#[test]
fn render_result_renders_sent() {
let lines = render_result(&CommandResult::ManagedSent {
id: "0123456789ab".to_string(),
tmux_name: "tmpm-red".to_string(),
});
let text = line_text(&lines[0]);
assert!(text.contains("tmpm-red"), "{text}");
assert!(text.contains("01234567"), "{text}");
}
#[test]
fn render_result_renders_activity() {
let lines = render_result(&CommandResult::ManagedActivity {
id: "0123456789ab".to_string(),
state: "working".to_string(),
summary: "running tests".to_string(),
raw_pane: "...".to_string(),
runtime_active: true,
pending_decision: Some("allow rm?".to_string()),
});
assert!(line_text(&lines[0]).contains("working"));
assert!(line_text(&lines[0]).contains("alive"));
assert!(lines.iter().any(|l| line_text(l).contains("running tests")));
assert!(lines.iter().any(|l| line_text(l).contains("allow rm?")));
}
#[test]
fn render_result_renders_help() {
let lines = render_result(&CommandResult::Help("line one\nline two".to_string()));
assert_eq!(lines.len(), 2);
assert_eq!(line_text(&lines[0]), "line one");
assert_eq!(line_text(&lines[1]), "line two");
}
#[test]
fn render_result_renders_health() {
use crate::client::HealthReport;
let up = render_result(&CommandResult::Health(HealthReport {
reachable: true,
url: "http://127.0.0.1:7880".into(),
status: "ok".into(),
catalog_stale: false,
catalog_unknown: false,
managed_total: 2,
managed_pending_decisions: 0,
}));
let up_joined = up.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(up_joined.contains("daemon ok"));
assert!(up_joined.contains("up to date"));
assert!(up_joined.contains("2 session(s)"));
let down = render_result(&CommandResult::Health(HealthReport {
reachable: false,
url: "http://127.0.0.1:0".into(),
status: "unreachable".into(),
..HealthReport::default()
}));
assert_eq!(down.len(), 1);
assert!(line_text(&down[0]).contains("unreachable"));
}