//! Tests for the `status` command submodules. use super::*; use crate::agents::{AgentPool, AgentStatus}; use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage, StoryId}; use chrono::Utc; /// Build a minimal PipelineItem for tests. fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: Vec::new(), retry_count: 0, } } /// Build a PipelineItem with dependencies for tests. fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec) -> PipelineItem { PipelineItem { story_id: StoryId(id.to_string()), name: name.to_string(), stage, depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(), retry_count: 0, } } #[test] fn status_command_matches() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); assert!(result.is_some(), "status command should match"); } #[test] fn status_command_returns_pipeline_text() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); let output = result.unwrap(); assert!( output.contains("Pipeline Status"), "status output should contain pipeline info: {output}" ); } #[test] fn status_command_case_insensitive() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); assert!(result.is_some(), "STATUS should match case-insensitively"); } // -- story_short_label -------------------------------------------------- #[test] fn short_label_extracts_number_and_name() { let label = story_short_label( "293_story_register_all_bot_commands", Some("Register all bot commands"), ); assert_eq!(label, "293 [story] — Register all bot commands"); } #[test] fn short_label_number_only_when_no_name() { let label = story_short_label("297_story_improve_bot_status_command_formatting", None); assert_eq!(label, "297 [story]"); } #[test] fn short_label_falls_back_to_stem_when_no_numeric_prefix() { let label = story_short_label("no_number_here", None); assert_eq!(label, "no_number_here"); } #[test] fn short_label_does_not_include_underscore_slug() { let label = story_short_label( "293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"), ); assert!( !label.contains("story_register"), "label should not contain the slug portion: {label}" ); } #[test] fn short_label_shows_bug_type() { let label = story_short_label( "375_bug_default_project_toml", Some("Default project.toml issue"), ); assert_eq!(label, "375 [bug] — Default project.toml issue"); } #[test] fn short_label_shows_spike_type() { let label = story_short_label( "61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"), ); assert_eq!(label, "61 [spike] — Filesystem watcher architecture"); } #[test] fn short_label_shows_refactor_type() { let label = story_short_label( "260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"), ); assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys"); } #[test] fn short_label_omits_unknown_type() { let label = story_short_label("42_task_do_something", Some("Do something")); assert_eq!(label, "42 — Do something"); } #[test] fn short_label_no_type_when_only_id() { // Stem with only a numeric ID and no type segment let label = story_short_label("42", Some("Some item")); assert_eq!(label, "42 — Some item"); } // -- build_status_from_items formatting ----------------------------------- #[test] fn status_does_not_show_full_filename_stem() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("293_story_register_all_bot_commands"), "output must not show full filename stem: {output}" ); assert!( output.contains("293 [story] — Register all bot commands"), "output must show number, type, and title: {output}" ); } // -- token cost in status output ---------------------------------------- #[test] fn status_shows_cost_when_token_usage_exists() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; // Write token usage for this story. let usage = crate::agents::TokenUsage { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.29, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show cost next to story: {output}" ); } #[test] fn status_no_cost_when_no_usage() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("$"), "output must not show cost when no usage exists: {output}" ); } #[test] fn status_aggregates_multiple_records_per_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "293_story_register_all_bot_commands", "Register all bot commands", Stage::Coding, )]; // Write two records for the same story — costs should be summed. for cost in [0.10, 0.19] { let usage = crate::agents::TokenUsage { input_tokens: 50, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: cost, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); } let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show aggregated cost: {output}" ); } // -- dependency display in status output -------------------------------- #[test] fn status_shows_waiting_on_for_story_with_unmet_deps() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Story 10 depends on story 999, which is NOT in all_items (treated as met) // OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet). let items = vec![ make_item_with_deps( "10_story_waiting", "Waiting Story", Stage::Coding, vec![999], ), make_item("999_story_dep", "Dep Story", Stage::Backlog), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("waiting on: 999"), "status should show waiting-on info for unmet deps: {output}" ); } #[test] fn status_does_not_show_waiting_on_when_dep_is_done() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Dep 999 is in Done stage — met. let items = vec![ make_item_with_deps( "10_story_unblocked", "Unblocked Story", Stage::Coding, vec![999], ), make_item( "999_story_dep", "Dep Story", Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("abc123".to_string()), }, ), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("waiting on"), "status should not show waiting-on when all deps are done: {output}" ); } #[test] fn status_shows_no_waiting_info_when_no_deps() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item("42_story_nodeps", "No Deps Story", Stage::Coding)]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( !output.contains("waiting on"), "status should not show waiting-on for stories without deps: {output}" ); } // -- traffic_light_dot -------------------------------------------------- #[test] fn dot_idle_when_no_agent() { assert_eq!(traffic_light_dot(false, false, false), "\u{26AA} "); // ⚪ } #[test] fn dot_running_when_agent_not_throttled() { assert_eq!(traffic_light_dot(false, false, true), "\u{1F7E2} "); // 🟢 } #[test] fn dot_throttled_when_agent_throttled() { assert_eq!(traffic_light_dot(false, true, true), "\u{1F7E0} "); // 🟠 } #[test] fn dot_blocked_takes_priority_over_throttled() { assert_eq!(traffic_light_dot(true, true, true), "\u{1F534} "); // 🔴 } #[test] fn dot_blocked_when_no_agent_but_blocked_flag() { assert_eq!(traffic_light_dot(true, false, false), "\u{1F534} "); // 🔴 } // -- Stage::is_blocked() replaces read_story_blocked -------------------- #[test] fn stage_is_blocked_returns_true_for_archived_blocked() { let stage = Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Blocked { reason: "too many retries".to_string(), }, }; assert!(stage.is_blocked()); } #[test] fn stage_is_blocked_returns_false_for_coding() { assert!(!Stage::Coding.is_blocked()); } #[test] fn stage_is_blocked_returns_false_for_archived_completed() { let stage = Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Completed, }; assert!(!stage.is_blocked()); } // -- status output shows idle dot for items with no active agent -------- #[test] fn status_shows_idle_dot_for_unassigned_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item("42_story_idle", "Idle Story", Stage::Coding)]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{26AA} "), // ⚪ "idle story should show white circle emoji: {output}" ); } #[test] fn status_shows_blocked_dot_for_blocked_story() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "42_story_blocked", "Blocked Story", Stage::Archived { archived_at: Utc::now(), reason: ArchiveReason::Blocked { reason: "retry limit exceeded".to_string(), }, }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F534} "), // 🔴 "blocked story should show red circle emoji: {output}" ); } // -- Regression: CRDT-only story appears in correct stage --------------- #[test] fn status_shows_crdt_done_story_in_done_not_backlog() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); // Story is Done in the typed API — even if a stale 1_backlog/ shadow existed // on the filesystem, the status must show it in Done, not Backlog. let items = vec![make_item( "503_story_some_feature", "Some Feature", Stage::Done { merged_at: Utc::now(), merge_commit: crate::pipeline_state::GitSha("deadbeef".to_string()), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); // Must appear under Done, not Backlog. let done_pos = output.find("**Done**").expect("Done section must exist"); let backlog_pos = output .find("**Backlog**") .expect("Backlog section must exist"); let story_pos = output .find("503 [story]") .expect("story must appear in output"); assert!( story_pos > done_pos, "story should appear after Done header: backlog={backlog_pos} done={done_pos} story={story_pos}\n{output}" ); assert!( story_pos > backlog_pos, "story should not be in the Backlog section: {output}" ); // Verify it's not in Backlog section specifically. let backlog_section = output.get(backlog_pos..done_pos).unwrap_or(""); assert!( !backlog_section.contains("503"), "503 must not appear in Backlog section: {backlog_section}" ); } // -- merge-stage breakdown indicators ------------------------------------ fn merge_stage() -> Stage { use crate::pipeline_state::BranchName; Stage::Merge { feature_branch: BranchName("feature/test".to_string()), commits_ahead: std::num::NonZeroU32::new(1).unwrap(), } } #[test] fn merge_item_queued_shows_hourglass() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "901_story_merge_q", "Queued Story", merge_stage(), )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{23F3}"), // ⏳ "queued merge item should show hourglass: {output}" ); } #[test] fn merge_item_with_active_agent_shows_robot() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "902_story_merge_mm", "Mergemaster Story", merge_stage(), )]; let agents = AgentPool::new_test(3000); agents.inject_test_agent("902_story_merge_mm", "mergemaster", AgentStatus::Running); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F916}"), // 🤖 "merge item with running agent should show robot: {output}" ); assert!( output.contains("mergemaster running"), "output should contain 'mergemaster running': {output}" ); } #[test] fn merge_item_with_failure_shows_stop_sign_and_snippet() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML. crate::crdt_state::write_merge_job( "903_story_merge_fail", "failed", 0.0, None, Some("conflicts in src/lib.rs"), ); let items = vec![make_item( "903_story_merge_fail", "Failed Story", merge_stage(), )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{26D4}"), // ⛔ "merge item with failure should show stop sign: {output}" ); assert!( output.contains("conflicts in src/lib.rs"), "output should contain failure snippet: {output}" ); } #[test] fn merge_item_failure_snippet_truncated_at_120_chars() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let long_reason = "x".repeat(200); // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML. crate::crdt_state::write_merge_job( "904_story_long_fail", "failed", 0.0, None, Some(&long_reason), ); let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("…"), "long failure should be truncated with ellipsis: {output}" ); // The snippet should not exceed 120 chars plus the ellipsis character. let snippet_start = output.find("\u{26D4}").expect("stop sign must be present"); let line = output .get(snippet_start..) .unwrap_or("") .lines() .next() .unwrap_or(""); // Find the last " — " separator (before the snippet) and take what follows. if let Some(sep_pos) = line.rfind(" \u{2014} ") { // " — " is 5 bytes (space + 3-byte em dash + space) let snippet = line.get(sep_pos + 5..).unwrap_or(""); assert!( snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing "snippet should be at most ~121 chars: {snippet}" ); } } #[test] fn merge_item_failure_snippet_is_first_non_empty_line() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // Multi-line failure reason — first non-empty line should be used. // Post-929: merge_failure detail lives on the MergeJob CRDT entry. crate::crdt_state::write_merge_job( "905_story_multiline", "failed", 0.0, None, Some("\nfirst line of error\nsecond line"), ); let items = vec![make_item( "905_story_multiline", "Multi Line", merge_stage(), )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("first line of error"), "snippet should use first non-empty line: {output}" ); assert!( !output.contains("second line"), "snippet should not include second line: {output}" ); } #[test] fn merge_item_det_merge_running_preferred_over_failure() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "906_story_det_over_fail", "4_merge", "---\nname: Det Over Fail\n---\n", crate::db::ItemMeta::named("Det Over Fail"), ); // Record a running deterministic merge in the CRDT. crate::crdt_state::write_merge_job("906_story_det_over_fail", "running", 0.0, None, None); let items = vec![make_item( "906_story_det_over_fail", "Det Over Fail", merge_stage(), )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F504}"), // 🔄 "deterministic merge running should be preferred over failure: {output}" ); assert!( !output.contains("\u{26D4}"), // ⛔ should not appear "stop sign should not appear when det merge is running: {output}" ); } // -- first_non_empty_snippet unit tests --------------------------------- #[test] fn snippet_empty_text_returns_empty() { assert_eq!(first_non_empty_snippet("", 120), ""); } #[test] fn snippet_short_text_returned_as_is() { assert_eq!(first_non_empty_snippet("short error", 120), "short error"); } #[test] fn snippet_long_text_truncated_with_ellipsis() { let text = "a".repeat(200); let result = first_non_empty_snippet(&text, 120); assert!(result.ends_with('…'), "should end with ellipsis: {result}"); assert_eq!( result.chars().count(), 121, "should be 120 + ellipsis: {result}" ); } #[test] fn snippet_skips_leading_empty_lines() { let text = "\n\n \nactual error here\nsecond line"; let result = first_non_empty_snippet(text, 120); assert_eq!(result, "actual error here"); } // -- AC1: blocked items appear in-place, not in a separate "Blocked" section -- #[test] fn blocked_item_appears_in_in_progress_not_separate_section() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "42_story_blocked", "Blocked Story", Stage::Blocked { reason: "retry limit exceeded".to_string(), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); // Must NOT appear under a separate "Blocked" section. assert!( !output.contains("**Blocked**"), "output must not have a separate Blocked section: {output}" ); // Must appear under "In Progress". let in_progress_pos = output .find("**In Progress**") .expect("In Progress section must exist"); let qa_pos = output.find("**QA**").expect("QA section must exist"); let story_pos = output .find("42 [story]") .expect("story must appear in output"); assert!( story_pos > in_progress_pos && story_pos < qa_pos, "blocked story should be in In Progress section: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}" ); } #[test] fn blocked_item_shows_red_dot_in_in_progress_section() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "50_story_blocked_dot", "Blocked With Dot", Stage::Blocked { reason: "too many failures".to_string(), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F534} "), // 🔴 "blocked story should show red circle emoji: {output}" ); } // -- AC2: stage counts include blocked items -- #[test] fn in_progress_count_includes_blocked_items() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![ make_item("10_story_coding", "Coding Story", Stage::Coding), make_item( "11_story_blocked", "Blocked Story", Stage::Blocked { reason: "failed".to_string(), }, ), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); // "In Progress" header should show count of 2 (one coding + one blocked). assert!( output.contains("**In Progress** (2)"), "In Progress count should include blocked items: {output}" ); } // -- AC4: frozen items appear in-place, with ❄️ indicator -- #[test] fn frozen_coding_item_appears_in_in_progress_section() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "60_story_frozen", "Frozen Coding Story", Stage::Frozen { resume_to: Box::new(Stage::Coding), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); let in_progress_pos = output .find("**In Progress**") .expect("In Progress section must exist"); let qa_pos = output.find("**QA**").expect("QA section must exist"); let story_pos = output .find("60 [story]") .expect("story must appear in output"); assert!( story_pos > in_progress_pos && story_pos < qa_pos, "frozen-coding story should appear in In Progress: in_progress={in_progress_pos} story={story_pos} qa={qa_pos}\n{output}" ); } #[test] fn frozen_qa_item_appears_in_qa_section() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "70_story_frozen_qa", "Frozen QA Story", Stage::Frozen { resume_to: Box::new(Stage::Qa), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); let qa_pos = output.find("**QA**").expect("QA section must exist"); let merge_pos = output.find("**Merge**").expect("Merge section must exist"); let story_pos = output .find("70 [story]") .expect("story must appear in output"); assert!( story_pos > qa_pos && story_pos < merge_pos, "frozen-QA story should appear in QA section: qa={qa_pos} story={story_pos} merge={merge_pos}\n{output}" ); } #[test] fn frozen_item_shows_snowflake_indicator() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "80_story_frozen_flake", "Frozen Flake Story", Stage::Frozen { resume_to: Box::new(Stage::Coding), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{2744}\u{FE0F}"), // ❄️ "frozen story should show snowflake prefix: {output}" ); } #[test] fn frozen_and_blocked_use_distinct_indicators() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![ make_item( "90_story_blocked_ind", "Blocked Story", Stage::Blocked { reason: "failed".to_string(), }, ), make_item( "91_story_frozen_ind", "Frozen Story", Stage::Frozen { resume_to: Box::new(Stage::Coding), }, ), ]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); assert!( output.contains("\u{1F534} "), // 🔴 for blocked "blocked story should show red dot: {output}" ); assert!( output.contains("\u{2744}\u{FE0F}"), // ❄️ for frozen "frozen story should show snowflake: {output}" ); } // -- merge-failure items appear in Merge section -- #[test] fn merge_failure_item_appears_in_merge_section_not_blocked() { use crate::pipeline_state::BranchName; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let items = vec![make_item( "100_story_merge_fail", "Merge Failure Story", Stage::MergeFailure { reason: "conflict in lib.rs".to_string(), feature_branch: BranchName("feature/100".to_string()), commits_ahead: std::num::NonZeroU32::new(1).unwrap(), }, )]; let agents = AgentPool::new_test(3000); let output = build_status_from_items(tmp.path(), &agents, &items); // Must not be in a separate "Blocked" section. assert!( !output.contains("**Blocked**"), "output must not have a separate Blocked section: {output}" ); let merge_pos = output.find("**Merge**").expect("Merge section must exist"); let done_pos = output.find("**Done**").expect("Done section must exist"); let story_pos = output .find("100 [story]") .expect("story must appear in output"); assert!( story_pos > merge_pos && story_pos < done_pos, "merge-failure story should appear in Merge section: merge={merge_pos} story={story_pos} done={done_pos}\n{output}" ); assert!( output.contains("\u{26D4}"), // ⛔ "merge failure should show stop sign: {output}" ); assert!( output.contains("conflict in lib.rs"), "merge failure reason should be shown: {output}" ); }