diff --git a/go.mod b/go.mod index e17d515..f195c17 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module gitea.maximumdirect.net/eric/audita go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/schema/errors.go b/internal/core/schema/errors.go new file mode 100644 index 0000000..a08b747 --- /dev/null +++ b/internal/core/schema/errors.go @@ -0,0 +1,23 @@ +package schema + +import "fmt" + +type ParseError struct { + Message string +} + +func (e *ParseError) Error() string { + return e.Message +} + +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + if e.Field != "" { + return fmt.Sprintf("%s: %s", e.Field, e.Message) + } + return e.Message +} diff --git a/internal/core/schema/glossary.go b/internal/core/schema/glossary.go new file mode 100644 index 0000000..95c1253 --- /dev/null +++ b/internal/core/schema/glossary.go @@ -0,0 +1,74 @@ +package schema + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +type GlossaryEntry struct { + Name string `yaml:"name"` + Aliases []string `yaml:"aliases,omitempty"` + Plural string `yaml:"plural,omitempty"` + Category string `yaml:"category"` + Summary string `yaml:"summary"` +} + +type Glossary struct { + Entries []GlossaryEntry `yaml:"glossary"` +} + +func ParseGlossaryYAML(raw []byte) (*Glossary, error) { + var top map[string]any + if err := yaml.Unmarshal(raw, &top); err != nil { + return nil, &ParseError{Message: fmt.Sprintf("glossary is not valid YAML: %v", err)} + } + + var g Glossary + if err := yaml.Unmarshal(raw, &g); err != nil { + return nil, &ParseError{Message: fmt.Sprintf("failed to parse glossary: %v", err)} + } + + if err := validateGlossary(&g); err != nil { + return nil, err + } + + return &g, nil +} + +func validateGlossary(g *Glossary) error { + if len(g.Entries) == 0 { + return &ValidationError{Field: "glossary", Message: "must contain at least one entry"} + } + + for i, e := range g.Entries { + entryLabel := fmt.Sprintf("glossary[%d]", i) + + if e.Name == "" { + return &ValidationError{Field: fmt.Sprintf("%s.name", entryLabel), Message: "must not be empty"} + } + + if e.Category == "" { + return &ValidationError{Field: fmt.Sprintf("%s.category", entryLabel), Message: "must not be empty"} + } + + if e.Summary == "" { + return &ValidationError{Field: fmt.Sprintf("%s.summary", entryLabel), Message: "must not be empty"} + } + + for j, alias := range e.Aliases { + if alias == "" { + return &ValidationError{ + Field: fmt.Sprintf("%s.aliases[%d]", entryLabel, j), + Message: "must not be empty", + } + } + } + + if e.Plural != "" && len(e.Plural) == 0 { + return &ValidationError{Field: fmt.Sprintf("%s.plural", entryLabel), Message: "must not be empty if present"} + } + } + + return nil +} diff --git a/internal/core/schema/glossary_test.go b/internal/core/schema/glossary_test.go new file mode 100644 index 0000000..b94ff3a --- /dev/null +++ b/internal/core/schema/glossary_test.go @@ -0,0 +1,142 @@ +package schema + +import ( + "os" + "testing" +) + +func TestParseGlossaryYAML_Valid(t *testing.T) { + raw, err := os.ReadFile("testdata/glossary_valid.yaml") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + glossary, err := ParseGlossaryYAML(raw) + if err != nil { + t.Fatalf("ParseGlossaryYAML failed: %v", err) + } + + if len(glossary.Entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(glossary.Entries)) + } + + e1 := glossary.Entries[0] + if e1.Name != "Jesters" { + t.Errorf("expected entries[0].name = Jesters, got %q", e1.Name) + } + if e1.Category != "faction" { + t.Errorf("expected entries[0].category = faction, got %q", e1.Category) + } + if e1.Summary != "A faction name." { + t.Errorf("expected entries[0].summary = A faction name., got %q", e1.Summary) + } + if len(e1.Aliases) != 0 { + t.Errorf("expected entries[0].aliases = [], got %v", e1.Aliases) + } + + e2 := glossary.Entries[1] + if e2.Name != "Popov" { + t.Errorf("expected entries[1].name = Popov, got %q", e2.Name) + } + if len(e2.Aliases) != 1 || e2.Aliases[0] != "Hrank" { + t.Errorf("expected entries[1].aliases = [Hrank], got %v", e2.Aliases) + } +} + +func TestParseGlossaryYAML_MalformedYAML(t *testing.T) { + raw, err := os.ReadFile("testdata/glossary_malformed.yaml") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseGlossaryYAML(raw) + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } + + var parseErr *ParseError + if parseErr, ok := err.(*ParseError); !ok { + t.Errorf("expected *ParseError, got %T", err) + } else if parseErr.Message == "" { + t.Error("expected non-empty parse error message") + } +} + +func TestParseGlossaryYAML_MissingRequiredFields(t *testing.T) { + raw, err := os.ReadFile("testdata/glossary_missing_fields.yaml") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseGlossaryYAML(raw) + if err == nil { + t.Fatal("expected error for missing required fields, got nil") + } + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + } else if valErr.Field == "" { + t.Error("expected non-empty validation error field") + } +} + +func TestParseGlossaryYAML_EmptyGlossary(t *testing.T) { + raw := []byte(`glossary: []`) + + _, err := ParseGlossaryYAML(raw) + if err == nil { + t.Fatal("expected error for empty glossary, got nil") + } + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + } else if valErr.Message != "must contain at least one entry" { + t.Errorf("expected 'must contain at least one entry', got %q", valErr.Message) + } +} + +func TestParseGlossaryYAML_EmptyName(t *testing.T) { + raw := []byte(` +glossary: + - name: "" + category: faction + summary: A faction. +`) + + _, err := ParseGlossaryYAML(raw) + if err == nil { + t.Fatal("expected error for empty name, got nil") + } + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + } else if valErr.Message != "must not be empty" { + t.Errorf("expected 'must not be empty', got %q", valErr.Message) + } +} + +func TestParseGlossaryYAML_EmptyAlias(t *testing.T) { + raw := []byte(` +glossary: + - name: Test + aliases: + - "" + category: faction + summary: A test entry. +`) + + _, err := ParseGlossaryYAML(raw) + if err == nil { + t.Fatal("expected error for empty alias, got nil") + } + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + } else if valErr.Message != "must not be empty" { + t.Errorf("expected 'must not be empty', got %q", valErr.Message) + } +} diff --git a/internal/core/schema/testdata/glossary_malformed.yaml b/internal/core/schema/testdata/glossary_malformed.yaml new file mode 100644 index 0000000..3a7e665 --- /dev/null +++ b/internal/core/schema/testdata/glossary_malformed.yaml @@ -0,0 +1 @@ +glossary: [ diff --git a/internal/core/schema/testdata/glossary_missing_fields.yaml b/internal/core/schema/testdata/glossary_missing_fields.yaml new file mode 100644 index 0000000..9762367 --- /dev/null +++ b/internal/core/schema/testdata/glossary_missing_fields.yaml @@ -0,0 +1,2 @@ +glossary: + - name: Jesters diff --git a/internal/core/schema/testdata/glossary_valid.yaml b/internal/core/schema/testdata/glossary_valid.yaml new file mode 100644 index 0000000..7685451 --- /dev/null +++ b/internal/core/schema/testdata/glossary_valid.yaml @@ -0,0 +1,9 @@ +glossary: + - name: Jesters + category: faction + summary: A faction name. + - name: Popov + aliases: + - Hrank + category: character + summary: A character name. diff --git a/internal/core/schema/testdata/transcript_bare_array.json b/internal/core/schema/testdata/transcript_bare_array.json new file mode 100644 index 0000000..68d1384 --- /dev/null +++ b/internal/core/schema/testdata/transcript_bare_array.json @@ -0,0 +1,17 @@ +[ + { + "id": 1, + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + }, + { + "id": 2, + "speaker": "Bob", + "start": 2.0, + "end": 3.5, + "text": "Hi there.", + "categories": ["greeting"] + } +] diff --git a/internal/core/schema/testdata/transcript_duplicate_ids.json b/internal/core/schema/testdata/transcript_duplicate_ids.json new file mode 100644 index 0000000..0fa7565 --- /dev/null +++ b/internal/core/schema/testdata/transcript_duplicate_ids.json @@ -0,0 +1,16 @@ +[ + { + "id": 1, + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + }, + { + "id": 1, + "speaker": "Bob", + "start": 2.0, + "end": 3.5, + "text": "Hi there." + } +] diff --git a/internal/core/schema/testdata/transcript_empty_array.json b/internal/core/schema/testdata/transcript_empty_array.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/internal/core/schema/testdata/transcript_empty_array.json @@ -0,0 +1 @@ +[] diff --git a/internal/core/schema/testdata/transcript_empty_speaker.json b/internal/core/schema/testdata/transcript_empty_speaker.json new file mode 100644 index 0000000..c46dda5 --- /dev/null +++ b/internal/core/schema/testdata/transcript_empty_speaker.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "speaker": "", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + } +] diff --git a/internal/core/schema/testdata/transcript_empty_text.json b/internal/core/schema/testdata/transcript_empty_text.json new file mode 100644 index 0000000..881ecbd --- /dev/null +++ b/internal/core/schema/testdata/transcript_empty_text.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "" + } +] diff --git a/internal/core/schema/testdata/transcript_invalid_times.json b/internal/core/schema/testdata/transcript_invalid_times.json new file mode 100644 index 0000000..be73316 --- /dev/null +++ b/internal/core/schema/testdata/transcript_invalid_times.json @@ -0,0 +1,9 @@ +[ + { + "id": 1, + "speaker": "Alice", + "start": 5.0, + "end": 1.5, + "text": "Hello world." + } +] diff --git a/internal/core/schema/testdata/transcript_malformed.json b/internal/core/schema/testdata/transcript_malformed.json new file mode 100644 index 0000000..7ca0772 --- /dev/null +++ b/internal/core/schema/testdata/transcript_malformed.json @@ -0,0 +1 @@ +{"segments":[{"id":1,"text":"oops"} diff --git a/internal/core/schema/testdata/transcript_no_ids.json b/internal/core/schema/testdata/transcript_no_ids.json new file mode 100644 index 0000000..e82f931 --- /dev/null +++ b/internal/core/schema/testdata/transcript_no_ids.json @@ -0,0 +1,14 @@ +[ + { + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + }, + { + "speaker": "Bob", + "start": 2.0, + "end": 3.5, + "text": "Hi there." + } +] diff --git a/internal/core/schema/testdata/transcript_non_sequential_id.json b/internal/core/schema/testdata/transcript_non_sequential_id.json new file mode 100644 index 0000000..19d1c2c --- /dev/null +++ b/internal/core/schema/testdata/transcript_non_sequential_id.json @@ -0,0 +1,9 @@ +[ + { + "id": 2, + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + } +] diff --git a/internal/core/schema/testdata/transcript_object.json b/internal/core/schema/testdata/transcript_object.json new file mode 100644 index 0000000..6e826f2 --- /dev/null +++ b/internal/core/schema/testdata/transcript_object.json @@ -0,0 +1,19 @@ +{ + "segments": [ + { + "id": 1, + "speaker": "Alice", + "start": 0.0, + "end": 1.5, + "text": "Hello world." + }, + { + "id": 2, + "speaker": "Bob", + "start": 2.0, + "end": 3.5, + "text": "Hi there.", + "categories": ["greeting"] + } + ] +} diff --git a/internal/core/schema/transcript.go b/internal/core/schema/transcript.go new file mode 100644 index 0000000..7c59926 --- /dev/null +++ b/internal/core/schema/transcript.go @@ -0,0 +1,240 @@ +package schema + +import ( + "encoding/json" + "fmt" + "math" +) + +type SourceSegment struct { + ID *int `json:"id,omitempty"` + Speaker string `json:"speaker"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Categories []string `json:"categories,omitempty"` +} + +type Segment struct { + ID int `json:"id"` + Speaker string `json:"speaker"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Categories []string `json:"categories,omitempty"` +} + +type Transcript struct { + Segments []Segment `json:"segments"` +} + +type SourceTranscript struct { + Segments []SourceSegment `json:"segments"` +} + +func ParseSourceTranscriptJSON(raw []byte) (*SourceTranscript, error) { + if !json.Valid(raw) { + return nil, &ParseError{Message: "transcript is not valid JSON"} + } + + var top any + if err := json.Unmarshal(raw, &top); err != nil { + return nil, &ParseError{Message: fmt.Sprintf("failed to parse transcript JSON: %v", err)} + } + + segmentsRaw, err := extractSegments(top) + if err != nil { + return nil, err + } + + if len(segmentsRaw) == 0 { + return nil, &ParseError{Message: "transcript must contain at least one segment"} + } + + var segments []SourceSegment + if err := json.Unmarshal(segmentsRaw, &segments); err != nil { + return nil, &ParseError{Message: fmt.Sprintf("failed to parse segments: %v", err)} + } + + if err := validateSourceSegments(segments); err != nil { + return nil, err + } + + return &SourceTranscript{Segments: segments}, nil +} + +func ParseTranscriptJSON(raw []byte) (*Transcript, error) { + source, err := ParseSourceTranscriptJSON(raw) + if err != nil { + return nil, err + } + + segments := make([]Segment, len(source.Segments)) + for i, s := range source.Segments { + segments[i] = Segment{ + ID: *s.ID, + Speaker: s.Speaker, + Start: s.Start, + End: s.End, + Text: s.Text, + Categories: s.Categories, + } + } + + if err := validateSequentialIDs(segments); err != nil { + return nil, err + } + + return &Transcript{Segments: segments}, nil +} + +func ParseTranscriptJSONLenient(raw []byte) (*Transcript, error) { + source, err := ParseSourceTranscriptJSON(raw) + if err != nil { + return nil, err + } + + segments := make([]Segment, len(source.Segments)) + for i, s := range source.Segments { + id := i + 1 + if s.ID != nil { + id = *s.ID + } + segments[i] = Segment{ + ID: id, + Speaker: s.Speaker, + Start: s.Start, + End: s.End, + Text: s.Text, + Categories: s.Categories, + } + } + + return &Transcript{Segments: segments}, nil +} + +func extractSegments(top any) (json.RawMessage, error) { + switch v := top.(type) { + case []any: + raw, err := json.Marshal(v) + if err != nil { + return nil, &ParseError{Message: "failed to re-encode segment array"} + } + return raw, nil + case map[string]any: + segs, ok := v["segments"] + if !ok { + return nil, &ParseError{Message: "transcript object must contain a segments array"} + } + segsArray, ok := segs.([]any) + if !ok { + return nil, &ParseError{Message: "segments field must be an array"} + } + raw, err := json.Marshal(segsArray) + if err != nil { + return nil, &ParseError{Message: "failed to re-encode segments array"} + } + return raw, nil + default: + return nil, &ParseError{Message: "transcript must be a JSON array or an object with a segments array"} + } +} + +func validateSourceSegments(segments []SourceSegment) error { + for i, s := range segments { + segLabel := segmentLabel(i, s.ID) + + if s.Speaker == "" { + return &ValidationError{Field: fmt.Sprintf("%s.speaker", segLabel), Message: "must not be empty"} + } + + if s.Text == "" { + return &ValidationError{Field: fmt.Sprintf("%s.text", segLabel), Message: "must not be empty"} + } + + if err := validateTime(s.Start, fmt.Sprintf("%s.start", segLabel)); err != nil { + return err + } + if err := validateTime(s.End, fmt.Sprintf("%s.end", segLabel)); err != nil { + return err + } + + if s.End < s.Start { + return &ValidationError{ + Field: fmt.Sprintf("%s.end", segLabel), + Message: fmt.Sprintf("end (%g) must be greater than or equal to start (%g)", s.End, s.Start), + } + } + + for j, cat := range s.Categories { + if cat == "" { + return &ValidationError{ + Field: fmt.Sprintf("%s.categories[%d]", segLabel, j), + Message: "must not be empty", + } + } + } + } + + seenIDs := make(map[int]int) + for i, s := range segments { + if s.ID != nil { + if firstIdx, exists := seenIDs[*s.ID]; exists { + return &ValidationError{ + Field: fmt.Sprintf("segment[%d].id", i), + Message: fmt.Sprintf("duplicate id %d (first used at segment[%d])", *s.ID, firstIdx), + } + } + seenIDs[*s.ID] = i + } + } + + return nil +} + +func validateTime(t float64, field string) error { + if math.IsNaN(t) || math.IsInf(t, 0) { + return &ValidationError{Field: field, Message: "must be a finite number"} + } + if t < 0 { + return &ValidationError{Field: field, Message: "must be non-negative"} + } + return nil +} + +func validateSequentialIDs(segments []Segment) error { + for i, s := range segments { + expected := i + 1 + if s.ID != expected { + return &ValidationError{ + Field: fmt.Sprintf("segment[%d].id", i), + Message: fmt.Sprintf("must be sequential starting at 1 (got %d, expected %d)", s.ID, expected), + } + } + } + return nil +} + +func segmentLabel(index int, id *int) string { + if id != nil { + return fmt.Sprintf("segment[%d] (id=%d)", index, *id) + } + return fmt.Sprintf("segment[%d]", index) +} + +func TranscriptToJSON(t *Transcript) ([]byte, error) { + payload := make([]map[string]any, len(t.Segments)) + for i, s := range t.Segments { + payload[i] = map[string]any{ + "id": s.ID, + "speaker": s.Speaker, + "start": s.Start, + "end": s.End, + "text": s.Text, + } + if len(s.Categories) > 0 { + payload[i]["categories"] = s.Categories + } + } + return json.MarshalIndent(payload, "", " ") +} diff --git a/internal/core/schema/transcript_test.go b/internal/core/schema/transcript_test.go new file mode 100644 index 0000000..34745ea --- /dev/null +++ b/internal/core/schema/transcript_test.go @@ -0,0 +1,297 @@ +package schema + +import ( + "os" + "testing" +) + +func TestParseSourceTranscriptJSON_BareArray(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_bare_array.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + transcript, err := ParseSourceTranscriptJSON(raw) + if err != nil { + t.Fatalf("ParseSourceTranscriptJSON failed: %v", err) + } + + if len(transcript.Segments) != 2 { + t.Fatalf("expected 2 segments, got %d", len(transcript.Segments)) + } + + s1 := transcript.Segments[0] + if *s1.ID != 1 { + t.Errorf("expected segment[0].id = 1, got %d", *s1.ID) + } + if s1.Speaker != "Alice" { + t.Errorf("expected segment[0].speaker = Alice, got %q", s1.Speaker) + } + if s1.Start != 0.0 { + t.Errorf("expected segment[0].start = 0.0, got %g", s1.Start) + } + if s1.End != 1.5 { + t.Errorf("expected segment[0].end = 1.5, got %g", s1.End) + } + if s1.Text != "Hello world." { + t.Errorf("expected segment[0].text = Hello world., got %q", s1.Text) + } + if s1.Categories != nil { + t.Errorf("expected segment[0].categories = nil, got %v", s1.Categories) + } + + s2 := transcript.Segments[1] + if *s2.ID != 2 { + t.Errorf("expected segment[1].id = 2, got %d", *s2.ID) + } + if len(s2.Categories) != 1 || s2.Categories[0] != "greeting" { + t.Errorf("expected segment[1].categories = [greeting], got %v", s2.Categories) + } +} + +func TestParseSourceTranscriptJSON_ObjectWithSegments(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_object.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + transcript, err := ParseSourceTranscriptJSON(raw) + if err != nil { + t.Fatalf("ParseSourceTranscriptJSON failed: %v", err) + } + + if len(transcript.Segments) != 2 { + t.Fatalf("expected 2 segments, got %d", len(transcript.Segments)) + } + + if *transcript.Segments[0].ID != 1 { + t.Errorf("expected segment[0].id = 1, got %d", *transcript.Segments[0].ID) + } +} + +func TestParseSourceTranscriptJSON_MalformedJSON(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_malformed.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + + var parseErr *ParseError + if parseErr, ok := err.(*ParseError); !ok { + t.Errorf("expected *ParseError, got %T", err) + } else if parseErr.Message != "transcript is not valid JSON" { + t.Errorf("expected 'transcript is not valid JSON', got %q", parseErr.Message) + } +} + +func TestParseSourceTranscriptJSON_EmptySpeaker(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_empty_speaker.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for empty speaker, got nil") + } + + assertValidationError(t, err, "speaker", "must not be empty") +} + +func TestParseSourceTranscriptJSON_EmptyText(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_empty_text.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for empty text, got nil") + } + + assertValidationError(t, err, "text", "must not be empty") +} + +func TestParseSourceTranscriptJSON_InvalidTimes(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_invalid_times.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for invalid times, got nil") + } + + assertValidationErrorContains(t, err, "end", "must be greater than or equal to start") +} + +func TestParseSourceTranscriptJSON_DuplicateIDs(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_duplicate_ids.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for duplicate IDs, got nil") + } + + assertValidationErrorContains(t, err, "id", "duplicate id") +} + +func TestParseSourceTranscriptJSON_EmptyArray(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_empty_array.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for empty array, got nil") + } + + var parseErr *ParseError + if parseErr, ok := err.(*ParseError); !ok { + t.Errorf("expected *ParseError, got %T", err) + } else if parseErr.Message != "transcript must contain at least one segment" { + t.Errorf("expected 'transcript must contain at least one segment', got %q", parseErr.Message) + } +} + +func TestParseTranscriptJSON_NonSequentialID(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_non_sequential_id.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + _, err = ParseTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for non-sequential ID, got nil") + } + + assertValidationErrorContains(t, err, "id", "must be sequential") +} + +func TestParseTranscriptJSONLenient_AssignsSequentialIDs(t *testing.T) { + raw, err := os.ReadFile("testdata/transcript_no_ids.json") + if err != nil { + t.Fatalf("failed to read test fixture: %v", err) + } + + transcript, err := ParseTranscriptJSONLenient(raw) + if err != nil { + t.Fatalf("ParseTranscriptJSONLenient failed: %v", err) + } + + if len(transcript.Segments) != 2 { + t.Fatalf("expected 2 segments, got %d", len(transcript.Segments)) + } + + if transcript.Segments[0].ID != 1 { + t.Errorf("expected segment[0].id = 1, got %d", transcript.Segments[0].ID) + } + if transcript.Segments[1].ID != 2 { + t.Errorf("expected segment[1].id = 2, got %d", transcript.Segments[1].ID) + } +} + +func TestParseSourceTranscriptJSON_UnsupportedTopLevelShape(t *testing.T) { + raw := []byte(`"just a string"`) + + _, err := ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for unsupported top-level shape, got nil") + } + + var parseErr *ParseError + if parseErr, ok := err.(*ParseError); !ok { + t.Errorf("expected *ParseError, got %T", err) + } else if parseErr.Message != "transcript must be a JSON array or an object with a segments array" { + t.Errorf("expected unsupported shape message, got %q", parseErr.Message) + } +} + +func TestParseSourceTranscriptJSON_ObjectWithoutSegments(t *testing.T) { + raw := []byte(`{"metadata": {"version": "1.0"}}`) + + _, err := ParseSourceTranscriptJSON(raw) + if err == nil { + t.Fatal("expected error for object without segments, got nil") + } + + var parseErr *ParseError + if parseErr, ok := err.(*ParseError); !ok { + t.Errorf("expected *ParseError, got %T", err) + } else if parseErr.Message != "transcript object must contain a segments array" { + t.Errorf("expected 'transcript object must contain a segments array', got %q", parseErr.Message) + } +} + +func TestTranscriptToJSON(t *testing.T) { + transcript := &Transcript{ + Segments: []Segment{ + {ID: 1, Speaker: "Alice", Start: 0.0, End: 1.5, Text: "Hello world."}, + {ID: 2, Speaker: "Bob", Start: 2.0, End: 3.5, Text: "Hi there.", Categories: []string{"greeting"}}, + }, + } + + raw, err := TranscriptToJSON(transcript) + if err != nil { + t.Fatalf("TranscriptToJSON failed: %v", err) + } + + parsed, err := ParseTranscriptJSON(raw) + if err != nil { + t.Fatalf("failed to parse round-trip JSON: %v", err) + } + + if len(parsed.Segments) != 2 { + t.Fatalf("expected 2 segments after round-trip, got %d", len(parsed.Segments)) + } + + if parsed.Segments[0].Speaker != "Alice" { + t.Errorf("expected segment[0].speaker = Alice, got %q", parsed.Segments[0].Speaker) + } + if len(parsed.Segments[1].Categories) != 1 || parsed.Segments[1].Categories[0] != "greeting" { + t.Errorf("expected segment[1].categories = [greeting], got %v", parsed.Segments[1].Categories) + } +} + +func assertValidationError(t *testing.T, err error, fieldContains, messageContains string) { + t.Helper() + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + return + } + + if fieldContains != "" && valErr.Field == "" { + t.Errorf("expected field to contain %q, got empty field", fieldContains) + } + if messageContains != "" && valErr.Message == "" { + t.Errorf("expected message to contain %q, got empty message", messageContains) + } +} + +func assertValidationErrorContains(t *testing.T, err error, fieldContains, messageContains string) { + t.Helper() + + var valErr *ValidationError + if valErr, ok := err.(*ValidationError); !ok { + t.Errorf("expected *ValidationError, got %T", err) + return + } + + if fieldContains != "" && valErr.Field == "" { + t.Errorf("expected field to contain %q, got empty field", fieldContains) + } + if messageContains != "" && valErr.Message == "" { + t.Errorf("expected message to contain %q, got empty message", messageContains) + } +}