package transcript import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestParseValidMinimalTranscript(t *testing.T) { raw := readFixture(t, "testdata/valid_minimal.json") doc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("Parse() error = %v, want nil", err) } if doc.ID != "session-alpha" { t.Fatalf("doc.ID = %q, want session-alpha", doc.ID) } if doc.Kind != DocumentKind { t.Fatalf("doc.Kind = %q, want %q", doc.Kind, DocumentKind) } if doc.Format != Format { t.Fatalf("doc.Format = %q, want %q", doc.Format, Format) } if doc.Digest == testDigest(raw) || !strings.HasPrefix(doc.Digest, "sha256:") { t.Fatalf("doc.Digest = %q, want canonical source digest distinct from raw input digest", doc.Digest) } if got := doc.Metadata["title"]; got != "Synthetic session transcript" { t.Fatalf("doc.Metadata[title] = %#v, want Synthetic session transcript", got) } if len(doc.Units) != 2 { t.Fatalf("len(doc.Units) = %d, want 2", len(doc.Units)) } for i, unit := range doc.Units { want := source.SourceRef{SourceID: doc.ID, StartUnitID: unit.ID, EndUnitID: unit.ID} if unit.Ref != want { t.Fatalf("doc.Units[%d].Ref = %#v, want %#v", i, unit.Ref, want) } } first := doc.Units[0] if first.ID != 1 { t.Fatalf("first.ID = %d, want 1", first.ID) } if first.Kind != UnitKind { t.Fatalf("first.Kind = %q, want %q", first.Kind, UnitKind) } if first.Text != "The stone door opens." { t.Fatalf("first.Text = %q, want fixture text", first.Text) } if speaker, ok := Speaker(first); !ok || speaker != "Narrator" { t.Fatalf("Speaker(first) = %q, %v; want Narrator, true", speaker, ok) } if start, ok := Start(first); !ok || start != json.Number("0") { t.Fatalf("Start(first) = %q, %v; want 0, true", start, ok) } if end, ok := End(first); !ok || end != json.Number("4.5") { t.Fatalf("End(first) = %q, %v; want 4.5, true", end, ok) } ref := source.SourceRef{ SourceID: doc.ID, StartUnitID: doc.Units[0].ID, EndUnitID: doc.Units[len(doc.Units)-1].ID, } if err := source.ValidateRef(doc, ref); err != nil { t.Fatalf("ValidateRef() error = %v, want nil", err) } } func TestParseAcceptsNumericSegmentIDs(t *testing.T) { raw := []byte(`{"metadata":{"application":"seriatim","version":"v1.5.0","output_schema":"seriatim-intermediate"},"segments":[{"id":1,"start":451.821,"end":469.685,"speaker":"Narrator","text":"The stone door opens.","categories":["scene"]},{"id":2,"start":470,"end":471,"speaker":"Player","text":"I step inside."}]}`) doc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("Parse() error = %v, want nil", err) } if got, want := doc.Metadata["output_schema"], "seriatim-intermediate"; got != want { t.Fatalf("doc.Metadata[output_schema] = %#v, want %q", got, want) } if doc.Format != Format { t.Fatalf("doc.Format = %q, want %q", doc.Format, Format) } if len(doc.Units) != 2 { t.Fatalf("len(doc.Units) = %d, want 2", len(doc.Units)) } if doc.Units[0].ID != 1 || doc.Units[1].ID != 2 { t.Fatalf("unit IDs = %#v, want numeric IDs", []int{doc.Units[0].ID, doc.Units[1].ID}) } for i, unit := range doc.Units { want := source.SourceRef{SourceID: doc.ID, StartUnitID: unit.ID, EndUnitID: unit.ID} if unit.Ref != want { t.Fatalf("doc.Units[%d].Ref = %#v, want %#v", i, unit.Ref, want) } } ref := source.SourceRef{ SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2, } if err := source.ValidateRef(doc, ref); err != nil { t.Fatalf("ValidateRef() error = %v, want nil", err) } } func TestParseRequestSourceIDOverridesMetadataIDs(t *testing.T) { raw := readFixture(t, "testdata/valid_minimal.json") doc, err := New().Parse(context.Background(), contracts.ParseRequest{ SourceID: " requested-source ", Raw: raw, }) if err != nil { t.Fatalf("Parse() error = %v, want nil", err) } if doc.ID != "requested-source" { t.Fatalf("doc.ID = %q, want requested-source", doc.ID) } } func TestParseFallbackDocumentIDIsDeterministic(t *testing.T) { raw := []byte(`{"metadata":{},"segments":[{"id":1,"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`) first, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("first Parse() error = %v, want nil", err) } second, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("second Parse() error = %v, want nil", err) } if first.ID != second.ID { t.Fatalf("fallback IDs differ: %q vs %q", first.ID, second.ID) } if !strings.HasPrefix(first.ID, "seriatim:") { t.Fatalf("fallback ID = %q, want seriatim prefix", first.ID) } if first.ID != "seriatim:"+strings.TrimPrefix(testDigest(raw), "sha256:")[:16] { t.Fatalf("fallback ID = %q, want digest-derived ID", first.ID) } } func TestParseUsesMetadataSourceIDWhenMetadataIDIsAbsent(t *testing.T) { raw := []byte(`{"metadata":{"source_id":" source-from-metadata "},"segments":[{"id":1,"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`) doc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err != nil { t.Fatalf("Parse() error = %v, want nil", err) } if doc.ID != "source-from-metadata" { t.Fatalf("doc.ID = %q, want source-from-metadata", doc.ID) } } func TestParseRejectsInvalidInput(t *testing.T) { tests := []struct { name string raw []byte wantErr []string }{ { name: "malformed JSON", raw: []byte(`{"metadata":`), wantErr: []string{"seriatim input", "parse JSON"}, }, { name: "trailing JSON", raw: []byte(`{"metadata":{},"segments":[]} {}`), wantErr: []string{"seriatim input", "trailing"}, }, { name: "missing metadata", raw: []byte(`{"segments":[]}`), wantErr: []string{"metadata"}, }, { name: "null metadata", raw: []byte(`{"metadata":null,"segments":[]}`), wantErr: []string{"metadata", "object"}, }, { name: "metadata wrong type", raw: []byte(`{"metadata":[],"segments":[]}`), wantErr: []string{"metadata", "object"}, }, { name: "missing segments", raw: []byte(`{"metadata":{}}`), wantErr: []string{"segments"}, }, { name: "null segments", raw: []byte(`{"metadata":{},"segments":null}`), wantErr: []string{"segments", "array"}, }, { name: "segments wrong type", raw: []byte(`{"metadata":{},"segments":{}}`), wantErr: []string{"segments", "array"}, }, { name: "empty segments", raw: []byte(`{"metadata":{},"segments":[]}`), wantErr: []string{"segments", "empty"}, }, { name: "missing segment id", raw: validJSONWithSegment(`"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"id", "positive"}, }, { name: "invalid segment id type", raw: validJSONWithSegment(`"id":{},"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"id", "string or number"}, }, { name: "whitespace segment id", raw: validJSONWithSegment(`"id":" 1 ","start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"id", "whitespace"}, }, { name: "empty text", raw: validJSONWithSegment(`"id":1,"start":0,"end":1,"speaker":"Narrator","text":" "`), wantErr: []string{"text", "empty"}, }, { name: "missing speaker", raw: validJSONWithSegment(`"id":1,"start":0,"end":1,"text":"Synthetic text."`), wantErr: []string{"speaker", "empty"}, }, { name: "missing start", raw: validJSONWithSegment(`"id":1,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"start", "empty"}, }, { name: "missing end", raw: validJSONWithSegment(`"id":1,"start":0,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"end", "empty"}, }, { name: "negative start", raw: validJSONWithSegment(`"id":1,"start":-1,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"start", "negative"}, }, { name: "non-numeric end", raw: validJSONWithSegment(`"id":1,"start":0,"end":"late","speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"segment[0]", "end", "number"}, }, { name: "non-finite timestamp", raw: validJSONWithSegment(`"id":1,"start":1e10000,"end":1e10000,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"start", "valid number"}, }, { name: "end before start", raw: validJSONWithSegment(`"id":1,"start":2,"end":1,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"end", "start"}, }, { name: "end before start beyond float precision", raw: validJSONWithSegment(`"id":1,"start":9007199254740993,"end":9007199254740992,"speaker":"Narrator","text":"Synthetic text."`), wantErr: []string{"end", "start"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: tt.raw}) if err == nil { t.Fatal("Parse() error = nil, want error") } for _, want := range tt.wantErr { if !strings.Contains(err.Error(), want) { t.Fatalf("Parse() error = %q, want substring %q", err.Error(), want) } } }) } } func TestParseRejectsDuplicateSegmentIDs(t *testing.T) { raw := readFixture(t, "testdata/duplicate_segment_id.json") _, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) if err == nil { t.Fatal("Parse() error = nil, want duplicate ID error") } if !strings.Contains(err.Error(), "duplicated") || !strings.Contains(err.Error(), "1") { t.Fatalf("Parse() error = %q, want duplicate segment context", err.Error()) } } func TestParseRejectsInvalidContextOrEmptyInput(t *testing.T) { if _, err := New().Parse(nil, contracts.ParseRequest{Raw: []byte(`{}`)}); err == nil { t.Fatal("Parse(nil context) error = nil, want error") } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := New().Parse(ctx, contracts.ParseRequest{Raw: []byte(`{}`)}); err == nil { t.Fatal("Parse(canceled context) error = nil, want error") } if _, err := New().Parse(context.Background(), contracts.ParseRequest{}); err == nil { t.Fatal("Parse(empty input) error = nil, want error") } } func readFixture(t *testing.T, path string) []byte { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) } return raw } func validJSONWithSegment(segmentFields string) []byte { return []byte(`{"metadata":{"id":"fixture"},"segments":[{` + segmentFields + `}]}`) } func testDigest(raw []byte) string { sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]) }