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(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Ok(Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::Blocked { .. },
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let m = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
|
|
|
|
};
|
|
|
|
|
let result = transition(
|
|
|
|
|
m,
|
|
|
|
|
PipelineEvent::Block {
|
|
|
|
|
reason: "stuck".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Ok(Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::Blocked { .. },
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn unblock_returns_to_backlog() {
|
|
|
|
|
let s = Stage::Archived {
|
|
|
|
|
archived_at: chrono::Utc::now(),
|
|
|
|
|
reason: ArchiveReason::Blocked {
|
|
|
|
|
reason: "test".into(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
let result = transition(s, PipelineEvent::Unblock).unwrap();
|
|
|
|
|
assert!(matches!(result, Stage::Backlog));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Abandon / supersede ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn abandon_from_any_active_or_done() {
|
|
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
|
|
|
|
Stage::Coding,
|
|
|
|
|
Stage::Qa,
|
|
|
|
|
Stage::Done {
|
|
|
|
|
merged_at: chrono::Utc::now(),
|
|
|
|
|
merge_commit: sha("x"),
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let result = transition(s, PipelineEvent::Abandon);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Ok(Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::Abandoned,
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn supersede_from_any_active_or_done() {
|
|
|
|
|
for s in [
|
|
|
|
|
Stage::Backlog,
|
|
|
|
|
Stage::Coding,
|
|
|
|
|
Stage::Qa,
|
|
|
|
|
Stage::Done {
|
|
|
|
|
merged_at: chrono::Utc::now(),
|
|
|
|
|
merge_commit: sha("x"),
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let result = transition(
|
|
|
|
|
s,
|
|
|
|
|
PipelineEvent::Supersede {
|
|
|
|
|
by: sid("999_story_new"),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Ok(Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::Superseded { .. },
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Review hold ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn review_hold_from_active_stages() {
|
|
|
|
|
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
|
|
|
|
|
let result = transition(
|
|
|
|
|
s.clone(),
|
|
|
|
|
PipelineEvent::ReviewHold {
|
|
|
|
|
reason: "review".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Ok(Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::ReviewHeld { .. },
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Merge failed final ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_failed_final() {
|
|
|
|
|
let s = Stage::Merge {
|
|
|
|
|
feature_branch: fb("f"),
|
|
|
|
|
commits_ahead: nz(1),
|
|
|
|
|
};
|
|
|
|
|
let result = transition(
|
|
|
|
|
s,
|
|
|
|
|
PipelineEvent::MergeFailedFinal {
|
|
|
|
|
reason: "conflicts".into(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Stage::Archived {
|
|
|
|
|
reason: ArchiveReason::MergeFailed { .. },
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn merge_failed_only_from_merge() {
|
|
|
|
|
let result = transition(
|
|
|
|
|
Stage::Coding,
|
|
|
|
|
PipelineEvent::MergeFailedFinal {
|
|
|
|
|
reason: "conflicts".into(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
result,
|
|
|
|
|
Err(TransitionError::InvalidTransition { .. })
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Execution state machine ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn execution_happy_path() {
|
|
|
|
|
let e = ExecutionState::Idle;
|
|
|
|
|
let e = execution_transition(
|
|
|
|
|
e,
|
|
|
|
|
ExecutionEvent::SpawnRequested {
|
|
|
|
|
agent: AgentName("coder-1".into()),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Pending { .. }));
|
|
|
|
|
|
|
|
|
|
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
|
|
|
|
|
|
|
|
let e = execution_transition(e, ExecutionEvent::Heartbeat).unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
|
|
|
|
|
|
|
|
let e = execution_transition(e, ExecutionEvent::Exited { exit_code: 0 }).unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Completed { exit_code: 0, .. }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn execution_rate_limit_then_resume() {
|
|
|
|
|
let e = ExecutionState::Running {
|
|
|
|
|
agent: AgentName("coder-1".into()),
|
|
|
|
|
started_at: chrono::Utc::now(),
|
|
|
|
|
last_heartbeat: chrono::Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
let e = execution_transition(
|
|
|
|
|
e,
|
|
|
|
|
ExecutionEvent::HitRateLimit {
|
|
|
|
|
resume_at: chrono::Utc::now() + chrono::Duration::minutes(5),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::RateLimited { .. }));
|
|
|
|
|
|
|
|
|
|
let e = execution_transition(e, ExecutionEvent::SpawnedSuccessfully).unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Running { .. }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn execution_stop_from_anywhere() {
|
|
|
|
|
let e = ExecutionState::Running {
|
|
|
|
|
agent: AgentName("coder-1".into()),
|
|
|
|
|
started_at: chrono::Utc::now(),
|
|
|
|
|
last_heartbeat: chrono::Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
let e = execution_transition(e, ExecutionEvent::Stopped).unwrap();
|
|
|
|
|
assert!(matches!(e, ExecutionState::Idle));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Projection tests ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bug_502_agent_not_in_stage() {
|
|
|
|
|
// Bug 502 was caused by a coder agent being assigned to a story in
|
|
|
|
|
// Merge stage. In the typed system, Stage has no `agent` field at all.
|
|
|
|
|
// Agent assignment is per-node ExecutionState. This test documents that
|
|
|
|
|
// the old failure mode is structurally impossible.
|
|
|
|
|
let merge = Stage::Merge {
|
|
|
|
|
feature_branch: BranchName("feature/story-1".into()),
|
|
|
|
|
commits_ahead: NonZeroU32::new(3).unwrap(),
|
|
|
|
|
};
|
|
|
|
|
// Stage::Merge has exactly two fields: feature_branch and commits_ahead.
|
|
|
|
|
// There is no way to attach an agent name to it. The type system
|
|
|
|
|
// prevents bug 502 by construction.
|
|
|
|
|
assert!(matches!(merge, Stage::Merge { .. }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── TransitionError Display ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn transition_error_display() {
|
|
|
|
|
let err = TransitionError::InvalidTransition {
|
|
|
|
|
from_stage: "Backlog".into(),
|
|
|
|
|
event: "Accepted".into(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted");
|
|
|
|
|
}
|
|
|
|
|
|
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";
|
|
|
|
|
crate::db::write_item_with_content(story_id, "2_current", content);
|
|
|
|
|
|
|
|
|
|
// 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-28 20:56:22 +00:00
|
|
|
// ── ProjectionError Display ─────────────────────────────────────────
|