huskies: merge 799
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
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");
|
||||
}
|
||||
|
||||
// ── ProjectionError Display ─────────────────────────────────────────
|
||||
Reference in New Issue
Block a user