251 lines
8.6 KiB
Go
251 lines
8.6 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{}
|
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
|
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
|
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
|
|
}
|