package app import ( "context" "crypto/sha256" "encoding/hex" "os" "path/filepath" "slices" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" "gitea.maximumdirect.net/eric/narratio/internal/stage" ) // executeStages keeps runner tests focused on controlled stage doubles. The // production command path always supplies one validated BoundedPlan directly. func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)} return executePlan(ctx, cfg, plan, opts) } func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.Manifest, names ...string) { t.Helper() providers := make(map[string]stage.SemanticConfigFingerprinter) for _, candidate := range stage.All() { if provider, ok := candidate.(stage.SemanticConfigFingerprinter); ok { providers[candidate.Name()] = provider } } for _, name := range names { record := m.Stages[name] if record == nil { t.Fatalf("stage %q must exist before semantic evidence is seeded", name) } provider, ok := providers[name] if !ok { t.Fatalf("stage %q has no semantic fingerprint provider", name) } fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg}) if err != nil { t.Fatalf("fingerprint stage %q: %v", name, err) } record.SemanticConfig = &fingerprint if name == "transcribe" { seedCurrentTranscribeEvidence(t, cfg, m, fingerprint) } } } func seedCurrentTranscribeEvidence( t *testing.T, cfg *config.Config, m *manifest.Manifest, semantic manifest.SemanticConfigFingerprint, ) { t.Helper() store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) paths := store.SessionPathsFor(cfg.Session.Campaign, m.SessionID) if strings.TrimSpace(m.RunID) == "" { m.RunID = "20260503T010203Z-a1b2c3d4" } records := make(map[string]manifest.TranscribeSpeakerRecord) for _, input := range m.Inputs { if input.Kind != "audio" { continue } audioPath := artifacts.ResolveSessionLocalPathForRead(paths, input.Path) audioBytes, err := os.ReadFile(audioPath) if err != nil { t.Fatalf("read transcribe audio fixture %q: %v", audioPath, err) } audioRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, audioPath) if err != nil { t.Fatalf("relativize transcribe audio fixture %q: %v", audioPath, err) } speaker := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath)) outputRelative := filepath.ToSlash(filepath.Join("transcripts", "raw", speaker+".json")) outputPath, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, outputRelative) if err != nil { t.Fatalf("resolve transcribe output fixture: %v", err) } if _, err := os.Stat(outputPath); os.IsNotExist(err) { mustWriteTestFile(t, outputPath, `{"segments":[]}`) } else if err != nil { t.Fatalf("inspect transcribe output fixture %q: %v", outputPath, err) } outputBytes, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read transcribe output fixture %q: %v", outputPath, err) } audioDigest := sha256.Sum256(audioBytes) outputDigest := sha256.Sum256(outputBytes) fingerprint, err := stage.FingerprintSemanticConfig( manifest.TranscribeFingerprintContractVersion, struct { SpeakerID string `json:"speaker_id"` AudioPath string `json:"audio_path"` AudioSHA256 string `json:"audio_sha256"` AudioSize int64 `json:"audio_size"` SemanticConfigVersion int `json:"semantic_config_version"` SemanticConfigDigest string `json:"semantic_config_digest"` }{ SpeakerID: speaker, AudioPath: audioRelative, AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)), SemanticConfigVersion: semantic.Version, SemanticConfigDigest: semantic.Digest, }, ) if err != nil { t.Fatalf("fingerprint transcribe speaker fixture %q: %v", speaker, err) } output := manifest.ArtifactRecord{ Kind: "transcript_raw", LocalPath: outputRelative, ProducerRunID: m.RunID, Checksum: hex.EncodeToString(outputDigest[:]), } records[speaker] = manifest.TranscribeSpeakerRecord{ FingerprintVersion: manifest.TranscribeFingerprintContractVersion, Fingerprint: fingerprint.Digest, AudioPath: audioRelative, AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)), Output: output, OutputSize: int64(len(outputBytes)), ProducerRunID: m.RunID, } } record := m.Stages["transcribe"] record.TranscribeStateVersion = manifest.TranscribeStateContractVersion record.TranscribeSpeakers = records if len(record.Outputs) == 0 { for _, speakerRecord := range records { record.Outputs = append(record.Outputs, speakerRecord.Output) } slices.SortFunc(record.Outputs, func(a, b manifest.ArtifactRecord) int { return strings.Compare(a.LocalPath, b.LocalPath) }) } } func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config { t.Helper() cfg, err := config.Load(pipelinePath, campaignPath, sessionPath) if err != nil { t.Fatalf("load config for semantic evidence: %v", err) } return cfg }