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();
|
|
|
|
|
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(),
|
|
|
|
|
},
|
|
|
|
|
);
|
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),
|
|
|
|
|
};
|
|
|
|
|
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();
|
|
|
|
|
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,
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
|
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 { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:12:23 +00:00
|
|
|
// ── Freeze / Unfreeze ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_from_active_stages() {
|
|
|
|
|
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
|
|
|
|
|
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result, Stage::Frozen { .. }),
|
|
|
|
|
"expected Frozen from {s:?}"
|
|
|
|
|
);
|
|
|
|
|
if let Stage::Frozen { resume_to } = result {
|
|
|
|
|
assert_eq!(*resume_to, s);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn freeze_from_merge() {
|
|
|
|
|
let m = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
|
|
|
|
};
|
|
|
|
|
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
|
|
|
|
|
assert!(matches!(result, Stage::Frozen { .. }));
|
|
|
|
|
if let Stage::Frozen { resume_to } = result {
|
|
|
|
|
assert_eq!(*resume_to, m);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unfreeze_restores_prior_stage() {
|
|
|
|
|
let prior = Stage::Coding;
|
|
|
|
|
let frozen = Stage::Frozen {
|
|
|
|
|
resume_to: Box::new(prior.clone()),
|
|
|
|
|
};
|
|
|
|
|
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
|
|
|
|
|
assert_eq!(result, prior);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cannot_freeze_done() {
|
|
|
|
|
let s = Stage::Done {
|
|
|
|
|
merged_at: chrono::Utc::now(),
|
|
|
|
|
merge_commit: sha("abc"),
|
|
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::Freeze);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Err(TransitionError::InvalidTransition { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cannot_freeze_archived() {
|
|
|
|
|
let s = Stage::Archived {
|
|
|
|
|
archived_at: chrono::Utc::now(),
|
|
|
|
|
reason: ArchiveReason::Completed,
|
|
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::Freeze);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Err(TransitionError::InvalidTransition { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cannot_unfreeze_coding() {
|
|
|
|
|
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Err(TransitionError::InvalidTransition { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
|
|
|
|
|
/// Verifies that the CRDT shows the correct prior stage restored.
|
|
|
|
|
#[test]
|
|
|
|
|
fn regression_freeze_unfreeze_restores_crdt_stage() {
|
|
|
|
|
crate::crdt_state::init_for_test();
|
|
|
|
|
crate::db::ensure_content_store();
|
|
|
|
|
|
|
|
|
|
let story_id = "9950_story_freeze_regression";
|
|
|
|
|
let content = "---\nname: Freeze Regression\n---\n# Story\n";
|
2026-04-30 22:23:21 +00:00
|
|
|
crate::db::write_item_with_content(
|
|
|
|
|
story_id,
|
|
|
|
|
"2_current",
|
|
|
|
|
content,
|
|
|
|
|
crate::db::ItemMeta::from_yaml(content),
|
|
|
|
|
);
|
2026-04-29 22:12:23 +00:00
|
|
|
|
|
|
|
|
// Confirm starting stage.
|
|
|
|
|
let item = read_typed(story_id).unwrap().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(item.stage, Stage::Coding),
|
|
|
|
|
"should start at Coding"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Freeze.
|
|
|
|
|
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
|
|
|
|
|
|
|
|
|
|
let item = read_typed(story_id).unwrap().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(item.stage, Stage::Frozen { .. }),
|
|
|
|
|
"should be Frozen after freeze: {:?}",
|
|
|
|
|
item.stage
|
|
|
|
|
);
|
|
|
|
|
if let Stage::Frozen { ref resume_to } = item.stage {
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(**resume_to, Stage::Coding),
|
|
|
|
|
"resume_to should be Coding: {:?}",
|
|
|
|
|
resume_to
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unfreeze.
|
|
|
|
|
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),
|
|
|
|
|
"should be restored to Coding after unfreeze: {:?}",
|
|
|
|
|
item.stage
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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-04-30 22:23:21 +00:00
|
|
|
crate::db::ItemMeta::from_yaml("---\nname: Merge Failure Event Test\n---\n# Story\n"),
|
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 {
|
|
|
|
|
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(),
|
|
|
|
|
"4_merge_failure",
|
|
|
|
|
"CRDT stage should be 4_merge_failure"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 20:56:22 +00:00
|
|
|
// ── ProjectionError Display ─────────────────────────────────────────
|