package config import ( "strings" "testing" ) func TestScriptoriumLoadAndValidate(t *testing.T) { 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: "pipeline.scriptorium.artifacts.session_recap.prompt_id is required when enabled", }, { name: "enabled artifact missing output path fails", scriptoriumYAML: `scriptorium: binary: scriptorium artifacts: session_recap: enabled: true prompt_id: dnd.session_recap `, wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.output_path is required when enabled", }, { name: "invalid timeout fails", scriptoriumYAML: `scriptorium: binary: scriptorium timeout: definitely-not-a-duration `, wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration", }, { name: "optional previous recap input 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: previous_session_artifact artifact: session_recap path: "" required: false vars: session_id: true output_kind: session_recap `, }, { 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: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact." must reference configured artifact key matching ^[a-z][a-z0-9_]*$`, }, { 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: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.session-recap" must reference configured artifact key matching ^[a-z][a-z0-9_]*$`, }, { 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: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.quest_log" references unknown artifact "quest_log"`, }, { 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.trimmed required: true `, }, { 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: `pipeline.scriptorium.artifacts.session_recap.inputs.transcript.source "narratio.unknown" is 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.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: "pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$", }, { 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: "pipeline.scriptorium.artifacts.session_recap.output_path must be under artifacts/", }, { name: "missing depends_on for 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.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 `, wantValidateErr: `pipeline.scriptorium.artifacts.player_handout.inputs.recap.source "narratio.artifact.session_recap" requires depends_on entry "session_recap"`, }, { 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: `pipeline.scriptorium.artifacts.player_handout.depends_on[0] "session_recap" is not a configured artifact key`, }, { 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: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself", }, { name: "enabled 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: "pipeline.scriptorium.artifacts enabled dependencies must not contain 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: `pipeline.scriptorium.artifacts.player_handout.inputs.recap.source "narratio.artifact.session-recap" is 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: "pipeline.scriptorium.artifacts.session_recap.output_path is required when artifact is 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 tt.wantValidateErr != "" { if err == nil { t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr) } if !strings.Contains(err.Error(), tt.wantValidateErr) { t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr) } return } if err != nil { t.Fatalf("Validate() error = %v", 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 `