use crate::reducer::event::*;
use crate::reducer::state::*;
use crate::reducer::state_reduction::reduce;
#[test]
fn test_diff_failed_event_is_noop_for_backward_compatibility() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_diff_prepared = true;
state.commit_diff_empty = false;
state.commit_diff_content_id_sha256 = Some("abc123".to_string());
let event = PipelineEvent::commit_diff_failed("git diff failed".to_string());
let new_state = reduce(state.clone(), event);
assert_eq!(new_state.phase, state.phase);
assert_eq!(new_state.commit_diff_prepared, state.commit_diff_prepared);
assert_eq!(new_state.commit_diff_empty, state.commit_diff_empty);
assert_eq!(
new_state.commit_diff_content_id_sha256,
state.commit_diff_content_id_sha256
);
assert_ne!(new_state.phase, PipelinePhase::Interrupted);
}
#[test]
fn test_diff_prepared_event_sets_flags() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
let event = PipelineEvent::commit_diff_prepared(false, "content_hash".to_string());
let new_state = reduce(state, event);
assert!(new_state.commit_diff_prepared);
assert!(!new_state.commit_diff_empty);
assert_eq!(
new_state.commit_diff_content_id_sha256,
Some("content_hash".to_string())
);
}
#[test]
fn test_diff_prepared_empty_sets_empty_flag() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
let event = PipelineEvent::commit_diff_prepared(true, "empty_hash".to_string());
let new_state = reduce(state, event);
assert!(new_state.commit_diff_prepared);
assert!(new_state.commit_diff_empty);
assert_eq!(
new_state.commit_diff_content_id_sha256,
Some("empty_hash".to_string())
);
}
#[test]
fn test_diff_invalidated_clears_flags() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_diff_prepared = true;
state.commit_diff_empty = false;
state.commit_diff_content_id_sha256 = Some("old_hash".to_string());
state.commit_prompt_prepared = true;
let event = PipelineEvent::commit_diff_invalidated("Diff file missing".to_string());
let new_state = reduce(state, event);
assert!(!new_state.commit_diff_prepared);
assert!(!new_state.commit_diff_empty);
assert_eq!(new_state.commit_diff_content_id_sha256, None);
assert!(!new_state.commit_prompt_prepared);
}
#[test]
fn test_pre_termination_uncommitted_changes_routes_back_to_commit_phase() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::Complete;
state.pre_termination_commit_checked = false;
state.termination_resume_phase = None;
let event = PipelineEvent::pre_termination_uncommitted_changes_detected(3);
let new_state = reduce(state, event);
assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
assert_eq!(
new_state.termination_resume_phase,
Some(PipelinePhase::Complete)
);
}
#[test]
fn test_post_commit_resumes_termination_phase_when_safety_commit_pending() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "msg".to_string()),
);
assert_eq!(new_state.phase, PipelinePhase::Complete);
assert_eq!(new_state.termination_resume_phase, None);
assert!(
!new_state.pre_termination_commit_checked,
"Safety commit completion must NOT auto-unblock termination; the pre-termination \
safety check must re-run to confirm the repo is clean"
);
}
#[test]
fn test_skip_does_not_unblock_termination_when_safety_commit_pending() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
let new_state = reduce(
state,
PipelineEvent::commit_skipped("no changes".to_string()),
);
assert_eq!(new_state.phase, PipelinePhase::Complete);
assert_eq!(new_state.termination_resume_phase, None);
assert!(
!new_state.pre_termination_commit_checked,
"Skip during safety-check commit must not unblock termination"
);
}
#[test]
fn test_empty_diff_skip_unblocks_termination_when_safety_commit_pending() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
state.commit_diff_empty = true;
let new_state = reduce(
state,
PipelineEvent::commit_skipped("No changes to commit (empty diff)".to_string()),
);
assert_eq!(new_state.phase, PipelinePhase::Complete);
assert_eq!(new_state.termination_resume_phase, None);
assert!(
new_state.pre_termination_commit_checked,
"Empty-diff skip during safety-check commit must unblock termination \
to prevent infinite loop"
);
}
#[test]
fn test_created_normal_resets_commit_diff_prepared() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_diff_prepared = true;
state.commit_diff_empty = false;
state.commit_diff_content_id_sha256 = Some("hash-to-reset".to_string());
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "fix: something".to_string()),
);
assert!(
!new_state.commit_diff_prepared,
"commit_diff_prepared must be false after CommitEvent::Created (normal path)"
);
assert!(
!new_state.commit_diff_empty,
"commit_diff_empty must be false after CommitEvent::Created (normal path)"
);
assert_eq!(
new_state.commit_diff_content_id_sha256, None,
"commit_diff_content_id_sha256 must be None after CommitEvent::Created (normal path)"
);
}
#[test]
fn test_created_pre_termination_resets_commit_diff_prepared() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
state.commit_diff_prepared = true;
state.commit_diff_empty = false;
state.commit_diff_content_id_sha256 = Some("hash-to-reset".to_string());
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "fix: something".to_string()),
);
assert!(
!new_state.commit_diff_prepared,
"commit_diff_prepared must be false after CommitEvent::Created (pre-termination path)"
);
assert!(
!new_state.commit_diff_empty,
"commit_diff_empty must be false after CommitEvent::Created (pre-termination path)"
);
assert_eq!(
new_state.commit_diff_content_id_sha256, None,
"commit_diff_content_id_sha256 must be None after CommitEvent::Created (pre-termination path)"
);
}
#[test]
fn test_skipped_normal_resets_commit_diff_prepared() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_diff_prepared = true;
state.commit_diff_empty = false;
state.commit_diff_content_id_sha256 = Some("hash-to-reset".to_string());
let new_state = reduce(
state,
PipelineEvent::commit_skipped("AI chose to skip".to_string()),
);
assert!(
!new_state.commit_diff_prepared,
"commit_diff_prepared must be false after CommitEvent::Skipped (normal path)"
);
assert!(
!new_state.commit_diff_empty,
"commit_diff_empty must be false after CommitEvent::Skipped (normal path)"
);
assert_eq!(
new_state.commit_diff_content_id_sha256, None,
"commit_diff_content_id_sha256 must be None after CommitEvent::Skipped (normal path)"
);
}
#[test]
fn test_skipped_pre_termination_resets_commit_diff_prepared() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
state.commit_diff_prepared = true;
state.commit_diff_empty = false; state.commit_diff_content_id_sha256 = Some("hash-to-reset".to_string());
let new_state = reduce(
state,
PipelineEvent::commit_skipped("AI chose to skip".to_string()),
);
assert!(
!new_state.commit_diff_prepared,
"commit_diff_prepared must be false after CommitEvent::Skipped (pre-termination path)"
);
assert!(
!new_state.commit_diff_empty,
"commit_diff_empty must be false after CommitEvent::Skipped (pre-termination path)"
);
assert_eq!(
new_state.commit_diff_content_id_sha256, None,
"commit_diff_content_id_sha256 must be None after CommitEvent::Skipped (pre-termination path)"
);
}
#[test]
fn test_created_normal_clears_prompt_inputs_commit() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_diff_prepared = true;
state.prompt_inputs.commit = Some(MaterializedCommitInputs {
attempt: 1,
diff: MaterializedPromptInput {
kind: PromptInputKind::Diff,
content_id_sha256: "stale-hash".to_string(),
consumer_signature_sha256: "stale-sig".to_string(),
original_bytes: 100,
final_bytes: 100,
model_budget_bytes: None,
inline_budget_bytes: None,
representation: PromptInputRepresentation::Inline,
reason: PromptMaterializationReason::WithinBudgets,
},
});
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "fix: something".to_string()),
);
assert!(
new_state.prompt_inputs.commit.is_none(),
"prompt_inputs.commit must be cleared after CommitEvent::Created (normal path) \
to prevent stale commit context reuse in later iterations"
);
}
#[test]
fn test_created_pre_termination_clears_prompt_inputs_commit() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
state.commit_diff_prepared = true;
state.prompt_inputs.commit = Some(MaterializedCommitInputs {
attempt: 1,
diff: MaterializedPromptInput {
kind: PromptInputKind::Diff,
content_id_sha256: "stale-hash".to_string(),
consumer_signature_sha256: "stale-sig".to_string(),
original_bytes: 100,
final_bytes: 100,
model_budget_bytes: None,
inline_budget_bytes: None,
representation: PromptInputRepresentation::Inline,
reason: PromptMaterializationReason::WithinBudgets,
},
});
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "fix: something".to_string()),
);
assert!(
new_state.prompt_inputs.commit.is_none(),
"prompt_inputs.commit must be cleared after CommitEvent::Created (pre-termination path)"
);
}
#[test]
fn test_skipped_normal_clears_prompt_inputs_commit() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_diff_prepared = true;
state.prompt_inputs.commit = Some(MaterializedCommitInputs {
attempt: 1,
diff: MaterializedPromptInput {
kind: PromptInputKind::Diff,
content_id_sha256: "stale-hash".to_string(),
consumer_signature_sha256: "stale-sig".to_string(),
original_bytes: 100,
final_bytes: 100,
model_budget_bytes: None,
inline_budget_bytes: None,
representation: PromptInputRepresentation::Inline,
reason: PromptMaterializationReason::WithinBudgets,
},
});
let new_state = reduce(
state,
PipelineEvent::commit_skipped("AI chose to skip".to_string()),
);
assert!(
new_state.prompt_inputs.commit.is_none(),
"prompt_inputs.commit must be cleared after CommitEvent::Skipped (normal path) \
to prevent stale commit context reuse in later iterations"
);
}
#[test]
fn test_diff_prepared_clears_stale_prompt_inputs_surviving_generation_failed() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.prompt_inputs.commit = Some(MaterializedCommitInputs {
attempt: 1,
diff: MaterializedPromptInput {
kind: PromptInputKind::Diff,
content_id_sha256: "iter-1-hash".to_string(),
consumer_signature_sha256: "iter-1-sig".to_string(),
original_bytes: 100,
final_bytes: 100,
model_budget_bytes: None,
inline_budget_bytes: None,
representation: PromptInputRepresentation::Inline,
reason: PromptMaterializationReason::WithinBudgets,
},
});
let after_failed = reduce(
state,
PipelineEvent::commit_generation_failed("agent timeout".to_string()),
);
assert!(
after_failed.prompt_inputs.commit.is_some(),
"GenerationFailed must NOT clear prompt_inputs.commit — DiffPrepared is the safety net"
);
let after_diff_prepared = reduce(
after_failed,
PipelineEvent::commit_diff_prepared(false, "iter-2-hash".to_string()),
);
assert!(
after_diff_prepared.prompt_inputs.commit.is_none(),
"DiffPrepared must clear prompt_inputs.commit to prevent stale diff context reuse"
);
}
#[test]
fn test_skipped_pre_termination_clears_prompt_inputs_commit() {
let mut state = PipelineState::initial(0, 0);
state.phase = PipelinePhase::CommitMessage;
state.termination_resume_phase = Some(PipelinePhase::Complete);
state.pre_termination_commit_checked = false;
state.commit_diff_prepared = true;
state.commit_diff_empty = false; state.prompt_inputs.commit = Some(MaterializedCommitInputs {
attempt: 1,
diff: MaterializedPromptInput {
kind: PromptInputKind::Diff,
content_id_sha256: "stale-hash".to_string(),
consumer_signature_sha256: "stale-sig".to_string(),
original_bytes: 100,
final_bytes: 100,
model_budget_bytes: None,
inline_budget_bytes: None,
representation: PromptInputRepresentation::Inline,
reason: PromptMaterializationReason::WithinBudgets,
},
});
let new_state = reduce(
state,
PipelineEvent::commit_skipped("AI chose to skip".to_string()),
);
assert!(
new_state.prompt_inputs.commit.is_none(),
"prompt_inputs.commit must be cleared after CommitEvent::Skipped (pre-termination path)"
);
}
#[test]
fn test_commit_xml_validated_stores_excluded_files() {
use crate::reducer::state::pipeline::{ExcludedFile, ExcludedFileReason};
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
let excluded = vec![ExcludedFile {
path: "src/sensitive.rs".to_string(),
reason: ExcludedFileReason::Sensitive,
}];
let event = PipelineEvent::commit_xml_validated(
"feat: add feature".to_string(),
vec!["src/main.rs".to_string()],
excluded.clone(),
1,
);
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_excluded_files, excluded,
"CommitXmlValidated must store excluded_files in commit_excluded_files"
);
}
#[test]
fn test_commit_xml_validated_empty_excluded_files() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
let event =
PipelineEvent::commit_xml_validated("feat: add feature".to_string(), vec![], vec![], 1);
let new_state = reduce(state, event);
assert!(
new_state.commit_excluded_files.is_empty(),
"Empty excluded_files must produce empty commit_excluded_files"
);
}
#[test]
fn test_residual_files_found_pass1_sets_retry_pass_to_2() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_residual_retry_pass = 0;
let event = PipelineEvent::residual_files_found(vec!["src/leftover.rs".to_string()], 1);
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_residual_retry_pass, 2,
"ResidualFilesFound pass=1 must set commit_residual_retry_pass=2"
);
assert!(
!new_state.commit_diff_prepared,
"commit_diff_prepared must be reset for second pass"
);
assert!(
!new_state.commit_agent_invoked,
"commit_agent_invoked must be reset for second pass"
);
assert!(
!new_state.commit_prompt_prepared,
"commit_prompt_prepared must be reset for second pass"
);
assert!(
new_state.commit_residual_files.is_empty(),
"commit_residual_files must stay empty after pass=1 (not yet carry-forward)"
);
}
#[test]
fn test_residual_files_found_pass2_enters_retry_pass3_when_budget_remains() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_residual_retry_pass = 2;
let residual = vec!["src/remaining.rs".to_string(), "tests/other.rs".to_string()];
let event = PipelineEvent::residual_files_found(residual, 2);
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_residual_retry_pass, 3,
"ResidualFilesFound pass=2 must advance to commit retry pass 3 while budget remains"
);
assert!(
new_state.commit_residual_files.is_empty(),
"ResidualFilesFound pass=2 must not carry files forward while retry budget remains"
);
}
#[test]
fn test_residual_files_found_final_retry_carries_forward() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_residual_retry_pass = 11;
let residual = vec!["src/remaining.rs".to_string(), "tests/other.rs".to_string()];
let event = PipelineEvent::residual_files_found(residual.clone(), 11);
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_residual_files, residual,
"Final residual retry pass must store files in commit_residual_files"
);
assert_eq!(
new_state.commit_residual_retry_pass, 0,
"Final residual retry pass must clear commit_residual_retry_pass"
);
}
#[test]
fn test_residual_files_found_uses_configured_retry_budget() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.max_commit_residual_retries = 1;
state.commit_residual_retry_pass = 2;
let residual = vec!["src/remaining.rs".to_string()];
let new_state = reduce(
state,
PipelineEvent::residual_files_found(residual.clone(), 2),
);
assert_eq!(new_state.commit_residual_files, residual);
assert_eq!(new_state.commit_residual_retry_pass, 0);
}
#[test]
fn test_residual_files_none_clears_retry_pass_and_residual() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_residual_retry_pass = 4;
state.commit_residual_files = vec!["src/old.rs".to_string()];
let event = PipelineEvent::residual_files_none();
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_residual_retry_pass, 0,
"ResidualFilesNone must clear commit_residual_retry_pass"
);
assert!(
new_state.commit_residual_files.is_empty(),
"ResidualFilesNone must clear commit_residual_files"
);
}
#[test]
fn test_residual_files_found_invalid_pass_routes_to_awaiting_dev_fix() {
let mut state = PipelineState::initial(1, 0);
state.phase = PipelinePhase::CommitMessage;
let event = PipelineEvent::residual_files_found(vec!["src/leftover.rs".to_string()], 0);
let new_state = reduce(state, event);
assert_eq!(new_state.phase, PipelinePhase::AwaitingDevFix);
assert_eq!(
new_state.failed_phase_for_recovery,
Some(PipelinePhase::CommitMessage)
);
assert_eq!(new_state.previous_phase, Some(PipelinePhase::CommitMessage));
assert_eq!(
new_state.commit_residual_retry_pass, 0,
"invalid pass must not trigger retry-pass behavior"
);
assert!(
new_state.commit_residual_files.is_empty(),
"invalid pass must not silently carry-forward residuals"
);
}
#[test]
fn test_commit_residual_files_survives_generation_started() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.commit_residual_files = vec!["src/leftover.rs".to_string()];
let event = PipelineEvent::commit_generation_started();
let new_state = reduce(state, event);
assert_eq!(
new_state.commit_residual_files,
vec!["src/leftover.rs".to_string()],
"commit_residual_files must survive GenerationStarted (carry-forward across cycles)"
);
}
#[test]
fn test_commit_residual_files_cleared_after_commit_created() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_residual_files = vec!["src/leftover.rs".to_string()];
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "feat: done".to_string()),
);
assert!(
new_state.commit_residual_files.is_empty(),
"commit_residual_files must be cleared after CommitEvent::Created"
);
}
#[test]
fn test_selective_commit_created_stays_in_commit_message_until_residual_check_completes() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit_selected_files = vec!["src/one.rs".to_string()];
let new_state = reduce(
state,
PipelineEvent::commit_created("abc123".to_string(), "msg".to_string()),
);
assert_eq!(
new_state.phase,
PipelinePhase::CommitMessage,
"Selective commit must keep phase in CommitMessage until residual checking completes"
);
assert_eq!(
new_state.previous_phase,
Some(PipelinePhase::Development),
"Selective commit must preserve previous_phase until post-commit transition occurs"
);
}
#[test]
fn test_residual_files_none_transitions_after_selective_commit() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit = CommitState::Committed {
hash: "abc123".to_string(),
};
state.commit_selected_files = vec!["src/one.rs".to_string()];
let new_state = reduce(state, PipelineEvent::residual_files_none());
assert_eq!(
new_state.phase,
PipelinePhase::Planning,
"After residuals are clean, pipeline should transition out of CommitMessage"
);
assert_eq!(
new_state.iteration, 1,
"Post-commit transition from Development should increment iteration"
);
assert!(
new_state.previous_phase.is_none(),
"Post-commit transition must clear previous_phase"
);
}
#[test]
fn test_residual_files_found_pass2_transitions_and_carries_forward_after_second_pass() {
let mut state = PipelineState::initial(2, 0);
state.phase = PipelinePhase::CommitMessage;
state.previous_phase = Some(PipelinePhase::Development);
state.iteration = 0;
state.commit = CommitState::Committed {
hash: "abc123".to_string(),
};
state.commit_residual_retry_pass = 11;
state.commit_selected_files = vec!["src/one.rs".to_string()];
let residual = vec!["src/remaining.rs".to_string()];
let new_state = reduce(
state,
PipelineEvent::residual_files_found(residual.clone(), 11),
);
assert_eq!(new_state.commit_residual_files, residual);
assert_eq!(
new_state.phase,
PipelinePhase::Planning,
"After pass 2 residuals are recorded, pipeline should transition out of CommitMessage"
);
assert_eq!(new_state.iteration, 1);
}