use gwm::config::MilestoneConfig;
use gwm::milestones::{
diff_milestones, normalize_due_on, parse_state, resolve_milestones, MilestoneAction, MilestoneDiff, MilestoneSpec,
MilestoneState, MilestoneUpdate, RemoteMilestone,
};
#[test]
fn normalize_due_on_accepts_short_date_form() {
let n = normalize_due_on("2026-07-15").unwrap();
assert_eq!(n, "2026-07-15T23:59:59Z");
}
#[test]
fn normalize_due_on_passes_canonical_z_form_through() {
let n = normalize_due_on("2026-07-15T17:00:00Z").unwrap();
assert_eq!(n, "2026-07-15T17:00:00Z");
}
#[test]
fn normalize_due_on_canonicalises_offset_zero_to_z() {
let n = normalize_due_on("2026-10-01T17:00:00+00:00").unwrap();
assert_eq!(n, "2026-10-01T17:00:00Z");
}
#[test]
fn normalize_due_on_converts_non_utc_offset_to_utc() {
let n = normalize_due_on("2026-07-15T19:00:00+02:00").unwrap();
assert_eq!(n, "2026-07-15T17:00:00Z");
}
#[test]
fn normalize_due_on_rejects_garbage() {
assert!(normalize_due_on("2026/07/15").is_err());
assert!(normalize_due_on("tomorrow").is_err());
assert!(normalize_due_on("").is_err());
}
#[test]
fn normalize_due_on_rejects_impossible_calendar_date() {
assert!(normalize_due_on("2026-02-30").is_err());
}
#[test]
fn parse_state_accepts_open_and_closed() {
assert_eq!(parse_state("open").unwrap(), MilestoneState::Open);
assert_eq!(parse_state("closed").unwrap(), MilestoneState::Closed);
}
#[test]
fn parse_state_is_case_sensitive() {
assert!(parse_state("Open").is_err());
assert!(parse_state("OPEN").is_err());
}
#[test]
fn parse_state_rejects_unknown() {
let err = parse_state("draft").unwrap_err();
assert!(err.to_string().contains("draft"));
}
#[test]
fn resolve_milestones_defaults_state_to_open() {
let declared = vec![MilestoneConfig {
title: "v0.7.0".into(),
description: None,
due_on: None,
state: None,
}];
let resolved = resolve_milestones(&declared).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].state, MilestoneState::Open);
}
#[test]
fn resolve_milestones_keeps_declared_state() {
let declared = vec![MilestoneConfig {
title: "v0.6.0".into(),
description: None,
due_on: None,
state: Some("closed".into()),
}];
let resolved = resolve_milestones(&declared).unwrap();
assert_eq!(resolved[0].state, MilestoneState::Closed);
}
#[test]
fn resolve_milestones_normalises_short_due_on() {
let declared = vec![MilestoneConfig {
title: "v0.7.0".into(),
description: None,
due_on: Some("2026-07-15".into()),
state: None,
}];
let resolved = resolve_milestones(&declared).unwrap();
assert_eq!(resolved[0].due_on.as_deref(), Some("2026-07-15T23:59:59Z"));
}
#[test]
fn resolve_milestones_collapses_empty_description() {
let declared = vec![MilestoneConfig {
title: "v0.7.0".into(),
description: Some("".into()),
due_on: None,
state: None,
}];
let resolved = resolve_milestones(&declared).unwrap();
assert_eq!(resolved[0].description, None);
}
#[test]
fn resolve_milestones_surfaces_invalid_state_with_title() {
let declared = vec![MilestoneConfig {
title: "v0.7.0".into(),
description: None,
due_on: None,
state: Some("draft".into()),
}];
let err = resolve_milestones(&declared).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("v0.7.0"), "should mention the title: {}", msg);
assert!(msg.contains("draft"), "should mention the bad value: {}", msg);
}
#[test]
fn resolve_milestones_surfaces_invalid_due_on_with_title() {
let declared = vec![MilestoneConfig {
title: "v0.7.0".into(),
description: None,
due_on: Some("not-a-date".into()),
state: None,
}];
let err = resolve_milestones(&declared).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("v0.7.0"), "should mention the title: {}", msg);
}
fn spec(title: &str, due: Option<&str>, desc: Option<&str>, state: MilestoneState) -> MilestoneSpec {
MilestoneSpec {
title: title.into(),
description: desc.map(|s| s.into()),
due_on: due.map(|s| s.into()),
state,
}
}
fn rmilestone(
number: u64,
title: &str,
due: Option<&str>,
desc: Option<&str>,
state: MilestoneState,
) -> RemoteMilestone {
RemoteMilestone {
number,
title: title.into(),
description: desc.map(|s| s.into()),
due_on: due.map(|s| s.into()),
state,
}
}
#[test]
fn diff_empty_declared_yields_only_extras() {
let declared = vec![];
let remote = vec![rmilestone(1, "old-sprint", None, None, MilestoneState::Closed)];
let diff = diff_milestones(&declared, &remote);
assert!(diff.to_create.is_empty());
assert!(diff.to_update.is_empty());
assert!(diff.matching.is_empty());
assert_eq!(diff.extra_on_remote.len(), 1);
assert_eq!(diff.extra_on_remote[0].title, "old-sprint");
}
#[test]
fn diff_declared_not_on_remote_yields_create() {
let declared = vec![spec(
"v0.7.0",
Some("2026-07-15T23:59:59Z"),
Some("sprint"),
MilestoneState::Open,
)];
let remote = vec![];
let diff = diff_milestones(&declared, &remote);
assert_eq!(diff.to_create.len(), 1);
assert_eq!(diff.to_create[0].title, "v0.7.0");
assert!(diff.to_update.is_empty());
assert!(diff.matching.is_empty());
assert!(diff.extra_on_remote.is_empty());
}
#[test]
fn diff_due_on_mismatch_yields_update_carrying_number() {
let declared = vec![spec("v0.7.0", Some("2026-07-15T23:59:59Z"), None, MilestoneState::Open)];
let remote = vec![rmilestone(
42,
"v0.7.0",
Some("2026-07-01T23:59:59Z"),
None,
MilestoneState::Open,
)];
let diff = diff_milestones(&declared, &remote);
assert!(diff.to_create.is_empty());
assert_eq!(diff.to_update.len(), 1);
assert_eq!(diff.to_update[0].action, MilestoneAction::Update);
assert_eq!(diff.to_update[0].number, 42);
assert_eq!(diff.to_update[0].spec.due_on.as_deref(), Some("2026-07-15T23:59:59Z"));
assert_eq!(
diff.to_update[0].previous_due_on.as_deref(),
Some("2026-07-01T23:59:59Z")
);
}
#[test]
fn diff_state_mismatch_yields_update() {
let declared = vec![spec("v0.6.0", None, None, MilestoneState::Closed)];
let remote = vec![rmilestone(7, "v0.6.0", None, None, MilestoneState::Open)];
let diff = diff_milestones(&declared, &remote);
assert_eq!(diff.to_update.len(), 1);
assert_eq!(diff.to_update[0].spec.state, MilestoneState::Closed);
assert_eq!(diff.to_update[0].previous_state, Some(MilestoneState::Open));
}
#[test]
fn diff_description_mismatch_yields_update() {
let declared = vec![spec("v0.7.0", None, Some("sprint"), MilestoneState::Open)];
let remote = vec![rmilestone(7, "v0.7.0", None, Some("old desc"), MilestoneState::Open)];
let diff = diff_milestones(&declared, &remote);
assert_eq!(diff.to_update.len(), 1);
assert_eq!(diff.to_update[0].spec.description.as_deref(), Some("sprint"));
}
#[test]
fn diff_full_match_yields_matching() {
let declared = vec![spec(
"v0.7.0",
Some("2026-07-15T23:59:59Z"),
Some("sprint"),
MilestoneState::Open,
)];
let remote = vec![rmilestone(
9,
"v0.7.0",
Some("2026-07-15T23:59:59Z"),
Some("sprint"),
MilestoneState::Open,
)];
let diff = diff_milestones(&declared, &remote);
assert_eq!(diff.matching.len(), 1);
assert_eq!(diff.matching[0].title, "v0.7.0");
assert!(diff.to_create.is_empty());
assert!(diff.to_update.is_empty());
assert!(diff.extra_on_remote.is_empty());
}
#[test]
fn diff_summary_counts_match_buckets() {
let diff = MilestoneDiff {
to_create: vec![
spec("a", None, None, MilestoneState::Open),
spec("b", None, None, MilestoneState::Open),
],
to_update: vec![MilestoneUpdate {
action: MilestoneAction::Update,
spec: spec("c", None, None, MilestoneState::Open),
number: 1,
previous_due_on: None,
previous_description: None,
previous_state: Some(MilestoneState::Closed),
}],
matching: vec![
spec("d", None, None, MilestoneState::Open),
spec("e", None, None, MilestoneState::Open),
spec("f", None, None, MilestoneState::Open),
],
extra_on_remote: vec![rmilestone(99, "g", None, None, MilestoneState::Open)],
};
let (c, u, m, x) = diff.counts();
assert_eq!(c, 2);
assert_eq!(u, 1);
assert_eq!(m, 3);
assert_eq!(x, 1);
}