package artifacts import ( "errors" "os" "path/filepath" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestNormalizeSessionArtifactSource(t *testing.T) { tests := []struct { name string source string wantID string wantErr string }{ {name: "legacy alias processed unsupported", source: "processed_transcript", wantErr: "unsupported artifact source"}, {name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"}, {name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"}, {name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"}, {name: "canonical", source: ArtifactTranscriptFinalTrimmed, wantID: ArtifactTranscriptFinalTrimmed}, {name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := NormalizeSessionArtifactSource(tt.source) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("NormalizeSessionArtifactSource() error = %v, want contains %q", err, tt.wantErr) } return } if err != nil { t.Fatalf("NormalizeSessionArtifactSource() error = %v", err) } if got != tt.wantID { t.Fatalf("NormalizeSessionArtifactSource() = %q, want %q", got, tt.wantID) } }) } } func TestConfiguredArtifactSourceHelpers(t *testing.T) { tests := []struct { name string source string wantName string wantMatch bool }{ { name: "valid", source: "narratio.artifact.session_recap", wantName: "session_recap", wantMatch: true, }, { name: "valid with surrounding whitespace", source: " narratio.artifact.player_handout ", wantName: "player_handout", wantMatch: true, }, { name: "missing name", source: "narratio.artifact.", wantMatch: false, }, { name: "invalid hyphen", source: "narratio.artifact.session-recap", wantMatch: false, }, { name: "built-in", source: ArtifactTranscriptBase, wantMatch: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsConfiguredArtifactSource(tt.source); got != tt.wantMatch { t.Fatalf("IsConfiguredArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch) } gotName, gotOK := ConfiguredArtifactName(tt.source) if gotOK != tt.wantMatch { t.Fatalf("ConfiguredArtifactName(%q) ok = %t, want %t", tt.source, gotOK, tt.wantMatch) } if gotName != tt.wantName { t.Fatalf("ConfiguredArtifactName(%q) name = %q, want %q", tt.source, gotName, tt.wantName) } }) } } func TestPreviousSessionArtifactSourceHelpers(t *testing.T) { tests := []struct { name string source string wantName string wantMatch bool }{ { name: "valid", source: "narratio.previous_session.artifact.session_recap", wantName: "session_recap", wantMatch: true, }, { name: "valid with surrounding whitespace", source: " narratio.previous_session.artifact.quest_log ", wantName: "quest_log", wantMatch: true, }, { name: "missing name", source: "narratio.previous_session.artifact.", wantMatch: false, }, { name: "invalid key characters", source: "narratio.previous_session.artifact.session-recap", wantMatch: false, }, { name: "wrong prefix", source: "narratio.previous.artifact.session_recap", wantMatch: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { name, ok := PreviousSessionArtifactName(tt.source) if ok != tt.wantMatch { t.Fatalf("PreviousSessionArtifactName(%q) ok = %t, want %t", tt.source, ok, tt.wantMatch) } if name != tt.wantName { t.Fatalf("PreviousSessionArtifactName(%q) name = %q, want %q", tt.source, name, tt.wantName) } if got := IsPreviousSessionArtifactSource(tt.source); got != tt.wantMatch { t.Fatalf("IsPreviousSessionArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch) } }) } } func TestExtractionArtifactSourceHelpers(t *testing.T) { if got := ExtractionArtifactSourceID("npc_registry"); got != "narratio.extraction.npc_registry" { t.Fatalf("ExtractionArtifactSourceID() = %q", got) } if !IsExtractionArtifactSource(" narratio.extraction.npc_registry ") { t.Fatal("IsExtractionArtifactSource(valid) = false") } if key, ok := ExtractionArtifactName("narratio.extraction.npc_registry"); !ok || key != "npc_registry" { t.Fatalf("ExtractionArtifactName() = %q, %t", key, ok) } for _, source := range []string{"narratio.extraction.", "narratio.extraction.npc-registry", "narratio.artifact.npc_registry"} { if IsExtractionArtifactSource(source) { t.Fatalf("IsExtractionArtifactSource(%q) = true, want false", source) } } } func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") if err := os.MkdirAll(paths.ArtifactsDir, 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") if err := os.WriteFile(manifestPath, []byte(`{"segments":[]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } canonicalPath := filepath.Join(paths.TranscriptsDir, "final.json") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte(`{"segments":[{"id":123}]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } m := manifest.New("session", time.Now().UTC()) m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ {Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"}, }) resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal) if err != nil { t.Fatalf("ResolveSessionArtifact() error = %v", err) } if resolved.Path != manifestPath { t.Fatalf("resolved path = %q, want %q", resolved.Path, manifestPath) } if resolved.Provenance != "manifest.normalize.outputs" { t.Fatalf("provenance = %q, want manifest.normalize.outputs", resolved.Provenance) } if resolved.ProducerRunID != "run-123" { t.Fatalf("producer run id = %q, want run-123", resolved.ProducerRunID) } } func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte(`{"segments":[]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed) if err != nil { t.Fatalf("ResolveSessionArtifact() error = %v", err) } if resolved.Path != canonicalPath { t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath) } if resolved.Provenance != "fallback.canonical_path" { t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) } } func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown) if err != nil { t.Fatalf("ResolveSessionArtifact() error = %v", err) } if resolved.Path != canonicalPath { t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath) } if resolved.Provenance != "fallback.canonical_path" { t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) } } func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, ErrSessionArtifactNotFound) { t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound) = false; err=%v", err) } } func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "polished.json") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte(`{"not_segments":[]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptPolished) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "top-level segments is required") { t.Fatalf("error = %q, want segments validation error", err.Error()) } } func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "file is empty") { t.Fatalf("error = %q, want empty file validation", err.Error()) } } func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json") if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(canonicalPath, []byte(`{"segments":[]}`), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog()) if err != nil { t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) } if resolved.Path != canonicalPath { t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath) } if resolved.Provenance != "fallback.canonical_path" { t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) } } func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(outputPath, []byte("recap\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } sourceID := ConfiguredArtifactSourceID("session_recap") if err := catalog.MarkAvailableGenerated(sourceID, outputPath); err != nil { t.Fatalf("MarkAvailableGenerated() error = %v", err) } resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog) if err != nil { t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) } if resolved.Path != outputPath { t.Fatalf("resolved.Path = %q, want %q", resolved.Path, outputPath) } if resolved.Provenance != ArtifactProvenanceGeneratedCurrentAnalyzeRun { t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceGeneratedCurrentAnalyzeRun) } } func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(outputPath, []byte("handout\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } sourceID := ConfiguredArtifactSourceID("player_handout") if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil { t.Fatalf("MarkAvailableFromDisk() error = %v", err) } resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog) if err != nil { t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) } if resolved.Provenance != ArtifactProvenanceDisabledFromDisk { t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk) } } func TestResolveSessionArtifactWithCatalogConfiguredPlannedButUnavailable(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } _, err := ResolveSessionArtifactWithCatalog(paths, nil, ConfiguredArtifactSourceID("session_recap"), catalog) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, ErrSessionArtifactNotFound) { t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound)=false; err=%v", err) } } func TestResolveSessionArtifactWithCatalogUnsupportedConfiguredSourceFails(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") catalog := NewArtifactCatalog() _, err := ResolveSessionArtifactWithCatalog(paths, nil, "narratio.artifact.unknown", catalog) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "unsupported artifact source") { t.Fatalf("error = %q, want unsupported artifact source", err.Error()) } } func TestResolvePreviousSessionArtifactWithCatalogPrefersManifestInputRecord(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") manifestBackedPath := mustSessionPreviousArtifactPath(t, paths, "artifacts/session_recap.md") fallbackPath := mustSessionPreviousArtifactPath(t, paths, "session_recap.md") if err := os.MkdirAll(filepath.Dir(manifestBackedPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(manifestBackedPath, []byte("recap from manifest input\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } m := manifest.New("session", time.Now().UTC()) m.Inputs = []manifest.InputRecord{ {Kind: "previous_artifact", Path: manifestBackedPath}, } resolved, err := ResolvePreviousSessionArtifactWithCatalog( paths, m, "narratio.previous_session.artifact.session_recap", catalog, ) if err != nil { t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err) } if resolved.Path != manifestBackedPath { t.Fatalf("resolved path = %q, want %q", resolved.Path, manifestBackedPath) } if resolved.Provenance != ArtifactProvenancePreviousCacheManifestInput { t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheManifestInput) } } func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") fallbackPath := mustSessionPreviousArtifactPath(t, paths, "session_recap.md") if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil { t.Fatalf("MkdirAll() error = %v", err) } if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil { t.Fatalf("WriteFile() error = %v", err) } catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } resolved, err := ResolvePreviousSessionArtifactWithCatalog( paths, nil, "narratio.previous_session.artifact.session_recap", catalog, ) if err != nil { t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err) } if resolved.Path != fallbackPath { t.Fatalf("resolved path = %q, want %q", resolved.Path, fallbackPath) } if resolved.Provenance != ArtifactProvenancePreviousCacheFilesystem { t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheFilesystem) } } func TestPreviousSessionCacheCandidatePathsStripsArtifactsPrefix(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") got := previousSessionCacheCandidatePaths(paths, "artifacts/session_recap.md") want := []string{filepath.Join(paths.PreviousArtifactsDir, "session_recap.md")} if !reflect.DeepEqual(got, want) { t.Fatalf("previousSessionCacheCandidatePaths() = %#v, want %#v", got, want) } } func TestResolvePreviousSessionArtifactWithCatalogMissingReturnsTypedError(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") catalog := NewArtifactCatalog() if err := catalog.RegisterConfiguredArtifacts( map[string]ConfiguredArtifactDefinition{ "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, }, nil, ); err != nil { t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) } _, err := ResolvePreviousSessionArtifactWithCatalog( paths, nil, "narratio.previous_session.artifact.session_recap", catalog, ) if err == nil { t.Fatal("expected error, got nil") } if !errors.Is(err, ErrSessionArtifactNotFound) { t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound)=false; err=%v", err) } }