use super::test_support::{blocker, catalog, task};
use super::*;
use crate::task::{On, Task};
fn claimed(title: &str, created: i64, by: &str) -> Task {
Task { claimant: Some(by.into()), ..task(title, created) }
}
#[test]
fn an_absent_store_loads_an_empty_catalog() {
let cat = catalog(&[]);
assert!(cat.entries().is_empty());
assert!(cat.is_resolved("bl-anything"));
}
#[test]
fn the_catalog_loads_and_id_sorts_its_balls() {
let cat = catalog(&[("bl-z", task("Z", 1)), ("bl-a", task("A", 2))]);
let ids: Vec<&str> = cat.entries().iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["bl-a", "bl-z"]);
}
#[test]
fn is_resolved_reflects_file_existence() {
let cat = catalog(&[("bl-live", task("Live", 1))]);
assert!(!cat.is_resolved("bl-live")); assert!(cat.is_resolved("bl-gone")); }
#[test]
fn status_climbs_the_three_rung_ladder() {
let mut blocked = task("Blocked", 1);
blocked.blockers = vec![blocker("bl-dep", On::Claim)];
let cat = catalog(&[
("bl-ready", task("Ready", 1)),
("bl-claimed", claimed("Claimed", 1, "me")),
("bl-blocked", blocked),
("bl-dep", task("Dep", 1)), ]);
assert_eq!(cat.status(cat.get("bl-ready").unwrap()), Status::Ready);
assert_eq!(cat.status(cat.get("bl-claimed").unwrap()), Status::Claimed);
assert_eq!(cat.status(cat.get("bl-blocked").unwrap()), Status::Blocked);
}
#[test]
fn the_linked_consumer_surface_composes_and_derives_status_itself() {
let mut blocked = task("Blocked", 1);
blocked.blockers = vec![blocker("bl-dep", On::Claim)];
let cat = catalog(&[("bl-blocked", blocked), ("bl-dep", task("Dep", 1))]);
let ids: Vec<&str> = cat.entries().iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["bl-blocked", "bl-dep"]);
let e = cat.get("bl-blocked").unwrap();
let resolved = |id: &str| cat.is_resolved(id);
assert_eq!(e.task.status(&resolved), Status::Blocked);
assert!(!e.task.ready(&resolved));
assert!(task_json(&e.id, &e.task).get("status").is_none());
}
#[test]
fn a_corrupt_ball_degrades_per_file_instead_of_blinding_the_store() {
let tmp = tempfile::TempDir::new().unwrap();
crate::taskfile::write_task(tmp.path(), "bl-good", &task("Good", 1)).unwrap();
std::fs::write(tmp.path().join("tasks/bl-bad.md"), "+++\ntitle = \"a\"\ntitle = \"b\"\n+++\n").unwrap();
let cat = Catalog::load(tmp.path()).unwrap();
assert!(cat.get("bl-good").is_some()); assert!(cat.get("bl-bad").is_none()); assert!(cat.corruption("bl-bad").unwrap().contains("duplicate"));
assert!(cat.corruption("bl-good").is_none());
assert!(!cat.is_resolved("bl-bad")); }
#[test]
fn get_finds_a_ball_or_none() {
let cat = catalog(&[("bl-1", task("One", 1))]);
assert!(cat.get("bl-1").is_some());
assert!(cat.get("bl-404").is_none());
}
#[test]
fn parse_reads_the_two_flags_and_one_positional() {
let f = parse(Verb::List, &["--json".into(), "--plain".into()]).unwrap();
assert!(f.json && f.plain && f.target.is_none() && f.status.is_none());
let f = parse(Verb::Show, &["bl-1".into()]).unwrap();
assert_eq!(f.target.as_deref(), Some("bl-1"));
assert!(!f.json && !f.plain);
}
#[test]
fn parse_reads_the_list_status_filter() {
let f = parse(Verb::List, &["--status".into(), "blocked".into()]).unwrap();
assert_eq!(f.status, Some(Status::Blocked));
let f = parse(Verb::List, &["-s".into(), "claimed".into()]).unwrap();
assert_eq!(f.status, Some(Status::Claimed));
let f = parse(Verb::List, &["-s".into(), "closed".into()]).unwrap();
assert!(f.status.is_none() && f.reach == Reach::Dead);
}
#[test]
fn parse_rejects_bad_input() {
assert!(parse(Verb::List, &["--nope".into()]).is_err()); assert!(parse(Verb::Show, &["-h".into()]).is_err()); assert!(parse(Verb::List, &["a".into(), "b".into()]).is_err()); assert!(parse(Verb::Show, &[]).is_err()); assert!(parse(Verb::List, &["--status".into(), "bogus".into()]).is_err()); assert!(parse(Verb::List, &["--status".into()]).is_err()); assert!(parse(Verb::Show, &["--status".into(), "ready".into()]).is_err()); }
#[test]
fn parse_reads_the_legacy_shim_flag_on_both_reads() {
let f = parse(Verb::List, &["--legacy".into()]).unwrap();
assert_eq!(f.legacy.as_deref(), Some(legacy::DEFAULT_SPEC));
let f = parse(Verb::Show, &["bl-1".into(), "--legacy=v1:old".into()]).unwrap();
assert_eq!(f.legacy.as_deref(), Some("v1:old"));
assert!(parse(Verb::List, &["--legacy".into(), "--all".into()]).is_err());
assert!(parse(Verb::List, &["--legacy".into(), "-s".into(), "closed".into()]).is_err());
}
fn list(args: &[&str]) -> io::Result<Flags> {
parse(Verb::List, &args.iter().map(ToString::to_string).collect::<Vec<_>>())
}
#[test]
fn parse_reads_the_history_reach_flags() {
assert_eq!(list(&[]).unwrap().reach, Reach::Live); assert_eq!(list(&["--status", "closed"]).unwrap().reach, Reach::Dead);
assert_eq!(list(&["-s", "closed"]).unwrap().reach, Reach::Dead); assert_eq!(list(&["--all"]).unwrap().reach, Reach::All);
assert_eq!(list(&["-s", "ready"]).unwrap().reach, Reach::Live);
}
#[test]
fn parse_rejects_two_reach_flags() {
assert!(list(&["--status", "closed", "--all"]).is_err());
assert!(list(&["--all", "--status", "closed"]).is_err());
assert!(parse(Verb::Show, &["bl-1".into(), "--all".into()]).is_err());
}
#[test]
fn parse_collects_repeatable_tags_and_the_text_needle() {
let f = list(&["--tag", "infra", "--tag", "api", "find me"]).unwrap();
assert_eq!(f.tags, ["infra", "api"]);
assert_eq!(f.target.as_deref(), Some("find me"));
}
#[test]
fn parse_reads_the_claimant_filter() {
let f = list(&["--claimant", "alice"]).unwrap();
assert_eq!(f.claimant.as_deref(), Some("alice"));
assert!(list(&["--claimant"]).is_err()); assert!(parse(Verb::Show, &["bl-1".into(), "--claimant".into(), "alice".into()]).is_err());
}
#[test]
fn parse_reads_the_date_window_with_an_inclusive_until() {
let f = list(&["--since", "2026-01-01", "--until", "2026-01-01"]).unwrap();
let start = crate::civil::start_of_day("2026-01-01").unwrap();
assert_eq!(f.since, Some(start));
assert_eq!(f.until, Some(start + 86_399)); }
#[test]
fn parse_rejects_bad_dates_and_missing_filter_values() {
assert!(list(&["--since", "nope"]).is_err()); assert!(list(&["--until", "2026-13-01"]).is_err()); assert!(list(&["--tag"]).is_err()); assert!(list(&["--since"]).is_err()); assert!(list(&["--until"]).is_err()); }
#[test]
fn reach_predicates_split_live_and_dead() {
assert!(Reach::Live.live() && !Reach::Live.dead());
assert!(!Reach::Dead.live() && Reach::Dead.dead());
assert!(Reach::All.live() && Reach::All.dead());
}
#[test]
fn every_retirement_reads_as_closed() {
let plain = Style { plain: true };
assert_eq!(plain.retired_badge(), "closed ");
let rich = Style { plain: false };
assert!(rich.retired_badge().contains('\u{2713}')); assert!(!rich.retired_badge().contains('\u{2717}')); }
#[test]
fn the_badge_is_a_padded_word_in_plain_mode() {
let plain = Style { plain: true };
assert_eq!(plain.badge(Status::Ready), "ready ");
assert_eq!(plain.badge(Status::Blocked), "blocked ");
assert_eq!(plain.badge(Status::Claimed), "claimed ");
}
#[test]
fn the_badge_is_a_coloured_glyph_in_rich_mode() {
let rich = Style { plain: false };
assert!(rich.badge(Status::Ready).contains('\u{25cf}'));
assert!(rich.badge(Status::Claimed).contains('\u{25d1}'));
assert!(rich.badge(Status::Blocked).contains('\u{2298}'));
assert!(rich.badge(Status::Ready).ends_with("\u{1b}[0m"));
}
#[test]
fn the_status_words_are_stable_tokens() {
assert_eq!(Status::Ready.word(), "ready");
assert_eq!(Status::Claimed.word(), "claimed");
assert_eq!(Status::Blocked.word(), "blocked");
}
#[test]
fn task_json_carries_the_machine_contract_fields() {
let mut t = task("Title", 1_700_000_000);
t.priority = Some(3);
t.tags = vec!["x".into()];
t.blockers = vec![blocker("bl-b", On::Close)];
let v = task_json("bl-1", &t);
assert_eq!(v["id"], "bl-1");
assert_eq!(v["priority"], 3);
assert_eq!(v["created"], 1_700_000_000);
assert!(v["created"].is_i64());
assert_eq!(v["blockers"][0]["on"], "close");
assert!(v.get("status").is_none());
}
#[test]
fn task_json_round_trips_preserved_extras() {
let mut t = task("Title", 1_700_000_000);
t.extra.insert("review-by".into(), "bob".into());
t.extra.insert("state".into(), "in-review".into());
let v = task_json("bl-1", &t);
assert_eq!(v["review-by"], "bob");
assert_eq!(v["state"], "in-review");
assert_eq!(v["id"], "bl-1");
assert_eq!(v["title"], "Title");
}
#[test]
fn json_line_is_pretty_and_newline_terminated() {
let line = json_line(&serde_json::json!({ "a": 1 }));
assert!(line.ends_with('\n'));
assert!(line.contains("\"a\": 1"));
}