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

409 lines
11 KiB
Rust
Raw Normal View History

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");
}
// ── ProjectionError Display ─────────────────────────────────────────