//! 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 { claim: None, plan: PlanState::Missing, retries: 0, }; 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 { claim: None, plan: PlanState::Missing, retries: 0, }; 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 { claim: None, plan: PlanState::Missing, retries: 0, }, 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 { claim: None, plan: PlanState::Missing, retries: 0, }, 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), claim: None, retries: 0, server_start_time: None, }; 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 { claim: None, plan: PlanState::Missing, retries: 0, }, Stage::Qa, Stage::Done { merged_at: chrono::Utc::now(), merge_commit: sha("x"), }, ] { let result = transition(s, PipelineEvent::Abandon); assert!(matches!(result, Ok(Stage::Abandoned { .. }))); } } #[test] fn supersede_from_any_active_or_done() { for s in [ Stage::Backlog, Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, 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::Superseded { .. }))); } } // ── Review hold ───────────────────────────────────────────────────── #[test] fn review_hold_from_active_stages() { // Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }` // with the resume_to set to the originating stage, replacing the legacy // boolean flag. for s in [ Stage::Backlog, Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, Stage::Qa, ] { let result = transition( s.clone(), PipelineEvent::ReviewHold { reason: "review".into(), }, ); let resumed = match result { Ok(Stage::ReviewHold { resume_to, .. }) => *resume_to, other => panic!("ReviewHold should produce Stage::ReviewHold; got {other:?}"), }; assert_eq!( resumed, s, "resume_to should preserve the originating stage" ); } } // ── Merge failed final ────────────────────────────────────────────── #[test] fn merge_failed_final() { let s = Stage::Merge { feature_branch: fb("f"), commits_ahead: nz(1), claim: None, retries: 0, server_start_time: None, }; 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 { claim: None, plan: PlanState::Missing, retries: 0, }, 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(), claim: None, retries: 0, server_start_time: None, }; // 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::Abandoned { .. })); } #[test] fn supersede_from_upcoming() { let result = transition( Stage::Upcoming, PipelineEvent::Supersede { by: sid("999_story_new"), }, ) .unwrap(); assert!(matches!(result, Stage::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 { claim: None, plan: PlanState::Missing, retries: 0, }, Stage::Qa, ] { let result = transition( s.clone(), PipelineEvent::Reject { reason: "not needed".into(), }, ); assert!(matches!(result, Ok(Stage::Rejected { .. }))); } let m = Stage::Merge { feature_branch: fb("f"), commits_ahead: nz(1), claim: None, retries: 0, server_start_time: None, }; let result = transition( m, PipelineEvent::Reject { reason: "not needed".into(), }, ); assert!(matches!(result, Ok(Stage::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_transitions_to_frozen_variant_with_resume_to() { // Story 945: freeze/unfreeze move the typed stage to `Stage::Frozen // { resume_to }` and back, replacing the orthogonal boolean flag. 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!(!matches!(item.stage, Stage::Frozen { .. })); super::apply::transition_to_frozen(story_id).expect("freeze should succeed"); let item = read_typed(story_id).unwrap().unwrap(); match &item.stage { Stage::Frozen { resume_to } => assert!( matches!(**resume_to, Stage::Coding { .. }), "resume_to should preserve the previous stage; got {resume_to:?}" ), other => panic!("stage should be Stage::Frozen after freeze; got {other:?}"), } assert!( matches!(item.stage, Stage::Frozen { .. }), "is_frozen() should be true 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 should return to Coding after unfreeze: {:?}", item.stage ); assert!( !matches!(item.stage, Stage::Frozen { .. }), "is_frozen() should be false 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 { kind: MergeFailureKind::Other(reason.to_string()), }, None, ) .expect("MergeFailed transition should succeed"); // The emitted event payload carries the full reason via display_reason(). match &fired.event { PipelineEvent::MergeFailed { kind } => { assert_eq!( kind.display_reason(), 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 { kind: MergeFailureKind::Other("initial failure".into()), feature_branch: fb("feature/story-1"), commits_ahead: nz(1), }; let result = transition( s, PipelineEvent::MergeFailed { kind: MergeFailureKind::Other("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 { kind: MergeFailureKind::Other("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 919: MergeFailure + Unblock → Merge (re-attempt) ───────── /// AC1: `MergeFailure + Unblock` transitions to `Merge` (re-attempt), not `Coding` or `Backlog`. #[test] fn merge_failure_unblock_returns_to_merge() { let s = Stage::MergeFailure { kind: MergeFailureKind::ConflictDetected(Some("conflicts in server/src/main.rs".into())), feature_branch: fb("feature/story-42"), commits_ahead: nz(3), }; let result = transition(s, PipelineEvent::Unblock).unwrap(); assert!( matches!(result, Stage::Merge { .. }), "MergeFailure + Unblock should return to Merge for immediate re-attempt, got: {result:?}" ); } /// AC1 (complement): `MergeFailure + Demote` still goes to `Backlog` for manual parking. #[test] fn merge_failure_demote_returns_to_backlog() { let s = Stage::MergeFailure { kind: MergeFailureKind::Other("conflicts".into()), feature_branch: fb("feature/story-1"), commits_ahead: nz(1), }; let result = transition(s, PipelineEvent::Demote).unwrap(); assert!( matches!(result, Stage::Backlog), "MergeFailure + Demote should park the story in Backlog, got: {result:?}" ); } // ── 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 { kind: MergeFailureKind::ConflictDetected(Some("conflicts unresolvable".into())), feature_branch: fb("feature/story-1"), commits_ahead: nz(1), }; 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" ); } // ── Story 919: MergeFailure + Unblock → Merge (regression) ───────── /// AC3: CRDT-based regression — set stage to `MergeFailure`, call `unblock_story` /// via `apply_transition`, assert the Stage register becomes `Stage::Merge`. #[test] fn merge_failure_unblock_moves_to_merge_via_crdt() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99919_story_merge_failure_unblock"; crate::db::write_item_with_content( story_id, "merge_failure", "---\nname: MergeFailure Unblock Regression\n---\n# Story\n", crate::db::ItemMeta::named("MergeFailure Unblock Regression"), ); let fired = super::apply::apply_transition(story_id, PipelineEvent::Unblock, None) .expect("MergeFailure + Unblock should succeed"); assert!( matches!(fired.before, Stage::MergeFailure { .. }), "fired.before should be MergeFailure: {:?}", fired.before ); assert!( matches!(fired.after, Stage::Merge { .. }), "fired.after should be Merge, not Coding or Backlog: {:?}", fired.after ); let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should exist"); assert_eq!( item.stage.dir_name(), "merge", "CRDT stage should be merge after MergeFailure + Unblock" ); assert!( !matches!(item.stage, Stage::MergeFailure { .. }), "MergeFailure variant must not remain after Unblock" ); } // ── Story 973: Merge → Coding (abort in-flight merge) ─────────────── /// AC1 (pure): `Merge + MergeAborted` transitions to `Coding`. #[test] fn merge_aborted_returns_to_coding() { let s = Stage::Merge { feature_branch: fb("feature/story-73"), commits_ahead: nz(2), claim: None, retries: 0, server_start_time: None, }; let result = transition(s, PipelineEvent::MergeAborted).unwrap(); assert!( matches!(result, Stage::Coding { .. }), "Merge + MergeAborted should return to Coding, got: {result:?}" ); } /// AC1 (CRDT): set stage to `Merge`, apply `MergeAborted`, assert CRDT stage is `coding`. #[test] fn merge_aborted_moves_to_coding_via_crdt() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99973_story_merge_aborted"; crate::db::write_item_with_content( story_id, "merge", "---\nname: Merge Aborted Test\n---\n# Story\n", crate::db::ItemMeta::named("Merge Aborted Test"), ); let fired = super::apply::apply_transition(story_id, PipelineEvent::MergeAborted, None) .expect("Merge + MergeAborted should succeed"); assert!( matches!(fired.before, Stage::Merge { .. }), "fired.before should be Merge: {:?}", fired.before ); assert!( matches!(fired.after, Stage::Coding { .. }), "fired.after should be Coding: {:?}", fired.after ); let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should exist"); assert_eq!( item.stage.dir_name(), "coding", "CRDT stage should be coding after Merge + MergeAborted" ); } /// AC1 (move_story): `move_story_to_stage` with target "current" on a Merge story succeeds. #[test] fn move_story_merge_to_current_succeeds() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "99973_story_move_merge_to_current"; crate::db::write_item_with_content( story_id, "merge", "---\nname: Move Merge To Current\n---\n", crate::db::ItemMeta::named("Move Merge To Current"), ); let result = crate::agents::lifecycle::move_story_to_stage(story_id, "current"); assert!( result.is_ok(), "move_story_to_stage(merge → current) should succeed: {result:?}" ); let (from, to) = result.unwrap(); assert_eq!(from, "merge", "from_stage should be 'merge'"); assert_eq!(to, "current", "to_stage should be 'current'"); let item = read_typed(story_id) .expect("CRDT read should succeed") .expect("item should exist"); assert!( matches!(item.stage, Stage::Coding { .. }), "story should be in Coding after move_story_to_stage(merge → current): {:?}", item.stage ); } // ── Story 974: Done → Coding (hotfix) ───────────────────────────── #[test] fn hotfix_requested_from_done_lands_in_coding() { let done = Stage::Done { merged_at: chrono::Utc::now(), merge_commit: sha("abc123"), }; let result = transition(done, PipelineEvent::HotfixRequested).unwrap(); assert!( matches!(result, Stage::Coding { .. }), "Done + HotfixRequested must land in Coding; got: {:?}", result ); } #[test] fn hotfix_requested_rejected_from_non_done_stages() { for stage in [ Stage::Backlog, Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, Stage::Qa, Stage::Merge { feature_branch: fb("feature/story-1"), commits_ahead: nz(1), claim: None, retries: 0, server_start_time: None, }, ] { let result = transition(stage.clone(), PipelineEvent::HotfixRequested); assert!( result.is_err(), "HotfixRequested must be rejected from {:?}", stage ); } } // ── Audit log subscriber (story 1014) ────────────────────────────────────── #[test] fn audit_entry_backlog_to_coding_exact_format() { let at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); let fired = TransitionFired { story_id: StoryId("1014_my_story".into()), before: Stage::Backlog, after: Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, event: PipelineEvent::DepsMet, at, }; assert_eq!( format_audit_entry(&fired), "audit ts=2026-01-01T00:00:00Z id=1014_my_story from=Backlog to=Coding event=DepsMet" ); } #[test] fn audit_entry_is_single_line_with_all_fields() { let fired = TransitionFired { story_id: StoryId("42_test".into()), before: Stage::Qa, after: Stage::Merge { feature_branch: fb("feature/story-42"), commits_ahead: nz(3), claim: None, retries: 0, server_start_time: None, }, event: PipelineEvent::GatesPassed { feature_branch: fb("feature/story-42"), commits_ahead: nz(3), }, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(!line.contains('\n'), "audit entry must be a single line"); assert!(line.starts_with("audit "), "must start with 'audit '"); assert!(line.contains("id=42_test"), "must contain id field"); assert!(line.contains("from=Qa"), "must contain from field"); assert!(line.contains("to=Merge"), "must contain to field"); assert!( line.contains("event=GatesPassed"), "must contain event field" ); // Stable field ordering: ts before id before from before to before event. let ts_pos = line.find("ts=").unwrap(); let id_pos = line.find("id=").unwrap(); let from_pos = line.find("from=").unwrap(); let to_pos = line.find("to=").unwrap(); let ev_pos = line.find("event=").unwrap(); assert!( ts_pos < id_pos && id_pos < from_pos && from_pos < to_pos && to_pos < ev_pos, "fields must appear in order ts, id, from, to, event" ); } #[test] fn audit_entry_merge_to_done() { let fired = TransitionFired { story_id: StoryId("100_s".into()), before: Stage::Merge { feature_branch: fb("f"), commits_ahead: nz(1), claim: None, retries: 0, server_start_time: None, }, after: Stage::Done { merged_at: chrono::Utc::now(), merge_commit: sha("abc"), }, event: PipelineEvent::MergeSucceeded { merge_commit: sha("abc"), }, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Merge"), "from=Merge"); assert!(line.contains("to=Done"), "to=Done"); assert!( line.contains("event=MergeSucceeded"), "event=MergeSucceeded" ); } #[test] fn audit_entry_done_to_archived() { let fired = TransitionFired { story_id: StoryId("200_s".into()), before: Stage::Done { merged_at: chrono::Utc::now(), merge_commit: sha("x"), }, after: Stage::Archived { archived_at: chrono::Utc::now(), reason: ArchiveReason::Completed, }, event: PipelineEvent::Accepted, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Done"), "from=Done"); assert!(line.contains("to=Archived"), "to=Archived"); assert!(line.contains("event=Accepted"), "event=Accepted"); } #[test] fn audit_entry_coding_to_blocked() { let fired = TransitionFired { story_id: StoryId("300_s".into()), before: Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, after: Stage::Blocked { reason: "waiting".into(), }, event: PipelineEvent::Block { reason: "waiting".into(), }, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Coding"), "from=Coding"); assert!(line.contains("to=Blocked"), "to=Blocked"); assert!(line.contains("event=Block"), "event=Block"); } #[test] fn audit_entry_blocked_to_coding() { let fired = TransitionFired { story_id: StoryId("400_s".into()), before: Stage::Blocked { reason: "test".into(), }, after: Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, event: PipelineEvent::Unblock, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Blocked"), "from=Blocked"); assert!(line.contains("to=Coding"), "to=Coding"); assert!(line.contains("event=Unblock"), "event=Unblock"); } #[test] fn audit_entry_merge_to_merge_failure() { let fired = TransitionFired { story_id: StoryId("500_s".into()), before: Stage::Merge { feature_branch: fb("f"), commits_ahead: nz(1), claim: None, retries: 0, server_start_time: None, }, after: Stage::MergeFailure { kind: MergeFailureKind::Other("conflicts".into()), feature_branch: fb("f"), commits_ahead: nz(1), }, event: PipelineEvent::MergeFailed { kind: MergeFailureKind::Other("conflicts".into()), }, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Merge"), "from=Merge"); assert!(line.contains("to=MergeFailure"), "to=MergeFailure"); assert!(line.contains("event=MergeFailed"), "event=MergeFailed"); } #[test] fn audit_entry_coding_to_frozen() { let fired = TransitionFired { story_id: StoryId("600_s".into()), before: Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, after: Stage::Frozen { resume_to: Box::new(Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }), }, event: PipelineEvent::Freeze, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Coding"), "from=Coding"); assert!(line.contains("to=Frozen"), "to=Frozen"); assert!(line.contains("event=Freeze"), "event=Freeze"); } #[test] fn audit_entry_coding_to_abandoned() { let fired = TransitionFired { story_id: StoryId("700_s".into()), before: Stage::Coding { claim: None, plan: PlanState::Missing, retries: 0, }, after: Stage::Abandoned { ts: chrono::Utc::now(), }, event: PipelineEvent::Abandon, at: chrono::Utc::now(), }; let line = format_audit_entry(&fired); assert!(line.contains("from=Coding"), "from=Coding"); assert!(line.contains("to=Abandoned"), "to=Abandoned"); assert!(line.contains("event=Abandon"), "event=Abandon"); } // ── ProjectionError Display ─────────────────────────────────────────