use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::events::{PendingAction, handle_key};
use super::panes::{actions_bar, projects, sessions};
use super::poll::{project_ctl_poll_daemon, project_to_row, session_to_row};
use super::state::{ConfirmKind, DeliverableLinkState, Pane, ProjectCtlState};
use crate::client::{DaemonClient, FleetProjectGroupWire, ManagedSessionSummary};
use crate::project::Project;
fn project(name: &str) -> Project {
Project {
name: name.to_string(),
repo_url: format!("https://github.com/acme/{name}"),
default_branch: "main".to_string(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn summary(id: &str, state: &str) -> ManagedSessionSummary {
ManagedSessionSummary {
id: id.to_string(),
name: format!("s-{id}"),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: Some("main".to_string()),
created_at: None,
last_activity_at: None,
pending_decision: None,
proposed_default: None,
source_id: None,
task: Some("ship it".to_string()),
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: 0,
deleted: false,
auto_resume_parked: None,
kind: None,
}
}
fn seeded_state() -> ProjectCtlState {
let trusty_tools = project("trusty-tools");
let genealogy = project("genealogy");
let group = FleetProjectGroupWire {
project_name: "trusty-tools".to_string(),
repo_url: trusty_tools.repo_url.clone(),
sessions: vec![summary("a1b2c3d4e5f6", "active")],
};
let mut state = ProjectCtlState {
projects: vec![
project_to_row(&trusty_tools, Some(&group)),
project_to_row(&genealogy, None),
],
projects_full: [
("trusty-tools".to_string(), trusty_tools.clone()),
("genealogy".to_string(), genealogy.clone()),
]
.into_iter()
.collect(),
..Default::default()
};
state.sessions_by_project.insert(
"trusty-tools".to_string(),
group.sessions.into_iter().map(session_to_row).collect(),
);
state.projects_nav.sync_len(state.projects.len());
state.sessions_nav.sync_len(state.current_sessions().len());
state
}
#[test]
fn seeded_state_renders_live_project_and_its_session() {
let state = seeded_state();
assert_eq!(state.projects.len(), 2);
assert_eq!(state.selected_project().unwrap().name, "trusty-tools");
assert_eq!(state.current_sessions().len(), 1);
let projects_line = projects::project_line(&state.projects[0]);
let text: String = projects_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.starts_with("●1"),
"expected a live glyph+count: {text}"
);
let session_line = sessions::session_line(
1,
&state.current_sessions()[0],
DeliverableLinkState::Unknown,
);
let text: String = session_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.contains("s-a1b2c3d4e5f6"),
"missing session name: {text}"
);
assert!(text.contains("ship it"), "missing task: {text}");
}
#[test]
fn drilling_in_then_killing_an_active_session_requires_confirmation() {
let mut state = seeded_state();
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(handle_key(&mut state, enter).is_none());
assert_eq!(state.focus, Pane::Sessions);
let kill = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
assert!(
handle_key(&mut state, kill).is_none(),
"kill on an Active session must open the confirm gate, not fire"
);
assert!(state.pending_confirm.is_some());
let confirm = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
let action = handle_key(&mut state, confirm);
assert_eq!(
action,
Some(PendingAction::Kill("a1b2c3d4e5f6".to_string()))
);
assert!(state.pending_confirm.is_none());
}
#[test]
fn switching_to_the_empty_project_clears_the_sessions_pane() {
let mut state = seeded_state();
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
handle_key(&mut state, down);
assert_eq!(state.selected_project().unwrap().name, "genealogy");
assert!(state.current_sessions().is_empty());
assert!(state.selected_session().is_none());
}
#[test]
fn action_bar_reflects_daemon_reachability_by_default() {
let state = seeded_state();
assert!(actions_bar::bar_text(&state).contains(actions_bar::DAEMON_UNREACHABLE));
}
use crate::test_support::dead_loopback_url;
#[tokio::test]
async fn poll_never_touches_pending_confirm() {
let mut state = seeded_state();
state.request_confirm(
ConfirmKind::Decommission,
"a1b2c3d4e5f6",
"trusty-tools-session",
);
let before = state.pending_confirm.clone().expect("confirm requested");
let mut client = DaemonClient::new(dead_loopback_url());
project_ctl_poll_daemon(&mut state, &mut client).await;
assert_eq!(
state.pending_confirm,
Some(before),
"a poll must never mutate an open confirmation gate's pinned target"
);
}
#[tokio::test]
async fn poll_never_closes_an_open_deliverable_view() {
let mut state = seeded_state();
state.open_deliverable_view("trusty-tools");
assert!(state.deliverable_view.is_some());
let mut client = DaemonClient::new(dead_loopback_url());
project_ctl_poll_daemon(&mut state, &mut client).await;
assert_eq!(
state
.deliverable_view
.as_ref()
.map(|v| v.project_name.as_str()),
Some("trusty-tools"),
"a poll must never close an open Deliverable/Milestone view"
);
}
#[tokio::test]
async fn poll_doesnt_touch_open_config_form() {
let mut state = seeded_state();
state.open_config_form("trusty-tools");
state.config_form.as_mut().unwrap().description.value = "unsaved edit in progress".to_string();
let before = state.config_form.clone().expect("form requested");
let mut client = DaemonClient::new(dead_loopback_url());
project_ctl_poll_daemon(&mut state, &mut client).await;
assert_eq!(
state.config_form,
Some(before),
"a poll must never mutate an open config form — not even its \
unsaved, not-yet-submitted field edits"
);
}
#[tokio::test]
async fn project_ctl_decommission_prunes_stale_worktree_bookkeeping() {
use std::future::IntoFuture;
fn git(dir: &std::path::Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
let tmp = tempfile::TempDir::new().unwrap();
let base = tmp.path().join("tm-5913-base");
std::fs::create_dir_all(&base).expect("create base repo dir");
git(&base, &["init", "--quiet"]);
git(&base, &["config", "user.email", "tm@example.invalid"]);
git(&base, &["config", "user.name", "tm test"]);
std::fs::write(base.join("README.md"), "seed\n").unwrap();
git(&base, &["add", "README.md"]);
git(&base, &["commit", "--quiet", "-m", "seed"]);
let id = "11111111-2222-3333-4444-555555555555";
let leaf = "tm-5913-project-ctl";
let ws = base.join(".worktrees").join(leaf);
git(
&base,
&[
"worktree",
"add",
"--quiet",
"-b",
&format!("session/{leaf}"),
&ws.to_string_lossy(),
],
);
assert!(
git(&base, &["worktree", "list", "--porcelain"]).contains(leaf),
"fixture invariant: the worktree must be registered before decommission"
);
std::fs::remove_dir_all(&ws).expect("remove the workspace");
let outcome = serde_json::json!({
"id": id,
"name": leaf,
"state": "decommissioned",
"workspace_removed": true,
"workspace_path_was": ws.to_string_lossy(),
});
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(move || std::future::ready(axum::Json(outcome.clone()))),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, app).into_future());
let client = DaemonClient::new(format!("http://{addr}"));
let mut state = seeded_state();
super::actions::dispatch(
&mut state,
&client,
PendingAction::Decommission(id.to_string()),
)
.await;
assert_eq!(
state.notice.as_deref(),
Some(format!("decommissioned {leaf} — now decommissioned").as_str()),
"the hotkey must still report the state change"
);
assert!(
!git(&base, &["worktree", "list", "--porcelain"]).contains(leaf),
"the TUI's [d] decommission left a stale worktree entry for {leaf} — it \
is not routed through the shared implementation"
);
}