Unify pre-execution stage decisions

This commit is contained in:
2026-09-04 01:01:46 +00:00
parent 9331373245
commit ca1b694aa9
5 changed files with 297 additions and 105 deletions

View File

@@ -2,6 +2,7 @@ package app
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
@@ -11,23 +12,185 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestDecideStageAction(t *testing.T) {
stages := BuildFullPlan()[:2]
m := manifest.New("2026-05-03", time.Now().UTC())
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
func TestDecideStageExecution(t *testing.T) {
statuses := []manifest.StageStatus{"", manifest.StatusSucceeded, manifest.StatusFailed, manifest.StatusStale, manifest.StatusSkipped}
validations := []struct {
name string
value *stage.ResumeValidation
}{
{name: "none"},
{name: "resumable", value: resumeValidation(stage.Resumable())},
{name: "not resumable", value: resumeValidation(stage.NonResumable("output changed"))},
}
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
for _, status := range statuses {
for _, force := range []bool{false, true} {
for _, validation := range validations {
name := fmt.Sprintf("status=%s/force=%t/resume=%s", status, force, validation.name)
t.Run(name, func(t *testing.T) {
got := decideStageExecution(status, force, validation.value)
want := preExecutionDecision{action: stageActionRun}
switch {
case force:
want.invalidationReason = staleReasonForcedReplacement
want.invalidateWhenStarted = true
case status == manifest.StatusSucceeded && validation.value != nil && !validation.value.Resumable:
want.markSelectedStale = true
want.selectedStaleReason = "output changed"
want.invalidationReason = staleReasonNotResumable
case status == manifest.StatusSucceeded:
want.action = stageActionSkip
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("decision = %#v, want %#v", got, want)
}
})
}
}
}
}
func resumeValidation(value stage.ResumeValidation) *stage.ResumeValidation { return &value }
func TestPlanningAndExecutionTransitionParity(t *testing.T) {
tests := []struct {
name string
seed func(*manifest.Manifest, time.Time)
force bool
resume *stage.ResumeValidation
result *stage.StageResult
wantAction stageAction
wantStatus manifest.StageStatus
}{
{
name: "ordinary skip",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageSucceeded("prepare", at, nil)
m.MarkStageSucceeded("transcribe", at, nil)
},
wantAction: stageActionSkip,
wantStatus: manifest.StatusSucceeded,
},
{
name: "non-resumable success",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageSucceeded("prepare", at, nil)
m.MarkStageSucceeded("transcribe", at, nil)
},
resume: resumeValidation(stage.NonResumable("output changed")),
result: &stage.StageResult{},
wantAction: stageActionRun,
wantStatus: manifest.StatusStale,
},
{
name: "forced replacement",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageSucceeded("prepare", at, nil)
m.MarkStageSucceeded("transcribe", at, nil)
},
force: true,
result: &stage.StageResult{},
wantAction: stageActionRun,
wantStatus: manifest.StatusStale,
},
{
name: "unchanged self-skip",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageSkipped("prepare", at, "disabled")
m.MarkStageSucceeded("transcribe", at, nil)
},
result: &stage.StageResult{
Disposition: stage.StageDispositionSkipped,
SkipReason: "disabled",
},
wantAction: stageActionRun,
wantStatus: manifest.StatusSucceeded,
},
{
name: "changed self-skip",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageSkipped("prepare", at, "old reason")
m.MarkStageSucceeded("transcribe", at, nil)
},
result: &stage.StageResult{
Disposition: stage.StageDispositionSkipped,
SkipReason: "new reason",
},
wantAction: stageActionRun,
wantStatus: manifest.StatusStale,
},
{
name: "success replacing non-success",
seed: func(m *manifest.Manifest, at time.Time) {
m.MarkStageFailed("prepare", at, "failed")
m.MarkStageSucceeded("transcribe", at, nil)
},
result: &stage.StageResult{},
wantAction: stageActionRun,
wantStatus: manifest.StatusStale,
},
}
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
at := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
planned := manifest.New("2026-05-03", at)
executed := manifest.New("2026-05-03", at)
test.seed(planned, at)
test.seed(executed, at)
plannedAction := applyParityTransition(t, planned, test.force, test.resume, test.result, false, at.Add(time.Second))
executedAction := applyParityTransition(t, executed, test.force, test.resume, test.result, true, at.Add(time.Second))
if plannedAction != test.wantAction || executedAction != test.wantAction {
t.Fatalf("actions = planned %q, executed %q; want %q", plannedAction, executedAction, test.wantAction)
}
if got := planned.Stages["transcribe"].Status; got != test.wantStatus {
t.Fatalf("planned dependent status = %q, want %q", got, test.wantStatus)
}
if got := executed.Stages["transcribe"].Status; got != test.wantStatus {
t.Fatalf("executed dependent status = %q, want %q", got, test.wantStatus)
}
plannedError := planned.Stages["transcribe"].Error
executedError := executed.Stages["transcribe"].Error
if !reflect.DeepEqual(plannedError, executedError) {
t.Fatalf("dependent errors differ: planned %#v, executed %#v", plannedError, executedError)
}
})
}
}
func applyParityTransition(
t *testing.T,
m *manifest.Manifest,
force bool,
resume *stage.ResumeValidation,
result *stage.StageResult,
actual bool,
at time.Time,
) stageAction {
t.Helper()
decision := decideStageExecution(recordedStageStatus(m, "prepare"), force, resume)
if _, err := applyPreExecutionDecision(m, "prepare", decision, at); err != nil {
t.Fatalf("apply pre-execution decision: %v", err)
}
if decision.action == stageActionSkip {
return decision.action
}
prior := capturePriorStageOutcome(m, "prepare")
if _, err := beginStageAttempt(m, "prepare", decision, at); err != nil {
t.Fatalf("begin attempt: %v", err)
}
if result != nil {
modeled := *result
if actual {
modeled.Logs = []string{"run.log"}
}
if _, err := applyCompletedStageOutcome(m, "prepare", &modeled, nil, nil, prior, at); err != nil {
t.Fatalf("apply completed outcome: %v", err)
}
}
return decision.action
}
func TestInvalidationDependents(t *testing.T) {
tests := []struct {
stage string