package cli import ( "bytes" "encoding/json" "io" "os" "path/filepath" "reflect" "strings" "testing" ) func TestRunRootHelp(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"--help"}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } if !strings.Contains(stdout.String(), "audita [options]") { t.Fatalf("expected root usage in stdout, got %q", stdout.String()) } if !strings.Contains(stdout.String(), "process") { t.Fatalf("expected process command in root help, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } } func TestRunProcessHelpListsExpectedFlags(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "--help"}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d", exitCode) } for _, expectedFlag := range []string{ "--glossary", "--output", "--report-json", "--modules", "--llm-api-key", "--validation-llm-api-key", "--model", "--validation-model", "--base-url", "--validation-base-url", "--llm-timeout-seconds", "--validation-llm-timeout-seconds", "--validation-max-prompt-tokens", "--target-sections", "--max-retries", "--validation-max-retries", "--validation-llm-concurrency", "--max-section-tokens", "--min-section-tokens", "--glossary-confidence-threshold", "--grammar-confidence-threshold", "--homophones-confidence-threshold", "--spoken-word-confidence-threshold", "--normalize-max-segment-gap", "--normalize-ellipsis-gap", "--normalize-max-segment-duration", "--normalize-max-segment-tokens", "--work-dir", "--work-dir-retention", } { if !strings.Contains(stdout.String(), expectedFlag) { t.Fatalf("expected process help to include %q, got %q", expectedFlag, stdout.String()) } } if stderr.Len() != 0 { t.Fatalf("expected empty stderr, got %q", stderr.String()) } } func TestRunUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"unknown"}, &stdout, &stderr) if exitCode != 2 { t.Fatalf("expected exit code 2, got %d", exitCode) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "unknown command") { t.Fatalf("expected unknown command error in stderr, got %q", stderr.String()) } } func TestRunProcessMissingTranscriptPath(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "expected exactly 1 transcript JSON path argument") { t.Fatalf("expected missing transcript error, got %q", stderr.String()) } } func TestRunProcessMissingGlossary(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("tiny_transcript.json")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "--glossary is required") { t.Fatalf("expected missing glossary error, got %q", stderr.String()) } } func TestRunProcessUnreadableTranscript(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer missingTranscript := filepath.Join(t.TempDir(), "missing.json") exitCode := Run([]string{"process", missingTranscript, "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "failed to read transcript file") { t.Fatalf("expected unreadable transcript error, got %q", stderr.String()) } } func TestRunProcessMalformedTranscriptJSON(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("malformed_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "is not valid JSON") { t.Fatalf("expected malformed transcript error, got %q", stderr.String()) } } func TestRunProcessOutputToFile(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "corrected.json") exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } inputBytes := readFile(t, transcriptPath) outputBytes := readFile(t, outputPath) assertJSONSemanticallyEqual(t, inputBytes, outputBytes) } func TestRunProcessOutputToStdout(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } inputBytes := readFile(t, transcriptPath) assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes()) } func TestRunProcessReportJSONSuccess(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "corrected.json") reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath, "--report-json", reportPath, }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) } report := readProcessReport(t, reportPath) if report.Status != "success" { t.Fatalf("expected success report status, got %q", report.Status) } if report.Operation != "process" { t.Fatalf("expected operation process, got %q", report.Operation) } if report.TranscriptPath != transcriptPath { t.Fatalf("unexpected transcript path in report: %q", report.TranscriptPath) } if report.GlossaryPath != glossaryPath { t.Fatalf("unexpected glossary path in report: %q", report.GlossaryPath) } if report.OutputPath != outputPath { t.Fatalf("unexpected output path in report: %q", report.OutputPath) } if len(report.Modules) == 0 { t.Fatalf("expected configured module sequence in report") } if report.StartedAt == "" || report.CompletedAt == "" { t.Fatalf("expected started_at and completed_at timestamps in report") } if report.ErrorMessage != "" { t.Fatalf("did not expect error message in success report, got %q", report.ErrorMessage) } } func TestRunProcessReportJSONNoStdoutPollution(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := fixturePath("tiny_transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", transcriptPath, "--glossary", glossaryPath, "--report-json", reportPath, }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } assertJSONSemanticallyEqual(t, readFile(t, transcriptPath), stdout.Bytes()) reportBytes := readFile(t, reportPath) if bytes.Contains(stdout.Bytes(), reportBytes) { t.Fatalf("stdout was polluted with report JSON") } } func TestRunProcessReportJSONRedactsSecrets(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer secretPrimary := "top-secret-primary-key" secretValidation := "top-secret-validation-key" t.Setenv("AUDITA_LLM_API_KEY", secretPrimary) t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secretValidation) reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--report-json", reportPath, }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } reportBytes := readFile(t, reportPath) if strings.Contains(string(reportBytes), secretPrimary) || strings.Contains(string(reportBytes), secretValidation) { t.Fatalf("report JSON leaked API key material: %q", string(reportBytes)) } } func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("malformed_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--report-json", reportPath, }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for malformed transcript") } report := readProcessReport(t, reportPath) if report.Status != "failed" { t.Fatalf("expected failed report status, got %q", report.Status) } if report.ErrorMessage == "" { t.Fatalf("expected error message in failed report") } if !strings.Contains(report.ErrorMessage, "not valid JSON") { t.Fatalf("expected malformed JSON failure in report error, got %q", report.ErrorMessage) } } func TestRunProcessInvalidTranscriptSchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid transcript schema") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "invalid transcript:") { t.Fatalf("expected invalid transcript error, got %q", stderr.String()) } if !strings.Contains(stderr.String(), "speaker") { t.Fatalf("expected speaker validation error, got %q", stderr.String()) } } func TestRunProcessValidBareArrayTranscript(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Should produce valid JSON output if !json.Valid(stdout.Bytes()) { t.Fatalf("expected valid JSON output, got %q", stdout.String()) } // Parse the output to verify it's a proper transcript var output any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("failed to unmarshal output JSON: %v", err) } // Should be an array at the top level (canonical object form would be {"segments": [...]}) outputArray, ok := output.([]any) if !ok { t.Fatalf("expected output to be JSON array, got %T", output) } if len(outputArray) != 2 { t.Fatalf("expected 2 segments in output, got %d", len(outputArray)) } } func TestRunProcessInvalidGlossarySchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", "../core/schema/testdata/glossary_missing_fields.yaml"}, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid glossary schema") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "invalid glossary:") { t.Fatalf("expected invalid glossary error, got %q", stderr.String()) } } func fixturePath(name string) string { return filepath.Join("testdata", name) } func readFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read file %q: %v", path, err) } return data } func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) { t.Helper() if !json.Valid(actual) { t.Fatalf("actual output is not valid JSON: %q", string(actual)) } var expectedValue any var actualValue any if err := json.Unmarshal(expected, &expectedValue); err != nil { t.Fatalf("failed to unmarshal expected JSON: %v", err) } if err := json.Unmarshal(actual, &actualValue); err != nil { t.Fatalf("failed to unmarshal actual JSON: %v", err) } if !reflect.DeepEqual(expectedValue, actualValue) { t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual)) } } type minimalProcessReport struct { Status string `json:"status"` Operation string `json:"operation"` TranscriptPath string `json:"transcript_path"` GlossaryPath string `json:"glossary_path"` OutputPath string `json:"output_path"` Modules []string `json:"modules"` StartedAt string `json:"started_at"` CompletedAt string `json:"completed_at"` ErrorMessage string `json:"error_message"` } func readProcessReport(t *testing.T, path string) minimalProcessReport { t.Helper() raw := readFile(t, path) var report minimalProcessReport if err := json.Unmarshal(raw, &report); err != nil { t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw)) } func TestRunProcessStdoutBehaviorWithoutOutput(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Should have transcript JSON in stdout if stdout.Len() == 0 { t.Fatalf("expected transcript JSON in stdout, got empty stdout") } if !json.Valid(stdout.Bytes()) { t.Fatalf("expected valid JSON in stdout, got %q", stdout.String()) } } func TestRunProcessEmptyStdoutWithOutput(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptPath := "../core/schema/testdata/transcript_bare_array.json" glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "corrected.json") exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Verify output file was created and contains valid JSON outputBytes := readFile(t, outputPath) if !json.Valid(outputBytes) { t.Fatalf("expected valid JSON in output file, got %q", string(outputBytes)) } } func TestRunProcessNormalizationMergeInOutput(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer // Create a test transcript with segments that should be merged transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "corrected.json") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Read and parse the output outputBytes := readFile(t, outputPath) var outputTranscript schema.Transcript if err := schema.ParseTranscriptJSON(outputBytes); err != nil { t.Fatalf("failed to parse output transcript: %v (output: %s)", err, string(outputBytes)) } // Should have merged the two segments into one if len(outputTranscript.Segments) != 1 { t.Fatalf("expected 1 merged segment, got %d segments", len(outputTranscript.Segments)) } // Should have sequential ID starting at 1 if outputTranscript.Segments[0].ID != 1 { t.Fatalf("expected segment ID 1, got %d", outputTranscript.Segments[0].ID) } // Should contain merged text if !strings.Contains(outputTranscript.Segments[0].Text, "Hello") || !strings.Contains(outputTranscript.Segments[0].Text, "world") { t.Fatalf("expected merged text to contain both parts, got %q", outputTranscript.Segments[0].Text) } } func TestRunProcessNormalizedOutputToStdout(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Should have normalized output in stdout if stdout.Len() == 0 { t.Fatalf("expected normalized transcript in stdout, got empty stdout") } // Should be valid JSON if !json.Valid(stdout.Bytes()) { t.Fatalf("expected valid JSON in stdout, got %q", stdout.String()) } // Parse and verify it's normalized (merged) var outputTranscript schema.Transcript if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { t.Fatalf("failed to parse stdout transcript: %v", err) } if len(outputTranscript.Segments) != 1 { t.Fatalf("expected 1 merged segment in stdout, got %d", len(outputTranscript.Segments)) } } func TestRunProcessSequentialIDsInOutput(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 5, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 10, "speaker": "Bob", "start": 2.0, "end": 3.0, "text": "Hi"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Parse output and verify sequential IDs var outputTranscript schema.Transcript if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { t.Fatalf("failed to parse output transcript: %v", err) } // Should have sequential IDs starting at 1 for i, segment := range outputTranscript.Segments { expectedID := i + 1 if segment.ID != expectedID { t.Fatalf("expected segment %d to have ID %d, got %d", i, expectedID, segment.ID) } } } func TestRunProcessNoStdoutWithOutput(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") outputPath := filepath.Join(t.TempDir(), "corrected.json") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Verify output file exists and is valid if _, err := os.Stat(outputPath); err != nil { t.Fatalf("expected output file to be created: %v", err) } } func TestRunProcessReportIncludesNormalizationSummary(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer // Create a test transcript with segments that will be merged transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") reportPath := filepath.Join(t.TempDir(), "report.json") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--report-json", reportPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout when --output is not used but --report-json is provided, got %q", stdout.String()) } if stderr.Len() != 0 { t.Fatalf("expected empty stderr on success, got %q", stderr.String()) } // Read and parse the report reportBytes := readFile(t, reportPath) var report reporting.ProcessReport if err := json.Unmarshal(reportBytes, &report); err != nil { t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) } // Verify report includes normalization summary if report.Status != "success" { t.Fatalf("expected success status, got %q", report.Status) } if report.InputSegmentCount == nil || *report.InputSegmentCount != 2 { t.Fatalf("expected input segment count 2, got %v", report.InputSegmentCount) } if report.NormalizedSegmentCount == nil || *report.NormalizedSegmentCount != 1 { t.Fatalf("expected normalized segment count 1, got %v", report.NormalizedSegmentCount) } if report.NormalizationMerges == nil || *report.NormalizationMerges != 1 { t.Fatalf("expected 1 merge, got %v", report.NormalizationMerges) } if report.NormalizationIDReassignments == nil || *report.NormalizationIDReassignments != 0 { t.Fatalf("expected 0 ID reassignments (already sequential), got %v", report.NormalizationIDReassignments) } } func TestRunProcessReportFailedTranscriptSchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--report-json", reportPath, }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid transcript") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } // Read and parse the report reportBytes := readFile(t, reportPath) var report reporting.ProcessReport if err := json.Unmarshal(reportBytes, &report); err != nil { t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) } // Verify failed report structure if report.Status != "failed" { t.Fatalf("expected failed status, got %q", report.Status) } if report.ErrorPhase != "transcript_schema" { t.Fatalf("expected error phase 'transcript_schema', got %q", report.ErrorPhase) } if report.ErrorMessage == "" { t.Fatalf("expected error message in failed report") } if !strings.Contains(report.ErrorMessage, "speaker") { t.Fatalf("expected speaker validation error, got %q", report.ErrorMessage) } } func TestRunProcessReportFailedGlossarySchema(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer reportPath := filepath.Join(t.TempDir(), "report.json") exitCode := Run([]string{ "process", fixturePath("tiny_transcript.json"), "--glossary", "../core/schema/testdata/glossary_missing_fields.yaml", "--report-json", reportPath, }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid glossary") } if stdout.Len() != 0 { t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } // Read and parse the report reportBytes := readFile(t, reportPath) var report reporting.ProcessReport if err := json.Unmarshal(reportBytes, &report); err != nil { t.Fatalf("failed to parse report JSON: %v (raw: %s)", err, string(reportBytes)) } // Verify failed report structure if report.Status != "failed" { t.Fatalf("expected failed status, got %q", report.Status) } if report.ErrorPhase != "glossary_schema" { t.Fatalf("expected error phase 'glossary_schema', got %q", report.ErrorPhase) } if report.ErrorMessage == "" { t.Fatalf("expected error message in failed report") } } func TestRunProcessRunDirectoryCreation(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") workDir := filepath.Join(t.TempDir(), "work") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Check that run directory was created runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } if len(runDirs) != 1 { t.Fatalf("expected 1 run directory, got %d", len(runDirs)) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Check that source transcript artifact exists if _, err := os.Stat(filepath.Join(runPath, "source-transcript.json")); err != nil { t.Fatalf("expected source transcript artifact: %v", err) } // Check that normalized transcript artifact exists if _, err := os.Stat(filepath.Join(runPath, "normalized-transcript.json")); err != nil { t.Fatalf("expected normalized transcript artifact: %v", err) } // Check that normalization summary artifact exists if _, err := os.Stat(filepath.Join(runPath, "normalization-summary.json")); err != nil { t.Fatalf("expected normalization summary artifact: %v", err) } // Check that report artifact exists if _, err := os.Stat(filepath.Join(runPath, "report.json")); err != nil { t.Fatalf("expected report artifact: %v", err) } } func TestRunProcessSourceTranscriptArtifact(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") workDir := filepath.Join(t.TempDir(), "work") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Read and verify source transcript artifact sourceBytes := readFile(t, filepath.Join(runPath, "source-transcript.json")) if !bytes.Equal(sourceBytes, []byte(transcriptJSON)) { t.Fatalf("source transcript artifact doesn't match input") } } func TestRunProcessNormalizedTranscriptArtifact(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") workDir := filepath.Join(t.TempDir(), "work") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Read and verify normalized transcript artifact normalizedBytes := readFile(t, filepath.Join(runPath, "normalized-transcript.json")) var normalizedTranscript schema.Transcript if err := schema.ParseTranscriptJSON(normalizedBytes); err != nil { t.Fatalf("failed to parse normalized transcript artifact: %v", err) } // Should be merged (same speaker, small gap) if len(normalizedTranscript.Segments) != 1 { t.Fatalf("expected 1 merged segment in normalized artifact, got %d", len(normalizedTranscript.Segments)) } } func TestRunProcessNormalizationSummaryArtifact(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") workDir := filepath.Join(t.TempDir(), "work") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Read and verify normalization summary artifact summaryBytes := readFile(t, filepath.Join(runPath, "normalization-summary.json")) var summary normalization.NormalizationSummary if err := json.Unmarshal(summaryBytes, &summary); err != nil { t.Fatalf("failed to parse normalization summary: %v", err) } // Verify summary content if summary.InputSegmentCount != 2 { t.Fatalf("expected input count 2, got %d", summary.InputSegmentCount) } if summary.OutputSegmentCount != 1 { t.Fatalf("expected output count 1, got %d", summary.OutputSegmentCount) } if summary.MergesPerformed != 1 { t.Fatalf("expected 1 merge, got %d", summary.MergesPerformed) } } func TestRunProcessReportArtifact(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[{"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") workDir := filepath.Join(t.TempDir(), "work") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--work-dir", workDir}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Verify report artifact exists and is valid reportBytes := readFile(t, filepath.Join(runPath, "report.json")) var report reporting.ProcessReport if err := json.Unmarshal(reportBytes, &report); err != nil { t.Fatalf("failed to parse report: %v", err) } if report.Status != "success" { t.Fatalf("expected success status, got %q", report.Status) } } func TestRunProcessErrorLogOnFailure(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer workDir := filepath.Join(t.TempDir(), "work") exitCode := Run([]string{ "process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--work-dir", workDir, }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid transcript") } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } if len(runDirs) != 1 { t.Fatalf("expected 1 run directory on failure, got %d", len(runDirs)) } runPath := filepath.Join(workDir, runDirs[0].Name()) // Verify error.log exists if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil { t.Fatalf("expected error.log on failure: %v", err) } // Verify error.log contains useful information errorBytes := readFile(t, filepath.Join(runPath, "error.log")) if !strings.Contains(string(errorBytes), "transcript_schema") { t.Fatalf("expected error phase in error.log, got %q", string(errorBytes)) } } func TestRunProcessFailedRunsRetained(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer workDir := filepath.Join(t.TempDir(), "work") exitCode := Run([]string{ "process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--work-dir", workDir, "--work-dir-retention", "never", // Even with "never", failed runs should be retained }, &stdout, &stderr) if exitCode == 0 { t.Fatalf("expected nonzero exit code for invalid transcript") } // Find the run directory runDirs, err := os.ReadDir(workDir) if err != nil { t.Fatalf("failed to read work directory: %v", err) } if len(runDirs) != 1 { t.Fatalf("expected failed run to be retained, got %d run directories", len(runDirs)) } // Failed runs should always be retained regardless of retention policy runPath := filepath.Join(workDir, runDirs[0].Name()) if _, err := os.Stat(runPath); err != nil { t.Fatalf("failed run directory should be retained: %v", err) } } func TestRunProcessCLIAndNormalizationAgree(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer transcriptJSON := `[ {"id": 1, "speaker": "Alice", "start": 0.0, "end": 1.0, "text": "Hello"}, {"id": 2, "speaker": "Alice", "start": 1.5, "end": 2.5, "text": "world"} ]` transcriptPath := filepath.Join(t.TempDir(), "transcript.json") glossaryPath := fixturePath("tiny_glossary.yaml") if err := os.WriteFile(transcriptPath, []byte(transcriptJSON), 0644); err != nil { t.Fatalf("failed to create test transcript: %v", err) } exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) } // Parse CLI output var cliOutput schema.Transcript if err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil { t.Fatalf("failed to parse CLI output: %v", err) } // Parse source transcript var sourceTranscript schema.SourceTranscript if err := schema.ParseSourceTranscriptJSON([]byte(transcriptJSON)); err != nil { t.Fatalf("failed to parse source transcript: %v", err) } // Convert to canonical format canonical := &schema.Transcript{ Segments: make([]schema.Segment, len(sourceTranscript.Segments)), } for i, s := range sourceTranscript.Segments { id := i + 1 if s.ID != nil { id = *s.ID } canonical.Segments[i] = schema.Segment{ ID: id, Speaker: s.Speaker, Start: s.Start, End: s.End, Text: s.Text, Categories: s.Categories, } } // Apply normalization using pure package normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{ MaxSegmentGap: 2.0, EllipsisGap: 1.0, MaxSegmentDuration: 60.0, MaxSegmentTokens: 100, }) packageOutput, _ := normalizer.Normalize(canonical) // Compare CLI and package outputs assertTranscriptsEqual(t, &cliOutput, packageOutput) } func assertTranscriptsEqual(t *testing.T, actual, expected *schema.Transcript) { t.Helper() if len(actual.Segments) != len(expected.Segments) { t.Errorf("segment count mismatch: expected %d, got %d", len(expected.Segments), len(actual.Segments)) return } for i := range actual.Segments { a := actual.Segments[i] e := expected.Segments[i] if a.ID != e.ID { t.Errorf("segment %d: ID mismatch: expected %d, got %d", i, e.ID, a.ID) } if a.Speaker != e.Speaker { t.Errorf("segment %d: speaker mismatch: expected %q, got %q", i, e.Speaker, a.Speaker) } if a.Start != e.Start { t.Errorf("segment %d: start mismatch: expected %f, got %f", i, e.Start, a.Start) } if a.End != e.End { t.Errorf("segment %d: end mismatch: expected %f, got %f", i, e.End, a.End) } if a.Text != e.Text { t.Errorf("segment %d: text mismatch: expected %q, got %q", i, e.Text, a.Text) } if len(a.Categories) != len(e.Categories) { t.Errorf("segment %d: categories count mismatch: expected %d, got %d", i, len(e.Categories), len(a.Categories)) continue } for j, cat := range a.Categories { if j >= len(e.Categories) || cat != e.Categories[j] { t.Errorf("segment %d: category %d mismatch: expected %q, got %q", i, j, e.Categories[j], cat) } } } }