package config import ( "os" "path/filepath" "strings" "testing" ) func TestLoadAndValidate(t *testing.T) { tests := []struct { name string pipelineYAML string sessionYAML string wantLoadErr string wantValidate string checkDefault bool wantRoot string }{ { name: "valid minimal config", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita notification: timeout: 15s `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, checkDefault: true, wantRoot: "/tmp/narratio", }, { name: "seriatim and audita sections can be omitted", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe notification: timeout: 15s `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, checkDefault: true, wantRoot: "/tmp/narratio", }, { name: "workspace root defaults when omitted", pipelineYAML: `whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe notification: timeout: 15s `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, checkDefault: true, wantRoot: DefaultWorkspaceRoot, }, { name: "unknown pipeline field fails", pipelineYAML: `workspace: root: /tmp/narratio bogus: true `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "pipeline file", }, { name: "legacy analyzer section fails strict decode", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe analyzer: timeout: 20m `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "unknown whisperx field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe bogus: true `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "unknown secrets field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim secrets: bogus: true `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "empty secrets env_dir fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim secrets: env_dir: " " `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured", }, { name: "unknown session field fails", pipelineYAML: `workspace: root: /tmp/narratio `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml unknown_field: true `, wantLoadErr: "session file", }, { name: "missing required field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim `, sessionYAML: `session_id: "" inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "session config \"session.yml\" invalid: session.session_id is required", }, { name: "valid previous_session_id passes", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 previous_session_id: 2026-04-26 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, }, { name: "previous_session_id equal to session_id fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 previous_session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id", }, { name: "missing transcribe_url fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required", }, { name: "invalid transcribe_url fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: :// seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL", }, { name: "invalid timeout fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe timeout: definitely-not-a-duration seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration", }, { name: "invalid retry_delay fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe retry_delay: not-a-duration seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration", }, { name: "negative retries fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe retries: -1 seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0", }, { name: "invalid concurrency fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe concurrency: 0 seriatim: binary: seriatim `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0", }, { name: "unknown seriatim field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim bogus: true `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "unknown seriatim env field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim env: unknown_knob: 1.0 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "missing seriatim binary uses default", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: timeout: 10m `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, }, { name: "invalid seriatim timeout fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim timeout: not-a-duration `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration", }, { name: "invalid seriatim output schema fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim output_schema: invalid-schema `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", }, { name: "negative seriatim coalesce gap fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim coalesce_gap: -0.1 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0", }, { name: "invalid seriatim env float fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim env: overlap_word_run_gap: 0 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided", }, { name: "unknown audita field fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita bogus: true `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "missing audita binary uses default", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: timeout: 3h `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, }, { name: "invalid audita timeout fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita timeout: bad-timeout `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration", }, { name: "empty audita modules is valid override", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita modules: [] `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, }, { name: "empty audita module item fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita modules: - glossary - "" `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty", }, { name: "invalid audita module item fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita modules: - glossary - bad_module `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar", }, { name: "invalid audita base_url fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita base_url: :// `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL", }, { name: "legacy audita llm_concurrency field fails strict decode", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita llm_concurrency: 1 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantLoadErr: "strict decode failed", }, { name: "invalid audita total_llm_concurrency fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita total_llm_concurrency: 0 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0", }, { name: "invalid audita proposal_llm_concurrency fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita proposal_llm_concurrency: 0 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0", }, { name: "invalid audita validation_llm_concurrency fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita validation_llm_concurrency: 0 `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0", }, { name: "invalid audita output_schema fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita output_schema: bad `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1", }, { name: "invalid audita work_dir_retention fails", pipelineYAML: `workspace: root: /tmp/narratio whisperx: transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe seriatim: binary: seriatim audita: binary: audita work_dir_retention: sometimes `, sessionYAML: `session_id: 2026-05-03 inputs: audio_dir: ./audio speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml `, wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYAML, tt.sessionYAML) 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) } if !strings.Contains(err.Error(), "strict decode failed") { t.Fatalf("load error = %q, want strict decode context", err.Error()) } return } if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.PipelinePath != pipelinePath { t.Fatalf("PipelinePath = %q, want %q", cfg.PipelinePath, pipelinePath) } if cfg.SessionPath != sessionPath { t.Fatalf("SessionPath = %q, want %q", cfg.SessionPath, sessionPath) } if tt.checkDefault { if tt.wantRoot != "" && cfg.Pipeline.Workspace.Root != tt.wantRoot { t.Fatalf("workspace.root = %q, want %q", cfg.Pipeline.Workspace.Root, tt.wantRoot) } if cfg.Pipeline.WhisperX.Language != "en" { t.Fatalf("whisperx.language = %q, want %q", cfg.Pipeline.WhisperX.Language, "en") } if cfg.Pipeline.WhisperX.Timeout != "30m" { t.Fatalf("whisperx.timeout = %q, want %q", cfg.Pipeline.WhisperX.Timeout, "30m") } if cfg.Pipeline.WhisperX.RetryDelay != "2s" { t.Fatalf("whisperx.retry_delay = %q, want %q", cfg.Pipeline.WhisperX.RetryDelay, "2s") } if cfg.Pipeline.WhisperX.Retries == nil || *cfg.Pipeline.WhisperX.Retries != 3 { t.Fatalf("whisperx.retries = %v, want 3", cfg.Pipeline.WhisperX.Retries) } if cfg.Pipeline.WhisperX.Concurrency == nil || *cfg.Pipeline.WhisperX.Concurrency != 2 { t.Fatalf("whisperx.concurrency = %v, want 2", cfg.Pipeline.WhisperX.Concurrency) } if cfg.Pipeline.Seriatim.Timeout != "10m" { t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m") } if cfg.Pipeline.Seriatim.Binary != "seriatim" { t.Fatalf("seriatim.binary = %q, want %q", cfg.Pipeline.Seriatim.Binary, "seriatim") } if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" { t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate") } if cfg.Pipeline.Seriatim.CoalesceGap == nil || *cfg.Pipeline.Seriatim.CoalesceGap != 3.0 { t.Fatalf("seriatim.coalesce_gap = %v, want 3.0", cfg.Pipeline.Seriatim.CoalesceGap) } if cfg.Pipeline.Seriatim.Report == nil || *cfg.Pipeline.Seriatim.Report != true { t.Fatalf("seriatim.report = %v, want true", cfg.Pipeline.Seriatim.Report) } if cfg.Pipeline.Audita.Timeout != "3h" { t.Fatalf("audita.timeout = %q, want %q", cfg.Pipeline.Audita.Timeout, "3h") } if cfg.Pipeline.Audita.Binary != "audita" { t.Fatalf("audita.binary = %q, want %q", cfg.Pipeline.Audita.Binary, "audita") } if cfg.Pipeline.Audita.LLMAPIKeyEnv != "" { t.Fatalf("audita.llm_api_key_env = %q, want empty by default", cfg.Pipeline.Audita.LLMAPIKeyEnv) } if cfg.Pipeline.Audita.Modules != nil { t.Fatalf("audita.modules = %#v, want nil default (optional override)", cfg.Pipeline.Audita.Modules) } if cfg.Pipeline.Audita.BaseURL != "" { t.Fatalf("audita.base_url = %q, want empty default", cfg.Pipeline.Audita.BaseURL) } if cfg.Pipeline.Audita.Model != "" { t.Fatalf("audita.model = %q, want empty default", cfg.Pipeline.Audita.Model) } if cfg.Pipeline.Audita.ValidationModel != "" { t.Fatalf("audita.validation_model = %q, want empty default", cfg.Pipeline.Audita.ValidationModel) } if cfg.Pipeline.Audita.TotalLLMConcurrency != nil { t.Fatalf("audita.total_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.TotalLLMConcurrency) } if cfg.Pipeline.Audita.ProposalLLMConcurrency != nil { t.Fatalf("audita.proposal_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.ProposalLLMConcurrency) } if cfg.Pipeline.Audita.ValidationLLMConcurrency != nil { t.Fatalf("audita.validation_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.ValidationLLMConcurrency) } if cfg.Pipeline.Audita.Report == nil || *cfg.Pipeline.Audita.Report != true { t.Fatalf("audita.report = %v, want true", cfg.Pipeline.Audita.Report) } if cfg.Pipeline.Normalize == nil { t.Fatal("normalize config should be present via defaults") } if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" { t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json") } if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") } if cfg.Pipeline.Normalize.Report == nil || *cfg.Pipeline.Normalize.Report != true { t.Fatalf("normalize.report = %v, want true", cfg.Pipeline.Normalize.Report) } } err = Validate(cfg) if tt.wantValidate != "" { if err == nil { t.Fatalf("expected validation error containing %q, got nil", tt.wantValidate) } if !strings.Contains(err.Error(), tt.wantValidate) { t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidate) } return } if err != nil { t.Fatalf("Validate() error = %v", err) } }) } } func TestValidateMissingAudioSource(t *testing.T) { retries := 3 concurrency := 2 cfg := &Config{ Pipeline: &PipelineConfig{ Workspace: WorkspaceConfig{Root: "/tmp/narratio"}, WhisperX: WhisperXConfig{ TranscribeURL: "https://transcription.ai.rakestrawhome.com/transcribe", Language: "en", Timeout: "30m", Retries: &retries, RetryDelay: "2s", Concurrency: &concurrency, }, Seriatim: SeriatimConfig{ Binary: "seriatim", Timeout: "10m", OutputSchema: "seriatim-intermediate", CoalesceGap: float64Ptr(3.0), Report: boolPtr(true), }, Audita: AuditaConfig{ Binary: "audita", Timeout: "3h", LLMAPIKeyEnv: "AUDITA_LLM_API_KEY", Modules: []string{"glossary", "homophones"}, BaseURL: "https://openrouter.ai/api/v1", Model: "openrouter/google/gemma-4-31b-it", TotalLLMConcurrency: intPtr(1), ProposalLLMConcurrency: intPtr(1), ValidationModel: "", ValidationLLMConcurrency: intPtr(1), Report: boolPtr(true), }, }, Campaign: &CampaignConfig{Campaign: "sample-campaign"}, Session: &SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", Inputs: SessionInputsConfig{ SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", }, }, } 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") 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", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) campaignPath := filepath.Join(examplesDir, "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) } }) } } 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: ` + campaignNameFromSessionYAML(sessionYAML) + ` inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.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) } 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" }