2026-04-27 21:55:04 +00:00
|
|
|
use super::*;
|
|
|
|
|
|
2026-04-27 22:16:12 +00:00
|
|
|
// ── is_config_file ────────────────────────────────────────────────────────
|
2026-04-27 21:55:04 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_config_file_identifies_root_project_toml() {
|
|
|
|
|
let git_root = PathBuf::from("/proj");
|
|
|
|
|
let config = git_root.join(".huskies").join("project.toml");
|
|
|
|
|
assert!(is_config_file(&config, &git_root));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_config_file_identifies_root_agents_toml() {
|
|
|
|
|
let git_root = PathBuf::from("/proj");
|
|
|
|
|
let agents = git_root.join(".huskies").join("agents.toml");
|
|
|
|
|
assert!(is_config_file(&agents, &git_root));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_config_file_rejects_worktree_copies() {
|
|
|
|
|
let git_root = PathBuf::from("/proj");
|
|
|
|
|
// project.toml inside a worktree must NOT be treated as the root config.
|
|
|
|
|
let worktree_config =
|
|
|
|
|
PathBuf::from("/proj/.huskies/worktrees/42_story_foo/.huskies/project.toml");
|
|
|
|
|
assert!(!is_config_file(&worktree_config, &git_root));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_config_file_rejects_other_files() {
|
|
|
|
|
let git_root = PathBuf::from("/proj");
|
|
|
|
|
// Random files must not match.
|
|
|
|
|
assert!(!is_config_file(
|
|
|
|
|
&PathBuf::from("/proj/.huskies/work/2_current/42_story_foo.md"),
|
|
|
|
|
&git_root
|
|
|
|
|
));
|
|
|
|
|
assert!(!is_config_file(
|
|
|
|
|
&PathBuf::from("/proj/.huskies/README.md"),
|
|
|
|
|
&git_root
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_config_file_rejects_wrong_root() {
|
|
|
|
|
let git_root = PathBuf::from("/proj");
|
|
|
|
|
let other_root_config = PathBuf::from("/other/.huskies/project.toml");
|
|
|
|
|
assert!(!is_config_file(&other_root_config, &git_root));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 22:16:12 +00:00
|
|
|
// ── stage_metadata ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn stage_metadata_returns_correct_actions() {
|
|
|
|
|
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
|
|
|
|
|
assert_eq!(action, "start");
|
|
|
|
|
assert_eq!(msg, "huskies: start 42_story_foo");
|
|
|
|
|
|
|
|
|
|
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
|
|
|
|
|
assert_eq!(action, "done");
|
|
|
|
|
assert_eq!(msg, "huskies: done 42_story_foo");
|
|
|
|
|
|
|
|
|
|
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
|
|
|
|
|
assert_eq!(action, "accept");
|
|
|
|
|
assert_eq!(msg, "huskies: accept 42_story_foo");
|
|
|
|
|
|
|
|
|
|
assert!(stage_metadata("unknown", "id").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:55:04 +00:00
|
|
|
// ── sweep_done_to_archived (CRDT-based) ─────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The sweep function reads from `read_all_typed()` and checks
|
|
|
|
|
// `Stage::Done { merged_at, .. }`. Items created via
|
|
|
|
|
// `write_item_with_content("5_done")` project `merged_at = Utc::now()`,
|
|
|
|
|
// so we test with Duration::ZERO to sweep immediately and with a long
|
|
|
|
|
// retention to verify items are kept. No filesystem access is involved.
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sweep_moves_old_items_to_archived() {
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
crate::db::write_item_with_content("9880_story_sweep_old", "5_done", "---\nname: old\n---\n");
|
|
|
|
|
|
|
|
|
|
// With ZERO retention, any Done item should be swept.
|
|
|
|
|
sweep_done_to_archived(Duration::ZERO);
|
|
|
|
|
|
|
|
|
|
// Verify the item was moved to 6_archived in the CRDT.
|
|
|
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let item = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.0 == "9880_story_sweep_old");
|
|
|
|
|
assert!(
|
|
|
|
|
item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
|
|
|
|
|
"item should be archived after sweep"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sweep_keeps_recent_items_in_done() {
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
crate::db::write_item_with_content("9881_story_sweep_new", "5_done", "---\nname: new\n---\n");
|
|
|
|
|
|
|
|
|
|
// With a very long retention, the item (merged_at ≈ now) should stay.
|
|
|
|
|
sweep_done_to_archived(Duration::from_secs(999_999));
|
|
|
|
|
|
|
|
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let item = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.0 == "9881_story_sweep_new");
|
|
|
|
|
assert!(
|
|
|
|
|
item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Done { .. })),
|
|
|
|
|
"item should remain in Done with long retention"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sweep_respects_custom_retention() {
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
crate::db::write_item_with_content(
|
|
|
|
|
"9882_story_sweep_custom",
|
|
|
|
|
"5_done",
|
|
|
|
|
"---\nname: custom\n---\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// With ZERO retention, sweep should promote.
|
|
|
|
|
sweep_done_to_archived(Duration::ZERO);
|
|
|
|
|
|
|
|
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let item = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.0 == "9882_story_sweep_custom");
|
|
|
|
|
assert!(
|
|
|
|
|
item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
|
|
|
|
|
"item should be archived with zero retention"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Prove that the sweep reads `merged_at` from the CRDT (not `Utc::now()`).
|
|
|
|
|
///
|
|
|
|
|
/// This test sets `merged_at` to 10 seconds in the past and uses a 5-second
|
|
|
|
|
/// retention. If the sweep were still using `Utc::now()` as the start time
|
|
|
|
|
/// (the original bug), the elapsed time would be ~0 and the item would NOT
|
|
|
|
|
/// be swept. With the fix, the item is swept because 10s > 5s retention.
|
|
|
|
|
#[test]
|
|
|
|
|
fn sweep_uses_crdt_merged_at_not_utc_now() {
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
|
|
|
|
|
let ten_seconds_ago = (chrono::Utc::now() - chrono::Duration::seconds(10)).timestamp() as f64;
|
|
|
|
|
|
|
|
|
|
// Write item in 5_done with an explicit past merged_at timestamp.
|
|
|
|
|
crate::crdt_state::write_item(
|
|
|
|
|
"9883_story_sweep_merged_at",
|
|
|
|
|
"5_done",
|
|
|
|
|
Some("merged_at test"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
Some(ten_seconds_ago),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 5-second retention: item is 10s old → should be swept.
|
|
|
|
|
sweep_done_to_archived(Duration::from_secs(5));
|
|
|
|
|
|
|
|
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let item = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.0 == "9883_story_sweep_merged_at");
|
|
|
|
|
assert!(
|
|
|
|
|
item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Archived { .. })),
|
|
|
|
|
"item with merged_at 10s ago should be archived with 5s retention"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Prove that an item with merged_at NEWER than done_retention is NOT swept.
|
|
|
|
|
#[test]
|
|
|
|
|
fn sweep_keeps_item_newer_than_retention() {
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
|
|
|
|
|
let one_second_ago = (chrono::Utc::now() - chrono::Duration::seconds(1)).timestamp() as f64;
|
|
|
|
|
|
|
|
|
|
crate::crdt_state::write_item(
|
|
|
|
|
"9884_story_sweep_recent",
|
|
|
|
|
"5_done",
|
|
|
|
|
Some("recent merged_at test"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
Some(one_second_ago),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 1-hour retention: item is only 1s old → should NOT be swept.
|
|
|
|
|
sweep_done_to_archived(Duration::from_secs(3600));
|
|
|
|
|
|
|
|
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
|
|
|
let item = items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.0 == "9884_story_sweep_recent");
|
|
|
|
|
assert!(
|
|
|
|
|
item.is_some_and(|i| matches!(i.stage, crate::pipeline_state::Stage::Done { .. })),
|
|
|
|
|
"item with merged_at 1s ago should stay in Done with 1-hour retention"
|
|
|
|
|
);
|
|
|
|
|
}
|