package app import ( "context" "errors" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestValidateBoundedPrerequisitesRejectsFirstUnusablePrefixStatus(t *testing.T) { plan := mustBoundedPlan(t, "render", "extract") now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) for _, test := range []struct { name string status manifest.StageStatus want string }{ {name: "absent", want: "absent"}, {name: "pending", status: manifest.StatusPending, want: "pending"}, {name: "running", status: manifest.StatusRunning, want: "running"}, {name: "failed", status: manifest.StatusFailed, want: "failed"}, {name: "stale", status: manifest.StatusStale, want: "stale"}, {name: "interrupted", status: manifest.StatusInterrupted, want: "interrupted"}, } { t.Run(test.name, func(t *testing.T) { m := manifest.New("session", now) if test.status != "" { m.Stages["prepare"] = &manifest.StageRecord{Name: "prepare", Status: test.status} } // A later terminal prefix must not hide the first unusable one. m.MarkStageSucceeded("transcribe", now, nil) err := validateBoundedPrerequisites(plan, m) if err == nil { t.Fatal("validateBoundedPrerequisites() error = nil") } for _, detail := range []string{`stage "prepare"`, `status "` + test.want + `"`, `selected start "render"`, "--from prepare", "recover prepare"} { if !strings.Contains(err.Error(), detail) { t.Fatalf("error = %q, want detail %q", err, detail) } } }) } } func TestValidateBoundedPrerequisitesAcceptsSucceededAndSkippedPrefix(t *testing.T) { plan := mustBoundedPlan(t, "render", "render") m := manifest.New("session", time.Now().UTC()) for index, name := range plan.PrefixNames() { if index%2 == 0 { m.MarkStageSucceeded(name, time.Now().UTC(), nil) } else { m.MarkStageSkipped(name, time.Now().UTC(), "not applicable") } } if err := validateBoundedPrerequisites(plan, m); err != nil { t.Fatalf("validateBoundedPrerequisites() error = %v", err) } } func TestValidateBoundedPrerequisitesHasNoPrefixAtPrepareAndIgnoresSuffix(t *testing.T) { preparePlan := mustBoundedPlan(t, "prepare", "prepare") if err := validateBoundedPrerequisites(preparePlan, nil); err != nil { t.Fatalf("prepare prerequisite validation error = %v", err) } renderPlan := mustBoundedPlan(t, "render", "render") m := manifest.New("session", time.Now().UTC()) markPrefixSucceeded(m, renderPlan) m.MarkStageFailed("analyze", time.Now().UTC(), "later failure") if err := validateBoundedPrerequisites(renderPlan, m); err != nil { t.Fatalf("suffix status affected prerequisite validation: %v", err) } } func TestExecuteStagesRejectsBoundedPrerequisitesBeforePersistentMutation(t *testing.T) { cfg := testConfig(t) plan := mustBoundedPlan(t, "render", "render") m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign m.MarkStageRunning("prepare", time.Now().UTC()) manifestPath := saveBoundedManifest(t, cfg, m) before, err := os.ReadFile(manifestPath) if err != nil { t.Fatalf("read seeded manifest: %v", err) } store := &prerequisiteMutationSpy{local: &manifest.LocalStore{}} runs := 0 _, err = executeStages(context.Background(), cfg, []stage.Stage{countingStage{name: "render", runs: &runs}}, RunOptions{ Plan: plan, Env: &Env{ManifestStore: store}, }) if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) { t.Fatalf("executeStages() error = %v, want running prerequisite", err) } if runs != 0 || store.creates != 0 || store.saves != 0 { t.Fatalf("runs=%d manifest creates=%d saves=%d, want no mutation", runs, store.creates, store.saves) } after, err := os.ReadFile(manifestPath) if err != nil { t.Fatalf("read manifest after rejection: %v", err) } if string(after) != string(before) { t.Fatal("manifest changed after prerequisite rejection") } if _, err := os.Stat(artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)); !os.IsNotExist(err) { t.Fatalf("runs directory stat error = %v, want not exist", err) } } func TestExecuteStagesRechecksBoundedPrerequisitesUnderSessionLock(t *testing.T) { cfg := testConfig(t) plan := mustBoundedPlan(t, "render", "render") m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign markPrefixSucceeded(m, plan) saveBoundedManifest(t, cfg, m) store := &prerequisiteChangingStore{local: &manifest.LocalStore{}} runs := 0 _, err := executeStages(context.Background(), cfg, []stage.Stage{countingStage{name: "render", runs: &runs}}, RunOptions{ Plan: plan, Env: &Env{ManifestStore: store}, }) if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) { t.Fatalf("executeStages() error = %v, want changed prerequisite rejection", err) } if store.loads != 2 { t.Fatalf("manifest loads = %d, want preflight and locked reload", store.loads) } if runs != 0 || store.creates != 0 || store.saves != 0 { t.Fatalf("runs=%d manifest creates=%d saves=%d, want no run or manifest mutation", runs, store.creates, store.saves) } } func TestExecuteStagesBoundedCompositionUsesOnlySelectedCollaborators(t *testing.T) { for _, test := range []struct { name string stageName string configure func(*config.Config) assertProbe func(*testing.T, *stage.Env) }{ { name: "render", stageName: "render", assertProbe: func(t *testing.T, env *stage.Env) { if env.Seriatim == nil || env.Notarius != nil || env.Scriptorium != nil { t.Fatalf("render collaborators: seriatim=%v notarius=%v scriptorium=%v", env.Seriatim, env.Notarius, env.Scriptorium) } }, }, { name: "extract", stageName: "extract", configure: func(cfg *config.Config) { cfg.Pipeline.Notarius = &config.NotariusConfig{Enabled: true} }, assertProbe: func(t *testing.T, env *stage.Env) { if env.Notarius == nil || env.Scriptorium != nil || env.Seriatim != nil { t.Fatalf("extract collaborators: notarius=%v scriptorium=%v seriatim=%v", env.Notarius, env.Scriptorium, env.Seriatim) } }, }, { name: "analyze", stageName: "analyze", assertProbe: func(t *testing.T, env *stage.Env) { if env.Scriptorium == nil || env.Notarius != nil || env.Seriatim != nil || env.WhisperX != nil || env.Audita != nil { t.Fatalf("analyze collaborators: scriptorium=%v notarius=%v seriatim=%v whisperx=%v audita=%v", env.Scriptorium, env.Notarius, env.Seriatim, env.WhisperX, env.Audita) } }, }, } { t.Run(test.name, func(t *testing.T) { cfg := testConfig(t) if test.configure != nil { test.configure(cfg) } plan := mustBoundedPlan(t, test.stageName, test.stageName) m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign markPrefixSucceeded(m, plan) saveBoundedManifest(t, cfg, m) var captured *stage.Env _, err := executeStages(context.Background(), cfg, []stage.Stage{collaboratorProbeStage{name: test.stageName, captured: &captured}}, RunOptions{Plan: plan}) if err != nil { t.Fatalf("executeStages() error = %v", err) } if captured == nil { t.Fatal("selected stage did not run") } test.assertProbe(t, captured) }) } } func TestExecuteStagesBoundedForceStalesButDoesNotRunDependentsOutsideRange(t *testing.T) { cfg := testConfig(t) plan := mustBoundedPlan(t, "render", "render") m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} { m.MarkStageSucceeded(name, time.Now().UTC(), nil) } saveBoundedManifest(t, cfg, m) runs := 0 _, err := executeStages(context.Background(), cfg, []stage.Stage{countingStage{name: "render", runs: &runs}}, RunOptions{Plan: plan, Force: true}) if err != nil { t.Fatalf("executeStages() error = %v", err) } if runs != 1 { t.Fatalf("selected render runs = %d, want 1", runs) } loaded, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg)) if err != nil { t.Fatalf("load manifest: %v", err) } for _, name := range []string{"analyze", "publish", "notify"} { if loaded.Stages[name].Status != manifest.StatusStale { t.Fatalf("stage %q status = %q, want stale", name, loaded.Stages[name].Status) } } if loaded.Stages["extract"].Status != manifest.StatusSucceeded { t.Fatalf("extract status = %q, want succeeded", loaded.Stages["extract"].Status) } } func TestExecuteStagesBoundedFailureStopsWithinSelectedRange(t *testing.T) { cfg := testConfig(t) plan := mustBoundedPlan(t, "render", "extract") m := manifest.New(cfg.Session.SessionID, time.Now().UTC()) m.Campaign = cfg.Session.Campaign markPrefixSucceeded(m, plan) saveBoundedManifest(t, cfg, m) extractRuns := 0 _, err := executeStages(context.Background(), cfg, []stage.Stage{ failingStage{name: "render", err: errors.New("render failed")}, countingStage{name: "extract", runs: &extractRuns}, }, RunOptions{Plan: plan}) if err == nil || !strings.Contains(err.Error(), "render failed") { t.Fatalf("executeStages() error = %v", err) } if extractRuns != 0 { t.Fatalf("extract runs = %d, want 0", extractRuns) } } type collaboratorProbeStage struct { name string captured **stage.Env } func (s collaboratorProbeStage) Name() string { return s.name } func (s collaboratorProbeStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { *s.captured = env return &stage.StageResult{}, nil } type prerequisiteMutationSpy struct { local *manifest.LocalStore creates int saves int } type prerequisiteChangingStore struct { local *manifest.LocalStore loads int creates int saves int } func (s *prerequisiteChangingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) { s.creates++ return s.local.Create(ctx, sessionID) } func (s *prerequisiteChangingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) { s.loads++ m, err := s.local.Load(ctx, path) if err != nil { return nil, err } if s.loads == 2 { m.MarkStageRunning("prepare", time.Now().UTC()) } return m, nil } func (s *prerequisiteChangingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error { s.saves++ return s.local.Save(ctx, path, m) } func (s *prerequisiteMutationSpy) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) { s.creates++ return s.local.Create(ctx, sessionID) } func (s *prerequisiteMutationSpy) Load(ctx context.Context, path string) (*manifest.Manifest, error) { return s.local.Load(ctx, path) } func (s *prerequisiteMutationSpy) Save(ctx context.Context, path string, m *manifest.Manifest) error { s.saves++ return s.local.Save(ctx, path, m) } func mustBoundedPlan(t *testing.T, from, through string) BoundedPlan { t.Helper() plan, err := BuildBoundedPlan(from, through) if err != nil { t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", from, through, err) } return plan } func markPrefixSucceeded(m *manifest.Manifest, plan BoundedPlan) { for _, name := range plan.PrefixNames() { m.MarkStageSucceeded(name, time.Now().UTC(), nil) } } func saveBoundedManifest(t *testing.T, cfg *config.Config, m *manifest.Manifest) string { t.Helper() path := manifestPathFor(cfg) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("create manifest directory: %v", err) } if err := (&manifest.LocalStore{}).Save(context.Background(), path, m); err != nil { t.Fatalf("save manifest: %v", err) } return path }