package app import ( "context" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "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) } if got := decideStageAction(stages[1], m, false); got != stageActionRun { t.Fatalf("transcribe action = %q, want %q", got, stageActionRun) } if got := decideStageAction(stages[0], m, true); got != stageActionRun { t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun) } } func TestInvalidationDependents(t *testing.T) { tests := []struct { stage string want []string }{ {"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}}, {"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}}, {"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}}, {"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}}, {"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}}, {"trim", []string{"render", "extract", "analyze", "publish", "notify"}}, {"render", []string{"analyze", "publish", "notify"}}, {"extract", []string{"analyze", "publish", "notify"}}, {"analyze", []string{"publish", "notify"}}, {"publish", []string{"notify"}}, {"notify", []string{}}, } for _, test := range tests { t.Run(test.stage, func(t *testing.T) { got, err := dependentStageNames(test.stage) if err != nil { t.Fatalf("dependentStageNames(%q) error = %v", test.stage, err) } if !reflect.DeepEqual(got, test.want) { t.Fatalf("dependentStageNames(%q) = %#v, want %#v", test.stage, got, test.want) } }) } if _, err := dependentStageNames("unknown"); err == nil || !strings.Contains(err.Error(), "unknown stage") { t.Fatalf("dependentStageNames(unknown) error = %v, want unknown-stage error", err) } } func TestInvalidationRelationRejectsInvalidInventory(t *testing.T) { canonical := []stage.Stage{ invalidationTestStage("one"), invalidationTestStage("two"), } tests := []struct { name string registry []stage.Stage edges map[string][]string want string }{ {name: "duplicate registry name", registry: append(canonical, invalidationTestStage("one")), edges: map[string][]string{"one": {"two"}, "two": {}}, want: "duplicate stage"}, {name: "unknown source", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {}, "three": {}}, want: "unknown stage"}, {name: "unknown target", registry: canonical, edges: map[string][]string{"one": {"three"}, "two": {}}, want: "unknown stage"}, {name: "missing classification", registry: canonical, edges: map[string][]string{"one": {"two"}}, want: "missing classification"}, {name: "cycle", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {"one"}}, want: "cycle"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := newInvalidationRelation(test.registry, test.edges) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("newInvalidationRelation() error = %v, want %q", err, test.want) } }) } } func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) { now := time.Now().UTC() m := manifest.New("2026-05-03", now) for _, name := range canonicalStageNames() { m.MarkStageSucceeded(name, now, nil) } m.MarkStageFailed("analyze", now, "analysis failed") got, err := invalidateDependentSucceededStagesWithReason(m, "polish", now.Add(time.Second), staleReasonChangedResult) if err != nil { t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err) } want := []string{"normalize", "trim", "render", "extract", "publish", "notify"} if !reflect.DeepEqual(got, want) { t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want) } for _, stageName := range want { if m.Stages[stageName].Status != manifest.StatusStale { t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status) } } if m.Stages["analyze"].Status != manifest.StatusFailed { t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status) } } func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) { now := time.Now().UTC() for _, upstream := range []string{"render", "extract"} { t.Run(upstream, func(t *testing.T) { m := manifest.New("2026-05-03", now) for _, name := range canonicalStageNames() { m.MarkStageSucceeded(name, now, nil) } got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement) if err != nil { t.Fatalf("invalidate dependents: %v", err) } want := []string{"analyze", "publish", "notify"} if !reflect.DeepEqual(got, want) { t.Fatalf("invalidated = %#v, want %#v", got, want) } sibling := "render" if upstream == "render" { sibling = "extract" } if m.Stages[sibling].Status != manifest.StatusSucceeded { t.Fatalf("%s invalidated sibling %s: %#v", upstream, sibling, m.Stages[sibling]) } }) } } type invalidationTestStage string func (s invalidationTestStage) Name() string { return string(s) } func (s invalidationTestStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { return &stage.StageResult{}, nil }