939 lines
28 KiB
Rust
939 lines
28 KiB
Rust
//! 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<u32>) -> 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}"
|
|
);
|
|
}
|