Files
narratio/internal/config/scriptorium_test.go

651 lines
18 KiB
Go

package config
import (
"strings"
"testing"
)
func TestScriptoriumLoadAndValidate(t *testing.T) {
legacyPreviousSource := "previous_session_" + "artifact"
tests := []struct {
name string
scriptoriumYAML string
wantLoadErr string
wantValidateErr []string
assert func(t *testing.T, cfg *Config)
}{
{
name: "valid minimal scriptorium config",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
vars:
session_id: true
output_kind: session_recap
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Scriptorium == nil {
t.Fatal("scriptorium config should be present")
}
if cfg.Pipeline.Scriptorium.Timeout != "10m" {
t.Fatalf("scriptorium.timeout default = %q, want 10m", cfg.Pipeline.Scriptorium.Timeout)
}
},
},
{
name: "unknown scriptorium field fails",
scriptoriumYAML: `scriptorium:
binary: scriptorium
bogus: true
`,
wantLoadErr: "strict decode failed",
},
{
name: "missing binary defaults when section present",
scriptoriumYAML: `scriptorium:
timeout: 10m
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Scriptorium == nil {
t.Fatal("scriptorium config should be present")
}
if cfg.Pipeline.Scriptorium.Binary != "scriptorium" {
t.Fatalf("scriptorium.binary = %q, want scriptorium", cfg.Pipeline.Scriptorium.Binary)
}
},
},
{
name: "enabled artifact missing prompt id fails",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
output_path: artifacts/session_recap.md
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.prompt_id", "required"},
},
{
name: "enabled artifact missing output path fails",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.output_path", "required"},
},
{
name: "invalid timeout fails",
scriptoriumYAML: `scriptorium:
binary: scriptorium
timeout: definitely-not-a-duration
`,
wantValidateErr: []string{"pipeline.scriptorium.timeout", "valid duration"},
},
{
name: "legacy previous session source fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
previous_recap:
source: ` + legacyPreviousSource + `
required: false
vars:
session_id: true
output_kind: session_recap
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source", "unsupported"},
},
{
name: "obsolete input passthrough fields fail strict decoding",
scriptoriumYAML: `scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
artifact: session_recap
`,
wantLoadErr: "decode.unknown_field",
},
{
name: "canonical previous-session source is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
vars:
session_id: true
output_kind: session_recap
`,
},
{
name: "canonical previous-session source missing artifact key fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.
required: false
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source", "configured artifact key"},
},
{
name: "canonical previous-session source invalid artifact key fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.session-recap
required: false
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source", "configured artifact key"},
},
{
name: "canonical previous-session source unknown artifact fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.quest_log
required: false
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source", "unknown artifact"},
},
{
name: "canonical artifact source is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.final_trimmed
required: true
`,
},
{
name: "markdown built in artifact source is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript_markdown:
source: narratio.transcript.final_markdown
required: true
`,
},
{
name: "prepared stable input sources are accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
`,
},
{
name: "unknown artifact source fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.unknown
required: true
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.transcript.source", "unsupported"},
},
{
name: "artifact render_debug override is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
render_debug: false
artifacts:
session_recap:
enabled: true
render_debug: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
artifact := cfg.Pipeline.Scriptorium.Artifacts["session_recap"]
if artifact.RenderDebug == nil || *artifact.RenderDebug != true {
t.Fatalf("artifact render_debug = %#v, want true", artifact.RenderDebug)
}
},
},
{
name: "multiple artifact definitions can be decoded",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
player_summary:
enabled: true
prompt_id: dnd.player_summary
profile_id: local-fast
output_path: artifacts/player_summary.md
timeout: 3m
inputs:
transcript:
source: narratio.transcript.polished
required: true
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Scriptorium == nil {
t.Fatal("scriptorium config should be present")
}
if len(cfg.Pipeline.Scriptorium.Artifacts) != 2 {
t.Fatalf("scriptorium.artifacts count = %d, want 2", len(cfg.Pipeline.Scriptorium.Artifacts))
}
},
},
{
name: "valid artifact dependency is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.final_trimmed
required: true
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`,
},
{
name: "valid dependency on disabled artifact with output path is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: false
output_path: artifacts/session_recap.md
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`,
},
{
name: "invalid artifact name fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
SessionRecap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts keys", "must match"},
},
{
name: "artifact output path outside artifacts root fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: transcripts/session_recap.md
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.output_path", "under artifacts/"},
},
{
name: "artifact source infers dependency",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
player_handout:
enabled: true
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
dependencies := cfg.Pipeline.Scriptorium.Artifacts["player_handout"].DependsOn
if len(dependencies) != 1 || dependencies[0] != "session_recap" {
t.Fatalf("inferred dependencies = %#v, want [session_recap]", dependencies)
}
},
},
{
name: "dependency on unknown artifact fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.player_handout.depends_on[0]", "not a configured artifact"},
},
{
name: "self dependency fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
depends_on:
- session_recap
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.depends_on", "itself"},
},
{
name: "configured dependency cycle fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
artifact_a:
enabled: true
depends_on:
- artifact_b
prompt_id: dnd.a
output_path: artifacts/a.md
inputs:
b:
source: narratio.artifact.artifact_b
required: true
artifact_b:
enabled: true
depends_on:
- artifact_a
prompt_id: dnd.b
output_path: artifacts/b.md
inputs:
a:
source: narratio.artifact.artifact_a
required: true
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts dependencies", "cycles"},
},
{
name: "disabled dependency cycle fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
artifact_a:
enabled: false
depends_on:
- artifact_b
output_path: artifacts/a.md
artifact_b:
enabled: false
depends_on:
- artifact_a
output_path: artifacts/b.md
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts dependencies", "cycles"},
},
{
name: "artifact source typo fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
player_handout:
enabled: true
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session-recap
required: true
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.player_handout.inputs.recap.source", "unsupported"},
},
{
name: "referenced disabled artifact missing output path fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: false
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`,
wantValidateErr: []string{"pipeline.scriptorium.artifacts.session_recap.output_path", "referenced"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + "\n" + tt.scriptoriumYAML
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if tt.wantLoadErr != "" {
if err == nil {
t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr)
}
if !strings.Contains(err.Error(), tt.wantLoadErr) {
t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if tt.assert != nil {
tt.assert(t, cfg)
}
err = Validate(cfg)
if len(tt.wantValidateErr) > 0 {
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr)
}
for _, fragment := range tt.wantValidateErr {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("validation error = %q, want to contain %q", err.Error(), fragment)
}
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}
func TestScriptoriumDiagnosticsAreIndependentOfMapInsertionOrder(t *testing.T) {
invalidArtifact := ScriptoriumArtifactConfig{Enabled: true}
firstArtifacts := make(map[string]ScriptoriumArtifactConfig)
firstArtifacts["beta"] = invalidArtifact
firstArtifacts["alpha"] = invalidArtifact
secondArtifacts := make(map[string]ScriptoriumArtifactConfig)
secondArtifacts["alpha"] = invalidArtifact
secondArtifacts["beta"] = invalidArtifact
firstErr := validateScriptorium(&ScriptoriumConfig{Binary: "scriptorium", Artifacts: firstArtifacts}, nil)
secondErr := validateScriptorium(&ScriptoriumConfig{Binary: "scriptorium", Artifacts: secondArtifacts}, nil)
if firstErr == nil || secondErr == nil {
t.Fatalf("validateScriptorium() errors = %v / %v, want validation failures", firstErr, secondErr)
}
if firstErr.Error() != secondErr.Error() {
t.Fatalf("equivalent maps produced different diagnostics:\nfirst: %v\nsecond: %v", firstErr, secondErr)
}
if !strings.Contains(firstErr.Error(), "pipeline.scriptorium.artifacts.") || !strings.Contains(firstErr.Error(), "prompt_id") {
t.Fatalf("validation error = %v, want artifact field and semantic category", firstErr)
}
}
func TestScriptoriumUnsupportedTranscriptSourceRejected(t *testing.T) {
source := "narratio.transcript." + "merged"
pipelineYAML := testPipelineBaseYAML + `
scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: ` + source + `
required: true
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "pipeline.scriptorium.artifacts.session_recap.inputs.transcript.source") || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("Validate() error = %v, want source field and unsupported category", err)
}
}
const testPipelineBaseYAML = `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
seriatim:
binary: seriatim
audita:
binary: audita
`
const testSessionBaseYAML = `session_id: 2026-05-03
campaign: test-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`