//! 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::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::Blocked { .. }))); } #[test] fn unblock_returns_to_coding() { let s = Stage::Blocked { reason: "test".into(), }; let result = transition(s, PipelineEvent::Unblock).unwrap(); assert!(matches!(result, Stage::Coding)); } #[test] fn blocked_demote_returns_to_backlog() { // Stuck-story parking lane: `Blocked + Demote → Backlog` lets operators // move a blocked story back to the backlog without losing it to // Archived. Complements `Blocked + Unblock → Coding` which re-enters // active work. let s = Stage::Blocked { reason: "waiting on dep".into(), }; let result = transition(s, PipelineEvent::Demote).unwrap(); assert!(matches!(result, Stage::Backlog)); } #[test] fn cannot_demote_from_done() { // Sanity: Demote remains illegal from terminal/archived stages — the // new `Blocked + Demote → Backlog` rule must NOT broaden it further. let s = Stage::Done { merged_at: chrono::Utc::now(), merge_commit: sha("x"), }; assert!(matches!( transition(s, PipelineEvent::Demote), Err(TransitionError::InvalidTransition { .. }) )); } #[test] fn cannot_demote_from_upcoming() { let s = Stage::Upcoming; assert!(matches!( transition(s, PipelineEvent::Demote), Err(TransitionError::InvalidTransition { .. }) )); } #[test] fn legacy_unblock_archived_blocked_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 { .. }) )); } // ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ──────────────── /// Freeze sets the `frozen` flag without changing the stage register. /// Unfreeze clears the flag — the stage was never touched so there's nothing /// to "restore". Tests the freeze/unfreeze API on the apply layer, since /// freeze/unfreeze are no longer pure stage transitions. #[test] fn freeze_sets_flag_without_changing_stage() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "9950_story_freeze_regression"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Freeze Regression\n---\n# Story\n", crate::db::ItemMeta::named("Freeze Regression"), ); let item = read_typed(story_id).unwrap().unwrap(); assert!(matches!(item.stage, Stage::Coding)); assert!(!item.is_frozen()); super::apply::transition_to_frozen(story_id).expect("freeze should succeed"); let item = read_typed(story_id).unwrap().unwrap(); assert!( matches!(item.stage, Stage::Coding), "stage register stays at Coding after freeze: {:?}", item.stage ); assert!(item.is_frozen(), "frozen flag should be set after freeze"); 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), "stage register still at Coding after unfreeze: {:?}", item.stage ); assert!( !item.is_frozen(), "frozen flag should be cleared after unfreeze" ); } // ── Story 868: MergeFailure regression ───────────────────────────── /// Regression test (story 868): applying `PipelineEvent::MergeFailed` to a story /// in `Stage::Merge` transitions it to `Stage::MergeFailure` and the emitted /// `TransitionFired` event carries the full reason string in its payload. #[test] fn merge_failure_transition_emits_event_with_full_reason() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99868_story_merge_failure_event"; crate::db::write_item_with_content( story_id, "4_merge", "---\nname: Merge Failure Event Test\n---\n# Story\n", crate::db::ItemMeta::named("Merge Failure Event Test"), ); let reason = "Conflict in server/src/main.rs: both modified"; let fired = super::apply::apply_transition( story_id, PipelineEvent::MergeFailed { reason: reason.to_string(), }, None, ) .expect("MergeFailed transition should succeed"); // The emitted event payload carries the full reason string. match &fired.event { PipelineEvent::MergeFailed { reason: r } => { assert_eq!(r, reason, "emitted event should carry the full reason"); } other => panic!("expected MergeFailed event, got: {other:?}"), } // The story transitioned to MergeFailure. assert!( matches!(fired.after, Stage::MergeFailure { .. }), "after-stage should be MergeFailure: {:?}", fired.after ); // Verify CRDT reflects the new stage. let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should exist"); assert_eq!( item.stage.dir_name(), "merge_failure", "CRDT stage should be 4_merge_failure" ); } // ── Story 913: MergeFailure + MergeFailed self-loop ──────────────── /// AC1: `MergeFailure + MergeFailed` is a valid self-transition — no error logged. #[test] fn merge_failure_plus_merge_failed_is_self_loop() { let s = Stage::MergeFailure { reason: "initial failure".into(), }; let result = transition( s, PipelineEvent::MergeFailed { reason: "second failure".into(), }, ); assert!( matches!(result, Ok(Stage::MergeFailure { .. })), "MergeFailure + MergeFailed should self-loop to MergeFailure, got: {result:?}" ); } /// AC2 + AC3: applying `MergeFailed` to a story already in `MergeFailure` succeeds and /// the `TransitionFired::before` is `MergeFailure`, allowing callers to suppress the /// duplicate notification. #[test] fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99913_story_merge_failure_selfloop"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: MergeFailure Self-loop Test\n---\n# Story\n", crate::db::ItemMeta::named("MergeFailure Self-loop Test"), ); // Apply a second MergeFailed to a story already in MergeFailure. let fired = super::apply::apply_transition( story_id, PipelineEvent::MergeFailed { reason: "duplicate failure".into(), }, None, ) .expect("MergeFailed on already-failed story should succeed without error"); // The before-stage was MergeFailure: this was a self-loop. // Callers check this to decide whether to suppress the chat notification. assert!( matches!(fired.before, Stage::MergeFailure { .. }), "fired.before should be MergeFailure (self-loop): {:?}", fired.before ); assert!( matches!(fired.after, Stage::MergeFailure { .. }), "fired.after should remain MergeFailure: {:?}", fired.after ); // Verify the CRDT stage is still merge_failure. let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should still exist"); assert_eq!( item.stage.dir_name(), "merge_failure", "CRDT stage should remain merge_failure after self-loop" ); // Simulate the caller's de-dup logic: since fired.before is already MergeFailure, // no notification should be dispatched. let should_notify = !matches!(fired.before, Stage::MergeFailure { .. }); assert!( !should_notify, "should_notify must be false for a self-loop to prevent duplicate notification" ); } // ── Story 892: MergeFailure → Done (manual recovery) ─────────────── /// Regression test (story 892): `accept_story` on a story in `MergeFailure` /// transitions it to `Done` and emits a `TransitionFired` event. #[test] fn merge_failure_accept_pure_transition() { let s = Stage::MergeFailure { reason: "conflicts unresolvable".into(), }; let result = transition(s, PipelineEvent::Accepted).unwrap(); assert!( matches!(result, Stage::Done { .. }), "MergeFailure + Accepted should yield Done, got: {result:?}" ); } /// Regression test (story 892): `apply_transition` on a CRDT-stored `MergeFailure` /// story moves it to `Done` and the emitted `TransitionFired` event is present. #[test] fn merge_failure_accept_moves_to_done_via_crdt() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99892_story_merge_failure_accept"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: MergeFailure Accept Test\n---\n# Story\n", crate::db::ItemMeta::named("MergeFailure Accept Test"), ); let fired = super::apply::apply_transition(story_id, PipelineEvent::Accepted, None) .expect("MergeFailure + Accepted should succeed"); // The before-stage was MergeFailure. assert!( matches!(fired.before, Stage::MergeFailure { .. }), "fired.before should be MergeFailure: {:?}", fired.before ); // The after-stage is Done. assert!( matches!(fired.after, Stage::Done { .. }), "fired.after should be Done: {:?}", fired.after ); // TransitionFired carries the Accepted event. assert!( matches!(fired.event, PipelineEvent::Accepted), "fired.event should be Accepted: {:?}", fired.event ); // CRDT reflects done. let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should exist"); assert_eq!( item.stage.dir_name(), "done", "CRDT stage should be done after MergeFailure + Accepted" ); } // ── ProjectionError Display ─────────────────────────────────────────