Add Go transcript and glossary schemas
This commit is contained in:
2
go.mod
2
go.mod
@@ -1,3 +1,5 @@
|
||||
module gitea.maximumdirect.net/eric/audita
|
||||
|
||||
go 1.22
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||
23
internal/core/schema/errors.go
Normal file
23
internal/core/schema/errors.go
Normal file
@@ -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
|
||||
}
|
||||
74
internal/core/schema/glossary.go
Normal file
74
internal/core/schema/glossary.go
Normal file
@@ -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
|
||||
}
|
||||
142
internal/core/schema/glossary_test.go
Normal file
142
internal/core/schema/glossary_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
1
internal/core/schema/testdata/glossary_malformed.yaml
vendored
Normal file
1
internal/core/schema/testdata/glossary_malformed.yaml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
glossary: [
|
||||
2
internal/core/schema/testdata/glossary_missing_fields.yaml
vendored
Normal file
2
internal/core/schema/testdata/glossary_missing_fields.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
glossary:
|
||||
- name: Jesters
|
||||
9
internal/core/schema/testdata/glossary_valid.yaml
vendored
Normal file
9
internal/core/schema/testdata/glossary_valid.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
glossary:
|
||||
- name: Jesters
|
||||
category: faction
|
||||
summary: A faction name.
|
||||
- name: Popov
|
||||
aliases:
|
||||
- Hrank
|
||||
category: character
|
||||
summary: A character name.
|
||||
17
internal/core/schema/testdata/transcript_bare_array.json
vendored
Normal file
17
internal/core/schema/testdata/transcript_bare_array.json
vendored
Normal file
@@ -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"]
|
||||
}
|
||||
]
|
||||
16
internal/core/schema/testdata/transcript_duplicate_ids.json
vendored
Normal file
16
internal/core/schema/testdata/transcript_duplicate_ids.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
1
internal/core/schema/testdata/transcript_empty_array.json
vendored
Normal file
1
internal/core/schema/testdata/transcript_empty_array.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
9
internal/core/schema/testdata/transcript_empty_speaker.json
vendored
Normal file
9
internal/core/schema/testdata/transcript_empty_speaker.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "",
|
||||
"start": 0.0,
|
||||
"end": 1.5,
|
||||
"text": "Hello world."
|
||||
}
|
||||
]
|
||||
9
internal/core/schema/testdata/transcript_empty_text.json
vendored
Normal file
9
internal/core/schema/testdata/transcript_empty_text.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "Alice",
|
||||
"start": 0.0,
|
||||
"end": 1.5,
|
||||
"text": ""
|
||||
}
|
||||
]
|
||||
9
internal/core/schema/testdata/transcript_invalid_times.json
vendored
Normal file
9
internal/core/schema/testdata/transcript_invalid_times.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "Alice",
|
||||
"start": 5.0,
|
||||
"end": 1.5,
|
||||
"text": "Hello world."
|
||||
}
|
||||
]
|
||||
1
internal/core/schema/testdata/transcript_malformed.json
vendored
Normal file
1
internal/core/schema/testdata/transcript_malformed.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"segments":[{"id":1,"text":"oops"}
|
||||
14
internal/core/schema/testdata/transcript_no_ids.json
vendored
Normal file
14
internal/core/schema/testdata/transcript_no_ids.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
9
internal/core/schema/testdata/transcript_non_sequential_id.json
vendored
Normal file
9
internal/core/schema/testdata/transcript_non_sequential_id.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"speaker": "Alice",
|
||||
"start": 0.0,
|
||||
"end": 1.5,
|
||||
"text": "Hello world."
|
||||
}
|
||||
]
|
||||
19
internal/core/schema/testdata/transcript_object.json
vendored
Normal file
19
internal/core/schema/testdata/transcript_object.json
vendored
Normal file
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
240
internal/core/schema/transcript.go
Normal file
240
internal/core/schema/transcript.go
Normal file
@@ -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, "", " ")
|
||||
}
|
||||
297
internal/core/schema/transcript_test.go
Normal file
297
internal/core/schema/transcript_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user