255 lines
11 KiB
Go
255 lines
11 KiB
Go
package manifest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) {
|
|
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
|
store := &LocalStore{}
|
|
zeroAudio := validTranscribeSpeakerRecord("Alice Table 1")
|
|
zeroAudio.AudioSize = 0
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
records map[string]TranscribeSpeakerRecord
|
|
}{
|
|
{
|
|
name: "populated",
|
|
records: map[string]TranscribeSpeakerRecord{
|
|
"Alice Table 1": validTranscribeSpeakerRecord("Alice Table 1"),
|
|
},
|
|
},
|
|
{name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}},
|
|
{name: "zero size audio", records: map[string]TranscribeSpeakerRecord{"Alice Table 1": zeroAudio}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
m := New("session", now)
|
|
m.Stages["transcribe"] = &StageRecord{
|
|
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
TranscribeStateVersion: TranscribeStateContractVersion,
|
|
TranscribeSpeakers: test.records,
|
|
}
|
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
|
if err := store.Save(context.Background(), path, m); err != nil {
|
|
t.Fatalf("Save() error = %v", err)
|
|
}
|
|
loaded, err := store.Load(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
stage := loaded.Stages["transcribe"]
|
|
if !stage.HasVersionedTranscribeState() {
|
|
t.Fatalf("transcribe state = %#v, want supported version", stage)
|
|
}
|
|
if len(stage.TranscribeSpeakers) != len(test.records) {
|
|
t.Fatalf("speaker count = %d, want %d", len(stage.TranscribeSpeakers), len(test.records))
|
|
}
|
|
if len(test.records) != 0 {
|
|
got := stage.TranscribeSpeakers["Alice Table 1"]
|
|
if got.AudioSize != test.records["Alice Table 1"].AudioSize {
|
|
t.Fatalf("audio size = %d, want %d", got.AudioSize, test.records["Alice Table 1"].AudioSize)
|
|
}
|
|
if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" {
|
|
t.Fatalf("round-tripped record = %#v", got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTranscribeSpeakerStateAllowsLegacyAggregateOnlyManifest(t *testing.T) {
|
|
legacy := `{
|
|
"schema_version": "narratio.session-manifest.v1",
|
|
"session_id": "session",
|
|
"created_at": "2026-09-21T12:00:00Z",
|
|
"updated_at": "2026-09-21T12:01:00Z",
|
|
"stages": {
|
|
"transcribe": {
|
|
"name": "transcribe",
|
|
"status": "succeeded",
|
|
"created_at": "2026-09-21T12:00:00Z",
|
|
"updated_at": "2026-09-21T12:01:00Z",
|
|
"outputs": [{"kind":"transcript_raw","local_path":"transcripts/raw/alice.json"}]
|
|
}
|
|
}
|
|
}`
|
|
loaded, err := (&LocalStore{}).LoadReader(context.Background(), strings.NewReader(legacy))
|
|
if err != nil {
|
|
t.Fatalf("LoadReader() error = %v", err)
|
|
}
|
|
if loaded.Stages["transcribe"].HasVersionedTranscribeState() {
|
|
t.Fatal("legacy aggregate-only manifest unexpectedly gained per-speaker authority")
|
|
}
|
|
if err := (&LocalStore{}).Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), loaded); err != nil {
|
|
t.Fatalf("Save(legacy aggregate-only manifest) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateTranscribeSpeakerCollectionRejectsMalformedEvidence(t *testing.T) {
|
|
valid := validTranscribeSpeakerRecord("alice")
|
|
tests := []struct {
|
|
name string
|
|
speakerID string
|
|
mutate func(*TranscribeSpeakerRecord)
|
|
want string
|
|
}{
|
|
{name: "empty speaker ID", speakerID: "", want: "speaker ID is required"},
|
|
{name: "unsupported fingerprint version", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.FingerprintVersion = 2 }, want: "unsupported fingerprint version"},
|
|
{name: "bad fingerprint", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Fingerprint = "bad" }, want: "fingerprint must be"},
|
|
{name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"},
|
|
{name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"},
|
|
{name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"},
|
|
{name: "negative audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = -1 }, want: "audio_size"},
|
|
{name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"},
|
|
{name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"},
|
|
{name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"},
|
|
{name: "bad output size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.OutputSize = 0 }, want: "output_size must be positive"},
|
|
{name: "bad producer run", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id is invalid"},
|
|
{name: "producer mismatch", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
record := valid
|
|
if test.mutate != nil {
|
|
test.mutate(&record)
|
|
}
|
|
err := ValidateTranscribeSpeakerCollection(TranscribeStateContractVersion, map[string]TranscribeSpeakerRecord{test.speakerID: record})
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
if err := ValidateTranscribeSpeakerCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported transcribe state version") {
|
|
t.Fatalf("unsupported version error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeSpeakerStateValidationAppliesOnSaveAndLoad(t *testing.T) {
|
|
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
|
invalid := validTranscribeSpeakerRecord("alice")
|
|
invalid.AudioSHA256 = "bad"
|
|
m := New("session", now)
|
|
m.Stages["transcribe"] = &StageRecord{
|
|
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
TranscribeStateVersion: TranscribeStateContractVersion,
|
|
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": invalid},
|
|
}
|
|
store := &LocalStore{}
|
|
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), "audio_sha256") {
|
|
t.Fatalf("Save() error = %v, want audio evidence validation", err)
|
|
}
|
|
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
if _, err := store.LoadReader(context.Background(), strings.NewReader(string(data))); err == nil || !strings.Contains(err.Error(), "audio_sha256") {
|
|
t.Fatalf("LoadReader() error = %v, want audio evidence validation", err)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeSpeakerStateOwnershipAndVersionAreValidated(t *testing.T) {
|
|
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
|
store := &LocalStore{}
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
stage *StageRecord
|
|
want string
|
|
}{
|
|
{
|
|
name: "wrong owner",
|
|
stage: &StageRecord{Name: "merge", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
TranscribeStateVersion: TranscribeStateContractVersion,
|
|
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{}},
|
|
want: "cannot contain transcribe-owned state",
|
|
},
|
|
{
|
|
name: "missing version",
|
|
stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}},
|
|
want: "unsupported transcribe state version 0",
|
|
},
|
|
{
|
|
name: "unsupported version",
|
|
stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
TranscribeStateVersion: 2},
|
|
want: "unsupported transcribe state version 2",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
m := New("session", now)
|
|
m.Stages[test.stage.Name] = test.stage
|
|
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("Save() error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCloneTranscribeSpeakerCollectionIsIsolated(t *testing.T) {
|
|
original := map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}
|
|
cloned := CloneTranscribeSpeakerCollection(original)
|
|
record := cloned["alice"]
|
|
record.AudioPath = "inputs/audio/replacement.flac"
|
|
record.Output.Checksum = strings.Repeat("c", 64)
|
|
cloned["alice"] = record
|
|
|
|
if got := original["alice"]; got.AudioPath != "inputs/audio/alice.flac" || got.Output.Checksum != strings.Repeat("b", 64) {
|
|
t.Fatalf("original changed through clone: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestTranscribeSpeakerStateSurvivesAggregateLifecycleTransitions(t *testing.T) {
|
|
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
|
for _, transition := range []struct {
|
|
name string
|
|
apply func(*Manifest)
|
|
}{
|
|
{name: "running", apply: func(m *Manifest) { m.MarkStageRunning("transcribe", now.Add(time.Minute)) }},
|
|
{name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("transcribe", now.Add(time.Minute), "request failed") }},
|
|
{name: "stale", apply: func(m *Manifest) { m.MarkStageStale("transcribe", now.Add(time.Minute), "audio changed") }},
|
|
} {
|
|
t.Run(transition.name, func(t *testing.T) {
|
|
m := New("session", now)
|
|
m.Stages["transcribe"] = &StageRecord{
|
|
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
|
Outputs: []ArtifactRecord{{Kind: "transcript_raw", LocalPath: "transcripts/raw/old.json"}},
|
|
Logs: []string{"runs/old/transcribe.log"}, Metadata: map[string]any{"attempts": 1},
|
|
TranscribeStateVersion: TranscribeStateContractVersion,
|
|
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")},
|
|
}
|
|
transition.apply(m)
|
|
stage := m.Stages["transcribe"]
|
|
if !stage.HasVersionedTranscribeState() || stage.TranscribeSpeakers["alice"].AudioPath != "inputs/audio/alice.flac" {
|
|
t.Fatalf("speaker completion was lost during %s: %#v", transition.name, stage)
|
|
}
|
|
if transition.name != "stale" && (len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.Metadata) != 0) {
|
|
t.Fatalf("aggregate details survived %s: %#v", transition.name, stage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func validTranscribeSpeakerRecord(speakerID string) TranscribeSpeakerRecord {
|
|
runID := "run-1"
|
|
return TranscribeSpeakerRecord{
|
|
FingerprintVersion: TranscribeFingerprintContractVersion,
|
|
Fingerprint: strings.Repeat("a", 64),
|
|
AudioPath: "inputs/audio/" + speakerID + ".flac",
|
|
AudioSHA256: strings.Repeat("1", 64),
|
|
AudioSize: 1024,
|
|
Output: ArtifactRecord{
|
|
Kind: "transcript_raw", LocalPath: "transcripts/raw/" + speakerID + ".json",
|
|
Checksum: strings.Repeat("b", 64), ProducerRunID: runID,
|
|
},
|
|
OutputSize: 256, ProducerRunID: runID,
|
|
}
|
|
}
|