package config import ( "io/fs" "os" "path/filepath" "reflect" "sort" "strings" "testing" ) func TestRepresentativeLoadDefaultsAndValidation(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, `whisperx: transcribe_url: https://transcription.example.com/transcribe `, `session_id: 2026-05-03 inputs: audio_dir: ./audio `) cfg, err := Load(pipelinePath, sessionPath) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Pipeline.Workspace.Root != DefaultWorkspaceRoot { t.Fatalf("workspace.root = %q, want %q", cfg.Pipeline.Workspace.Root, DefaultWorkspaceRoot) } if cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { t.Fatalf("storage = %#v, want local backend without S3 configuration", cfg.Pipeline.Storage) } if cfg.Pipeline.WhisperX.Timeout != DefaultWhisperXTimeout || cfg.Pipeline.WhisperX.RetryDelay != DefaultWhisperXRetryDelay { t.Fatalf("whisperx defaults = %#v, want timeout and retry-delay defaults", cfg.Pipeline.WhisperX) } if err := Validate(cfg); err != nil { t.Fatalf("Validate() error = %v", err) } } func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "pipeline.yml") if err := os.WriteFile(path, []byte(`scriptorium: artifacts: zeta: enabled: false output_path: artifacts/zeta.md alpha: enabled: false output_path: artifacts/alpha.md workspace: root: /srv/narratio whisperx: transcribe_url: https://transcription.example.com/transcribe `), 0o644); err != nil { t.Fatal(err) } first, err := LoadPipeline(path) if err != nil { t.Fatal(err) } second, err := LoadPipeline(path) if err != nil { t.Fatal(err) } firstYAML, err := MarshalEffectivePipeline(first) if err != nil { t.Fatal(err) } secondYAML, err := MarshalEffectivePipeline(second) if err != nil { t.Fatal(err) } if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") { t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML) } output := string(firstYAML) if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") { t.Fatalf("effective YAML leaked runtime fields: %q", output) } if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") { t.Fatalf("configured artifacts are not sorted: %q", output) } } func TestValidateMissingAudioSource(t *testing.T) { cfg := loadedValidConfig(t) cfg.Session.Inputs.AudioDir = "" cfg.Session.Inputs.AudioFiles = nil cfg.Session.Inputs.AudioS3 = nil err := Validate(cfg) if err == nil { t.Fatal("expected validation error, got nil") } if !strings.Contains(err.Error(), "audio_dir, at least one audio_files entry, or audio_s3") { t.Fatalf("error = %q, want audio source guidance", err.Error()) } if !strings.Contains(err.Error(), "session config") { t.Fatalf("error = %q, want session config context", err.Error()) } } func TestExamplesLoadAndValidate(t *testing.T) { examplesDir := filepath.Join("..", "..", "examples") if got, want := maintainedPipelineRoots(t, examplesDir), []string{ "pipeline.extraction-subset.yml", "pipeline.full.annotated.yml", "pipeline.minimal.yml", "pipeline.production.yml", filepath.Join("production-testing", "pipeline.yml"), }; !reflect.DeepEqual(got, want) { t.Fatalf("maintained pipeline roots = %#v, want %#v", got, want) } tests := []struct { name string pipelineFile string sessionFile string }{ { name: "minimal pipeline with local audio session", pipelineFile: "pipeline.minimal.yml", sessionFile: "session.local-audio.yml", }, { name: "production pipeline with s3 audio session", pipelineFile: "pipeline.production.yml", sessionFile: "session.s3-audio.yml", }, { name: "full annotated pipeline with local audio session", pipelineFile: "pipeline.full.annotated.yml", sessionFile: "session.local-audio.yml", }, { name: "extraction subset pipeline with local audio session", pipelineFile: "pipeline.extraction-subset.yml", sessionFile: "session.local-audio.yml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml") sessionPath := filepath.Join(examplesDir, tt.sessionFile) cfg, err := Load(pipelinePath, campaignPath, sessionPath) if err != nil { t.Fatalf("load example config error = %v", err) } if err := Validate(cfg); err != nil { t.Fatalf("validate example config error = %v", err) } }) } t.Run("split production and testing bundle", func(t *testing.T) { pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml") campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml") sessionPath := filepath.Join(examplesDir, "session.local-audio.yml") campaign, err := LoadCampaign(campaignPath) if err != nil { t.Fatalf("load split bundle campaign: %v", err) } for _, profile := range []struct { name string model string }{ {name: "production", model: "narratio-production-model-placeholder"}, {name: "testing", model: "narratio-testing-model-placeholder"}, } { t.Run(profile.name, func(t *testing.T) { selected := profile.name pipeline, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: &selected}) if err != nil { t.Fatalf("load split bundle pipeline: %v", err) } loaded, err := LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign) if err != nil { t.Fatalf("resolve split bundle campaign: %v", err) } cfg, err := LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, SessionLoadOptions{}) if err != nil { t.Fatalf("load split bundle session: %v", err) } if err := Validate(cfg); err != nil { t.Fatalf("validate split bundle: %v", err) } if cfg.Pipeline.Audita.Model != profile.model { t.Fatalf("audita model = %q, want %q", cfg.Pipeline.Audita.Model, profile.model) } if cfg.Pipeline.Secrets != nil || cfg.Pipeline.Audita.LLMAPIKeyEnv != "" || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { t.Fatalf("split bundle must remain offline and secret-free: secrets=%#v audita=%#v storage=%#v", cfg.Pipeline.Secrets, cfg.Pipeline.Audita, cfg.Pipeline.Storage) } if selectedProfile, ok := SelectedPipelineProfile(cfg.Pipeline); !ok || selectedProfile.Name != profile.name { t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.name) } for _, key := range []string{ "character_meta_arannis", "character_meta_brenna", "character_items_arannis", "character_items_brenna", } { if _, exists := cfg.Pipeline.Scriptorium.Artifacts[key]; !exists { t.Fatalf("expanded artifact %q is absent", key) } } if got := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"].Inputs["character_meta"].Source; got != "narratio.artifact.character_meta_arannis" { t.Fatalf("same-member input source = %q", got) } if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 { t.Fatalf("canonical party = %#v", cfg.Party) } characters := cfg.Party.Canonical.Characters if characters[0].Player.Name != characters[1].Player.Name || len(characters[0].Character.Aliases) != 2 || len(characters[1].Character.Classes) != 2 { t.Fatalf("canonical party does not preserve repeated player, aliases, and multiclass data: %#v", characters) } if len(cfg.Pipeline.Publish.Outputs) != 3 { t.Fatalf("publish outputs = %#v, want transcript plus two family outputs", cfg.Pipeline.Publish.Outputs) } if profile.name == "testing" { artifact, exists := cfg.Pipeline.Scriptorium.Artifacts["testing_notes"] if !exists || artifact.Enabled { t.Fatalf("testing-only disabled artifact = %#v", artifact) } } }) } }) } func maintainedPipelineRoots(t *testing.T, examplesDir string) []string { t.Helper() var roots []string err := filepath.WalkDir(examplesDir, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() { return nil } name := entry.Name() if name != "pipeline.yml" && !(strings.HasPrefix(name, "pipeline.") && (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml"))) { return nil } relative, err := filepath.Rel(examplesDir, path) if err != nil { return err } roots = append(roots, relative) return nil }) if err != nil { t.Fatalf("discover maintained pipeline roots: %v", err) } sort.Strings(roots) return roots } func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) { examplesDir := filepath.Join("..", "..", "examples") full, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.full.annotated.yml")) if err != nil { t.Fatalf("load full example error = %v", err) } want := map[string]NotariusOutputConfig{ "item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"}, "npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"}, "location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"}, "scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"}, "item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"}, "spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"}, "combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"}, "npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"}, "location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"}, "enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"}, } if full.Notarius == nil || !reflect.DeepEqual(full.Notarius.Outputs, want) { t.Fatalf("full example outputs = %#v, want %#v", full.Notarius, want) } subset, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.extraction-subset.yml")) if err != nil { t.Fatalf("load subset example error = %v", err) } brief := subset.Scriptorium.Artifacts["session_brief"] wantSources := map[string]string{ "npcs": "narratio.extraction.npc_registry", "locations": "narratio.extraction.location_registry", "scenes": "narratio.extraction.scene_descriptions", } gotSources := make(map[string]string, len(brief.Inputs)) for name, input := range brief.Inputs { gotSources[name] = input.Source } if !reflect.DeepEqual(gotSources, wantSources) { t.Fatalf("subset example sources = %#v, want %#v", gotSources, wantSources) } } func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, string) { t.Helper() if !strings.Contains(pipelineYAML, "\naudita:") && !strings.HasPrefix(pipelineYAML, "audita:") { if !strings.HasSuffix(pipelineYAML, "\n") { pipelineYAML += "\n" } pipelineYAML += "audita:\n binary: audita\n" } if !strings.Contains(sessionYAML, "\ncampaign:") && !strings.HasPrefix(sessionYAML, "campaign:") { if !strings.HasSuffix(sessionYAML, "\n") { sessionYAML += "\n" } sessionYAML += "campaign: sample-campaign\n" } dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") campaignPath := filepath.Join(dir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + ` inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml players_file: ./players.yml party_file: ./party.yml ` if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign.yml: %v", err) } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil { t.Fatalf("write party.yml: %v", err) } return pipelinePath, sessionPath } func campaignNameFromSessionYAML(sessionYAML string) string { for _, line := range strings.Split(sessionYAML, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "campaign:") { return strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "campaign:")), `"'`) } } return "sample-campaign" }