//! Tests for the typed pipeline state machine. 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"); } // ── 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 { .. }) )); } // ── ProjectionError Display ─────────────────────────────────────────