3410 lines
114 KiB
Rust
3410 lines
114 KiB
Rust
use super::render::*;
|
|
use super::*;
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
};
|
|
#[test]
|
|
fn orchestration_worktree_layout_is_stable_under_original_workspace_root() {
|
|
let root = Path::new("/tmp/Yoi Workspace");
|
|
let layout = orchestration_worktree_layout(root);
|
|
assert_eq!(
|
|
layout.path,
|
|
PathBuf::from("/tmp/Yoi Workspace/.worktree/orchestration")
|
|
);
|
|
assert_eq!(layout.branch, "orchestration");
|
|
}
|
|
|
|
#[test]
|
|
fn orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace() {
|
|
let original = PathBuf::from("/repo/yoi");
|
|
let orchestration = original
|
|
.join(".worktree")
|
|
.join("orchestration")
|
|
.join("yoi-orchestrator");
|
|
let context = build_orchestrator_launch_context(&original, &orchestration, "yoi-orchestrator");
|
|
assert_eq!(context.workspace_root, orchestration);
|
|
assert_eq!(
|
|
context.original_workspace_root.as_deref(),
|
|
Some(original.as_path())
|
|
);
|
|
assert_eq!(
|
|
context.target_workspace_root.as_deref(),
|
|
Some(original.as_path())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_existing_orchestration_path_is_diagnostic_not_cleanup() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let layout = orchestration_worktree_layout(&root);
|
|
std::fs::create_dir_all(&layout.path).unwrap();
|
|
std::fs::write(layout.path.join("keep.txt"), "do not delete").unwrap();
|
|
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("not a Git worktree"));
|
|
assert!(layout.path.join("keep.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_orchestration_worktree_creates_and_reuses_git_worktree() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
run_test_git(&root, &["init"]).unwrap();
|
|
std::fs::write(root.join("README.md"), "repo").unwrap();
|
|
run_test_git(&root, &["add", "README.md"]).unwrap();
|
|
run_test_git(
|
|
&root,
|
|
&[
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=Yoi Test",
|
|
"commit",
|
|
"-m",
|
|
"init",
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let created = ensure_orchestration_worktree(&root).unwrap();
|
|
assert_eq!(created.status, OrchestrationWorktreeStatus::Created);
|
|
assert!(created.layout.path.exists());
|
|
assert!(git_inside_worktree(&created.layout.path));
|
|
|
|
let reused = ensure_orchestration_worktree(&root).unwrap();
|
|
assert_eq!(reused.status, OrchestrationWorktreeStatus::Reused);
|
|
assert_eq!(reused.layout, created.layout);
|
|
|
|
std::fs::write(created.layout.path.join("dirty.txt"), "dirty").unwrap();
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("dirty"));
|
|
assert!(created.layout.path.join("dirty.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn ensure_and_restore_use_configured_orchestration_layout() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
write_test_ticket_config(
|
|
&root,
|
|
r#"
|
|
[orchestration]
|
|
branch = "orchestration/custom-panel"
|
|
worktree_dir = "custom-worktrees"
|
|
worktree_name = "panel"
|
|
"#,
|
|
);
|
|
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
|
|
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
|
|
|
|
let resolved = resolved_orchestration_worktree_layout(&root).unwrap();
|
|
assert_eq!(resolved.branch, "orchestration/custom-panel");
|
|
assert!(resolved.path.ends_with("custom-worktrees/panel"));
|
|
|
|
let created = ensure_orchestration_worktree(&root).unwrap();
|
|
assert_eq!(created.status, OrchestrationWorktreeStatus::Created);
|
|
assert_eq!(created.layout, resolved);
|
|
let branch = run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap();
|
|
assert_eq!(branch.trim(), "orchestration/custom-panel");
|
|
|
|
let restored = prepare_orchestration_worktree_for_restore(&root).unwrap();
|
|
assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused);
|
|
assert_eq!(restored.layout, created.layout);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
write_test_ticket_config(
|
|
&root,
|
|
r#"
|
|
[orchestration]
|
|
branch = "orchestration/bad:branch"
|
|
"#,
|
|
);
|
|
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("failed to load ticket config"));
|
|
assert!(err.contains("git branch name"));
|
|
assert!(!root.join(".worktree").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
write_test_ticket_config(
|
|
&root,
|
|
r#"
|
|
[orchestration]
|
|
branch = "orchestration/custom-panel"
|
|
"#,
|
|
);
|
|
run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap();
|
|
run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap();
|
|
let layout = resolved_orchestration_worktree_layout(&root).unwrap();
|
|
run_test_git(
|
|
&root,
|
|
&[
|
|
"worktree",
|
|
"add",
|
|
&layout.path.display().to_string(),
|
|
"-b",
|
|
"orchestration/other-panel",
|
|
"HEAD",
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err();
|
|
assert!(err.contains("expected orchestration/custom-panel"));
|
|
let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap();
|
|
assert_eq!(branch.trim(), "orchestration/other-panel");
|
|
}
|
|
|
|
#[test]
|
|
fn restore_uses_existing_orchestration_worktree_even_when_dirty() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
let created = ensure_orchestration_worktree(&root).unwrap();
|
|
std::fs::write(created.layout.path.join("orchestrator-notes.txt"), "dirty").unwrap();
|
|
|
|
let restored = prepare_orchestration_worktree_for_restore(&root).unwrap();
|
|
|
|
assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused);
|
|
assert_eq!(restored.layout.path, created.layout.path);
|
|
assert_ne!(restored.layout.path, root);
|
|
assert!(restored.layout.path.ends_with(".worktree/orchestration"));
|
|
}
|
|
|
|
#[test]
|
|
fn existing_wrong_branch_worktree_is_rejected_without_cleanup() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
let layout = orchestration_worktree_layout(&root);
|
|
run_test_git(
|
|
&root,
|
|
&[
|
|
"worktree",
|
|
"add",
|
|
&layout.path.display().to_string(),
|
|
"-b",
|
|
"wrong-branch",
|
|
"HEAD",
|
|
],
|
|
)
|
|
.unwrap();
|
|
std::fs::write(layout.path.join("keep.txt"), "keep").unwrap();
|
|
run_test_git(&layout.path, &["add", "keep.txt"]).unwrap();
|
|
run_test_git(
|
|
&layout.path,
|
|
&[
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=Yoi Test",
|
|
"commit",
|
|
"-m",
|
|
"keep",
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("expected orchestration"));
|
|
assert!(layout.path.join("keep.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn existing_unrelated_repo_with_expected_branch_is_rejected_without_cleanup() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
let layout = orchestration_worktree_layout(&root);
|
|
std::fs::create_dir_all(&layout.path).unwrap();
|
|
init_test_repo(&layout.path);
|
|
run_test_git(&layout.path, &["checkout", "-b", &layout.branch]).unwrap();
|
|
std::fs::write(layout.path.join("unrelated.txt"), "keep").unwrap();
|
|
run_test_git(&layout.path, &["add", "unrelated.txt"]).unwrap();
|
|
run_test_git(
|
|
&layout.path,
|
|
&[
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"-c",
|
|
"user.name=Yoi Test",
|
|
"commit",
|
|
"-m",
|
|
"unrelated",
|
|
],
|
|
)
|
|
.unwrap();
|
|
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("different Git repository"));
|
|
assert!(layout.path.join("unrelated.txt").exists());
|
|
}
|
|
|
|
fn write_test_ticket_config(root: &Path, content: &str) {
|
|
let config_dir = root.join(".yoi");
|
|
std::fs::create_dir_all(&config_dir).unwrap();
|
|
std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap();
|
|
}
|
|
|
|
fn init_test_repo(root: &Path) {
|
|
std::fs::create_dir_all(root).unwrap();
|
|
run_test_git(root, &["init"]).unwrap();
|
|
run_test_git(root, &["config", "user.email", "test@example.invalid"]).unwrap();
|
|
run_test_git(root, &["config", "user.name", "Yoi Test"]).unwrap();
|
|
std::fs::write(root.join("README.md"), "repo").unwrap();
|
|
run_test_git(root, &["add", "README.md"]).unwrap();
|
|
run_test_git(root, &["commit", "-m", "init"]).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn inherited_parent_worktree_directory_is_rejected_without_cleanup() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("repo");
|
|
init_test_repo(&root);
|
|
let layout = orchestration_worktree_layout(&root);
|
|
run_test_git(&root, &["checkout", "-b", &layout.branch]).unwrap();
|
|
std::fs::create_dir_all(&layout.path).unwrap();
|
|
std::fs::write(layout.path.join("plain.txt"), "keep").unwrap();
|
|
|
|
let err = ensure_orchestration_worktree(&root).unwrap_err();
|
|
assert!(err.contains("not the worktree root"));
|
|
assert!(layout.path.join("plain.txt").exists());
|
|
}
|
|
|
|
fn run_test_git(root: &Path, args: &[&str]) -> Result<(), String> {
|
|
let mut command = Command::new("git");
|
|
command.arg("-C").arg(root).args(args);
|
|
run_git_command(command, "run test git")
|
|
}
|
|
|
|
fn run_test_git_output(root: &Path, args: &[&str]) -> Result<String, String> {
|
|
let output = Command::new("git")
|
|
.arg("-C")
|
|
.arg(root)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|error| format!("could not run test git: {error}"))?;
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"git failed to run test git: {}",
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
));
|
|
}
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
}
|
|
|
|
use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo};
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
use ticket::{
|
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, TicketBackend, TicketEventKind,
|
|
TicketWorkflowState,
|
|
};
|
|
|
|
fn ticket_workspace(
|
|
title: &str,
|
|
state: TicketWorkflowState,
|
|
configure: impl FnOnce(&mut NewTicket),
|
|
) -> (TempDir, String, LocalTicketBackend) {
|
|
let temp = TempDir::new().unwrap();
|
|
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
|
fs::write(
|
|
temp.path().join(".gitignore"),
|
|
".worktree/\n.yoi/tickets/.ticket-backend.lock\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
temp.path().join(".yoi/.gitignore"),
|
|
"tickets/.ticket-backend.lock\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
temp.path().join(".yoi/ticket.config.toml"),
|
|
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
|
)
|
|
.unwrap();
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
let mut input = NewTicket::new(title);
|
|
input.body = MarkdownText::from("Ready for panel action");
|
|
input.workflow_state = Some(state);
|
|
configure(&mut input);
|
|
let ticket = backend.create(input).unwrap();
|
|
(temp, ticket.id, backend)
|
|
}
|
|
|
|
fn ready_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
|
|
ticket_workspace(title, TicketWorkflowState::Ready, |_| {})
|
|
}
|
|
|
|
fn ready_ticket_git_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace(title);
|
|
run_test_git(temp.path(), &["init"]).unwrap();
|
|
run_test_git(
|
|
temp.path(),
|
|
&["config", "user.email", "test@example.invalid"],
|
|
)
|
|
.unwrap();
|
|
run_test_git(temp.path(), &["config", "user.name", "Yoi Test"]).unwrap();
|
|
run_test_git(temp.path(), &["add", "."]).unwrap();
|
|
run_test_git(temp.path(), &["commit", "-m", "seed tickets"]).unwrap();
|
|
ensure_orchestration_worktree(temp.path()).unwrap();
|
|
(temp, ticket_id, backend)
|
|
}
|
|
|
|
fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
|
|
ticket_workspace(title, TicketWorkflowState::Done, |_| {})
|
|
}
|
|
|
|
fn request_for(temp: &TempDir, ticket_id: String, action: NextUserAction) -> TicketActionRequest {
|
|
TicketActionRequest {
|
|
workspace_root: temp.path().to_path_buf(),
|
|
ticket_id,
|
|
action,
|
|
orchestrator: None,
|
|
}
|
|
}
|
|
|
|
fn planning_return_request(
|
|
temp: &TempDir,
|
|
ticket_id: String,
|
|
instruction: &str,
|
|
) -> ReadyTicketPlanningReturnRequest {
|
|
ReadyTicketPlanningReturnRequest {
|
|
workspace_root: temp.path().to_path_buf(),
|
|
ticket_id,
|
|
user_instruction: instruction.to_string(),
|
|
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
|
|
pod_name: "stale-intake".to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing()
|
|
{
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready");
|
|
|
|
let outcome = dispatch_ready_ticket_planning_return(planning_return_request(
|
|
&temp,
|
|
ticket_id.clone(),
|
|
"please add acceptance detail before queueing",
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(outcome.notice.contains("returned to planning"));
|
|
assert!(outcome.notice.contains("instruction was recorded"));
|
|
assert!(matches!(
|
|
outcome.followup,
|
|
ReadyTicketPlanningReturnAfterMutation::None
|
|
));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
|
|
assert!(ticket.meta.queued_by.is_none());
|
|
assert!(ticket.meta.queued_at.is_none());
|
|
let state_change = ticket
|
|
.events
|
|
.iter()
|
|
.find(|event| {
|
|
event.kind == TicketEventKind::StateChanged
|
|
&& event.state_field.as_deref() == Some("state")
|
|
&& event.from.as_deref() == Some("ready")
|
|
&& event.to.as_deref() == Some("planning")
|
|
})
|
|
.expect("ready -> planning state_changed event is recorded");
|
|
assert_eq!(state_change.author.as_deref(), Some("workspace-panel"));
|
|
assert!(
|
|
state_change
|
|
.body
|
|
.as_str()
|
|
.contains("please add acceptance detail")
|
|
);
|
|
assert!(state_change.body.as_str().contains("not Queue routing"));
|
|
assert!(
|
|
state_change
|
|
.body
|
|
.as_str()
|
|
.contains("must not start implementation")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() {
|
|
let (temp, ticket_id, backend) =
|
|
ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {});
|
|
|
|
let error = dispatch_ready_ticket_planning_return(planning_return_request(
|
|
&temp,
|
|
ticket_id.clone(),
|
|
"refine please",
|
|
))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("expected ready"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
|
|
assert!(
|
|
ticket
|
|
.events
|
|
.iter()
|
|
.all(|event| !(event.kind == TicketEventKind::StateChanged
|
|
&& event.from.as_deref() == Some("ready")
|
|
&& event.to.as_deref() == Some("planning")))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"20260608-000123-ready",
|
|
"Ready Ticket",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let mut app = app_with_panel(empty_test_list(), panel);
|
|
app.select_next();
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("clarify expected behavior");
|
|
|
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
|
DashboardAction::ReturnReadyTicketToPlanning(request) => request,
|
|
_ => panic!("ready Ticket row with Ticket Intake text should return to planning"),
|
|
};
|
|
|
|
assert_eq!(request.ticket_id, "20260608-000123-ready");
|
|
assert_eq!(request.user_instruction, "clarify expected behavior");
|
|
assert!(matches!(
|
|
request.followup,
|
|
ReadyTicketPlanningReturnFollowup::LaunchIntake(_)
|
|
));
|
|
assert!(app.sending);
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Returning ready Ticket")
|
|
);
|
|
assert_eq!(input_text(&app), "clarify expected behavior");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn planning_return_with_launch_followup_changes_state_before_launch_followup() {
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch");
|
|
let request = ReadyTicketPlanningReturnRequest {
|
|
workspace_root: temp.path().to_path_buf(),
|
|
ticket_id: ticket_id.clone(),
|
|
user_instruction: "launch intake after state change".to_string(),
|
|
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
|
|
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
|
|
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
|
|
peer_registration: IntakePeerRegistrationRequest::Skip {
|
|
reason: "test".to_string(),
|
|
},
|
|
registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket {
|
|
registry_root: temp.path().join(".yoi/local-role-sessions"),
|
|
ticket_id: ticket_id.clone(),
|
|
ticket_slug: None,
|
|
},
|
|
}),
|
|
};
|
|
|
|
let outcome = dispatch_ready_ticket_planning_return(request)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
outcome.followup,
|
|
ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_)
|
|
));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() {
|
|
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue");
|
|
let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap();
|
|
|
|
let outcome =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap();
|
|
|
|
let root_head_after = git_rev_parse(temp.path(), "HEAD").unwrap();
|
|
let layout = orchestration_worktree_layout(temp.path());
|
|
let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap();
|
|
assert_ne!(root_head_after, root_head_before);
|
|
assert_eq!(orchestration_head, root_head_after);
|
|
assert!(outcome.notice.contains("Queued Ticket"));
|
|
assert!(outcome.notice.contains(&root_head_after));
|
|
assert!(outcome.notice.contains("root Queue commit"));
|
|
assert!(outcome.notice.contains("ff-only synced"));
|
|
assert!(
|
|
outcome
|
|
.notice
|
|
.contains("Orchestrator routing is authorized")
|
|
);
|
|
assert!(outcome.notice.contains("queued -> inprogress acceptance"));
|
|
assert!(!outcome.notice.contains("No implementation was started"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
|
|
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
|
|
assert!(ticket.meta.queued_at.is_some());
|
|
let state_change = ticket
|
|
.events
|
|
.iter()
|
|
.find(|event| {
|
|
event.kind == TicketEventKind::StateChanged
|
|
&& event.state_field.as_deref() == Some("state")
|
|
&& event.from.as_deref() == Some("ready")
|
|
&& event.to.as_deref() == Some("queued")
|
|
})
|
|
.expect("queue state_changed event is recorded");
|
|
assert_eq!(state_change.author.as_deref(), Some("workspace-panel"));
|
|
let orchestration_backend = LocalTicketBackend::new(layout.path.join(".yoi/tickets"));
|
|
let orchestration_ticket = orchestration_backend
|
|
.show(TicketIdOrSlug::Id(ticket_id))
|
|
.unwrap();
|
|
assert_eq!(
|
|
orchestration_ticket.meta.workflow_state,
|
|
TicketWorkflowState::Queued
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_action_allows_unrelated_dirty_root_changes() {
|
|
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-root");
|
|
fs::write(temp.path().join("README.md"), "dirty root notes\n").unwrap();
|
|
fs::write(temp.path().join("dirty.txt"), "dirty").unwrap();
|
|
|
|
let outcome =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(outcome.notice.contains("Queued Ticket"));
|
|
assert!(temp.path().join("dirty.txt").is_file());
|
|
let root_status = git_status_porcelain(temp.path()).unwrap();
|
|
assert!(root_status.iter().any(|line| line.contains("README.md")));
|
|
assert!(root_status.iter().any(|line| line.contains("dirty.txt")));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
|
|
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_action_blocks_preexisting_target_ticket_changes() {
|
|
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-ticket");
|
|
fs::write(
|
|
backend.root().join(&ticket_id).join("thread.md"),
|
|
"local uncommitted ticket edit\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let error =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap_err();
|
|
let message = error.to_string();
|
|
|
|
assert!(message.contains("root-ticket-clean"));
|
|
assert!(message.contains(&ticket_id));
|
|
assert!(message.contains("pre-existing changes"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
|
assert!(ticket.meta.queued_by.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_action_merges_orchestration_branch_before_queue() {
|
|
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-diverged");
|
|
let layout = orchestration_worktree_layout(temp.path());
|
|
fs::write(layout.path.join("orchestrator-only.txt"), "diverged").unwrap();
|
|
run_test_git(&layout.path, &["add", "orchestrator-only.txt"]).unwrap();
|
|
run_test_git(&layout.path, &["commit", "-m", "orchestrator-only"]).unwrap();
|
|
let orchestration_commit = git_rev_parse(&layout.path, "HEAD").unwrap();
|
|
|
|
let outcome =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap();
|
|
|
|
let root_head = git_rev_parse(temp.path(), "HEAD").unwrap();
|
|
let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap();
|
|
assert_eq!(root_head, orchestration_head);
|
|
assert!(temp.path().join("orchestrator-only.txt").is_file());
|
|
assert!(outcome.notice.contains("merged orchestration branch"));
|
|
assert!(outcome.notice.contains(&orchestration_commit));
|
|
assert!(outcome.notice.contains("root Queue commit"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
|
|
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_action_blocks_conflicting_orchestration_merge_without_mutation() {
|
|
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-conflict");
|
|
let layout = orchestration_worktree_layout(temp.path());
|
|
fs::write(temp.path().join("README.md"), "root change\n").unwrap();
|
|
run_test_git(temp.path(), &["add", "README.md"]).unwrap();
|
|
run_test_git(temp.path(), &["commit", "-m", "root-change"]).unwrap();
|
|
fs::write(layout.path.join("README.md"), "orchestration change\n").unwrap();
|
|
run_test_git(&layout.path, &["add", "README.md"]).unwrap();
|
|
run_test_git(&layout.path, &["commit", "-m", "orchestration-change"]).unwrap();
|
|
let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap();
|
|
|
|
let error =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap_err();
|
|
let message = error.to_string();
|
|
|
|
assert!(message.contains("root-orchestration-merge"));
|
|
assert!(message.contains("merge was aborted"));
|
|
assert!(message.contains(&ticket_id));
|
|
assert_eq!(
|
|
git_rev_parse(temp.path(), "HEAD").unwrap(),
|
|
root_head_before
|
|
);
|
|
assert!(git_status_porcelain(temp.path()).unwrap().is_empty());
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
|
assert!(ticket.meta.queued_by.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_close_action_blocks_non_done_ticket_without_mutation() {
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-not-done");
|
|
|
|
let error =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("state is ready"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
|
assert!(ticket.resolution.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_action_rejects_stale_absent_config_without_mutation() {
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-no-config");
|
|
fs::remove_file(temp.path().join(".yoi/ticket.config.toml")).unwrap();
|
|
|
|
let error =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("Ticket config is absent"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
|
|
assert!(ticket.meta.queued_by.is_none());
|
|
assert!(!ticket.events.iter().any(|event| {
|
|
event.kind == TicketEventKind::StateChanged && event.state_field.as_deref() == Some("state")
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_close_action_closes_done_ticket_with_deterministic_resolution() {
|
|
let (temp, ticket_id, backend) = done_ticket_workspace("panel-close");
|
|
|
|
let outcome =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(outcome.notice.contains("Closed Ticket"));
|
|
assert!(outcome.notice.contains("state was already done"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed);
|
|
let resolution = ticket
|
|
.resolution
|
|
.as_ref()
|
|
.expect("Dashboard Close records resolution.md")
|
|
.as_str();
|
|
assert!(resolution.contains("state: done"));
|
|
assert!(resolution.contains("No implementation work"));
|
|
assert!(resolution.contains("state change"));
|
|
assert!(resolution.contains("worker invocation"));
|
|
assert!(ticket.events.iter().any(|event| {
|
|
event.kind == TicketEventKind::Close && event.body.as_str().contains("workspace Dashboard")
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_close_action_blocks_existing_resolution_without_moving_ticket() {
|
|
let (temp, ticket_id, backend) = done_ticket_workspace("panel-close-resolution");
|
|
fs::write(
|
|
temp.path()
|
|
.join(".yoi/tickets")
|
|
.join(&ticket_id)
|
|
.join("resolution.md"),
|
|
"Already resolved\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let error =
|
|
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Close))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("resolution.md already exists"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert_eq!(
|
|
ticket.resolution.as_ref().unwrap().as_str(),
|
|
"Already resolved\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_review_action_does_not_silently_approve() {
|
|
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-review");
|
|
backend
|
|
.add_event(
|
|
TicketIdOrSlug::Id(ticket_id.clone()),
|
|
NewTicketEvent::new(TicketEventKind::ImplementationReport, "implemented"),
|
|
)
|
|
.unwrap();
|
|
|
|
let error = dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Wait))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(error.to_string().contains("current action is Queue"));
|
|
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
|
assert!(
|
|
!ticket
|
|
.events
|
|
.iter()
|
|
.any(|event| event.kind == TicketEventKind::Review)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_queue_notification_message_carries_routing_contract() {
|
|
let row = panel_test_ticket_row(
|
|
"00001KTTW04W2",
|
|
"Route queued\nTicket",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"queued",
|
|
);
|
|
let ticket = row.ticket.as_ref().unwrap();
|
|
|
|
let message = orchestrator_queue_notification_message(ticket);
|
|
|
|
assert!(message.contains("Ticket `00001KTTW04W2`, title `Route queued Ticket`"));
|
|
assert!(message.contains("human authorized Orchestrator routing"));
|
|
assert!(message.contains("not an unattended scheduler"));
|
|
assert!(message.contains("Read the Ticket"));
|
|
assert!(message.contains("inspect current Orchestrator workspace state"));
|
|
assert!(message.contains("transition state queued -> inprogress"));
|
|
assert!(message.contains("before any worktree/SpawnPod implementation side effects"));
|
|
assert!(message.contains("After inprogress acceptance"));
|
|
assert!(message.contains("worktree-workflow"));
|
|
assert!(message.contains("`.worktree/<task-name>`"));
|
|
assert!(message.contains("tracked `.yoi` project records visible"));
|
|
assert!(
|
|
message.contains(
|
|
"`.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded"
|
|
)
|
|
);
|
|
assert!(message.contains("multi-agent-workflow"));
|
|
assert!(message.contains("sibling coder/reviewer Pods"));
|
|
assert!(message.contains("coder narrow child-worktree write scope"));
|
|
assert!(message.contains("reviewer read-only by default"));
|
|
assert!(message.contains(
|
|
"integrate the implementation branch into the orchestration branch automatically"
|
|
));
|
|
assert!(message.contains("validate in the Orchestrator worktree"));
|
|
assert!(message.contains("clean up only child implementation worktrees/branches"));
|
|
assert!(message.contains("Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace"));
|
|
assert!(message.contains("If blocked, record a concise reason"));
|
|
assert!(message.contains("leave the Ticket queued or return it to planning"));
|
|
assert!(!message.contains("Do not start implementation directly"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ticket_queue_notification_sends_notify_when_socket_available() {
|
|
let temp = TempDir::new().unwrap();
|
|
let socket_path = temp.path().join("orchestrator.sock");
|
|
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let (reader, writer) = stream.into_split();
|
|
let mut reader = JsonLineReader::new(reader);
|
|
let mut writer = JsonLineWriter::new(writer);
|
|
writer
|
|
.write(&Event::Snapshot {
|
|
entries: Vec::new(),
|
|
greeting: protocol::Greeting {
|
|
pod_name: "test-orchestrator".to_string(),
|
|
cwd: temp.path().display().to_string(),
|
|
provider: "test".to_string(),
|
|
model: "test".to_string(),
|
|
scope_summary: "test".to_string(),
|
|
tools: Vec::new(),
|
|
context_window: 0,
|
|
context_tokens: 0,
|
|
},
|
|
status: PodStatus::Idle,
|
|
in_flight: Default::default(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
reader.next::<Method>().await.unwrap().unwrap()
|
|
});
|
|
|
|
send_notify_only(&socket_path, "Dashboard Queue".to_string(), true)
|
|
.await
|
|
.unwrap();
|
|
let method = server.await.unwrap();
|
|
assert!(matches!(
|
|
method,
|
|
Method::Notify { message, auto_run: true } if message == "Dashboard Queue"
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_notify_only_can_deliver_weak_notification_without_auto_run() {
|
|
let temp = TempDir::new().unwrap();
|
|
let socket_path = temp.path().join("companion.sock");
|
|
let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let (reader, writer) = stream.into_split();
|
|
let mut reader = JsonLineReader::new(reader);
|
|
let mut writer = JsonLineWriter::new(writer);
|
|
writer
|
|
.write(&Event::Snapshot {
|
|
entries: Vec::new(),
|
|
greeting: protocol::Greeting {
|
|
pod_name: "yoi".to_string(),
|
|
cwd: temp.path().display().to_string(),
|
|
provider: "test".to_string(),
|
|
model: "test".to_string(),
|
|
scope_summary: "test".to_string(),
|
|
tools: Vec::new(),
|
|
context_window: 0,
|
|
context_tokens: 0,
|
|
},
|
|
status: PodStatus::Idle,
|
|
in_flight: Default::default(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
reader.next::<Method>().await.unwrap().unwrap()
|
|
});
|
|
|
|
send_notify_only(&socket_path, "Dashboard progress".to_string(), false)
|
|
.await
|
|
.unwrap();
|
|
let method = server.await.unwrap();
|
|
assert!(matches!(
|
|
method,
|
|
Method::Notify { message, auto_run: false } if message == "Dashboard progress"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn no_ticket_selection_keeps_enter_pod_centric() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::Open
|
|
));
|
|
assert!(app.prepare_ticket_action_dispatch().is_none());
|
|
assert_eq!(app.notice.as_deref(), Some("No Ticket action is selected."));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_initial_display_does_not_auto_select_visible_rows() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let app = app_with_panel(
|
|
PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("alpha", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
),
|
|
panel,
|
|
);
|
|
|
|
assert!(visible_panel_keys(&app.panel, &app.list).len() > 1);
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_clear_selection_survives_reload_and_keeps_draft() {
|
|
let mut app = test_app(vec![live_info_with_updated_at(
|
|
"alpha",
|
|
PodStatus::Idle,
|
|
10,
|
|
)]);
|
|
app.select_next();
|
|
assert_eq!(
|
|
app.selected_row,
|
|
Some(PanelRowKey::Pod("alpha".to_string()))
|
|
);
|
|
app.input.insert_str("draft survives");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Esc)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
|
|
app.apply_reloaded_list(PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info_with_updated_at("alpha", PodStatus::Running, 20)],
|
|
None,
|
|
10,
|
|
));
|
|
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
assert_eq!(input_text(&app), "draft survives");
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_no_selection_ticket_intake_submit_uses_global_intake() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let mut app = app_with_panel(empty_test_list(), panel);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("new planning request");
|
|
|
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
|
DashboardAction::LaunchIntake(request) => request,
|
|
_ => panic!("no selection should launch global Intake"),
|
|
};
|
|
|
|
assert!(request.context.ticket.is_none());
|
|
assert_eq!(
|
|
request.context.user_instruction.as_deref(),
|
|
Some("new planning request")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_panel_keyboard_navigation_explicitly_creates_selection() {
|
|
let mut app = test_app(vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("beta", PodStatus::Idle),
|
|
]);
|
|
assert!(app.selected_row.is_none());
|
|
|
|
app.select_next();
|
|
assert_eq!(
|
|
app.selected_row,
|
|
Some(PanelRowKey::Pod("alpha".to_string()))
|
|
);
|
|
|
|
app.select_next();
|
|
assert_eq!(app.selected_row, Some(PanelRowKey::Pod("beta".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ticket_action_rows_precede_pods_and_pod_actions_still_work() {
|
|
let temp = TempDir::new().unwrap();
|
|
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
|
|
fs::write(
|
|
temp.path().join(".yoi/ticket.config.toml"),
|
|
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
|
|
)
|
|
.unwrap();
|
|
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
|
let mut ticket = NewTicket::new("Ready Ticket");
|
|
ticket.workflow_state = Some(TicketWorkflowState::Ready);
|
|
backend.create(ticket).unwrap();
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("idle", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
let panel = build_workspace_panel(temp.path(), &list);
|
|
let mut app = app_with_panel(list, panel);
|
|
assert!(app.selected_row.is_none());
|
|
app.select_next();
|
|
|
|
assert_eq!(app.selected_panel_row().unwrap().title, "Ready Ticket");
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
|
let lines = list_lines(&app, 100, 6)
|
|
.into_iter()
|
|
.map(|line| plain_line(&line))
|
|
.collect::<Vec<_>>();
|
|
let ticket_line = lines
|
|
.iter()
|
|
.position(|line| line.contains("Ready Ticket"))
|
|
.unwrap();
|
|
let pod_line = lines.iter().position(|line| line.contains("idle")).unwrap();
|
|
assert!(ticket_line < pod_line);
|
|
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
|
let open = app.prepare_open().unwrap();
|
|
assert_eq!(open.pod_name, "idle");
|
|
assert_eq!(open.socket_override, Some(PathBuf::from("/tmp/idle.sock")));
|
|
|
|
app.input.insert_str("draft after ticket row");
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(!app.sending);
|
|
assert_eq!(input_text(&app), "draft after ticket row");
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Workspace Companion is unavailable")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn row_hit_testing_maps_only_visible_selectable_rows() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-2",
|
|
"Queued",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
));
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("alpha", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
let app = app_with_panel(list, panel);
|
|
|
|
let rows = list_rows(&app, 80, 8);
|
|
let boxes = row_hit_boxes(&rows, Rect::new(3, 5, 80, 8));
|
|
|
|
assert_eq!(boxes.len(), 3);
|
|
assert_eq!(boxes[0].key, PanelRowKey::Ticket("TICKET-1".into()));
|
|
assert_eq!(boxes[0].rect, Rect::new(3, 6, 80, 2));
|
|
assert_eq!(boxes[1].key, PanelRowKey::Ticket("TICKET-2".into()));
|
|
assert_eq!(boxes[1].rect, Rect::new(3, 8, 80, 2));
|
|
assert_eq!(boxes[2].key, PanelRowKey::Pod("alpha".into()));
|
|
assert_eq!(boxes[2].rect, Rect::new(3, 11, 80, 1));
|
|
assert!(boxes.iter().all(|hit| !hit.contains(2, hit.rect.y)));
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_selects_panel_row_for_blank_enter_action() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-2",
|
|
"Queued",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
));
|
|
let mut app = app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
|
panel,
|
|
);
|
|
let rows = list_rows(&app, 80, 6);
|
|
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
|
|
|
|
assert!(app.handle_mouse_event(left_click(2, 3)));
|
|
assert_eq!(
|
|
app.selected_row,
|
|
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
|
);
|
|
app.selected_row = None;
|
|
assert!(app.handle_mouse_event(left_click(2, 4)));
|
|
assert_eq!(
|
|
app.selected_row,
|
|
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
|
);
|
|
assert_eq!(app.selected_panel_row().unwrap().title, "Queued");
|
|
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait));
|
|
let selected_title =
|
|
plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
|
|
assert!(selected_title.starts_with("▶ queued"));
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_non_row_click_is_noop_and_preserves_composer_draft() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let mut app = app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
|
panel,
|
|
);
|
|
let rows = list_rows(&app, 80, 6);
|
|
app.set_row_hit_boxes(&rows, Rect::new(10, 4, 80, 6));
|
|
app.input.insert_paste("draft".into());
|
|
let selected = app.selected_row.clone();
|
|
|
|
assert!(!app.handle_mouse_event(left_click(9, 5)));
|
|
assert_eq!(app.selected_row, selected);
|
|
assert_eq!(input_text(&app), "draft");
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_does_not_override_existing_composer_keyboard_behavior() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-2",
|
|
"Queued",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
));
|
|
let mut app = app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
|
panel,
|
|
);
|
|
let rows = list_rows(&app, 80, 6);
|
|
app.set_row_hit_boxes(&rows, Rect::new(0, 0, 80, 6));
|
|
|
|
assert!(app.handle_mouse_event(left_click(2, 4)));
|
|
assert_eq!(
|
|
app.selected_row,
|
|
Some(PanelRowKey::Ticket("TICKET-2".into()))
|
|
);
|
|
app.input.insert_paste("hello".into());
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "hello");
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Esc)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(app.selected_row, None);
|
|
assert_eq!(input_text(&app), "hello");
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Down)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(app.selected_row, None);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.companion = Some(CompanionPanelState::new(
|
|
"yoi",
|
|
CompanionPanelStatus::Live,
|
|
None,
|
|
));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"00001KTWPE3KQ",
|
|
"Queue Me",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("yoi", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
let mut app = app_with_panel(list, panel);
|
|
app.select_next();
|
|
app.input.insert_str("draft to companion");
|
|
|
|
assert_eq!(
|
|
app.selected_ticket_action(),
|
|
Some(NextUserAction::Queue),
|
|
"selected row remains a Ticket action row"
|
|
);
|
|
let actionbar_left = actionbar_left_text(&app);
|
|
let actionbar_right = actionbar_right_text(&app);
|
|
let target_status = plain_line(&target_status_line(&app));
|
|
|
|
assert_eq!(actionbar_left, "");
|
|
assert_eq!(actionbar_right, "");
|
|
assert_eq!(target_status, "");
|
|
let selected_title =
|
|
plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
|
|
assert!(selected_title.starts_with("▶ ready"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_bare_panel_letters_append_to_composer_and_arrows_select_when_blank() {
|
|
let mut app = test_app(vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("beta", PodStatus::Idle),
|
|
]);
|
|
assert!(app.selected_row.is_none());
|
|
|
|
for c in ['j', 'k', 'o', 'r'] {
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Char(c))),
|
|
DashboardAction::None
|
|
));
|
|
}
|
|
|
|
assert_eq!(input_text(&app), "jkor");
|
|
assert!(app.selected_row.is_none());
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Down)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "jkor");
|
|
assert!(app.selected_row.is_none());
|
|
|
|
app.input.clear();
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Down)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "");
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Down)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Up)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "");
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_selection_changes_preserve_composer_contents() {
|
|
let mut app = test_app(vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("beta", PodStatus::Idle),
|
|
]);
|
|
app.input.insert_str("draft message");
|
|
let before = input_text(&app);
|
|
|
|
app.select_next();
|
|
|
|
assert_eq!(input_text(&app), before);
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_poll_reload_preserves_selection_composer_and_notice() {
|
|
let mut app = test_app(vec![
|
|
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
|
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
|
]);
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
app.input.insert_str("draft survives polling");
|
|
app.notice = Some("keep this notice".to_string());
|
|
let refreshed = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![
|
|
live_info_with_updated_at("gamma", PodStatus::Idle, 60),
|
|
live_info_with_updated_at("alpha", PodStatus::Running, 50),
|
|
live_info_with_updated_at("beta", PodStatus::Idle, 40),
|
|
],
|
|
None,
|
|
10,
|
|
);
|
|
|
|
app.apply_reloaded_list(refreshed);
|
|
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
assert_eq!(
|
|
app.list
|
|
.selected_entry()
|
|
.unwrap()
|
|
.live
|
|
.as_ref()
|
|
.unwrap()
|
|
.status,
|
|
Some(PodStatus::Idle)
|
|
);
|
|
assert_eq!(input_text(&app), "draft survives polling");
|
|
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_poll_reload_falls_back_when_selected_pod_disappears() {
|
|
let mut app = test_app(vec![
|
|
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
|
live_info_with_updated_at("beta", PodStatus::Running, 20),
|
|
]);
|
|
app.select_next();
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
let refreshed = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![stopped_info_with_updated_at("closed", 30)],
|
|
vec![live_info_with_updated_at("alpha", PodStatus::Idle, 40)],
|
|
None,
|
|
10,
|
|
);
|
|
|
|
app.apply_reloaded_list(refreshed);
|
|
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_poll_reload_error_keeps_previous_list_and_composer() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.input.insert_str("keep draft");
|
|
|
|
app.apply_reload_result(Err(DashboardError::Io(io::Error::other("boom"))));
|
|
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
assert_eq!(input_text(&app), "keep draft");
|
|
let notice = app.notice.as_deref().unwrap();
|
|
assert!(notice.contains("Refresh failed"));
|
|
assert!(notice.contains("boom"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_orchestrator_failure_persists_over_plain_observe_missing() {
|
|
let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer";
|
|
let mut app = app_with_panel(
|
|
empty_test_list(),
|
|
panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)),
|
|
);
|
|
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None),
|
|
});
|
|
|
|
let orchestrator = app.panel.header.orchestrator.as_ref().unwrap();
|
|
assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable);
|
|
assert_eq!(orchestrator.detail.as_deref(), Some(detail));
|
|
assert_eq!(
|
|
app.panel
|
|
.header
|
|
.diagnostics
|
|
.iter()
|
|
.filter(|diagnostic| diagnostic.as_str() == detail)
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_orchestrator_plain_missing_remains_when_no_prior_failure_exists() {
|
|
let mut app = app_with_panel(
|
|
empty_test_list(),
|
|
panel_with_orchestrator(OrchestratorPanelStatus::Missing, None),
|
|
);
|
|
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None),
|
|
});
|
|
|
|
let orchestrator = app.panel.header.orchestrator.as_ref().unwrap();
|
|
assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing);
|
|
assert!(orchestrator.detail.is_none());
|
|
assert!(app.panel.header.diagnostics.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_orchestrator_failure_clears_after_live_lifecycle() {
|
|
let detail = "could not spawn workspace Orchestrator: delegated scope conflicts with writer";
|
|
let mut app = app_with_panel(
|
|
empty_test_list(),
|
|
panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(detail)),
|
|
);
|
|
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Live, None),
|
|
});
|
|
assert_eq!(
|
|
app.panel.header.orchestrator.as_ref().unwrap().status,
|
|
OrchestratorPanelStatus::Live
|
|
);
|
|
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None),
|
|
});
|
|
|
|
let orchestrator = app.panel.header.orchestrator.as_ref().unwrap();
|
|
assert_eq!(orchestrator.status, OrchestratorPanelStatus::Missing);
|
|
assert!(orchestrator.detail.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_orchestrator_failure_supersedes_prior_failure() {
|
|
let old_detail = "could not spawn workspace Orchestrator: old scope conflict";
|
|
let new_detail = "could not restore workspace Orchestrator: socket refused";
|
|
let mut app = app_with_panel(
|
|
empty_test_list(),
|
|
panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(old_detail)),
|
|
);
|
|
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Unavailable, Some(new_detail)),
|
|
});
|
|
app.apply_reloaded_snapshot(DashboardSnapshot {
|
|
list: empty_test_list(),
|
|
panel: panel_with_orchestrator(OrchestratorPanelStatus::Missing, None),
|
|
});
|
|
|
|
let orchestrator = app.panel.header.orchestrator.as_ref().unwrap();
|
|
assert_eq!(orchestrator.status, OrchestratorPanelStatus::Unavailable);
|
|
assert_eq!(orchestrator.detail.as_deref(), Some(new_detail));
|
|
assert!(
|
|
!app.panel
|
|
.header
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic == old_detail)
|
|
);
|
|
assert!(
|
|
app.panel
|
|
.header
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic == new_detail)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dashboard_poll_reload_does_not_overlap_in_flight_reload() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
let mut pending = PendingReload::default();
|
|
|
|
assert!(pending.start_with_handle(tokio::spawn(async {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
Err(DashboardError::Io(io::Error::other("boom")))
|
|
})));
|
|
assert!(!pending.start_with_handle(tokio::spawn(async {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("beta", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
Ok(DashboardSnapshot {
|
|
panel: WorkspacePanelViewModel::empty(Path::new("test")),
|
|
list,
|
|
})
|
|
})));
|
|
assert!(pending.finish_if_ready().await.is_none());
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let result = pending.finish_if_ready().await.unwrap();
|
|
app.apply_reload_result(result);
|
|
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
assert!(app.notice.as_deref().unwrap().contains("Refresh failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dashboard_quit_aborts_background_reload_and_notice_without_waiting() {
|
|
struct DropFlag(Arc<AtomicBool>);
|
|
impl Drop for DropFlag {
|
|
fn drop(&mut self) {
|
|
self.0.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
let reload_cancelled = Arc::new(AtomicBool::new(false));
|
|
let notice_cancelled = Arc::new(AtomicBool::new(false));
|
|
let mut pending_reload = PendingReload::default();
|
|
let mut pending_notice = PendingQueueAttentionNotice::default();
|
|
let (reload_started_tx, reload_started_rx) = tokio::sync::oneshot::channel();
|
|
let (notice_started_tx, notice_started_rx) = tokio::sync::oneshot::channel();
|
|
|
|
let reload_flag = Arc::clone(&reload_cancelled);
|
|
assert!(pending_reload.start_with_handle(tokio::spawn(async move {
|
|
let _drop_flag = DropFlag(reload_flag);
|
|
let _ = reload_started_tx.send(());
|
|
std::future::pending::<()>().await;
|
|
Err(DashboardError::Io(io::Error::other(
|
|
"unreachable reload completion",
|
|
)))
|
|
})));
|
|
|
|
let notice_flag = Arc::clone(¬ice_cancelled);
|
|
assert!(pending_notice.start_with_handle(tokio::spawn(async move {
|
|
let _drop_flag = DropFlag(notice_flag);
|
|
let _ = notice_started_tx.send(());
|
|
std::future::pending::<()>().await;
|
|
OrchestratorQueueAttentionNoticeResult::failed(
|
|
"unreachable".to_string(),
|
|
"unreachable notice completion",
|
|
)
|
|
})));
|
|
reload_started_rx.await.expect("reload task should start");
|
|
notice_started_rx.await.expect("notice task should start");
|
|
|
|
tokio::time::timeout(Duration::from_millis(20), async {
|
|
abort_panel_background_work_for_quit(&mut pending_reload, &mut pending_notice);
|
|
})
|
|
.await
|
|
.expect("quit abort should not wait for background task completion");
|
|
|
|
tokio::time::timeout(Duration::from_millis(100), async {
|
|
while !(reload_cancelled.load(Ordering::SeqCst) && notice_cancelled.load(Ordering::SeqCst))
|
|
{
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("quit abort should cancel reload and notice tasks");
|
|
|
|
assert!(pending_reload.finish_if_ready().await.is_none());
|
|
assert!(pending_notice.finish_if_ready().await.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_idle_live_selected_target_is_open_eligible() {
|
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.select_next();
|
|
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_status_label_for_live_without_reported_status_is_softened() {
|
|
let mut live = live_info("probing", PodStatus::Idle);
|
|
live.status = None;
|
|
let app = test_app(vec![live]);
|
|
|
|
let (label, _) = row_status_label(app.list.selected_entry().unwrap());
|
|
|
|
assert_eq!(label, "live");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_status_labels_preserve_explicit_live_statuses() {
|
|
for (status, expected_label) in [
|
|
(PodStatus::Idle, "live idle"),
|
|
(PodStatus::Running, "live running"),
|
|
(PodStatus::Paused, "live paused"),
|
|
] {
|
|
let app = test_app(vec![live_info("pod", status)]);
|
|
let (label, _) = row_status_label(app.list.selected_entry().unwrap());
|
|
|
|
assert_eq!(label, expected_label);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_title_omits_redundant_key_hint_guidance() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.ticket_configured = true;
|
|
panel.header.companion = Some(CompanionPanelState::new(
|
|
"yoi",
|
|
CompanionPanelStatus::Live,
|
|
Some("idle".to_string()),
|
|
));
|
|
let app = app_with_panel(
|
|
PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("yoi", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
),
|
|
panel,
|
|
);
|
|
|
|
let title = plain_line(&title_line(&app));
|
|
|
|
assert!(title.contains("workspace dashboard"));
|
|
assert!(title.contains("companion live"));
|
|
assert!(!title.contains("Row selection"));
|
|
assert!(!title.contains("blank Enter"));
|
|
assert!(!title.contains("Tab target"));
|
|
}
|
|
|
|
#[test]
|
|
fn panel_ticket_rows_render_state_title_then_detail_line() {
|
|
let row = panel_test_ticket_row(
|
|
"00001KTX1QMG9",
|
|
"Workspace Dashboard composer targets",
|
|
ActionPriority::ActiveWork,
|
|
NextUserAction::Wait,
|
|
"inprogress",
|
|
);
|
|
|
|
let lines = panel_row_lines(&row, true, 160);
|
|
let (title, detail) = (&lines[0], &lines[1]);
|
|
let title_line = plain_line(&title);
|
|
let detail_line = plain_line(&detail);
|
|
let state_start = 2;
|
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
|
let row_id = row.ticket.as_ref().unwrap().id.as_str();
|
|
|
|
assert!(title_line.starts_with("▶ "));
|
|
assert!(detail_line.starts_with("│ meta "));
|
|
assert!(!title_line.contains(row_id));
|
|
assert_eq!(display_column(&title_line, "inprogress"), state_start);
|
|
assert_eq!(
|
|
display_column(&title_line, "Workspace Dashboard composer targets"),
|
|
title_start
|
|
);
|
|
assert!(detail_line.contains(row_id));
|
|
assert!(detail_line.contains("Gate: clear"));
|
|
assert!(detail_line.contains("Action: Wait"));
|
|
}
|
|
|
|
#[test]
|
|
fn panel_ticket_non_selected_rows_align_with_selected_marker_space() {
|
|
let row = panel_test_ticket_row(
|
|
"00001KTTB479X",
|
|
"Long Ticket title that should be rendered after short columns",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
);
|
|
|
|
let lines = panel_row_lines(&row, false, 160);
|
|
let (title, detail) = (&lines[0], &lines[1]);
|
|
let title_line = plain_line(&title);
|
|
let detail_line = plain_line(&detail);
|
|
let state_start = 2;
|
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
|
|
|
assert!(title_line.starts_with(" ready"));
|
|
assert!(detail_line.starts_with(" meta 00001KTTB479X"));
|
|
assert_eq!(display_column(&title_line, "ready"), state_start);
|
|
assert_eq!(
|
|
display_column(&title_line, "Long Ticket title"),
|
|
title_start
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn panel_ticket_title_truncates_after_state_column() {
|
|
let row = panel_test_ticket_row(
|
|
"00001KTTB479X",
|
|
"Very long Ticket title that should truncate only after the state column",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
);
|
|
|
|
let lines = panel_row_lines(&row, false, 42);
|
|
let (title, detail) = (&lines[0], &lines[1]);
|
|
let title_line = plain_line(&title);
|
|
let detail_line = plain_line(&detail);
|
|
let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1;
|
|
|
|
assert_eq!(title_line.width(), 42);
|
|
assert_eq!(display_column(&title_line, "Very long Ticket"), title_start);
|
|
assert!(title_line.ends_with('…'));
|
|
assert_eq!(detail_line.width(), 42);
|
|
assert!(detail_line.starts_with(" meta 00001KTTB479X · Gate: clear"));
|
|
assert!(detail_line.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
|
|
let mut row = panel_test_ticket_row(
|
|
"00001OVERLAY",
|
|
"Overlay column regression",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
);
|
|
row.kind = PanelRowKind::Review;
|
|
row.status = "q→done".to_string();
|
|
row.disabled_reason = Some(
|
|
"orchestration worktree overlay shows Ticket state done; local state remains queued"
|
|
.to_string(),
|
|
);
|
|
row.ticket.as_mut().unwrap().orchestration_overlay =
|
|
Some(crate::workspace_panel::TicketStateOverlay {
|
|
source: "orchestration".to_string(),
|
|
workflow_state: TicketWorkflowState::Done,
|
|
});
|
|
|
|
let lines = panel_row_lines(&row, false, 160);
|
|
let title_line = plain_line(&lines[0]);
|
|
let detail_line = plain_line(&lines[1]);
|
|
let state_start = 2;
|
|
let title_start = state_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
|
|
|
assert!(row.status.width() <= TICKET_STATE_COLUMN_WIDTH);
|
|
assert_eq!(display_column(&title_line, "q→done"), state_start);
|
|
assert_eq!(
|
|
display_column(&title_line, "Overlay column regression"),
|
|
title_start
|
|
);
|
|
assert!(!title_line.contains("orchestration"));
|
|
assert!(detail_line.contains("Overlay: local queued · orchestration done · merge pending"));
|
|
}
|
|
|
|
#[test]
|
|
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
|
|
let mut row = panel_test_ticket_row(
|
|
"00001WAITING",
|
|
"Ready but gated",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"ready",
|
|
);
|
|
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
|
|
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
|
|
|
|
let lines = panel_row_lines(&row, true, 160);
|
|
let detail = &lines[1];
|
|
let detail_line = plain_line(&detail);
|
|
|
|
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
|
|
assert!(detail_line.contains("Action: queue disabled"));
|
|
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn panel_ticket_intake_child_rows_render_as_indented_single_line() {
|
|
let row = panel_test_intake_child_row(
|
|
"00001TICKET",
|
|
"intake-live",
|
|
TicketLocalClaimStatus::Live,
|
|
Some(NextUserAction::OpenPod),
|
|
);
|
|
|
|
let lines = panel_row_lines(&row, false, 160);
|
|
assert_eq!(lines.len(), 1);
|
|
let line = plain_line(&lines[0]);
|
|
let status_start = 4;
|
|
let title_start = status_start + TICKET_STATE_COLUMN_WIDTH + 1;
|
|
|
|
assert!(line.starts_with(" └ live"));
|
|
assert_eq!(display_column(&line, "live"), status_start);
|
|
assert_eq!(
|
|
display_column(&line, "Intake Pod: intake-live"),
|
|
title_start
|
|
);
|
|
assert!(!line.starts_with(" live"));
|
|
|
|
let selected_line = plain_line(&panel_row_lines(&row, true, 160)[0]);
|
|
assert!(selected_line.starts_with(" ▶ live"));
|
|
assert!(selected_line.contains("Intake Pod: intake-live"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_ticket_intake_child_keeps_row_marker_without_status_line() {
|
|
let ticket_id = "00001TICKET";
|
|
let pod_name = "intake-live";
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_intake_child_row(
|
|
ticket_id,
|
|
pod_name,
|
|
TicketLocalClaimStatus::Live,
|
|
Some(NextUserAction::OpenPod),
|
|
));
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info(pod_name, PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
let mut app = app_with_panel(list, panel);
|
|
app.select_panel_key(PanelRowKey::TicketIntakePod {
|
|
ticket_id: ticket_id.to_string(),
|
|
pod_name: pod_name.to_string(),
|
|
});
|
|
|
|
let status = plain_line(&target_status_line(&app));
|
|
|
|
assert_eq!(status, "");
|
|
|
|
let title_line = plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
|
|
assert!(title_line.starts_with(" ▶ live"));
|
|
assert!(title_line.contains("Intake"));
|
|
}
|
|
|
|
#[test]
|
|
fn panel_pod_rows_use_aligned_columns_before_pod_name() {
|
|
let app = test_app(vec![
|
|
live_info("companion", PodStatus::Idle),
|
|
live_info("very-long-background-worker-name", PodStatus::Running),
|
|
]);
|
|
let idle = app
|
|
.list
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.name == "companion")
|
|
.unwrap();
|
|
let running = app
|
|
.list
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.name == "very-long-background-worker-name")
|
|
.unwrap();
|
|
|
|
let idle_line = plain_line(&row_line(idle, false, 120));
|
|
let running_line = plain_line(&row_line(running, false, 120));
|
|
let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1;
|
|
|
|
assert!(!running_line.starts_with(" very-long-background-worker-name"));
|
|
assert_eq!(display_column(&idle_line, "live idle"), 2);
|
|
assert_eq!(display_column(&running_line, "live running"), 2);
|
|
assert_eq!(display_column(&idle_line, "companion"), name_start);
|
|
assert_eq!(
|
|
display_column(&running_line, "very-long-background-worker-name"),
|
|
name_start
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn panel_pod_name_truncates_after_status() {
|
|
let app = test_app(vec![live_info(
|
|
"very-long-background-worker-name-that-keeps-going",
|
|
PodStatus::Running,
|
|
)]);
|
|
let entry = app.list.selected_entry().unwrap();
|
|
|
|
let line = plain_line(&row_line(entry, false, 58));
|
|
let name_start = 2 + POD_STATUS_COLUMN_WIDTH + 1;
|
|
|
|
assert_eq!(line.width(), 58);
|
|
assert_eq!(display_column(&line, "live running"), 2);
|
|
assert_eq!(display_column(&line, "very-long"), name_start);
|
|
assert!(line.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
|
|
let mut app = test_app(vec![
|
|
live_info("running", PodStatus::Running),
|
|
live_info("paused", PodStatus::Paused),
|
|
]);
|
|
let stopped = stopped_info("stopped");
|
|
app.list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![stopped],
|
|
vec![
|
|
live_info_with_updated_at("running", PodStatus::Running, 30),
|
|
live_info_with_updated_at("paused", PodStatus::Paused, 20),
|
|
],
|
|
Some("running".to_string()),
|
|
10,
|
|
);
|
|
app.selected_row = None;
|
|
app.ensure_selection_visible();
|
|
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "stopped");
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_sections_classify_pending_working_and_closed() {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![stopped_info_with_updated_at("closed", 60)],
|
|
vec![
|
|
live_info_with_updated_at("idle", PodStatus::Idle, 50),
|
|
live_info_with_updated_at("running", PodStatus::Running, 40),
|
|
live_info_with_updated_at("paused", PodStatus::Paused, 30),
|
|
],
|
|
Some("idle".to_string()),
|
|
10,
|
|
);
|
|
|
|
let sections = sectioned_entries(&list);
|
|
|
|
assert_eq!(section_names(&list, §ions[0]), vec!["idle"]);
|
|
assert_eq!(
|
|
section_names(&list, §ions[1]),
|
|
vec!["running", "paused"]
|
|
);
|
|
assert_eq!(section_names(&list, §ions[2]), vec!["closed"]);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_closed_section_is_limited_to_three_visible_rows() {
|
|
let list = closed_list(5, Some("closed-0"));
|
|
let visible = visible_entry_indices(&list)
|
|
.into_iter()
|
|
.map(|index| list.entries[index].name.clone())
|
|
.collect::<Vec<_>>();
|
|
let sections = sectioned_entries(&list);
|
|
let closed = sections
|
|
.iter()
|
|
.find(|section| section.kind == DashboardSectionKind::Closed)
|
|
.unwrap();
|
|
let app = app_with_list(list);
|
|
let lines = list_lines(&app, 80, 8)
|
|
.into_iter()
|
|
.map(|line| plain_line(&line))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(visible, vec!["closed-0", "closed-1", "closed-2"]);
|
|
assert_eq!(closed.hidden_count(), 2);
|
|
assert!(
|
|
lines
|
|
.iter()
|
|
.any(|line| line.contains("closed 5 total, +2 hidden"))
|
|
);
|
|
assert!(lines.iter().any(|line| line.contains("closed-2")));
|
|
assert!(!lines.iter().any(|line| line.contains("closed-3")));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_selection_follows_visible_section_order_without_hidden_closed_rows() {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
(0..5)
|
|
.map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index))
|
|
.collect(),
|
|
vec![
|
|
live_info_with_updated_at("running", PodStatus::Running, 70),
|
|
live_info_with_updated_at("idle", PodStatus::Idle, 60),
|
|
],
|
|
Some("idle".to_string()),
|
|
20,
|
|
);
|
|
let mut app = app_with_list(list);
|
|
|
|
assert!(app.selected_row.is_none());
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "idle");
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "running");
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "closed-0");
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "closed-1");
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "closed-2");
|
|
app.select_next();
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "closed-2");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_selection_does_not_default_to_orchestrator_only_row() {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("test-orchestrator", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
);
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
|
"test-orchestrator",
|
|
OrchestratorPanelStatus::Live,
|
|
None,
|
|
));
|
|
let app = app_with_panel(list, panel);
|
|
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
assert_eq!(app.selected_open_eligibility(), OpenEligibility::Disabled);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_selection_has_no_default_when_orchestrator_pod_exists() {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![
|
|
live_info_with_updated_at("test-orchestrator", PodStatus::Idle, 80),
|
|
live_info_with_updated_at("worker", PodStatus::Idle, 70),
|
|
],
|
|
None,
|
|
10,
|
|
);
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
|
"test-orchestrator",
|
|
OrchestratorPanelStatus::Live,
|
|
None,
|
|
));
|
|
let app = app_with_panel(list, panel);
|
|
|
|
assert!(app.selected_row.is_none());
|
|
assert!(app.list.selected_name.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_list_renders_workspace_diagnostics_before_rows() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel
|
|
.header
|
|
.diagnostics
|
|
.push("Ticket config is unusable".to_string());
|
|
let app = app_with_panel(
|
|
PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
vec![live_info("idle", PodStatus::Idle)],
|
|
None,
|
|
10,
|
|
),
|
|
panel,
|
|
);
|
|
let lines = list_lines(&app, 80, 4)
|
|
.into_iter()
|
|
.map(|line| plain_line(&line))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(lines[0].contains("Ticket config is unusable"));
|
|
assert!(lines.iter().any(|line| line.contains("idle")));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_list_pins_closed_section_below_live_flexible_area() {
|
|
let list = PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
(0..3)
|
|
.map(|index| stopped_info_with_updated_at(&format!("closed-{index}"), 50 - index))
|
|
.collect(),
|
|
vec![
|
|
live_info_with_updated_at("running", PodStatus::Running, 70),
|
|
live_info_with_updated_at("idle", PodStatus::Idle, 60),
|
|
],
|
|
Some("idle".to_string()),
|
|
20,
|
|
);
|
|
let app = app_with_list(list);
|
|
let lines = list_lines(&app, 80, 12)
|
|
.into_iter()
|
|
.map(|line| plain_line(&line))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(lines[0].contains("pending"));
|
|
assert!(lines[2].contains("working"));
|
|
assert!(lines[4].is_empty());
|
|
assert!(lines[8].contains("closed"));
|
|
assert!(lines[11].contains("closed-2"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_layout_uses_single_boundary_separator_between_list_and_composer() {
|
|
let layout = dashboard_layout(Rect::new(0, 0, 80, 24), 1);
|
|
|
|
assert_eq!(layout.boundary.height, 1);
|
|
assert!(!layout.list_draws_own_separator);
|
|
assert_eq!(layout.boundary.y, layout.list.y + layout.list.height);
|
|
assert_eq!(
|
|
layout.target_status.y,
|
|
layout.boundary.y + layout.boundary.height
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_companion_submit_routes_to_workspace_companion_not_selected_pod() {
|
|
let mut app = companion_app(
|
|
vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("yoi", PodStatus::Idle),
|
|
],
|
|
CompanionPanelStatus::Live,
|
|
);
|
|
let alpha_index = app
|
|
.list
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry.name == "alpha")
|
|
.unwrap();
|
|
app.list.select_index(alpha_index);
|
|
app.input.insert_str("send to companion");
|
|
|
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
|
DashboardAction::SendCompanion(request) => request,
|
|
_ => panic!("Companion target should send to the workspace Companion"),
|
|
};
|
|
|
|
assert_eq!(request.pod_name, "yoi");
|
|
assert_eq!(request.socket_path, PathBuf::from("/tmp/yoi.sock"));
|
|
assert!(app.sending);
|
|
assert_eq!(input_text(&app), "send to companion");
|
|
assert!(app.notice.as_deref().unwrap().contains("Companion yoi"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_companion_submit_unavailable_keeps_composer_contents() {
|
|
let mut app = companion_app(vec![], CompanionPanelStatus::Missing);
|
|
app.input.insert_str("keep me");
|
|
let before = input_text(&app);
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert_eq!(input_text(&app), before);
|
|
assert!(!app.sending);
|
|
assert!(app.notice.as_deref().unwrap().contains("draft kept"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_companion_submit_empty_reports_empty_composer() {
|
|
let mut app = companion_app(
|
|
vec![live_info("yoi", PodStatus::Idle)],
|
|
CompanionPanelStatus::Live,
|
|
);
|
|
|
|
assert!(app.prepare_companion_send().is_none());
|
|
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(!app.sending);
|
|
assert_eq!(app.notice.as_deref(), Some("Composer is empty."));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_companion_finish_success_clears_composer() {
|
|
let mut app = companion_app(
|
|
vec![live_info("yoi", PodStatus::Idle)],
|
|
CompanionPanelStatus::Live,
|
|
);
|
|
app.input.insert_str("done");
|
|
app.sending = true;
|
|
|
|
app.finish_companion_send(Ok(CompanionSendOutcome {
|
|
notice: "Sent to Companion yoi.".to_string(),
|
|
}));
|
|
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(!app.sending);
|
|
assert_eq!(app.notice.as_deref(), Some("Sent to Companion yoi."));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_open_request_keeps_dashboard_state_for_nested_console() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.input.insert_str("draft survives open");
|
|
app.select_next();
|
|
|
|
let request = app.prepare_open().unwrap();
|
|
|
|
assert_eq!(request.pod_name, "alpha");
|
|
assert_eq!(
|
|
request.socket_override,
|
|
Some(PathBuf::from("/tmp/alpha.sock"))
|
|
);
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
assert_eq!(input_text(&app), "draft survives open");
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Attaching to alpha")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_open_failure_keeps_composer_and_sets_notice() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.input.insert_str("keep this draft");
|
|
let before = input_text(&app);
|
|
let error = io::Error::other("boom");
|
|
|
|
app.finish_open("alpha", Err(&error));
|
|
|
|
assert_eq!(input_text(&app), before);
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Open failed for alpha")
|
|
);
|
|
assert!(app.refreshing);
|
|
assert!(matches!(
|
|
app.enter_reload,
|
|
Some(OrchestratorLifecycleMode::Observe)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() {
|
|
let app = DashboardApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi"));
|
|
|
|
assert!(app.panel.rows.is_empty());
|
|
assert!(
|
|
app.panel
|
|
.header
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.contains("Loading workspace dashboard"))
|
|
);
|
|
assert!(app.refreshing);
|
|
assert!(matches!(
|
|
app.enter_reload,
|
|
Some(OrchestratorLifecycleMode::Ensure { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_nested_console_success_continues_without_dropping_state() {
|
|
let mut app = test_app(vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("beta", PodStatus::Idle),
|
|
]);
|
|
app.select_next();
|
|
app.select_next();
|
|
app.input.insert_str("keep this draft");
|
|
app.panel_diagnostic = Some(PanelDiagnostic {
|
|
title: "diagnostic stays".to_string(),
|
|
details: "details stay".to_string(),
|
|
});
|
|
app.panel_diagnostic_open = true;
|
|
|
|
finish_nested_console_open(&mut app, "beta", Ok(())).unwrap();
|
|
|
|
assert_eq!(input_text(&app), "keep this draft");
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
assert_eq!(
|
|
app.panel_diagnostic
|
|
.as_ref()
|
|
.map(|diagnostic| (diagnostic.title.as_str(), diagnostic.details.as_str(),)),
|
|
Some(("diagnostic stays", "details stay"))
|
|
);
|
|
assert!(app.panel_diagnostic_open);
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Returned from beta. Refreshing workspace")
|
|
);
|
|
assert!(app.refreshing);
|
|
assert!(matches!(
|
|
app.enter_reload,
|
|
Some(OrchestratorLifecycleMode::Observe)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_nested_console_recoverable_failure_continues_without_dropping_state() {
|
|
let mut app = test_app(vec![
|
|
live_info("alpha", PodStatus::Idle),
|
|
live_info("beta", PodStatus::Idle),
|
|
]);
|
|
app.select_next();
|
|
app.select_next();
|
|
app.input.insert_str("keep this draft");
|
|
app.panel_diagnostic = Some(PanelDiagnostic {
|
|
title: "diagnostic stays".to_string(),
|
|
details: "details stay".to_string(),
|
|
});
|
|
app.panel_diagnostic_open = true;
|
|
let error = crate::spawn::SpawnError::Io(io::Error::other("spawn failed"));
|
|
|
|
finish_nested_console_open(&mut app, "beta", Err(Box::new(error))).unwrap();
|
|
|
|
assert_eq!(input_text(&app), "keep this draft");
|
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
|
assert_eq!(
|
|
app.panel_diagnostic
|
|
.as_ref()
|
|
.map(|diagnostic| (diagnostic.title.as_str(), diagnostic.details.as_str(),)),
|
|
Some(("diagnostic stays", "details stay"))
|
|
);
|
|
assert!(app.panel_diagnostic_open);
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Open failed for beta: io error: spawn failed. Refreshing workspace")
|
|
);
|
|
assert!(app.refreshing);
|
|
assert!(matches!(
|
|
app.enter_reload,
|
|
Some(OrchestratorLifecycleMode::Observe)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_nested_console_nonrecoverable_failure_bubbles_without_state_finish() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.input.insert_str("keep this draft");
|
|
app.notice = Some("opening alpha".to_string());
|
|
let error = io::Error::other("fatal console error");
|
|
|
|
let err = finish_nested_console_open(&mut app, "alpha", Err(Box::new(error))).unwrap_err();
|
|
|
|
assert_eq!(err.to_string(), "fatal console error");
|
|
assert_eq!(input_text(&app), "keep this draft");
|
|
assert_eq!(app.notice.as_deref(), Some("opening alpha"));
|
|
assert!(!app.refreshing);
|
|
assert!(app.enter_reload.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_open_disabled_target_stays_in_dashboard() {
|
|
let mut live = live_info("unreachable", PodStatus::Idle);
|
|
live.reachable = false;
|
|
live.status = None;
|
|
let mut app = test_app(vec![live]);
|
|
app.select_next();
|
|
|
|
assert!(app.prepare_open().is_none());
|
|
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_empty_enter_uses_open_action() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.select_next();
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::Open
|
|
));
|
|
let request = app.prepare_open().unwrap();
|
|
|
|
assert_eq!(request.pod_name, "alpha");
|
|
assert_eq!(
|
|
request.socket_override,
|
|
Some(PathBuf::from("/tmp/alpha.sock"))
|
|
);
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Attaching to alpha")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_whitespace_only_enter_uses_open_action() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.select_next();
|
|
app.input.insert_str(" \n\t");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::Open
|
|
));
|
|
let request = app.prepare_open().unwrap();
|
|
|
|
assert_eq!(request.pod_name, "alpha");
|
|
assert_eq!(input_text(&app), " \n\t");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_non_empty_enter_reports_companion_unavailable() {
|
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("keep this draft");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert_eq!(input_text(&app), "keep this draft");
|
|
assert!(!app.sending);
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Workspace Companion is unavailable")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_alt_enter_inserts_newline_without_companion_send() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("first line");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert_eq!(input_text(&app), "first line\n");
|
|
assert!(!app.sending);
|
|
assert!(app.notice.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_alt_enter_on_blank_pod_selection_inserts_newline_without_opening() {
|
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
let selected_before = app.selected_row.clone();
|
|
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert_eq!(input_text(&app), "\n");
|
|
assert_eq!(app.selected_row, selected_before);
|
|
assert!(app.notice.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_alt_enter_on_blank_ticket_action_inserts_newline_without_dispatch() {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.rows.push(panel_test_ticket_row(
|
|
"TICKET-1",
|
|
"Ready",
|
|
ActionPriority::ReadyForQueue,
|
|
NextUserAction::Queue,
|
|
"ready",
|
|
));
|
|
let mut app = app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10),
|
|
panel,
|
|
);
|
|
app.select_next();
|
|
let selected_before = app.selected_row.clone();
|
|
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Queue));
|
|
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Enter, KeyModifiers::ALT)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert_eq!(input_text(&app), "\n");
|
|
assert_eq!(app.selected_row, selected_before);
|
|
assert!(!app.sending);
|
|
assert!(app.notice.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_composer_shared_word_motion_and_delete_keys() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("hello world");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Left, KeyModifiers::CONTROL)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Char('!'))),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "hello !world");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Right, KeyModifiers::CONTROL)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Char('w'), KeyModifiers::CONTROL)),
|
|
DashboardAction::None
|
|
));
|
|
assert_eq!(input_text(&app), "hello !");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_esc_clears_row_selection_without_quitting_and_preserves_draft() {
|
|
let mut app = ticket_enabled_app(vec![live_info("alpha", PodStatus::Idle)]);
|
|
app.select_next();
|
|
app.input.insert_str("draft message");
|
|
|
|
assert!(app.selected_row.is_some());
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Esc)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(app.selected_row.is_none());
|
|
assert_eq!(input_text(&app), "draft message");
|
|
assert!(
|
|
app.notice
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("Row selection cleared")
|
|
);
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
|
DashboardAction::Quit
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_composer_target_switch_preserves_typed_text() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("draft intake request");
|
|
|
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
|
let selected_before = app.selected_row.clone();
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Tab)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert!(matches!(
|
|
app.composer_target(),
|
|
ComposerTarget::TicketIntake
|
|
));
|
|
assert_eq!(app.selected_row, selected_before);
|
|
assert_eq!(input_text(&app), "draft intake request");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ctrl_t_does_not_switch_composer_target() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("draft intake request");
|
|
|
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
|
assert!(matches!(
|
|
app.handle_key(modified_key(KeyCode::Char('t'), KeyModifiers::CONTROL)),
|
|
DashboardAction::None
|
|
));
|
|
|
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
|
assert_eq!(input_text(&app), "draft intake request");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_no_ticket_workspace_exposes_only_companion_target() {
|
|
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.input.insert_str("draft message");
|
|
|
|
app.cycle_composer_target();
|
|
|
|
assert_eq!(
|
|
app.panel.composer.available_targets,
|
|
vec![ComposerTarget::Companion]
|
|
);
|
|
assert!(matches!(app.composer_target(), ComposerTarget::Companion));
|
|
assert_eq!(input_text(&app), "draft message");
|
|
assert!(app.notice.as_deref().unwrap().contains("unavailable"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_blank_ticket_intake_enter_uses_selected_row_and_preserves_input() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str(" \n\t");
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::Open
|
|
));
|
|
|
|
assert!(matches!(
|
|
app.composer_target(),
|
|
ComposerTarget::TicketIntake
|
|
));
|
|
assert!(!app.sending);
|
|
assert_eq!(input_text(&app), " \n\t");
|
|
assert!(
|
|
!app.notice
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.contains("input is empty")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ticket_intake_enter_builds_launch_request_not_direct_send() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("please intake this work");
|
|
|
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
|
DashboardAction::LaunchIntake(request) => request,
|
|
_ => panic!("Ticket Intake target should launch Intake"),
|
|
};
|
|
|
|
assert_eq!(request.context.role, TicketRole::Intake);
|
|
assert_eq!(
|
|
request.context.user_instruction.as_deref(),
|
|
Some("please intake this work")
|
|
);
|
|
assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi"));
|
|
assert_eq!(
|
|
request.context.intake_handoff,
|
|
Some(TicketIntakeHandoff::new("test-orchestrator", "test"))
|
|
);
|
|
assert_eq!(
|
|
request.peer_registration,
|
|
IntakePeerRegistrationRequest::Register {
|
|
orchestrator_pod: "test-orchestrator".to_string()
|
|
}
|
|
);
|
|
assert!(app.sending);
|
|
assert!(app.notice.as_deref().unwrap().contains("Launching"));
|
|
assert_eq!(input_text(&app), "please intake this work");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() {
|
|
let mut app = ticket_enabled_app_with_orchestrator(
|
|
vec![live_info("idle", PodStatus::Idle)],
|
|
OrchestratorPanelStatus::Unavailable,
|
|
);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("please intake this work");
|
|
|
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
|
DashboardAction::LaunchIntake(request) => request,
|
|
_ => panic!("Ticket Intake target should launch Intake"),
|
|
};
|
|
|
|
assert_eq!(
|
|
request.context.intake_handoff,
|
|
Some(TicketIntakeHandoff::new("test-orchestrator", "test"))
|
|
);
|
|
match request.peer_registration {
|
|
IntakePeerRegistrationRequest::Skip { reason } => {
|
|
assert!(reason.contains("test-orchestrator"));
|
|
assert!(reason.contains("unavailable"));
|
|
}
|
|
other => panic!("expected peer registration skip, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("please intake this work");
|
|
app.sending = true;
|
|
|
|
app.finish_intake_launch(Ok(IntakeLaunchOutcome {
|
|
launch: TicketRoleLaunchResult {
|
|
plan: client::ticket_role::TicketRoleLaunchPlan {
|
|
workspace_root: PathBuf::from("/tmp/workspace"),
|
|
cwd: None,
|
|
original_workspace_root: PathBuf::from("/tmp/workspace"),
|
|
target_workspace_root: PathBuf::from("/tmp/workspace"),
|
|
implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"),
|
|
role: TicketRole::Intake,
|
|
pod_name: "intake-pod".to_string(),
|
|
profile: "builtin:default".to_string(),
|
|
workflow: "ticket-intake-workflow".to_string(),
|
|
launch_prompt_ref: None,
|
|
run_segments: vec![],
|
|
},
|
|
ready: client::SpawnReady {
|
|
pod_name: "intake-pod".to_string(),
|
|
socket_path: PathBuf::from("/tmp/intake.sock"),
|
|
},
|
|
pre_run_warnings: vec![],
|
|
},
|
|
peer_registration: IntakePeerRegistrationStatus::Registered {
|
|
orchestrator_pod: "test-orchestrator".to_string(),
|
|
},
|
|
registry_warning: None,
|
|
}));
|
|
|
|
assert!(!app.sending);
|
|
assert_eq!(input_text(&app), "");
|
|
let notice = app.notice.as_deref().unwrap();
|
|
assert!(notice.contains("intake-pod"));
|
|
assert!(notice.contains("Handoff peer registered"));
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_ticket_intake_finish_failure_keeps_composer() {
|
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
|
app.cycle_composer_target();
|
|
app.input.insert_str("please keep this");
|
|
app.sending = true;
|
|
|
|
app.finish_intake_launch(Err(TicketRoleLaunchError::EmptyPodName));
|
|
|
|
assert!(!app.sending);
|
|
assert_eq!(input_text(&app), "please keep this");
|
|
assert!(app.notice.as_deref().unwrap().contains("composer kept"));
|
|
}
|
|
|
|
#[test]
|
|
fn intake_registry_update_claim_is_durable_only_after_commit() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("registry");
|
|
let store = PanelRegistryStore::from_root(root.clone());
|
|
let update = IntakeRegistryUpdate::ClaimTicket {
|
|
registry_root: root,
|
|
ticket_id: "20260608-000000-existing".to_string(),
|
|
ticket_slug: Some("existing".to_string()),
|
|
pod_name: "existing-intake".to_string(),
|
|
};
|
|
|
|
assert!(
|
|
store
|
|
.claim_for_ticket("20260608-000000-existing")
|
|
.unwrap()
|
|
.is_none(),
|
|
"holding a pending Intake registry update must not persist a Ticket claim"
|
|
);
|
|
|
|
assert!(commit_intake_registry_update(update.clone(), None).is_none());
|
|
assert!(
|
|
store
|
|
.claim_for_ticket("20260608-000000-existing")
|
|
.unwrap()
|
|
.is_some(),
|
|
"the claim is persisted only by the post-acceptance commit step"
|
|
);
|
|
|
|
assert!(commit_intake_registry_update(update, None).is_none());
|
|
let snapshot = store.snapshot().unwrap();
|
|
assert_eq!(snapshot.claims.len(), 1);
|
|
assert_eq!(snapshot.sessions.len(), 1);
|
|
assert_eq!(snapshot.sessions[0].pod_name, "existing-intake");
|
|
assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim);
|
|
assert_eq!(snapshot.sessions[0].related_tickets.len(), 1);
|
|
assert_eq!(
|
|
snapshot.sessions[0].related_tickets[0].id,
|
|
"20260608-000000-existing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn intake_registry_claims_launched_ticket_with_accepted_pod_name() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("registry");
|
|
let store = PanelRegistryStore::from_root(root.clone());
|
|
let update = IntakeRegistryUpdate::ClaimLaunchedTicket {
|
|
registry_root: root,
|
|
ticket_id: "20260608-000000-ready".to_string(),
|
|
ticket_slug: None,
|
|
};
|
|
|
|
assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none());
|
|
|
|
let claim = store
|
|
.claim_for_ticket("20260608-000000-ready")
|
|
.unwrap()
|
|
.expect("launched Intake Pod is claimed after accepted launch");
|
|
assert_eq!(claim.pod_name, "launched-intake");
|
|
let snapshot = store.snapshot().unwrap();
|
|
assert_eq!(snapshot.claims.len(), 1);
|
|
assert_eq!(snapshot.sessions.len(), 1);
|
|
assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim);
|
|
assert_eq!(snapshot.sessions[0].pod_name, "launched-intake");
|
|
}
|
|
|
|
#[test]
|
|
fn intake_registry_launched_ticket_claim_without_pod_name_is_diagnostic() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("registry");
|
|
let store = PanelRegistryStore::from_root(root.clone());
|
|
|
|
let warning = commit_intake_registry_update(
|
|
IntakeRegistryUpdate::ClaimLaunchedTicket {
|
|
registry_root: root,
|
|
ticket_id: "20260608-000000-ready".to_string(),
|
|
ticket_slug: None,
|
|
},
|
|
None,
|
|
)
|
|
.expect("missing launched Pod name should be diagnostic");
|
|
|
|
assert!(warning.contains("missing launched Pod name"));
|
|
assert!(
|
|
store
|
|
.claim_for_ticket("20260608-000000-ready")
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
|
|
let temp = TempDir::new().unwrap();
|
|
let root = temp.path().join("registry");
|
|
let store = PanelRegistryStore::from_root(root.clone());
|
|
store
|
|
.claim_ticket(
|
|
"20260608-000001-existing",
|
|
Some("existing"),
|
|
"first-intake",
|
|
TicketRole::Intake.as_str(),
|
|
)
|
|
.unwrap();
|
|
|
|
let warning = commit_intake_registry_update(
|
|
IntakeRegistryUpdate::ClaimTicket {
|
|
registry_root: root,
|
|
ticket_id: "20260608-000001-existing".to_string(),
|
|
ticket_slug: Some("existing".to_string()),
|
|
pod_name: "second-intake".to_string(),
|
|
},
|
|
None,
|
|
)
|
|
.expect("conflicting post-success claim should be reported");
|
|
|
|
assert!(warning.contains("could not be committed"));
|
|
let claim = store
|
|
.claim_for_ticket("20260608-000001-existing")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(claim.pod_name, "first-intake");
|
|
let snapshot = store.snapshot().unwrap();
|
|
assert_eq!(snapshot.claims.len(), 1);
|
|
assert_eq!(snapshot.sessions.len(), 1);
|
|
assert_eq!(snapshot.sessions[0].pod_name, "first-intake");
|
|
}
|
|
|
|
#[test]
|
|
fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
|
|
let mut app = test_app(vec![unreachable_live_info("unreachable")]);
|
|
app.select_next();
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Enter)),
|
|
DashboardAction::Open
|
|
));
|
|
assert!(app.prepare_open().is_none());
|
|
|
|
assert!(app.notice.as_deref().unwrap().contains("cannot be opened"));
|
|
}
|
|
|
|
#[test]
|
|
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
|
app.panel.rows = vec![panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
)];
|
|
app.refresh_orchestrator_work_set();
|
|
|
|
let request = app
|
|
.prepare_orchestrator_queue_attention_notice()
|
|
.expect("idle orchestrator should receive queued-work attention");
|
|
|
|
assert_eq!(request.pod_name, "test-orchestrator");
|
|
assert!(request.notice.message.contains("00001QUEUE"));
|
|
assert!(request.notice.message.contains("new_queued"));
|
|
assert!(request.notice.message.contains("queued -> inprogress"));
|
|
}
|
|
|
|
#[test]
|
|
fn active_inprogress_suppresses_queued_attention_and_retains_waiting_reason() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
|
app.panel.rows = vec![
|
|
panel_test_ticket_row(
|
|
"00001ACTIVE",
|
|
"Active work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"inprogress",
|
|
),
|
|
panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
),
|
|
];
|
|
app.refresh_orchestrator_work_set();
|
|
app.apply_orchestrator_work_set_detail();
|
|
|
|
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
|
let queued = app
|
|
.orchestrator_work_set
|
|
.queued
|
|
.iter()
|
|
.find(|item| item.id == "00001QUEUE")
|
|
.expect("queued item retained");
|
|
assert_eq!(
|
|
queued.classification,
|
|
OrchestratorQueuedClassification::PlannedQueued
|
|
);
|
|
assert!(
|
|
queued
|
|
.waiting_reason
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("active_inprogress")
|
|
);
|
|
assert!(
|
|
app.panel
|
|
.header
|
|
.orchestrator
|
|
.as_ref()
|
|
.unwrap()
|
|
.detail
|
|
.as_deref()
|
|
.unwrap()
|
|
.contains("suppressed")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn planned_queued_prompts_when_active_work_clears() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
|
app.panel.rows = vec![
|
|
panel_test_ticket_row(
|
|
"00001ACTIVE",
|
|
"Active work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"inprogress",
|
|
),
|
|
panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
),
|
|
];
|
|
app.refresh_orchestrator_work_set();
|
|
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
|
|
|
app.panel.rows = vec![panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
)];
|
|
app.refresh_orchestrator_work_set();
|
|
let request = app
|
|
.prepare_orchestrator_queue_attention_notice()
|
|
.expect("planned queued work should prompt after active work clears");
|
|
|
|
assert!(request.notice.message.contains("planned_queued"));
|
|
assert!(
|
|
!request
|
|
.notice
|
|
.message
|
|
.contains("waiting for active_inprogress")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn queued_attention_is_suppressed_when_existing_claim_prevents_duplicate_start() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
|
let mut row = panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
);
|
|
row.ticket.as_mut().unwrap().local_claim =
|
|
Some(crate::workspace_panel::TicketLocalClaimEntry {
|
|
pod_name: "coder-00001QUEUE".to_string(),
|
|
role: "coder".to_string(),
|
|
status: TicketLocalClaimStatus::Live,
|
|
});
|
|
row.related_pods.push("reviewer-00001QUEUE".to_string());
|
|
app.panel.rows = vec![row];
|
|
app.refresh_orchestrator_work_set();
|
|
app.apply_orchestrator_work_set_detail();
|
|
|
|
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
|
let waiting = app.orchestrator_work_set.queued[0]
|
|
.waiting_reason
|
|
.as_deref()
|
|
.unwrap();
|
|
assert!(waiting.contains("duplicate start"));
|
|
assert!(waiting.contains("coder-00001QUEUE"));
|
|
}
|
|
|
|
#[test]
|
|
fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
|
app.orchestrator_work_set = OrchestratorWorkSet::default();
|
|
app.panel.rows = vec![panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
)];
|
|
|
|
let request = app
|
|
.prepare_orchestrator_queue_attention_notice()
|
|
.expect("queued ticket state should be rediscovered safely");
|
|
|
|
assert!(request.notice.message.contains("new_queued"));
|
|
assert!(request.notice.message.contains("00001QUEUE"));
|
|
}
|
|
|
|
#[test]
|
|
fn queued_attention_requires_idle_orchestrator_to_avoid_duplicate_rekick() {
|
|
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Running)]);
|
|
app.panel.rows = vec![panel_test_ticket_row(
|
|
"00001QUEUE",
|
|
"Queued work",
|
|
ActionPriority::Background,
|
|
NextUserAction::Wait,
|
|
"queued",
|
|
)];
|
|
app.refresh_orchestrator_work_set();
|
|
|
|
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
|
|
}
|
|
|
|
fn test_app(live: Vec<LivePodInfo>) -> DashboardApp {
|
|
app_with_list(PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
vec![],
|
|
live,
|
|
None,
|
|
10,
|
|
))
|
|
}
|
|
|
|
fn companion_app(live: Vec<LivePodInfo>, status: CompanionPanelStatus) -> DashboardApp {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.companion = Some(CompanionPanelState::new("yoi", status, None));
|
|
app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
|
panel,
|
|
)
|
|
}
|
|
|
|
fn ticket_enabled_app(live: Vec<LivePodInfo>) -> DashboardApp {
|
|
ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live)
|
|
}
|
|
|
|
fn ticket_enabled_app_with_orchestrator(
|
|
live: Vec<LivePodInfo>,
|
|
orchestrator_status: OrchestratorPanelStatus,
|
|
) -> DashboardApp {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
|
panel.header.companion = Some(CompanionPanelState::new(
|
|
"yoi",
|
|
CompanionPanelStatus::Live,
|
|
None,
|
|
));
|
|
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
|
"test-orchestrator",
|
|
orchestrator_status,
|
|
None,
|
|
));
|
|
app_with_panel(
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
|
panel,
|
|
)
|
|
}
|
|
|
|
fn app_with_list(list: PodList) -> DashboardApp {
|
|
app_with_panel(list, WorkspacePanelViewModel::empty(Path::new("test")))
|
|
}
|
|
|
|
fn empty_test_list() -> PodList {
|
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], vec![], None, 10)
|
|
}
|
|
|
|
fn panel_with_orchestrator(
|
|
status: OrchestratorPanelStatus,
|
|
detail: Option<&str>,
|
|
) -> WorkspacePanelViewModel {
|
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
|
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
|
"test-orchestrator",
|
|
status,
|
|
detail.map(str::to_string),
|
|
));
|
|
if let Some(detail) = detail {
|
|
panel.header.diagnostics.push(detail.to_string());
|
|
}
|
|
panel
|
|
}
|
|
|
|
fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> DashboardApp {
|
|
let last_companion_lifecycle_failure = companion_lifecycle_failure_from_panel(&panel);
|
|
let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel);
|
|
let mut app = DashboardApp {
|
|
list,
|
|
panel,
|
|
input: InputBuffer::new(),
|
|
selected_row: None,
|
|
row_hit_boxes: Vec::new(),
|
|
composer_target: ComposerTarget::Companion,
|
|
notice: None,
|
|
panel_diagnostic: None,
|
|
panel_diagnostic_open: false,
|
|
sending: false,
|
|
refreshing: false,
|
|
enter_reload: None,
|
|
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
|
|
last_companion_lifecycle_failure,
|
|
last_orchestrator_lifecycle_failure,
|
|
orchestrator_work_set: OrchestratorWorkSet::default(),
|
|
orchestrator_queue_attention: None,
|
|
};
|
|
app.ensure_selection_visible();
|
|
app.ensure_composer_target_available();
|
|
app.refresh_orchestrator_work_set();
|
|
app.apply_orchestrator_work_set_detail();
|
|
app
|
|
}
|
|
|
|
fn panel_test_ticket_row(
|
|
id: &str,
|
|
title: &str,
|
|
priority: ActionPriority,
|
|
next_action: NextUserAction,
|
|
state: &str,
|
|
) -> PanelRow {
|
|
let ticket = crate::workspace_panel::TicketPanelEntry {
|
|
id: id.to_string(),
|
|
title: title.to_string(),
|
|
priority: "P2".to_string(),
|
|
workflow_state: TicketWorkflowState::parse(state).unwrap_or(TicketWorkflowState::Planning),
|
|
workflow_state_explicit: true,
|
|
orchestration_overlay: None,
|
|
next_action: Some(next_action),
|
|
updated_at: None,
|
|
latest_event_kind: Some("implementation_report".to_string()),
|
|
latest_event_excerpt: Some("latest event stays out of the primary row".to_string()),
|
|
blocked_reason: None,
|
|
related_pods: Vec::new(),
|
|
local_claim: None,
|
|
intake_pods: Vec::new(),
|
|
};
|
|
PanelRow {
|
|
key: PanelRowKey::Ticket(ticket.id.clone()),
|
|
kind: crate::workspace_panel::PanelRowKind::Ticket,
|
|
title: title.to_string(),
|
|
subtitle: Some("id · priority · latest event".to_string()),
|
|
status: state.to_string(),
|
|
priority,
|
|
next_action: Some(next_action),
|
|
ticket: Some(ticket),
|
|
related_pods: Vec::new(),
|
|
disabled_reason: None,
|
|
key_hint: Some("Enter".to_string()),
|
|
}
|
|
}
|
|
|
|
fn panel_test_intake_child_row(
|
|
ticket_id: &str,
|
|
pod_name: &str,
|
|
status: TicketLocalClaimStatus,
|
|
next_action: Option<NextUserAction>,
|
|
) -> PanelRow {
|
|
PanelRow {
|
|
key: PanelRowKey::TicketIntakePod {
|
|
ticket_id: ticket_id.to_string(),
|
|
pod_name: pod_name.to_string(),
|
|
},
|
|
kind: PanelRowKind::TicketIntakePod,
|
|
title: format!("Intake Pod: {pod_name}"),
|
|
subtitle: Some(format!("Intake claim for Ticket {ticket_id}")),
|
|
status: status.label().to_string(),
|
|
priority: match status {
|
|
TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => {
|
|
ActionPriority::ActiveWork
|
|
}
|
|
TicketLocalClaimStatus::Stale => ActionPriority::Background,
|
|
},
|
|
next_action,
|
|
ticket: None,
|
|
related_pods: vec![pod_name.to_string()],
|
|
disabled_reason: (status == TicketLocalClaimStatus::Stale)
|
|
.then(|| "claim metadata is stale".to_string()),
|
|
key_hint: Some(format!("Ticket {ticket_id} Intake Pod {pod_name}")),
|
|
}
|
|
}
|
|
|
|
fn closed_list(count: usize, selected: Option<&str>) -> PodList {
|
|
PodList::from_sources(
|
|
PodVisibilitySource::ResumePicker,
|
|
(0..count)
|
|
.map(|index| {
|
|
stopped_info_with_updated_at(&format!("closed-{index}"), 100 - index as u64)
|
|
})
|
|
.collect(),
|
|
vec![],
|
|
selected.map(str::to_string),
|
|
count.max(1),
|
|
)
|
|
}
|
|
|
|
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo {
|
|
live_info_with_updated_at(pod_name, status, 0)
|
|
}
|
|
|
|
fn unreachable_live_info(pod_name: &str) -> LivePodInfo {
|
|
let mut live = live_info(pod_name, PodStatus::Idle);
|
|
live.reachable = false;
|
|
live.status = None;
|
|
live
|
|
}
|
|
|
|
fn live_info_with_updated_at(pod_name: &str, status: PodStatus, updated_at: u64) -> LivePodInfo {
|
|
LivePodInfo {
|
|
pod_name: pod_name.to_string(),
|
|
socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")),
|
|
status: Some(status),
|
|
reachable: true,
|
|
segment_id: None,
|
|
summary: PodEntrySummary {
|
|
active_session_id: None,
|
|
active_segment_id: None,
|
|
updated_at,
|
|
preview: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn stopped_info(pod_name: &str) -> StoredPodInfo {
|
|
stopped_info_with_updated_at(pod_name, 10)
|
|
}
|
|
|
|
fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo {
|
|
StoredPodInfo {
|
|
pod_name: pod_name.to_string(),
|
|
metadata_state: StoredMetadataState::Present,
|
|
active_session_id: None,
|
|
active_segment_id: None,
|
|
updated_at,
|
|
workspace_root: None,
|
|
preview: None,
|
|
}
|
|
}
|
|
|
|
fn section_names<'a>(list: &'a PodList, section: &DashboardSection) -> Vec<&'a str> {
|
|
section
|
|
.entries
|
|
.iter()
|
|
.map(|index| list.entries[*index].name.as_str())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn ticket_action_error_records_f2_diagnostic_details() {
|
|
let mut app = DashboardApp::loading(PodRuntimeCommand::for_executable("/tmp/yoi"));
|
|
let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs";
|
|
|
|
app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string())));
|
|
|
|
assert!(app.notice.as_deref().unwrap().contains("F2 details"));
|
|
let diagnostic = app.panel_diagnostic.as_ref().expect("diagnostic");
|
|
assert_eq!(diagnostic.title, "Ticket action rejected");
|
|
assert_eq!(diagnostic.details, long_error);
|
|
assert!(!app.panel_diagnostic_open);
|
|
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::F(2))),
|
|
DashboardAction::None
|
|
));
|
|
assert!(app.panel_diagnostic_open);
|
|
assert!(matches!(
|
|
app.handle_key(key(KeyCode::Esc)),
|
|
DashboardAction::None
|
|
));
|
|
assert!(!app.panel_diagnostic_open);
|
|
}
|
|
|
|
fn plain_line(line: &Line<'_>) -> String {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect()
|
|
}
|
|
|
|
fn display_column(text: &str, needle: &str) -> usize {
|
|
let byte_index = text.find(needle).unwrap();
|
|
text[..byte_index].width()
|
|
}
|
|
|
|
fn input_text(app: &DashboardApp) -> String {
|
|
Segment::flatten_to_text(&app.input.submit_segments())
|
|
}
|
|
|
|
fn key(code: KeyCode) -> KeyEvent {
|
|
modified_key(code, KeyModifiers::NONE)
|
|
}
|
|
|
|
fn left_click(column: u16, row: u16) -> MouseEvent {
|
|
MouseEvent {
|
|
kind: MouseEventKind::Down(MouseButton::Left),
|
|
column,
|
|
row,
|
|
modifiers: KeyModifiers::NONE,
|
|
}
|
|
}
|
|
|
|
fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
|
KeyEvent::new(code, modifiers)
|
|
}
|