Files
narratio/internal/app/bounded_prerequisites_test.go

346 lines
12 KiB
Go

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
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err = executePlan(context.Background(), cfg, plan, RunOptions{
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
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{
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
plan.stages = []stage.Stage{collaboratorProbeStage{name: test.stageName, captured: &captured}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
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
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{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
plan.stages = []stage.Stage{
failingStage{name: "render", err: errors.New("render failed")},
countingStage{name: "extract", runs: &extractRuns},
}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
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
}