Files
narratio/internal/app/plan_test.go

254 lines
8.7 KiB
Go

package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"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 TestPlanDoesNotCreateWorkdir(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer
args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("first Plan() error = %v", err)
}
got := out.String()
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
t.Fatalf("first output = %q, want read-only workdir", got)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") {
t.Fatalf("first output = %q, missing stage %q", got, name)
}
}
if !strings.Contains(got, "totals: run=11 skip=0") {
t.Fatalf("first output = %q, want totals", got)
}
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("workdir stat error = %v, want absent", err)
}
out.Reset()
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err)
}
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
t.Fatalf("second output = %q, want read-only workdir", out.String())
}
}
func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{}
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
m, err := store.Load(context.Background(), manifestPath)
if err != nil {
t.Fatalf("load prepared manifest: %v", err)
}
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
if err := store.Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save manifest: %v", err)
}
var out bytes.Buffer
if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil {
t.Fatalf("Plan() error = %v", err)
}
got := out.String()
if !strings.Contains(got, "prepare: skip") || !strings.Contains(got, "transcribe: skip") {
t.Fatalf("output = %q, want prepare/transcribe skipped", got)
}
if !strings.Contains(got, "trim: run") {
t.Fatalf("output = %q, want trim run", got)
}
if !strings.Contains(got, "totals: run=9 skip=2") {
t.Fatalf("output = %q, want totals run=9 skip=2", got)
}
}
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
workspaceRoot := t.TempDir()
configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, configDir)
sessionPath := filepath.Join(configDir, "session.yml")
pipelineYAML := `workspace:
root: ` + workspaceRoot + `
storage:
backend: local
secrets:
env_dir: ./missing-secrets
whisperx:
transcribe_url: https://example.com/transcribe
seriatim:
binary: seriatim
audita:
binary: audita
notification:
mode: noop
`
sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
var out bytes.Buffer
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
}
}
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
if err != nil {
t.Fatalf("load config: %v", err)
}
analyze, err := stage.Select("analyze")
if err != nil {
t.Fatal(err)
}
fake := &scriptorium.FakeRunner{}
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("initial analyze: %v", err)
}
if len(fake.RunRequests) != 2 {
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
}
store := &manifest.LocalStore{}
m, err := store.Load(context.Background(), first.ManifestPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
t.Fatal(err)
}
manifestBefore, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
marker := filepath.Join(t.TempDir(), "adapter-invoked")
binaryDir := t.TempDir()
binary := filepath.Join(binaryDir, "scriptorium")
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var out bytes.Buffer
err = Plan(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
"--from", "render", "--through", "analyze",
}, &out)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
got := out.String()
for _, want := range []string{
"render: run", "extract: run", "analyze: run",
" targets: player_handout, session_recap",
" prerequisites: none", " execute: none",
"player_handout(target:current)", "session_recap(target:current)",
} {
if !strings.Contains(got, want) {
t.Fatalf("plan output = %q, want %q", got, want)
}
}
manifestAfter, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(manifestBefore, manifestAfter) {
t.Fatal("plan modified the session manifest")
}
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("adapter marker stat = %v, want absent", err)
}
fake.RunRequests = nil
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
resultStage{name: "render", result: &stage.StageResult{}},
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
analyze,
}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("actual analyze: %v", err)
}
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
}
}
func planFixtureFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
files = append(files, relative+":"+entry.Type().String())
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}