package cli import ( "encoding/json" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/seriatim/internal/config" "gitea.maximumdirect.net/eric/seriatim/internal/report" "gitea.maximumdirect.net/eric/seriatim/schema" ) func TestNormalizeCommandIsRecognized(t *testing.T) { cmd := NewRootCommand() cmd.SetArgs([]string{"normalize", "--help"}) if err := cmd.Execute(); err != nil { t.Fatalf("normalize command should be recognized: %v", err) } } func TestNormalizeMissingInputFileFails(t *testing.T) { dir := t.TempDir() output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--output-file", output, ) if err == nil { t.Fatal("expected missing input-file error") } if !strings.Contains(err.Error(), "--input-file is required") { t.Fatalf("unexpected error: %v", err) } } func TestNormalizeMissingOutputFileFails(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) err := executeNormalize( "--input-file", input, ) if err == nil { t.Fatal("expected missing output-file error") } if !strings.Contains(err.Error(), "--output-file is required") { t.Fatalf("unexpected error: %v", err) } } func TestNormalizeInvalidOutputSchemaFails(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-schema", "compact", ) if err == nil { t.Fatal("expected invalid output schema error") } if !strings.Contains(err.Error(), "--output-schema must be one of") { t.Fatalf("unexpected error: %v", err) } } func TestNormalizeInvalidOutputModuleFails(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-modules", "yaml", ) if err == nil { t.Fatal("expected invalid output module error") } if !strings.Contains(err.Error(), "unknown output module") { t.Fatalf("unexpected error: %v", err) } } func TestNormalizeDefaultOutputSchemaIsIntermediate(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{ "segments": [ {"id": 99, "start": 5, "end": 6, "speaker": "Bob", "text": "second", "categories": ["filler"]}, {"id": 10, "start": 1, "end": 2, "speaker": "Alice", "text": "first", "categories": ["backchannel"]} ] }`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.IntermediateTranscript readJSON(t, output, &transcript) if transcript.Metadata.OutputSchema != config.OutputSchemaIntermediate { t.Fatalf("output schema = %q, want %q", transcript.Metadata.OutputSchema, config.OutputSchemaIntermediate) } if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } if transcript.Segments[0].ID != 1 || transcript.Segments[1].ID != 2 { t.Fatalf("segment IDs = %d,%d, want 1,2", transcript.Segments[0].ID, transcript.Segments[1].ID) } if transcript.Segments[0].Text != "first" || transcript.Segments[1].Text != "second" { t.Fatalf("unexpected sort order: %#v", transcript.Segments) } if len(transcript.Segments[0].Categories) != 1 || transcript.Segments[0].Categories[0] != "backchannel" { t.Fatalf("expected categories preserved on first segment, got %#v", transcript.Segments[0].Categories) } } func TestNormalizeBareArrayInputToIntermediateOutput(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `[ {"start": 2, "end": 3, "speaker": "Bob", "text": "second"}, {"start": 1, "end": 2, "speaker": "Alice", "text": "first"} ]`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-schema", config.OutputSchemaIntermediate, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.IntermediateTranscript readJSON(t, output, &transcript) if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } if transcript.Segments[0].Speaker != "Alice" || transcript.Segments[1].Speaker != "Bob" { t.Fatalf("unexpected sorted speakers: %#v", transcript.Segments) } } func TestNormalizeInputIndexTieBreakerIsDeterministic(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `[ {"start": 1, "end": 2, "speaker": "Zulu", "text": "first in"}, {"start": 1, "end": 2, "speaker": "Alpha", "text": "second in"} ]`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.IntermediateTranscript readJSON(t, output, &transcript) if transcript.Segments[0].Speaker != "Zulu" || transcript.Segments[1].Speaker != "Alpha" { t.Fatalf("tie-break order mismatch: %#v", transcript.Segments) } } func TestNormalizeMinimalSchemaOmitsCategories(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{ "segments": [ {"start": 1, "end": 2, "speaker": "Alice", "text": "first", "categories": ["filler"]} ] }`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-schema", config.OutputSchemaMinimal, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.MinimalTranscript readJSON(t, output, &transcript) if transcript.Metadata.OutputSchema != config.OutputSchemaMinimal { t.Fatalf("output schema = %q, want %q", transcript.Metadata.OutputSchema, config.OutputSchemaMinimal) } if len(transcript.Segments) != 1 || transcript.Segments[0].ID != 1 { t.Fatalf("unexpected minimal output: %#v", transcript.Segments) } bytes, readErr := os.ReadFile(output) if readErr != nil { t.Fatalf("read output: %v", readErr) } if strings.Contains(string(bytes), "categories") { t.Fatalf("minimal output unexpectedly contains categories:\n%s", string(bytes)) } } func TestNormalizeFullSchemaOutputValidatesAndHasProvenanceFallback(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `[ {"start": 1, "end": 2, "speaker": "Alice", "text": "first"}, {"start": 3, "end": 4, "speaker": "Bob", "text": "second", "source":"custom.json", "source_segment_index": 7} ]`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-schema", config.OutputSchemaFull, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if err := schema.ValidateTranscript(transcript); err != nil { t.Fatalf("full output should validate: %v", err) } if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } if transcript.Segments[0].Source != filepath.Base(input) { t.Fatalf("source fallback = %q, want %q", transcript.Segments[0].Source, filepath.Base(input)) } if transcript.Segments[0].SourceSegmentIndex == nil || *transcript.Segments[0].SourceSegmentIndex != 0 { t.Fatalf("source_segment_index fallback = %v, want 0", transcript.Segments[0].SourceSegmentIndex) } if transcript.Segments[1].Source != "custom.json" { t.Fatalf("explicit source preserved = %q, want custom.json", transcript.Segments[1].Source) } if transcript.Segments[1].SourceSegmentIndex == nil || *transcript.Segments[1].SourceSegmentIndex != 7 { t.Fatalf("explicit source_segment_index preserved = %v, want 7", transcript.Segments[1].SourceSegmentIndex) } if transcript.OverlapGroups == nil || len(transcript.OverlapGroups) != 0 { t.Fatalf("overlap_groups = %#v, want empty array", transcript.OverlapGroups) } } func TestNormalizeEmptySegmentsArrayProducesValidOutput(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.IntermediateTranscript readJSON(t, output, &transcript) if len(transcript.Segments) != 0 { t.Fatalf("segment count = %d, want 0", len(transcript.Segments)) } if err := schema.ValidateIntermediateTranscript(transcript); err != nil { t.Fatalf("intermediate output should validate: %v", err) } } func TestNormalizeSelectedOutputSchemaIsHonored(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[{"start":1,"end":2,"speaker":"A","text":"one"}]}`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--output-schema", config.OutputSchemaMinimal, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var transcript schema.MinimalTranscript readJSON(t, output, &transcript) if transcript.Metadata.OutputSchema != config.OutputSchemaMinimal { t.Fatalf("output schema = %q, want %q", transcript.Metadata.OutputSchema, config.OutputSchemaMinimal) } } func TestNormalizeReportFileWrittenAndContainsObjectInputShape(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[{"start":1,"end":2,"speaker":"A","text":"one"}]}`) output := filepath.Join(dir, "normalized.json") reportPath := filepath.Join(dir, "report.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--report-file", reportPath, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) audit := extractNormalizeAudit(t, rpt) if audit.InputShape != "object_with_segments" { t.Fatalf("input shape = %q, want object_with_segments", audit.InputShape) } if audit.InputSegmentCount != 1 { t.Fatalf("input segment count = %d, want 1", audit.InputSegmentCount) } if audit.OutputSchema != config.OutputSchemaIntermediate { t.Fatalf("output schema = %q, want %q", audit.OutputSchema, config.OutputSchemaIntermediate) } if len(audit.OutputModules) != 1 || audit.OutputModules[0] != "json" { t.Fatalf("output modules = %v, want [json]", audit.OutputModules) } } func TestNormalizeReportIncludesBareArrayShape(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `[{"start":1,"end":2,"speaker":"A","text":"one"}]`) output := filepath.Join(dir, "normalized.json") reportPath := filepath.Join(dir, "report.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--report-file", reportPath, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) audit := extractNormalizeAudit(t, rpt) if audit.InputShape != "bare_segments_array" { t.Fatalf("input shape = %q, want bare_segments_array", audit.InputShape) } } func TestNormalizeReportDoesNotIncludeTranscriptText(t *testing.T) { dir := t.TempDir() const segmentText = "normalize-report-secret-text" input := writeJSONFile(t, dir, "input.json", `[{"start":1,"end":2,"speaker":"A","text":"`+segmentText+`"}]`) output := filepath.Join(dir, "normalized.json") reportPath := filepath.Join(dir, "report.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--report-file", reportPath, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) for _, event := range rpt.Events { if strings.Contains(event.Message, segmentText) { t.Fatalf("report unexpectedly contained transcript text in event %#v", event) } } } func TestNormalizeReportEmptyInputEmitsWarning(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[]}`) output := filepath.Join(dir, "normalized.json") reportPath := filepath.Join(dir, "report.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--report-file", reportPath, ) if err != nil { t.Fatalf("normalize failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) found := false for _, event := range rpt.Events { if event.Stage == "normalize" && event.Module == "normalize" && event.Severity == report.SeverityWarning && strings.Contains(event.Message, "zero segments") { found = true break } } if !found { t.Fatalf("expected empty transcript warning event, got %#v", rpt.Events) } } func TestNormalizeReportWriteFailureReturnsClearError(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "input.json", `{"segments":[{"start":1,"end":2,"speaker":"A","text":"one"}]}`) output := filepath.Join(dir, "normalized.json") err := executeNormalize( "--input-file", input, "--output-file", output, "--report-file", dir, ) if err == nil { t.Fatal("expected report write failure") } if !strings.Contains(err.Error(), "write --report-file") { t.Fatalf("unexpected error: %v", err) } } func executeNormalize(args ...string) error { cmd := NewRootCommand() cmd.SetArgs(append([]string{"normalize"}, args...)) return cmd.Execute() } type normalizeAudit struct { Command string `json:"command"` InputFile string `json:"input_file"` OutputFile string `json:"output_file"` InputShape string `json:"input_shape"` InputSegmentCount int `json:"input_segment_count"` OutputSchema string `json:"output_schema"` OutputModules []string `json:"output_modules"` IDsReassigned bool `json:"ids_reassigned"` SortingChangedInput bool `json:"sorting_changed_input_order"` SegmentsWithCategories int `json:"segments_with_categories"` } func extractNormalizeAudit(t *testing.T, rpt report.Report) normalizeAudit { t.Helper() for _, event := range rpt.Events { if event.Stage == "normalize" && event.Module == "normalize-audit" { var audit normalizeAudit if err := json.Unmarshal([]byte(event.Message), &audit); err != nil { t.Fatalf("decode normalize audit: %v", err) } return audit } } t.Fatalf("missing normalize-audit event: %#v", rpt.Events) return normalizeAudit{} }