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();
|
2026-05-13 22:50:13 +00:00
|
|
|
assert!(matches!(s, Stage::Coding { .. }));
|
2026-04-28 20:56:22 +00:00
|
|
|
|
|
|
|
|
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() {
|
2026-05-14 08:07:43 +00:00
|
|
|
let s = Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
};
|
2026-04-28 20:56:22 +00:00
|
|
|
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() {
|
2026-05-14 08:07:43 +00:00
|
|
|
let s = Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
};
|
2026-04-28 20:56:22 +00:00
|
|
|
let s = transition(s, PipelineEvent::GatesStarted).unwrap();
|
|
|
|
|
assert!(matches!(s, Stage::Qa));
|
|
|
|
|
|
|
|
|
|
let s = transition(
|
|
|
|
|
s,
|
|
|
|
|
PipelineEvent::GatesFailed {
|
|
|
|
|
reason: "tests failed".into(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-05-13 22:50:13 +00:00
|
|
|
assert!(matches!(s, Stage::Coding { .. }));
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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() {
|
2026-05-14 08:07:43 +00:00
|
|
|
let result = transition(
|
|
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
|
|
|
|
PipelineEvent::Accepted,
|
|
|
|
|
);
|
2026-04-28 20:56:22 +00:00
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Err(TransitionError::InvalidTransition { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Block from any active stage ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn block_from_any_active_stage() {
|
2026-05-14 08:07:43 +00:00
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
|
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
|
|
|
|
Stage::Qa,
|
|
|
|
|
] {
|
2026-04-28 20:56:22 +00:00
|
|
|
let result = transition(
|
|
|
|
|
s.clone(),
|
|
|
|
|
PipelineEvent::Block {
|
|
|
|
|
reason: "stuck".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-29 22:42:59 +00:00
|
|
|
assert!(matches!(result, Ok(Stage::Blocked { .. })));
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let m = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-04-28 20:56:22 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(
|
|
|
|
|
m,
|
|
|
|
|
PipelineEvent::Block {
|
|
|
|
|
reason: "stuck".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-29 22:42:59 +00:00
|
|
|
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();
|
2026-05-13 22:50:13 +00:00
|
|
|
assert!(matches!(result, Stage::Coding { .. }));
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-12 13:13:18 +01:00
|
|
|
#[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 { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 20:56:22 +00:00
|
|
|
#[test]
|
2026-04-29 22:42:59 +00:00
|
|
|
fn legacy_unblock_archived_blocked_returns_to_backlog() {
|
2026-04-28 20:56:22 +00:00
|
|
|
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,
|
2026-05-14 08:07:43 +00:00
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-04-28 20:56:22 +00:00
|
|
|
Stage::Qa,
|
|
|
|
|
Stage::Done {
|
|
|
|
|
merged_at: chrono::Utc::now(),
|
|
|
|
|
merge_commit: sha("x"),
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let result = transition(s, PipelineEvent::Abandon);
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Ok(Stage::Abandoned { .. })));
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn supersede_from_any_active_or_done() {
|
|
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
2026-05-14 08:07:43 +00:00
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-04-28 20:56:22 +00:00
|
|
|
Stage::Qa,
|
|
|
|
|
Stage::Done {
|
|
|
|
|
merged_at: chrono::Utc::now(),
|
|
|
|
|
merge_commit: sha("x"),
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let result = transition(
|
|
|
|
|
s,
|
|
|
|
|
PipelineEvent::Supersede {
|
|
|
|
|
by: sid("999_story_new"),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Ok(Stage::Superseded { .. })));
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Review hold ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn review_hold_from_active_stages() {
|
2026-05-13 06:05:01 +00:00
|
|
|
// Story 945: `ReviewHold` transitions to `Stage::ReviewHold { resume_to }`
|
|
|
|
|
// with the resume_to set to the originating stage, replacing the legacy
|
|
|
|
|
// boolean flag.
|
2026-05-14 08:07:43 +00:00
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
|
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
|
|
|
|
Stage::Qa,
|
|
|
|
|
] {
|
2026-04-28 20:56:22 +00:00
|
|
|
let result = transition(
|
|
|
|
|
s.clone(),
|
|
|
|
|
PipelineEvent::ReviewHold {
|
|
|
|
|
reason: "review".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 06:05:01 +00:00
|
|
|
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"
|
|
|
|
|
);
|
2026-04-28 20:56:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Merge failed final ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_failed_final() {
|
|
|
|
|
let s = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-04-28 20:56:22 +00:00
|
|
|
};
|
|
|
|
|
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(
|
2026-05-14 08:07:43 +00:00
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-04-28 20:56:22 +00:00
|
|
|
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(),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-04-28 20:56:22 +00:00
|
|
|
};
|
|
|
|
|
// 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();
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Stage::Abandoned { .. }));
|
2026-04-29 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn supersede_from_upcoming() {
|
|
|
|
|
let result = transition(
|
|
|
|
|
Stage::Upcoming,
|
|
|
|
|
PipelineEvent::Supersede {
|
|
|
|
|
by: sid("999_story_new"),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Stage::Superseded { .. }));
|
2026-04-29 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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() {
|
2026-05-14 08:07:43 +00:00
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
|
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
|
|
|
|
Stage::Qa,
|
|
|
|
|
] {
|
2026-04-29 17:38:38 +00:00
|
|
|
let result = transition(
|
|
|
|
|
s.clone(),
|
|
|
|
|
PipelineEvent::Reject {
|
|
|
|
|
reason: "not needed".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Ok(Stage::Rejected { .. })));
|
2026-04-29 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let m = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-04-29 17:38:38 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(
|
|
|
|
|
m,
|
|
|
|
|
PipelineEvent::Reject {
|
|
|
|
|
reason: "not needed".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 16:43:19 +00:00
|
|
|
assert!(matches!(result, Ok(Stage::Rejected { .. })));
|
2026-04-29 17:38:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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-05-12 22:31:59 +01:00
|
|
|
// ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ────────────────
|
2026-04-29 22:12:23 +00:00
|
|
|
|
2026-05-12 22:31:59 +01:00
|
|
|
/// 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.
|
2026-04-29 22:12:23 +00:00
|
|
|
#[test]
|
2026-05-13 06:05:01 +00:00
|
|
|
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.
|
2026-04-29 22:12:23 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
|
|
|
|
|
let story_id = "9950_story_freeze_regression";
|
2026-04-30 22:23:21 +00:00
|
|
|
crate::db::write_item_with_content(
|
|
|
|
|
story_id,
|
|
|
|
|
"2_current",
|
2026-05-12 22:31:59 +01:00
|
|
|
"---\nname: Freeze Regression\n---\n# Story\n",
|
2026-05-12 20:55:25 +01:00
|
|
|
crate::db::ItemMeta::named("Freeze Regression"),
|
2026-04-30 22:23:21 +00:00
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
|
|
|
|
|
let item = read_typed(story_id).unwrap().unwrap();
|
2026-05-13 22:50:13 +00:00
|
|
|
assert!(matches!(item.stage, Stage::Coding { .. }));
|
2026-05-13 17:24:18 +00:00
|
|
|
assert!(!matches!(item.stage, Stage::Frozen { .. }));
|
2026-04-29 22:12:23 +00:00
|
|
|
|
|
|
|
|
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
|
|
|
|
|
|
|
|
|
|
let item = read_typed(story_id).unwrap().unwrap();
|
2026-05-13 06:05:01 +00:00
|
|
|
match &item.stage {
|
|
|
|
|
Stage::Frozen { resume_to } => assert!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(**resume_to, Stage::Coding { .. }),
|
2026-05-13 06:05:01 +00:00
|
|
|
"resume_to should preserve the previous stage; got {resume_to:?}"
|
|
|
|
|
),
|
|
|
|
|
other => panic!("stage should be Stage::Frozen after freeze; got {other:?}"),
|
|
|
|
|
}
|
2026-05-13 17:24:18 +00:00
|
|
|
assert!(
|
|
|
|
|
matches!(item.stage, Stage::Frozen { .. }),
|
|
|
|
|
"is_frozen() should be true after freeze"
|
|
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
|
|
|
|
|
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
|
|
|
|
|
|
|
|
|
|
let item = read_typed(story_id).unwrap().unwrap();
|
|
|
|
|
assert!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(item.stage, Stage::Coding { .. }),
|
2026-05-13 06:05:01 +00:00
|
|
|
"stage should return to Coding after unfreeze: {:?}",
|
2026-04-29 22:12:23 +00:00
|
|
|
item.stage
|
|
|
|
|
);
|
2026-05-12 22:31:59 +01:00
|
|
|
assert!(
|
2026-05-13 17:24:18 +00:00
|
|
|
!matches!(item.stage, Stage::Frozen { .. }),
|
2026-05-13 06:05:01 +00:00
|
|
|
"is_frozen() should be false after unfreeze"
|
2026-05-12 22:31:59 +01:00
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 23:28:57 +00:00
|
|
|
// ── 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",
|
2026-05-12 20:55:25 +01:00
|
|
|
crate::db::ItemMeta::named("Merge Failure Event Test"),
|
2026-04-29 23:28:57 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let reason = "Conflict in server/src/main.rs: both modified";
|
|
|
|
|
let fired = super::apply::apply_transition(
|
|
|
|
|
story_id,
|
|
|
|
|
PipelineEvent::MergeFailed {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::Other(reason.to_string()),
|
2026-04-29 23:28:57 +00:00
|
|
|
},
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
.expect("MergeFailed transition should succeed");
|
|
|
|
|
|
2026-05-13 15:30:03 +00:00
|
|
|
// The emitted event payload carries the full reason via display_reason().
|
2026-04-29 23:28:57 +00:00
|
|
|
match &fired.event {
|
2026-05-13 15:30:03 +00:00
|
|
|
PipelineEvent::MergeFailed { kind } => {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
kind.display_reason(),
|
|
|
|
|
reason,
|
|
|
|
|
"emitted event should carry the full reason"
|
|
|
|
|
);
|
2026-04-29 23:28:57 +00:00
|
|
|
}
|
|
|
|
|
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(),
|
2026-05-12 22:31:59 +01:00
|
|
|
"merge_failure",
|
2026-04-29 23:28:57 +00:00
|
|
|
"CRDT stage should be 4_merge_failure"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:25:12 +00:00
|
|
|
// ── 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 {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::Other("initial failure".into()),
|
2026-05-13 06:22:22 +00:00
|
|
|
feature_branch: fb("feature/story-1"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-12 15:25:12 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(
|
|
|
|
|
s,
|
|
|
|
|
PipelineEvent::MergeFailed {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::Other("second failure".into()),
|
2026-05-12 15:25:12 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
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,
|
2026-05-12 22:31:59 +01:00
|
|
|
"merge_failure",
|
2026-05-12 15:25:12 +00:00
|
|
|
"---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
|
2026-05-12 20:55:25 +01:00
|
|
|
crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
|
2026-05-12 15:25:12 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Apply a second MergeFailed to a story already in MergeFailure.
|
|
|
|
|
let fired = super::apply::apply_transition(
|
|
|
|
|
story_id,
|
|
|
|
|
PipelineEvent::MergeFailed {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::Other("duplicate failure".into()),
|
2026-05-12 15:25:12 +00:00
|
|
|
},
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-12 22:31:59 +01:00
|
|
|
// Verify the CRDT stage is still merge_failure.
|
2026-05-12 15:25:12 +00:00
|
|
|
let item = read_typed(story_id)
|
|
|
|
|
.expect("CRDT read should succeed")
|
|
|
|
|
.expect("item should still exist");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
item.stage.dir_name(),
|
2026-05-12 22:31:59 +01:00
|
|
|
"merge_failure",
|
|
|
|
|
"CRDT stage should remain merge_failure after self-loop"
|
2026-05-12 15:25:12 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 06:22:22 +00:00
|
|
|
// ── Story 919: MergeFailure + Unblock → Merge (re-attempt) ─────────
|
2026-05-12 22:42:04 +00:00
|
|
|
|
2026-05-13 06:22:22 +00:00
|
|
|
/// AC1: `MergeFailure + Unblock` transitions to `Merge` (re-attempt), not `Coding` or `Backlog`.
|
2026-05-12 22:42:04 +00:00
|
|
|
#[test]
|
2026-05-13 06:22:22 +00:00
|
|
|
fn merge_failure_unblock_returns_to_merge() {
|
2026-05-12 22:42:04 +00:00
|
|
|
let s = Stage::MergeFailure {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::ConflictDetected(Some("conflicts in server/src/main.rs".into())),
|
2026-05-13 06:22:22 +00:00
|
|
|
feature_branch: fb("feature/story-42"),
|
|
|
|
|
commits_ahead: nz(3),
|
2026-05-12 22:42:04 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::Unblock).unwrap();
|
|
|
|
|
assert!(
|
2026-05-13 06:22:22 +00:00
|
|
|
matches!(result, Stage::Merge { .. }),
|
|
|
|
|
"MergeFailure + Unblock should return to Merge for immediate re-attempt, got: {result:?}"
|
2026-05-12 22:42:04 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// AC1 (complement): `MergeFailure + Demote` still goes to `Backlog` for manual parking.
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_failure_demote_returns_to_backlog() {
|
|
|
|
|
let s = Stage::MergeFailure {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::Other("conflicts".into()),
|
2026-05-13 06:22:22 +00:00
|
|
|
feature_branch: fb("feature/story-1"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-12 22:42:04 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::Demote).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result, Stage::Backlog),
|
|
|
|
|
"MergeFailure + Demote should park the story in Backlog, got: {result:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:46:37 +00:00
|
|
|
// ── 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 {
|
2026-05-13 15:30:03 +00:00
|
|
|
kind: MergeFailureKind::ConflictDetected(Some("conflicts unresolvable".into())),
|
2026-05-13 06:22:22 +00:00
|
|
|
feature_branch: fb("feature/story-1"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-12 16:46:37 +00:00
|
|
|
};
|
|
|
|
|
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,
|
2026-05-12 22:31:59 +01:00
|
|
|
"merge_failure",
|
2026-05-12 16:46:37 +00:00
|
|
|
"---\nname: MergeFailure Accept Test\n---\n# Story\n",
|
2026-05-12 20:55:25 +01:00
|
|
|
crate::db::ItemMeta::named("MergeFailure Accept Test"),
|
2026-05-12 16:46:37 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-12 22:31:59 +01:00
|
|
|
// CRDT reflects done.
|
2026-05-12 16:46:37 +00:00
|
|
|
let item = read_typed(story_id)
|
|
|
|
|
.expect("CRDT read should succeed")
|
|
|
|
|
.expect("item should exist");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
item.stage.dir_name(),
|
2026-05-12 22:31:59 +01:00
|
|
|
"done",
|
|
|
|
|
"CRDT stage should be done after MergeFailure + Accepted"
|
2026-05-12 16:46:37 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 06:22:22 +00:00
|
|
|
// ── 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:02:16 +00:00
|
|
|
// ── 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),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-13 14:02:16 +00:00
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::MergeAborted).unwrap();
|
|
|
|
|
assert!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(result, Stage::Coding { .. }),
|
2026-05-13 14:02:16 +00:00
|
|
|
"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!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(fired.after, Stage::Coding { .. }),
|
2026-05-13 14:02:16 +00:00
|
|
|
"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!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(item.stage, Stage::Coding { .. }),
|
2026-05-13 14:02:16 +00:00
|
|
|
"story should be in Coding after move_story_to_stage(merge → current): {:?}",
|
|
|
|
|
item.stage
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 14:21:49 +00:00
|
|
|
// ── 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!(
|
2026-05-13 22:50:13 +00:00
|
|
|
matches!(result, Stage::Coding { .. }),
|
2026-05-13 14:21:49 +00:00
|
|
|
"Done + HotfixRequested must land in Coding; got: {:?}",
|
|
|
|
|
result
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn hotfix_requested_rejected_from_non_done_stages() {
|
|
|
|
|
for stage in [
|
|
|
|
|
Stage::Backlog,
|
2026-05-14 08:07:43 +00:00
|
|
|
Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 14:21:49 +00:00
|
|
|
Stage::Qa,
|
|
|
|
|
Stage::Merge {
|
|
|
|
|
feature_branch: fb("feature/story-1"),
|
|
|
|
|
commits_ahead: nz(1),
|
2026-05-13 22:50:13 +00:00
|
|
|
claim: None,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-13 14:21:49 +00:00
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let result = transition(stage.clone(), PipelineEvent::HotfixRequested);
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
|
|
|
|
"HotfixRequested must be rejected from {:?}",
|
|
|
|
|
stage
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 23:18:59 +00:00
|
|
|
// ── 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,
|
2026-05-14 08:07:43 +00:00
|
|
|
after: Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 23:18:59 +00:00
|
|
|
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,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-13 23:18:59 +00:00
|
|
|
},
|
|
|
|
|
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,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-13 23:18:59 +00:00
|
|
|
},
|
|
|
|
|
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()),
|
2026-05-14 08:07:43 +00:00
|
|
|
before: Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 23:18:59 +00:00
|
|
|
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(),
|
|
|
|
|
},
|
2026-05-14 08:07:43 +00:00
|
|
|
after: Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 23:18:59 +00:00
|
|
|
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,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-13 23:18:59 +00:00
|
|
|
},
|
|
|
|
|
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()),
|
2026-05-14 08:07:43 +00:00
|
|
|
before: Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 23:18:59 +00:00
|
|
|
after: Stage::Frozen {
|
2026-05-14 08:07:43 +00:00
|
|
|
resume_to: Box::new(Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
}),
|
2026-05-13 23:18:59 +00:00
|
|
|
},
|
|
|
|
|
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()),
|
2026-05-14 08:07:43 +00:00
|
|
|
before: Stage::Coding {
|
|
|
|
|
claim: None,
|
|
|
|
|
plan: PlanState::Missing,
|
2026-05-14 11:01:06 +00:00
|
|
|
retries: 0,
|
2026-05-14 08:07:43 +00:00
|
|
|
},
|
2026-05-13 23:18:59 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 20:56:22 +00:00
|
|
|
// ── ProjectionError Display ─────────────────────────────────────────
|