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 TestTrimKeepModeEndToEnd(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "2,4", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } if transcript.Segments[0].Text != "two" || transcript.Segments[1].Text != "four" { t.Fatalf("unexpected kept text order: %#v", transcript.Segments) } assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) } func TestTrimRemoveModeEndToEnd(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--remove", "2,4", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } if transcript.Segments[0].Text != "one" || transcript.Segments[1].Text != "three" { t.Fatalf("unexpected remaining text order: %#v", transcript.Segments) } assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) } func TestTrimMutualExclusionFailure(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", "--remove", "2", ) if err == nil { t.Fatal("expected mutual exclusion error") } if !strings.Contains(err.Error(), "mutually exclusive") { t.Fatalf("unexpected error: %v", err) } } func TestTrimMissingSelectionFailure(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, ) if err == nil { t.Fatal("expected selection flag error") } if !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") { t.Fatalf("unexpected error: %v", err) } } func TestTrimInvalidSelectedIDFailure(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "99", ) if err == nil { t.Fatal("expected missing selected ID error") } if !strings.Contains(err.Error(), "does not exist") { t.Fatalf("unexpected error: %v", err) } } func TestTrimOmittedOutputSchemaPreservesInputSchema(t *testing.T) { dir := t.TempDir() input := writeTrimMinimalFixture(t, dir, "input-minimal.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err != nil { t.Fatalf("trim 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 trim output: %#v", transcript.Segments) } } func TestTrimExplicitOutputSchemaChangesOutputSchema(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1,3", "--output-schema", config.OutputSchemaMinimal, ) if err != nil { t.Fatalf("trim 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) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) } func TestTrimExplicitOutputSchemaConvertsMinimalToIntermediate(t *testing.T) { dir := t.TempDir() input := writeTrimMinimalFixture(t, dir, "input-minimal.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1-2", "--output-schema", config.OutputSchemaIntermediate, ) if err != nil { t.Fatalf("trim 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)) } assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) } func TestTrimIntermediateInputPreservesIntermediateOutputAndCategories(t *testing.T) { dir := t.TempDir() input := writeTrimIntermediateFixture(t, dir, "input-intermediate.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "2", ) if err != nil { t.Fatalf("trim 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) != 1 { t.Fatalf("segment count = %d, want 1", len(transcript.Segments)) } if transcript.Segments[0].ID != 1 { t.Fatalf("segment ID = %d, want 1", transcript.Segments[0].ID) } assertIntSliceEqual(t, []int{len(transcript.Segments[0].Categories)}, []int{2}) if transcript.Segments[0].Categories[0] != "filler" || transcript.Segments[0].Categories[1] != "backchannel" { t.Fatalf("categories = %v, want [filler backchannel]", transcript.Segments[0].Categories) } } func TestTrimFullInputPreservesFullShapeAndRecomputesOverlapGroups(t *testing.T) { dir := t.TempDir() input := writeTrimFullOverlapFixture(t, dir, "input-full-overlap.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1,2", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 2 { t.Fatalf("segment count = %d, want 2", len(transcript.Segments)) } assertSequentialIDs(t, []int{transcript.Segments[0].ID, transcript.Segments[1].ID}) if len(transcript.OverlapGroups) != 1 { t.Fatalf("overlap group count = %d, want 1", len(transcript.OverlapGroups)) } if transcript.OverlapGroups[0].ID != 1 { t.Fatalf("overlap group id = %d, want 1", transcript.OverlapGroups[0].ID) } if transcript.Segments[0].OverlapGroupID != 1 || transcript.Segments[1].OverlapGroupID != 1 { t.Fatalf("segment overlap IDs = %d,%d, want 1,1", transcript.Segments[0].OverlapGroupID, transcript.Segments[1].OverlapGroupID) } } func TestTrimMalformedSelectorFailsWithClearError(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1-", ) if err == nil { t.Fatal("expected malformed selector error") } if !strings.Contains(err.Error(), "invalid selector") || !strings.Contains(err.Error(), "malformed element") { t.Fatalf("unexpected error: %v", err) } } func TestTrimMalformedInputArtifactFailsClearly(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "broken.json", `{"metadata":`) output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err == nil { t.Fatal("expected malformed artifact error") } if !strings.Contains(err.Error(), "input JSON is malformed") { t.Fatalf("unexpected error: %v", err) } } func TestTrimDuplicateInputSegmentIDsFail(t *testing.T) { dir := t.TempDir() input := writeTrimMinimalWithIDsFixture(t, dir, "input-dup.json", []int{1, 1}) output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err == nil { t.Fatal("expected duplicate segment ID failure") } if !strings.Contains(err.Error(), "not a valid seriatim output artifact") { t.Fatalf("unexpected error: %v", err) } } func TestTrimNonSequentialInputSegmentIDsFail(t *testing.T) { dir := t.TempDir() input := writeTrimMinimalWithIDsFixture(t, dir, "input-nonseq.json", []int{1, 3}) output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err == nil { t.Fatal("expected non-sequential segment ID failure") } if !strings.Contains(err.Error(), "not a valid seriatim output artifact") { t.Fatalf("unexpected error: %v", err) } } func TestTrimKeepSelectorWithOverlappingRanges(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1-3,2-4", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 4 { t.Fatalf("segment count = %d, want 4", len(transcript.Segments)) } assertSequentialIDs(t, []int{ transcript.Segments[0].ID, transcript.Segments[1].ID, transcript.Segments[2].ID, transcript.Segments[3].ID, }) } func TestTrimRemoveSelectorWithOverlappingRanges(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--remove", "2-3,3-4", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 1 { t.Fatalf("segment count = %d, want 1", len(transcript.Segments)) } if transcript.Segments[0].Text != "one" { t.Fatalf("remaining segment = %#v, want one", transcript.Segments[0]) } } func TestTrimSelectorOrderDoesNotAffectTranscriptOrder(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "4,1,3", ) if err != nil { t.Fatalf("trim failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 3 { t.Fatalf("segment count = %d, want 3", len(transcript.Segments)) } got := []string{ transcript.Segments[0].Text, transcript.Segments[1].Text, transcript.Segments[2].Text, } want := []string{"one", "three", "four"} if got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { t.Fatalf("segment text order = %v, want %v", got, want) } } func TestTrimAllowEmptyBehavior(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--remove", "1-4", ) if err == nil { t.Fatal("expected empty-output error") } if !strings.Contains(err.Error(), "empty transcript") { t.Fatalf("unexpected error: %v", err) } err = executeTrim( "--input-file", input, "--output-file", output, "--remove", "1-4", "--allow-empty", ) if err != nil { t.Fatalf("trim with --allow-empty failed: %v", err) } var transcript schema.Transcript readJSON(t, output, &transcript) if len(transcript.Segments) != 0 { t.Fatalf("segment count = %d, want 0", len(transcript.Segments)) } } func TestTrimRejectsNonSeriatimInputArtifacts(t *testing.T) { dir := t.TempDir() input := writeJSONFile(t, dir, "raw-whisperx.json", `{ "segments": [ {"start": 1, "end": 2, "text": "hello"} ] }`) output := filepath.Join(dir, "trimmed.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err == nil { t.Fatal("expected invalid artifact error") } if !strings.Contains(err.Error(), "not a valid seriatim output artifact") { t.Fatalf("unexpected error: %v", err) } } func TestTrimReportFileContainsAuditFields(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") reportPath := filepath.Join(dir, "trim-report.json") err := executeTrim( "--input-file", input, "--output-file", output, "--report-file", reportPath, "--remove", "4,2", ) if err != nil { t.Fatalf("trim failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) if len(rpt.Events) == 0 { t.Fatal("expected report events") } if !hasReportEvent(rpt, "trim", "trim", "trimmed 4 input segment(s) into 2 output segment(s) with mode=remove") { t.Fatal("expected trim summary event") } if !hasReportEvent(rpt, "trim", "validate-output", "validated 2 output segment(s)") { t.Fatal("expected validation event") } audit := extractTrimAuditEvent(t, rpt) if audit.Operation != "trim" { t.Fatalf("operation = %q, want trim", audit.Operation) } if audit.InputFile != input { t.Fatalf("input_file = %q, want %q", audit.InputFile, input) } if audit.OutputFile != output { t.Fatalf("output_file = %q, want %q", audit.OutputFile, output) } if audit.InputSchema != config.OutputSchemaFull || audit.OutputSchema != config.OutputSchemaFull { t.Fatalf("schemas = %q -> %q, want full -> full", audit.InputSchema, audit.OutputSchema) } if audit.Mode != "remove" { t.Fatalf("mode = %q, want remove", audit.Mode) } if audit.Selector != "4,2" { t.Fatalf("selector = %q, want %q", audit.Selector, "4,2") } assertIntSliceEqual(t, audit.SelectedIDs, []int{2, 4}) if audit.AllowEmpty { t.Fatal("allow_empty should be false") } if audit.InputSegmentCount != 4 || audit.RetainedSegmentCount != 2 || audit.RemovedSegmentCount != 2 { t.Fatalf("counts = input:%d retained:%d removed:%d, want 4/2/2", audit.InputSegmentCount, audit.RetainedSegmentCount, audit.RemovedSegmentCount) } assertIntSliceEqual(t, audit.RemovedInputIDs, []int{2, 4}) if len(audit.OldToNewIDMapping) != 2 { t.Fatalf("mapping length = %d, want 2", len(audit.OldToNewIDMapping)) } if audit.OldToNewIDMapping[0].OldID != 1 || audit.OldToNewIDMapping[0].NewID != 1 { t.Fatalf("mapping[0] = %#v, want old_id=1 new_id=1", audit.OldToNewIDMapping[0]) } if audit.OldToNewIDMapping[1].OldID != 3 || audit.OldToNewIDMapping[1].NewID != 2 { t.Fatalf("mapping[1] = %#v, want old_id=3 new_id=2", audit.OldToNewIDMapping[1]) } if !audit.OverlapGroupsRecomputed { t.Fatal("expected overlap_groups_recomputed=true for full schema trim") } } func TestTrimReportOldToNewMappingIsDeterministicSorted(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") reportPath := filepath.Join(dir, "trim-report.json") err := executeTrim( "--input-file", input, "--output-file", output, "--report-file", reportPath, "--keep", "4,1,3", ) if err != nil { t.Fatalf("trim failed: %v", err) } var rpt report.Report readJSON(t, reportPath, &rpt) audit := extractTrimAuditEvent(t, rpt) if len(audit.OldToNewIDMapping) != 3 { t.Fatalf("mapping length = %d, want 3", len(audit.OldToNewIDMapping)) } for index, expectedOld := range []int{1, 3, 4} { if audit.OldToNewIDMapping[index].OldID != expectedOld { t.Fatalf("mapping[%d].old_id = %d, want %d", index, audit.OldToNewIDMapping[index].OldID, expectedOld) } } } func TestTrimNoReportFileWhenOmitted(t *testing.T) { dir := t.TempDir() input := writeTrimFullFixture(t, dir, "input.json") output := filepath.Join(dir, "trimmed.json") reportPath := filepath.Join(dir, "trim-report.json") err := executeTrim( "--input-file", input, "--output-file", output, "--keep", "1", ) if err != nil { t.Fatalf("trim failed: %v", err) } _, statErr := os.Stat(reportPath) if !os.IsNotExist(statErr) { t.Fatalf("expected no report file at %q, got err=%v", reportPath, statErr) } } func executeTrim(args ...string) error { cmd := NewRootCommand() cmd.SetArgs(append([]string{"trim"}, args...)) return cmd.Execute() } func writeTrimFullFixture(t *testing.T, dir string, name string) string { t.Helper() first := 10 second := 20 third := 30 fourth := 40 value := schema.Transcript{ Metadata: schema.Metadata{ Application: "seriatim", Version: "v-test", InputReader: "json-files", InputFiles: []string{"a.json"}, PreprocessingModules: []string{"validate-raw"}, PostprocessingModules: []string{"assign-ids"}, OutputModules: []string{"json"}, }, Segments: []schema.Segment{ {ID: 1, Source: "a.json", SourceSegmentIndex: &first, SourceRef: "a.json#10", Speaker: "A", Start: 1, End: 2, Text: "one", OverlapGroupID: 9}, {ID: 2, Source: "a.json", SourceSegmentIndex: &second, SourceRef: "a.json#20", Speaker: "B", Start: 2, End: 3, Text: "two", OverlapGroupID: 9}, {ID: 3, Source: "a.json", SourceSegmentIndex: &third, SourceRef: "a.json#30", Speaker: "C", Start: 4, End: 5, Text: "three", OverlapGroupID: 10}, {ID: 4, Source: "a.json", SourceSegmentIndex: &fourth, SourceRef: "a.json#40", Speaker: "D", Start: 5, End: 6, Text: "four", OverlapGroupID: 10}, }, OverlapGroups: []schema.OverlapGroup{ {ID: 9, Start: 1, End: 3, Segments: []string{"a.json#10", "a.json#20"}, Speakers: []string{"A", "B"}, Class: "unknown", Resolution: "unresolved"}, }, } return writeTrimArtifactFile(t, dir, name, value) } func writeTrimMinimalFixture(t *testing.T, dir string, name string) string { t.Helper() value := schema.MinimalTranscript{ Metadata: schema.MinimalMetadata{ Application: "seriatim", Version: "v-test", OutputSchema: config.OutputSchemaMinimal, }, Segments: []schema.MinimalSegment{ {ID: 1, Start: 1, End: 2, Speaker: "A", Text: "one"}, {ID: 2, Start: 2, End: 3, Speaker: "B", Text: "two"}, }, } return writeTrimArtifactFile(t, dir, name, value) } func writeTrimIntermediateFixture(t *testing.T, dir string, name string) string { t.Helper() value := schema.IntermediateTranscript{ Metadata: schema.IntermediateMetadata{ Application: "seriatim", Version: "v-test", OutputSchema: config.OutputSchemaIntermediate, }, Segments: []schema.IntermediateSegment{ {ID: 1, Start: 1, End: 2, Speaker: "A", Text: "one", Categories: []string{"word-run"}}, {ID: 2, Start: 2, End: 3, Speaker: "B", Text: "two", Categories: []string{"filler", "backchannel"}}, }, } return writeTrimArtifactFile(t, dir, name, value) } func writeTrimMinimalWithIDsFixture(t *testing.T, dir string, name string, ids []int) string { t.Helper() if len(ids) < 2 { t.Fatalf("need at least two IDs, got %d", len(ids)) } value := schema.MinimalTranscript{ Metadata: schema.MinimalMetadata{ Application: "seriatim", Version: "v-test", OutputSchema: config.OutputSchemaMinimal, }, Segments: []schema.MinimalSegment{ {ID: ids[0], Start: 1, End: 2, Speaker: "A", Text: "one"}, {ID: ids[1], Start: 2, End: 3, Speaker: "B", Text: "two"}, }, } return writeTrimArtifactFile(t, dir, name, value) } func writeTrimFullOverlapFixture(t *testing.T, dir string, name string) string { t.Helper() first := 10 second := 20 third := 30 value := schema.Transcript{ Metadata: schema.Metadata{ Application: "seriatim", Version: "v-test", InputReader: "json-files", InputFiles: []string{"a.json"}, PreprocessingModules: []string{"validate-raw"}, PostprocessingModules: []string{"detect-overlaps", "assign-ids"}, OutputModules: []string{"json"}, }, Segments: []schema.Segment{ {ID: 1, Source: "a.json", SourceSegmentIndex: &first, SourceRef: "a.json#10", Speaker: "A", Start: 1, End: 3, Text: "one", OverlapGroupID: 5}, {ID: 2, Source: "a.json", SourceSegmentIndex: &second, SourceRef: "a.json#20", Speaker: "B", Start: 2, End: 4, Text: "two", OverlapGroupID: 5}, {ID: 3, Source: "a.json", SourceSegmentIndex: &third, SourceRef: "a.json#30", Speaker: "C", Start: 6, End: 7, Text: "three", OverlapGroupID: 6}, }, OverlapGroups: []schema.OverlapGroup{ {ID: 99, Start: 0, End: 100, Segments: []string{"stale"}, Speakers: []string{"stale"}, Class: "unknown", Resolution: "unresolved"}, }, } return writeTrimArtifactFile(t, dir, name, value) } func writeTrimArtifactFile(t *testing.T, dir string, name string, value any) string { t.Helper() data, err := json.MarshalIndent(value, "", " ") if err != nil { t.Fatalf("marshal fixture: %v", err) } path := filepath.Join(dir, name) if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { t.Fatalf("write fixture: %v", err) } return path } func assertSequentialIDs(t *testing.T, ids []int) { t.Helper() for index, id := range ids { want := index + 1 if id != want { t.Fatalf("id at index %d = %d, want %d", index, id, want) } } } func extractTrimAuditEvent(t *testing.T, rpt report.Report) trimAuditReport { t.Helper() for _, event := range rpt.Events { if event.Stage == "trim" && event.Module == "trim-audit" { var audit trimAuditReport if err := json.Unmarshal([]byte(event.Message), &audit); err != nil { t.Fatalf("decode trim audit event: %v", err) } return audit } } t.Fatal("missing trim-audit event") return trimAuditReport{} } func assertIntSliceEqual(t *testing.T, got []int, want []int) { t.Helper() if len(got) != len(want) { t.Fatalf("slice length = %d, want %d", len(got), len(want)) } for index := range got { if got[index] != want[index] { t.Fatalf("slice[%d] = %d, want %d (full got=%v, want=%v)", index, got[index], want[index], got, want) } } }