Files
huskies/server/src/pipeline_state/tests.rs
T

662 lines
18 KiB
Rust
Raw Normal View History

2026-04-29 09:25:05 +00:00
//! Tests for the typed pipeline state machine.
2026-04-28 20:56:22 +00:00
use super::*;
use std::num::NonZeroU32;
fn nz(n: u32) -> NonZeroU32 {
NonZeroU32::new(n).unwrap()
}
fn fb(name: &str) -> BranchName {
BranchName(name.to_string())
}
fn sha(s: &str) -> GitSha {
GitSha(s.to_string())
}
fn sid(s: &str) -> StoryId {
StoryId(s.to_string())
}
#[test]
fn happy_path_backlog_through_archived() {
let s = Stage::Backlog;
let s = transition(s, PipelineEvent::DepsMet).unwrap();
assert!(matches!(s, Stage::Coding));
let s = transition(
s,
PipelineEvent::QaSkipped {
feature_branch: fb("feature/story-1"),
commits_ahead: nz(3),
},
)
.unwrap();
assert!(matches!(s, Stage::Merge { .. }));
let s = transition(
s,
PipelineEvent::MergeSucceeded {
merge_commit: sha("abc123"),
},
)
.unwrap();
assert!(matches!(s, Stage::Done { .. }));
let s = transition(s, PipelineEvent::Accepted).unwrap();
assert!(matches!(
s,
Stage::Archived {
reason: ArchiveReason::Completed,
..
}
));
}
#[test]
fn happy_path_with_qa() {
let s = Stage::Coding;
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
let s = transition(
s,
PipelineEvent::GatesPassed {
feature_branch: fb("feature/story-2"),
commits_ahead: nz(5),
},
)
.unwrap();
assert!(matches!(s, Stage::Merge { .. }));
}
#[test]
fn qa_retry_loop() {
let s = Stage::Coding;
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
assert!(matches!(s, Stage::Qa));
let s = transition(
s,
PipelineEvent::GatesFailed {
reason: "tests failed".into(),
},
)
.unwrap();
assert!(matches!(s, Stage::Coding));
}
// ── Bug 519: Merge with zero commits is unrepresentable ─────────────
#[test]
fn merge_with_zero_commits_is_unrepresentable() {
assert!(NonZeroU32::new(0).is_none());
}
// ── Invalid transitions ─────────────────────────────────────────────
#[test]
fn cannot_jump_backlog_to_done() {
let result = transition(Stage::Backlog, PipelineEvent::Accepted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unblock_done_story() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Unblock);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unblock_review_held_story() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::ReviewHeld {
reason: "TBD".into(),
},
};
let result = transition(s, PipelineEvent::Unblock);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_merge_from_backlog() {
let result = transition(
Stage::Backlog,
PipelineEvent::MergeSucceeded {
merge_commit: sha("abc"),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_start_gates_from_backlog() {
let result = transition(Stage::Backlog, PipelineEvent::GatesStarted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_accept_from_coding() {
let result = transition(Stage::Coding, PipelineEvent::Accepted);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Block from any active stage ─────────────────────────────────────
#[test]
fn block_from_any_active_stage() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Block {
reason: "stuck".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Blocked { .. },
..
})
));
}
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
m,
PipelineEvent::Block {
reason: "stuck".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Blocked { .. },
..
})
));
}
#[test]
fn unblock_returns_to_backlog() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Blocked {
reason: "test".into(),
},
};
let result = transition(s, PipelineEvent::Unblock).unwrap();
assert!(matches!(result, Stage::Backlog));
}
// ── Abandon / supersede ─────────────────────────────────────────────
#[test]
fn abandon_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
},
] {
let result = transition(s, PipelineEvent::Abandon);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Abandoned,
..
})
));
}
}
#[test]
fn supersede_from_any_active_or_done() {
for s in [
Stage::Backlog,
Stage::Coding,
Stage::Qa,
Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("x"),
},
] {
let result = transition(
s,
PipelineEvent::Supersede {
by: sid("999_story_new"),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
})
));
}
}
// ── Review hold ─────────────────────────────────────────────────────
#[test]
fn review_hold_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::ReviewHold {
reason: "review".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::ReviewHeld { .. },
..
})
));
}
}
// ── Merge failed final ──────────────────────────────────────────────
#[test]
fn merge_failed_final() {
let s = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
s,
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::MergeFailed { .. },
..
}
));
}
#[test]
fn merge_failed_only_from_merge() {
let result = transition(
Stage::Coding,
PipelineEvent::MergeFailedFinal {
reason: "conflicts".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Execution state machine ─────────────────────────────────────────
#[test]
fn execution_happy_path() {
let e = ExecutionState::Idle;
let e = execution_transition(
e,
ExecutionEvent::SpawnRequested {
agent: AgentName("coder-1".into()),
},
)
.unwrap();
assert!(matches!(e, ExecutionState::Pending { .. }));
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
let e = execution_transition(e, ExecutionEvent::Heartbeat).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
let e = execution_transition(e, ExecutionEvent::Exited { exit_code: 0 }).unwrap();
assert!(matches!(e, ExecutionState::Completed { exit_code: 0, .. }));
}
#[test]
fn execution_rate_limit_then_resume() {
let e = ExecutionState::Running {
agent: AgentName("coder-1".into()),
started_at: chrono::Utc::now(),
last_heartbeat: chrono::Utc::now(),
};
let e = execution_transition(
e,
ExecutionEvent::HitRateLimit {
resume_at: chrono::Utc::now() + chrono::Duration::minutes(5),
},
)
.unwrap();
assert!(matches!(e, ExecutionState::RateLimited { .. }));
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
assert!(matches!(e, ExecutionState::Running { .. }));
}
#[test]
fn execution_stop_from_anywhere() {
let e = ExecutionState::Running {
agent: AgentName("coder-1".into()),
started_at: chrono::Utc::now(),
last_heartbeat: chrono::Utc::now(),
};
let e = execution_transition(e, ExecutionEvent::Stopped).unwrap();
assert!(matches!(e, ExecutionState::Idle));
}
// ── Projection tests ────────────────────────────────────────────────
#[test]
fn bug_502_agent_not_in_stage() {
// Bug 502 was caused by a coder agent being assigned to a story in
// Merge stage. In the typed system, Stage has no `agent` field at all.
// Agent assignment is per-node ExecutionState. This test documents that
// the old failure mode is structurally impossible.
let merge = Stage::Merge {
feature_branch: BranchName("feature/story-1".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
};
// Stage::Merge has exactly two fields: feature_branch and commits_ahead.
// There is no way to attach an agent name to it. The type system
// prevents bug 502 by construction.
assert!(matches!(merge, Stage::Merge { .. }));
}
// ── TransitionError Display ─────────────────────────────────────────
#[test]
fn transition_error_display() {
let err = TransitionError::InvalidTransition {
from_stage: "Backlog".into(),
event: "Accepted".into(),
};
assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted");
}
2026-04-29 17:38:38 +00:00
// ── Upcoming / Triage ──────────────────────────────────────────────
#[test]
fn triage_upcoming_to_backlog() {
let s = Stage::Upcoming;
let s = transition(s, PipelineEvent::Triage).unwrap();
assert!(matches!(s, Stage::Backlog));
}
#[test]
fn cannot_triage_from_backlog() {
let result = transition(Stage::Backlog, PipelineEvent::Triage);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn abandon_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Abandoned,
..
}
));
}
#[test]
fn supersede_from_upcoming() {
let result = transition(
Stage::Upcoming,
PipelineEvent::Supersede {
by: sid("999_story_new"),
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
}
));
}
#[test]
fn cannot_deps_met_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::DepsMet);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Reject ─────────────────────────────────────────────────────────
#[test]
fn reject_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
m,
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
#[test]
fn cannot_reject_from_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "too late".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_reject_from_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "already done".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
2026-04-29 22:12:23 +00:00
// ── Freeze / Unfreeze ───────────────────────────────────────────────
#[test]
fn freeze_from_active_stages() {
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
assert!(
matches!(result, Stage::Frozen { .. }),
"expected Frozen from {s:?}"
);
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, s);
}
}
}
#[test]
fn freeze_from_merge() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
assert!(matches!(result, Stage::Frozen { .. }));
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, m);
}
}
#[test]
fn unfreeze_restores_prior_stage() {
let prior = Stage::Coding;
let frozen = Stage::Frozen {
resume_to: Box::new(prior.clone()),
};
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
assert_eq!(result, prior);
}
#[test]
fn cannot_freeze_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_freeze_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unfreeze_coding() {
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
/// Verifies that the CRDT shows the correct prior stage restored.
#[test]
fn regression_freeze_unfreeze_restores_crdt_stage() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "9950_story_freeze_regression";
let content = "---\nname: Freeze Regression\n---\n# Story\n";
crate::db::write_item_with_content(story_id, "2_current", content);
// Confirm starting stage.
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should start at Coding"
);
// Freeze.
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Frozen { .. }),
"should be Frozen after freeze: {:?}",
item.stage
);
if let Stage::Frozen { ref resume_to } = item.stage {
assert!(
matches!(**resume_to, Stage::Coding),
"resume_to should be Coding: {:?}",
resume_to
);
}
// Unfreeze.
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should be restored to Coding after unfreeze: {:?}",
item.stage
);
}
2026-04-28 20:56:22 +00:00
// ── ProjectionError Display ─────────────────────────────────────────