436 lines
15 KiB
Go
436 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
)
|
|
|
|
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"run-stage", "extract", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stages "analyze" and "publish"`) {
|
|
t.Fatalf("stderr = %q, want stage-gating error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var capturedStages []string
|
|
var capturedArtifacts []string
|
|
origExecuteStagesFn := executeStagesFn
|
|
t.Cleanup(func() {
|
|
executeStagesFn = origExecuteStagesFn
|
|
})
|
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
for _, s := range stages {
|
|
capturedStages = append(capturedStages, s.Name())
|
|
}
|
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"publish"}}, nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{
|
|
"run-stage", "publish", "2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--artifacts", "session_recap",
|
|
},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if len(capturedStages) != 1 || capturedStages[0] != "publish" {
|
|
t.Fatalf("captured stages = %#v, want [publish]", capturedStages)
|
|
}
|
|
if strings.Join(capturedArtifacts, ",") != "session_recap" {
|
|
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), `run: --artifacts includes unknown artifact "unknown_artifact"`) {
|
|
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
|
|
|
store := &manifest.LocalStore{}
|
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
|
seed.MarkStageSucceeded("analyze", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
|
t.Fatalf("save manifest: %v", err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
err := RunStage(
|
|
context.Background(),
|
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
|
|
&out,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("RunStage() error = %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
|
|
t.Fatalf("output = %q, want analyze skip without force", out.String())
|
|
}
|
|
}
|
|
|
|
func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
|
|
|
store := &manifest.LocalStore{}
|
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
|
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
|
}
|
|
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
|
t.Fatalf("save manifest: %v", err)
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
err := Run(
|
|
context.Background(),
|
|
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
|
&out,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), "executed=1 skipped=11") {
|
|
t.Fatalf("output = %q, want all stages skipped", out.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var capturedStages []string
|
|
var capturedForce bool
|
|
origExecuteStagesFn := executeStagesFn
|
|
t.Cleanup(func() {
|
|
executeStagesFn = origExecuteStagesFn
|
|
})
|
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
for _, s := range stages {
|
|
capturedStages = append(capturedStages, s.Name())
|
|
}
|
|
capturedForce = opts.Force
|
|
return &RunSummary{
|
|
ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"),
|
|
Executed: []string{"analyze"},
|
|
}, nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if len(capturedStages) != 1 || capturedStages[0] != "analyze" {
|
|
t.Fatalf("captured stages = %#v, want [analyze]", capturedStages)
|
|
}
|
|
if !capturedForce {
|
|
t.Fatal("captured force = false, want true")
|
|
}
|
|
if !strings.Contains(stdout.String(), "narratio analyze: executed=1 skipped=0 force=true; manifest=") {
|
|
t.Fatalf("stdout = %q, want analyze summary", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var capturedArtifacts []string
|
|
origExecuteStagesFn := executeStagesFn
|
|
t.Cleanup(func() {
|
|
executeStagesFn = origExecuteStagesFn
|
|
})
|
|
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{
|
|
"analyze",
|
|
"2026-05-03",
|
|
"--config", pipelinePath,
|
|
"--campaign-file", campaignPath,
|
|
"--session", sessionPath,
|
|
"--artifacts", "player_handout,session_recap",
|
|
},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if strings.Join(capturedArtifacts, ",") != "player_handout,session_recap" {
|
|
t.Fatalf("captured artifacts = %#v, want sorted selected artifacts", capturedArtifacts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), `analyze: --artifacts includes unknown artifact "unknown_artifact"`) {
|
|
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "extra positional", args: []string{"analyze", "2026-05-03", "extra"}, want: "analyze: unexpected positional arguments"},
|
|
{name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(tc.args, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), tc.want) {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") {
|
|
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var capturedStages []string
|
|
var capturedForce bool
|
|
var capturedArtifacts []string
|
|
origExecuteStagesFn := executeStagesFn
|
|
t.Cleanup(func() {
|
|
executeStagesFn = origExecuteStagesFn
|
|
})
|
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
for _, s := range stages {
|
|
capturedStages = append(capturedStages, s.Name())
|
|
}
|
|
capturedForce = opts.Force
|
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
|
return &RunSummary{
|
|
ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"),
|
|
Executed: []string{"publish"},
|
|
}, nil
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if len(capturedStages) != 1 || capturedStages[0] != "publish" {
|
|
t.Fatalf("captured stages = %#v, want [publish]", capturedStages)
|
|
}
|
|
if !capturedForce {
|
|
t.Fatal("captured force = false, want true")
|
|
}
|
|
if strings.Join(capturedArtifacts, ",") != "session_recap" {
|
|
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
|
|
}
|
|
if !strings.Contains(stdout.String(), "narratio publish: executed=1 skipped=0 force=true; manifest=") {
|
|
t.Fatalf("stdout = %q, want publish summary", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "extra positional", args: []string{"publish", "2026-05-03", "extra"}, want: "publish: unexpected positional arguments"},
|
|
{name: "force flag", args: []string{"publish", "--force"}, want: "publish: invalid flags: flag provided but not defined: -force"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(tc.args, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), tc.want) {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(
|
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
|
&stdout,
|
|
&stderr,
|
|
)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), `publish: --artifacts includes unknown artifact "unknown_artifact"`) {
|
|
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
|
|
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestExecuteUsageIncludesAnalyzeAndPublish(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute(nil, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "analyze") {
|
|
t.Fatalf("stderr = %q, want usage to include analyze", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "publish") {
|
|
t.Fatalf("stderr = %q, want usage to include publish", stderr.String())
|
|
}
|
|
}
|
|
|
|
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) {
|
|
t.Helper()
|
|
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
|
|
if err != nil {
|
|
t.Fatalf("open pipeline config for append: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
extra := `
|
|
scriptorium:
|
|
binary: scriptorium
|
|
artifacts:
|
|
session_recap:
|
|
enabled: true
|
|
prompt_id: dnd.session_recap
|
|
output_path: artifacts/session_recap.md
|
|
player_handout:
|
|
enabled: true
|
|
prompt_id: dnd.player_handout
|
|
output_path: artifacts/player_handout.md
|
|
depends_on:
|
|
- session_recap
|
|
inputs:
|
|
recap:
|
|
source: narratio.artifact.session_recap
|
|
required: true
|
|
`
|
|
if _, err := f.WriteString(extra); err != nil {
|
|
t.Fatalf("append scriptorium config: %v", err)
|
|
}
|
|
return pipelinePath, campaignPath, sessionPath
|
|
}
|