2026-05-19 18:36:30 -05:00
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
2026-05-21 23:02:19 -05:00
"gitea.maximumdirect.net/eric/narratio/internal/config"
2026-05-19 18:36:30 -05:00
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
2026-05-21 23:02:19 -05:00
"gitea.maximumdirect.net/eric/narratio/internal/stage"
2026-05-19 18:36:30 -05:00
)
func TestExecuteRunStageArtifactsNonAnalyzeFails ( t * testing . T ) {
workspaceRoot := t . TempDir ( )
2026-05-20 20:41:28 -05:00
pipelinePath , campaignPath , sessionPath := writeValidConfigFilesWithScriptoriumArtifacts ( t , workspaceRoot )
2026-05-19 18:36:30 -05:00
var stdout bytes . Buffer
var stderr bytes . Buffer
code := Execute (
2026-05-20 20:41:28 -05:00
[ ] string { "run-stage" , "--config" , pipelinePath , "--campaign" , campaignPath , "--session" , sessionPath , "--artifacts" , "session_recap" , "polish" } ,
2026-05-19 18:36:30 -05:00
& 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 stage "analyze" ` ) {
t . Fatalf ( "stderr = %q, want stage-gating error" , stderr . String ( ) )
}
}
func TestExecuteUnknownArtifactsFailValidation ( t * testing . T ) {
workspaceRoot := t . TempDir ( )
2026-05-20 20:41:28 -05:00
pipelinePath , campaignPath , sessionPath := writeValidConfigFilesWithScriptoriumArtifacts ( t , workspaceRoot )
2026-05-19 18:36:30 -05:00
var stdout bytes . Buffer
var stderr bytes . Buffer
code := Execute (
2026-05-20 20:41:28 -05:00
[ ] string { "run" , "--config" , pipelinePath , "--campaign" , campaignPath , "--session" , sessionPath , "--artifacts" , "unknown_artifact" } ,
2026-05-19 18:36:30 -05:00
& 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 ( )
2026-05-20 20:41:28 -05:00
pipelinePath , campaignPath , sessionPath := writeValidConfigFilesWithScriptoriumArtifacts ( t , workspaceRoot )
2026-05-19 18:36:30 -05:00
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 ( ) ,
2026-05-20 20:41:28 -05:00
[ ] string { "--config" , pipelinePath , "--campaign" , campaignPath , "--session" , sessionPath , "--artifacts" , "session_recap,session_recap" , "analyze" } ,
2026-05-19 18:36:30 -05:00
& 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 ( ) )
}
}
2026-05-19 18:43:55 -05:00
func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced ( t * testing . T ) {
workspaceRoot := t . TempDir ( )
2026-05-20 20:41:28 -05:00
pipelinePath , campaignPath , sessionPath := writeValidConfigFilesWithScriptoriumArtifacts ( t , workspaceRoot )
2026-05-19 18:43:55 -05:00
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" , "analyze" , "archive" , "notify" } {
seed . MarkStageSucceeded ( stageName , 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 := Resume (
context . Background ( ) ,
2026-05-20 20:41:28 -05:00
[ ] string { "--config" , pipelinePath , "--campaign" , campaignPath , "--session" , sessionPath , "--artifacts" , "session_recap" } ,
2026-05-19 18:43:55 -05:00
& out ,
)
if err != nil {
t . Fatalf ( "Resume() error = %v" , err )
}
if ! strings . Contains ( out . String ( ) , "has no remaining stages" ) {
t . Fatalf ( "output = %q, want no remaining stages" , out . String ( ) )
}
}
2026-05-21 23:02:19 -05:00
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" , "--config" , pipelinePath , "--campaign" , 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" ,
"--config" , pipelinePath ,
"--campaign" , 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" , "--config" , pipelinePath , "--campaign" , 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 : "positional" , args : [ ] string { "analyze" , "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" } , & 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 ( ) )
}
}
2026-05-22 11:28:38 -05:00
func TestExecutePublishForceRunsArchive ( 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 { "archive" } ,
} , nil
}
var stdout bytes . Buffer
var stderr bytes . Buffer
code := Execute (
[ ] string { "publish" , "--config" , pipelinePath , "--campaign" , 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 ] != "archive" {
t . Fatalf ( "captured stages = %#v, want [archive]" , capturedStages )
}
if ! capturedForce {
t . Fatal ( "captured force = false, want true" )
}
if len ( capturedArtifacts ) != 0 {
t . Fatalf ( "captured artifacts = %#v, want empty" , 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 : "positional" , args : [ ] string { "publish" , "archive" } , want : "publish: unexpected positional arguments" } ,
{ name : "force flag" , args : [ ] string { "publish" , "--force" } , want : "publish: invalid flags: flag provided but not defined: -force" } ,
{ name : "artifacts flag" , args : [ ] string { "publish" , "--artifacts" , "session_recap" } , want : "publish: invalid flags: flag provided but not defined: -artifacts" } ,
}
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 TestExecutePublishMissingConfigUsesRunStageLoadingPath ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
code := Execute ( [ ] string { "publish" } , & 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 ) {
2026-05-21 23:02:19 -05:00
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 ( ) )
}
2026-05-22 11:28:38 -05:00
if ! strings . Contains ( stderr . String ( ) , "publish" ) {
t . Fatalf ( "stderr = %q, want usage to include publish" , stderr . String ( ) )
}
2026-05-21 23:02:19 -05:00
}
2026-05-20 20:41:28 -05:00
func writeValidConfigFilesWithScriptoriumArtifacts ( t * testing . T , workspaceRoot string ) ( string , string , string ) {
2026-05-19 18:36:30 -05:00
t . Helper ( )
2026-05-20 20:41:28 -05:00
pipelinePath , campaignPath , sessionPath := writeValidConfigFiles ( t , workspaceRoot )
2026-05-19 18:36:30 -05:00
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 )
}
2026-05-20 20:41:28 -05:00
return pipelinePath , campaignPath , sessionPath
2026-05-19 18:36:30 -05:00
}