use trusty_mpm::client::ManagedSessionSummary;
use super::{PickerDecision, next_launch_slot, parse_picker_choice};
use crate::commands::session_picker_render::{
StateColor, colorize, command_legend, format_session_row, picker_use_color,
restart_confirm_hint, state_color, table_use_color,
};
fn session(name: &str, state: &str, slot: u32) -> ManagedSessionSummary {
ManagedSessionSummary {
id: format!("{name}-id"),
name: name.to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: None,
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot,
deleted: false,
}
}
#[test]
fn next_launch_slot_matches_ascending_order() {
let sessions = vec![session("s1", "active", 1), session("s2", "active", 2)];
assert_eq!(next_launch_slot(&sessions), 3);
}
#[test]
fn next_launch_slot_survives_recency_reorder() {
let sessions = vec![
session("s31", "active", 31),
session("s12", "active", 12),
session("s5", "active", 5), ];
assert_eq!(
next_launch_slot(&sessions),
32,
"must be (max slot)+1, not (last element's slot)+1"
);
}
#[test]
fn next_launch_slot_survives_alpha_reorder() {
let sessions = vec![
session("s12", "active", 12),
session("s31", "active", 31),
session("s5", "active", 5),
];
assert_eq!(next_launch_slot(&sessions), 32);
}
#[test]
fn next_launch_slot_empty_sessions_is_one() {
assert_eq!(next_launch_slot(&[]), 1);
}
#[test]
fn next_launch_slot_stale_slots_uses_positional_fallback() {
let sessions = vec![session("s1", "active", 0), session("s2", "active", 0)];
assert_eq!(next_launch_slot(&sessions), 3);
}
#[test]
fn parse_picker_choice_launch_new_uses_max_slot_after_reorder() {
let sessions = vec![
session("s31", "active", 31),
session("s12", "active", 12),
session("s5", "active", 5),
];
assert_eq!(
parse_picker_choice("31", &sessions, false),
PickerDecision::Resume(0),
"31 must resolve to the real session at that slot, not collide with launch-new"
);
assert_eq!(
parse_picker_choice("32", &sessions, false),
PickerDecision::LaunchNew(None)
);
}
#[test]
fn parse_picker_choice_rename_no_space_between_prefix_and_number() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r1 new-name", &sessions, false),
PickerDecision::Rename(0, "new-name".to_string())
);
}
#[test]
fn parse_picker_choice_rename_space_between_prefix_and_number() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r 1 new-name", &sessions, false),
PickerDecision::Rename(0, "new-name".to_string())
);
}
#[test]
fn parse_picker_choice_rename_uppercase_prefix() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("R1 new-name", &sessions, false),
PickerDecision::Rename(0, "new-name".to_string())
);
}
#[test]
fn parse_picker_choice_rename_trims_and_joins_multiword_name() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r1 my new name \n", &sessions, false),
PickerDecision::Rename(0, "my new name".to_string())
);
}
#[test]
fn parse_picker_choice_rename_missing_name_is_unrecognised() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r1", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn parse_picker_choice_rename_whitespace_only_name_is_unrecognised() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r1 ", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn parse_picker_choice_rename_unknown_slot_is_unrecognised() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("r99 new-name", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn parse_picker_choice_rename_non_numeric_slot_is_unrecognised() {
let sessions = vec![session("s1", "active", 1)];
assert_eq!(
parse_picker_choice("rabc new-name", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn parse_picker_choice_rename_deleted_slot_is_slot_deleted() {
let mut gone = session("gone", "deleted", 3);
gone.deleted = true;
let sessions = vec![gone];
assert_eq!(
parse_picker_choice("r3 new-name", &sessions, false),
PickerDecision::SlotDeleted(0)
);
}
#[test]
fn parse_picker_choice_glob_delete_recognised() {
let sessions = vec![session("tm-test-01", "stopped", 1)];
let parsed = parse_picker_choice("d tm-test-*", &sessions, false);
match parsed {
PickerDecision::DeleteGlob(req) => {
assert_eq!(req.pattern, "tm-test-*");
assert!(!req.dry_run);
}
other => panic!("expected DeleteGlob, got {other:?}"),
}
}
#[test]
fn parse_picker_choice_glob_delete_dry_run() {
let sessions = vec![session("tm-test-01", "stopped", 1)];
match parse_picker_choice("d tm-test-* --dry-run", &sessions, false) {
PickerDecision::DeleteGlob(req) => assert!(req.dry_run),
other => panic!("expected DeleteGlob, got {other:?}"),
}
}
#[test]
fn parse_picker_choice_glob_delete_does_not_shadow_slot_delete() {
let sessions = vec![session("s1", "stopped", 1)];
assert_eq!(
parse_picker_choice("d1", &sessions, false),
PickerDecision::Delete(0)
);
}
#[test]
fn parse_picker_choice_glob_delete_typo_stays_unrecognised() {
let sessions = vec![session("elete", "stopped", 1)];
assert_eq!(
parse_picker_choice("delete", &sessions, false),
PickerDecision::Unrecognised
);
assert_eq!(
parse_picker_choice("d99", &sessions, false),
PickerDecision::Unrecognised
);
}
#[test]
fn colorize_wraps_when_color_enabled() {
assert_eq!(
colorize("active", StateColor::Green, true),
"\x1b[32mactive\x1b[0m"
);
}
#[test]
fn colorize_plain_when_color_disabled() {
assert_eq!(colorize("active", StateColor::Green, false), "active");
}
#[test]
fn colorize_plain_variant_never_wraps_even_with_color_enabled() {
assert_eq!(
colorize("weird-state", StateColor::Plain, true),
"weird-state"
);
}
#[test]
fn state_color_attached_wins_over_active() {
assert_eq!(state_color("active", true), StateColor::AttachedCyan);
assert_eq!(state_color("stopped", true), StateColor::AttachedCyan);
}
#[test]
fn state_color_maps_known_states() {
assert_eq!(state_color("active", false), StateColor::Green);
assert_eq!(state_color("stopped", false), StateColor::Dim);
assert_eq!(state_color("errored", false), StateColor::Red);
assert_eq!(state_color("provisioning", false), StateColor::Yellow);
}
#[test]
fn state_color_unknown_state_is_plain() {
assert_eq!(state_color("some-future-state", false), StateColor::Plain);
}
#[test]
fn picker_use_color_requires_tty() {
assert!(!picker_use_color(false));
}
#[test]
#[serial_test::serial(no_color_env)]
fn picker_use_color_false_when_no_color_set_even_on_tty() {
let prev = std::env::var_os("NO_COLOR");
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let result = picker_use_color(true);
unsafe {
match prev {
Some(v) => std::env::set_var("NO_COLOR", v),
None => std::env::remove_var("NO_COLOR"),
}
}
assert!(
!result,
"NO_COLOR must disable color even when stderr is a TTY"
);
}
#[test]
fn table_use_color_requires_stdout_tty() {
assert!(!table_use_color(false));
if std::env::var_os("NO_COLOR").is_none() {
assert!(table_use_color(true));
}
}
#[test]
#[serial_test::serial(no_color_env)]
fn table_use_color_false_when_no_color_set_even_on_tty() {
let prev = std::env::var_os("NO_COLOR");
unsafe {
std::env::set_var("NO_COLOR", "");
}
let result = table_use_color(true);
unsafe {
match prev {
Some(v) => std::env::set_var("NO_COLOR", v),
None => std::env::remove_var("NO_COLOR"),
}
}
assert!(
!result,
"an EMPTY NO_COLOR must still disable color on a stdout TTY"
);
}
#[test]
fn format_session_row_normal_is_verbless() {
let s = session("tm-proj-01", "active", 5);
let row = format_session_row(5, &s, false);
assert_eq!(row, "[5] tm-proj-01 (active)");
assert!(!row.contains("resume"));
assert!(!row.contains("restart"));
}
#[test]
fn format_session_row_stopped_is_verbless() {
let s = session("tm-proj-01", "stopped", 11);
let row = format_session_row(11, &s, false);
assert_eq!(row, "[11] tm-proj-01 (stopped)");
assert!(!row.contains("restart"));
}
#[test]
fn format_session_row_attached_uses_attached_word() {
let mut s = session("tm-proj-01", "active", 2);
s.attached = true;
let row = format_session_row(2, &s, false);
assert_eq!(row, "[2] tm-proj-01 (attached)");
}
#[test]
fn format_session_row_deleted_notice() {
let mut s = session("gone", "deleted", 7);
s.deleted = true;
let row = format_session_row(7, &s, false);
assert_eq!(row, "[7] -- deleted --");
}
#[test]
fn format_session_row_unresumable_notice() {
let mut s = session("tm-dead-01", "stopped", 9);
s.unresumable = true;
let row = format_session_row(9, &s, false);
assert_eq!(
row,
"[9] tm-dead-01 (stopped) — DEAD: workspace removed; use [d9] to remove the record"
);
}
#[test]
fn format_session_row_color_enabled_wraps_state_word_only() {
let s = session("tm-proj-01", "active", 5);
let row = format_session_row(5, &s, true);
assert_eq!(row, "[5] tm-proj-01 (\x1b[32mactive\x1b[0m)");
}
#[test]
fn format_session_row_color_disabled_is_plain_text() {
let s = session("tm-proj-01", "active", 5);
let row = format_session_row(5, &s, false);
assert!(
!row.contains('\x1b'),
"no ANSI escapes when use_color is false"
);
}
#[test]
fn command_legend_empty_menu_shape() {
let lines = command_legend(None);
let keys: Vec<&str> = lines
.iter()
.map(|l| l.split(" ").next().unwrap())
.collect();
assert_eq!(keys, ["[Enter]", "[n <name>]", "[ls]", "[q]"]);
}
#[test]
fn command_legend_populated_menu_shape() {
let lines = command_legend(Some(4));
let keys: Vec<&str> = lines
.iter()
.map(|l| l.split(" ").next().unwrap())
.collect();
assert_eq!(
keys,
[
"[4]",
"[n <name>]",
"[d<N>]",
"[d <glob>]",
"[r<N> <new-name>]",
"[ls]",
"[q]"
]
);
}
#[test]
fn command_legend_glob_row_advertises_dry_run() {
let lines = command_legend(Some(4));
let row = lines
.iter()
.find(|l| l.starts_with("[d <glob>]"))
.expect("the glob-delete row must be present");
assert!(
row.contains("--dry-run"),
"row must offer the preview: {row}"
);
assert!(
row.contains("stopped"),
"row must say it only deletes stopped sessions: {row}"
);
}
#[test]
fn command_legend_n_row_names_the_resulting_session_shape() {
let lines = command_legend(Some(4));
let n_row = lines
.iter()
.find(|l| l.starts_with("[n <name>]"))
.expect("the n row must be present");
assert!(
n_row.contains("tm-<name>-NN"),
"the n row must spell out the name it produces: {n_row}"
);
}
#[test]
fn command_legend_columns_are_aligned() {
for lines in [command_legend(None), command_legend(Some(12))] {
let offsets: Vec<usize> = lines
.iter()
.map(|l| {
l.find("launch")
.or_else(|| l.find("delete"))
.or_else(|| l.find("rename"))
.or_else(|| l.find("re-print"))
.or_else(|| l.find("quit"))
.expect("every row has a description")
})
.collect();
assert!(
offsets.windows(2).all(|w| w[0] == w[1]),
"descriptions must all start in the same column: {lines:#?}"
);
}
}
#[test]
fn restart_confirm_hint_names_the_refusal_key() {
let hint = restart_confirm_hint(3);
assert!(hint.contains("[3] confirms"), "confirm key missing: {hint}");
assert!(hint.contains("[q] quits"), "refusal key missing: {hint}");
}
#[test]
fn restart_confirm_hint_disowns_n_as_no() {
let hint = restart_confirm_hint(1);
assert!(hint.contains("[n]"), "`n` unmentioned: {hint}");
assert!(hint.contains("not \"no\""), "`n` not disowned: {hint}");
assert!(
hint.contains("NEW session"),
"`n`'s real effect unstated: {hint}"
);
}
#[test]
fn store_banner_is_absent_for_a_healthy_listing() {
assert_eq!(
super::store_degradation_banner(r#"{"sessions":[]}"#),
None,
"a healthy listing must print nothing"
);
}
#[test]
fn store_banner_is_absent_for_an_unparseable_body() {
assert_eq!(super::store_degradation_banner("not json"), None);
}
#[test]
fn store_banner_names_corruption_and_the_repair_command() {
let raw = r#"{"sessions":[],"store_health":{"message":"/x/sessions.json is corrupt: trailing characters (line 3755, column 2); a complete JSON document ends at byte 145090 of 146201, followed by 1111 trailing byte(s). Repair with `tm repair session-store`","corrupt":true,"observed_at":"2026-08-06T09:01:17Z"}}"#;
let banner = super::store_degradation_banner(raw).expect("a corrupt store must warn");
assert!(banner.contains("CORRUPT"), "{banner}");
assert!(
banner.contains("every write to the store is failing"),
"{banner}"
);
assert!(banner.contains("byte 145090"), "{banner}");
assert!(banner.contains("tm repair session-store"), "{banner}");
}
#[test]
fn store_banner_goes_to_stderr_so_json_stays_machine_readable() {
const CHILD: &str = "TM_5027_BANNER_CHILD";
const MARKER: &str = "CORRUPT";
let raw = r#"{"sessions":[],"store_health":{"message":"/x/sessions.json is corrupt","corrupt":true,"observed_at":"2026-08-06T09:01:17Z"}}"#;
if std::env::var_os(CHILD).is_some() {
super::warn_if_store_degraded(raw);
return;
}
let module = module_path!();
let module = module.split_once("::").map_or(module, |(_, rest)| rest);
let name = format!("{module}::store_banner_goes_to_stderr_so_json_stays_machine_readable");
let out = std::process::Command::new(std::env::current_exe().expect("test binary path"))
.args([&name, "--exact", "--nocapture", "--test-threads", "1"])
.env(CHILD, "1")
.output()
.expect("re-run this test as a child process");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stdout.contains("1 passed"),
"the child must have actually run the case; stdout: {stdout}\nstderr: {stderr}"
);
assert!(
stderr.contains(MARKER),
"the banner must go to stderr; stderr: {stderr}"
);
assert!(
!stdout.contains(MARKER),
"the banner must NEVER reach stdout — `tm ls --json` writes the response body \
there; stdout: {stdout}"
);
}
#[test]
fn store_banner_marks_a_transient_read_failure_as_stale_not_corrupt() {
let raw = r#"{"sessions":[],"store_health":{"message":"session store I/O error: nfs hiccup","corrupt":false,"observed_at":"2026-08-06T09:01:17Z"}}"#;
let banner = super::store_degradation_banner(raw).expect("a degraded store must warn");
assert!(banner.contains("may be stale"), "{banner}");
assert!(
!banner.contains("CORRUPT"),
"a transient failure must not be called corruption: {banner}"
);
}