use clap::Parser;
use crate::cli::{Cli, Command, SessionAction};
use crate::commands::managed::{filter_live_sessions, is_live_session_state};
#[test]
fn cli_parses_sessions_plural_canonical() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "list"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::List { dir },
} => assert_eq!(dir, None),
other => panic!("expected sessions list, got {other:?}"),
}
}
#[test]
fn cli_session_and_sessions_agree_for_every_verb() {
let cases: &[&[&str]] = &[
&["start"],
&["stop", "id-1"],
&["kill", "id-1"],
&["list"],
&["tui"],
&["clean"],
&["info", "id-1"],
&["instructions"],
&["events", "id-1"],
&["breakers"],
&["pause", "id-1"],
&["resume", "id-1"],
&["run", "id-1", "echo hi"],
&["output", "id-1"],
&["new", "https://example.com/o/r.git", "--task", "t"],
&["ls"],
&["activity", "id-1"],
&["send", "id-1", "text"],
&["answer", "id-1", "yes"],
&["attach", "id-1"],
&["managed-stop", "id-1"],
&["runtime-stop", "id-1"],
&["managed-resume", "id-1"],
&["decommission", "id-1"],
&["delete", "id-1"],
&["prune-idle"],
&["decommission-ephemeral"],
&["catchup"],
&["prune", "--state", "all"],
&["prune-worktrees"],
];
for verb_args in cases {
let mut singular_args = vec!["trusty-mpm", "session"];
singular_args.extend_from_slice(verb_args);
let mut plural_args = vec!["trusty-mpm", "sessions"];
plural_args.extend_from_slice(verb_args);
let singular_action = match Cli::try_parse_from(singular_args)
.unwrap_or_else(|e| panic!("`tm session {verb_args:?}` failed to parse: {e}"))
.command
.unwrap()
{
Command::Session { action } => action,
other => panic!("expected Command::Session for {verb_args:?}, got {other:?}"),
};
let plural_action = match Cli::try_parse_from(plural_args)
.unwrap_or_else(|e| panic!("`tm sessions {verb_args:?}` failed to parse: {e}"))
.command
.unwrap()
{
Command::Sessions { action } => action,
other => panic!("expected Command::Sessions for {verb_args:?}, got {other:?}"),
};
assert_eq!(
format!("{singular_action:?}"),
format!("{plural_action:?}"),
"session/sessions parsed to different actions for {verb_args:?}"
);
}
}
#[test]
fn top_level_alias_notice_message() {
use crate::commands::managed::deprecation_message;
assert_eq!(
deprecation_message("session", "sessions"),
"warning: 'session' is deprecated; use 'sessions'"
);
}
#[test]
fn unresumable_remedy_line_cites_real_subcommands() {
use crate::commands::guided_resume::unresumable_remedy_line;
let id = "6ca3950b-90d5-4367-94c7-68576b61dafa";
let workspace_missing = unresumable_remedy_line(id, Some("workspace_missing"));
assert!(
!workspace_missing.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {workspace_missing:?}"
);
let cli = Cli::try_parse_from(["trusty-mpm", "session", "delete", id, "--force"])
.expect("the exact verb cited by the workspace_missing remedy must parse");
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Delete {
id: parsed_id,
force,
},
} => {
assert_eq!(parsed_id, id);
assert!(force, "the cited invocation must pass --force");
}
other => panic!("expected session delete, got {other:?}"),
}
let pane_gone = unresumable_remedy_line(id, Some("pane_gone"));
assert!(
!pane_gone.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {pane_gone:?}"
);
assert!(
pane_gone.contains("sibling window"),
"pane_gone remedy must explain the sibling-window hazard, got: {pane_gone:?}"
);
assert!(
pane_gone.contains("tmux list-panes") || pane_gone.contains("session info"),
"pane_gone remedy must point at an inspection step before any teardown, got: {pane_gone:?}"
);
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission", id])
.expect("the decommission verb cited by the pane_gone remedy must parse");
assert!(matches!(
cli.command.unwrap(),
Command::Session {
action: SessionAction::Decommission { .. }
}
));
let cli_info = Cli::try_parse_from(["trusty-mpm", "session", "info", id])
.expect("the info verb cited by the pane_gone remedy must parse");
assert!(matches!(
cli_info.command.unwrap(),
Command::Session {
action: SessionAction::Info { .. }
}
));
let fallback = unresumable_remedy_line(id, None);
assert!(
!fallback.contains("session rm"),
"must never cite the nonexistent `tm session rm` verb, got: {fallback:?}"
);
assert!(
!fallback.contains("delete") && !fallback.contains("decommission"),
"the no-reason fallback must not suggest a destructive verb, got: {fallback:?}"
);
}
#[test]
fn truncate_for_display_leaves_short_bodies_unchanged() {
use crate::commands::guided_resume::truncate_for_display;
let short = "workspace directory /gone no longer exists";
assert_eq!(truncate_for_display(short), short);
}
#[test]
fn truncate_for_display_caps_long_bodies() {
use crate::commands::guided_resume::truncate_for_display;
let long = "x".repeat(5000);
let result = truncate_for_display(&long);
assert!(
result.chars().count() < long.chars().count(),
"an oversized body must be shortened"
);
assert!(
result.ends_with("… (truncated)"),
"a truncated body must be marked as such, got tail: {:?}",
&result[result.len().saturating_sub(20)..]
);
}
#[test]
fn picker_filter_live_state_excludes_decommissioned() {
assert!(
!is_live_session_state("decommissioned", false),
"decommissioned must be excluded from default view"
);
assert!(
is_live_session_state("active", false),
"active must be included in default view"
);
assert!(
is_live_session_state("stopped", false),
"stopped must be included in default view"
);
assert!(
is_live_session_state("errored", false),
"errored must be included in default view"
);
assert!(
is_live_session_state("provisioning", false),
"provisioning must be included in default view"
);
}
#[test]
fn is_live_session_state_excludes_soft_deleted_record() {
assert!(
!is_live_session_state("deleted", false),
"a soft-deleted, still-in-store record must be excluded from the \
default picker/list view"
);
assert!(!is_live_session_state("decommissioned", false));
assert!(is_live_session_state("active", false));
assert!(is_live_session_state("stopped", false));
}
#[test]
fn is_live_session_state_keeps_slot_tombstone_visible() {
assert!(
is_live_session_state("deleted", true),
"a #3034 slot tombstone must remain visible in the default view"
);
}
#[test]
fn picker_filter_excludes_decommissioned_keeps_active() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "a1", "name": "sess-active", "state": "active" },
{ "id": "b2", "name": "sess-dead-1", "state": "decommissioned" },
{ "id": "c3", "name": "sess-stopped", "state": "stopped" },
{ "id": "d4", "name": "sess-dead-2", "state": "decommissioned" },
{ "id": "e5", "name": "sess-provisioning", "state": "provisioning" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert_eq!(
filtered.len(),
3,
"filter must keep exactly 3 live sessions (active, stopped, provisioning)"
);
assert!(
filtered.iter().any(|s| s.state == "active"),
"active session must survive filter"
);
assert!(
filtered.iter().any(|s| s.state == "stopped"),
"stopped session must survive filter"
);
assert!(
filtered.iter().any(|s| s.state == "provisioning"),
"provisioning session must survive filter"
);
assert!(
!filtered.iter().any(|s| s.state == "decommissioned"),
"decommissioned tombstones must be excluded"
);
}
#[test]
fn picker_filter_all_live_sessions_unchanged() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "x1", "name": "sess-a", "state": "active" },
{ "id": "x2", "name": "sess-b", "state": "stopped" },
{ "id": "x3", "name": "sess-c", "state": "errored" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert_eq!(
filtered.len(),
3,
"all-live input must pass through unchanged (3 sessions)"
);
}
#[test]
fn picker_filter_all_decommissioned_returns_empty() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "z1", "name": "old-1", "state": "decommissioned" },
{ "id": "z2", "name": "old-2", "state": "decommissioned" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert!(
filtered.is_empty(),
"all-decommissioned input must produce empty list"
);
}
#[test]
fn picker_filter_keeps_slot_tombstone_hides_soft_deleted_record() {
let sessions: Vec<trusty_mpm::client::ManagedSessionSummary> =
serde_json::from_value(serde_json::json!([
{ "id": "a1", "name": "sess-active", "state": "active" },
{ "id": "", "name": "", "state": "deleted", "slot": 2, "deleted": true },
{ "id": "b2", "name": "sess-soft-deleted", "state": "deleted" },
]))
.expect("test data must deserialize");
let filtered = filter_live_sessions(sessions);
assert_eq!(
filtered.len(),
2,
"exactly the active session and the slot tombstone must survive"
);
assert!(
filtered.iter().any(|s| s.deleted),
"the slot tombstone (deleted: true) must survive the filter"
);
assert!(
!filtered.iter().any(|s| s.state == "deleted" && !s.deleted),
"the soft-deleted, still-in-store record (deleted: false) must be excluded"
);
}