package cli import ( "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" ) func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) { components := productionTestComponents(t) resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), pipeline.ResolveOptions{}, catalogFromRegistries(components.registries)) if err != nil { t.Fatalf("ResolvePipeline() error = %v", err) } if len(resolved.Steps) != 2 || len(resolved.Steps[1].ArtifactLanes) != 1 { t.Fatalf("resolved pipeline = %#v", resolved) } lane := resolved.Steps[1].ArtifactLanes[0] if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key { t.Fatalf("interaction lane = %#v", lane) } for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} { if len(bindings) != 1 || bindings[0].SlotName != "npcs" || bindings[0].Artifact == nil || bindings[0].Artifact.Step != "npcs" || bindings[0].Artifact.Lane != "npcs" { t.Fatalf("generated bindings = %#v", bindings) } } if _, err := pipeline.Prepare(resolved, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil { t.Fatalf("Prepare() error = %v", err) } catalog := catalogFromRegistries(components.registries) codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind) if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion { t.Fatalf("NPC interaction codec spec = %#v", codecSpec) } } func TestProductionNPCInteractionReferencesRequireEarlierCompatibleProducer(t *testing.T) { components := productionTestComponents(t) catalog := catalogFromRegistries(components.registries) laterProfile := npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")) laterProfile.Steps[0].ID = "seed" laterProfile.Steps[0].Artifacts["seed"] = laterProfile.Steps[0].Artifacts["npcs"] delete(laterProfile.Steps[0].Artifacts, "npcs") laterProfile.Steps = append(laterProfile.Steps, pipeline.PipelineStepProfile{ID: "future", Artifacts: map[string]pipeline.ArtifactLaneProfile{ "npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)}, }}) laterProfile.Steps[1].References["npcs"] = pipeline.GeneratedReference("future", "npcs") tests := []struct { name string profile pipeline.PipelineProfile want string }{ {name: "missing", profile: npcInteractionProfile(pipeline.ReferenceSource{}), want: "source must not be empty"}, {name: "same step", profile: npcInteractionProfile(pipeline.GeneratedReference("interactions", "interactions")), want: "earlier step"}, {name: "later step", profile: laterProfile, want: "earlier step"}, {name: "wrong artifact kind", profile: npcInteractionProfile(pipeline.GeneratedReference("npcs", "npcs")), want: "does not accept artifact kind"}, } tests[3].profile.Steps[0].Artifacts["npcs"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("dnd/spells")} for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := pipeline.ResolvePipeline(test.profile, pipeline.ResolveOptions{}, catalog) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want) } }) } } func TestProductionNPCInteractionReferencesRejectIncompatibleExternalRegistries(t *testing.T) { components := productionTestComponents(t) catalog := catalogFromRegistries(components.registries) root := t.TempDir() for _, test := range []struct { name string file string content string prepare bool want string }{ {name: "media type", file: "registry.txt", content: "not JSON", want: "media type"}, {name: "artifact schema", file: "registry.json", content: `{"npcs":[{"name":"missing required fields"}]}`, prepare: true, want: "NPC registry"}, } { t.Run(test.name, func(t *testing.T) { path := filepath.Join(root, test.file) if err := os.WriteFile(path, []byte(test.content), 0o600); err != nil { t.Fatal(err) } resolved, err := pipeline.ResolvePipeline(npcInteractionProfile(pipeline.ExternalReference(path)), pipeline.ResolveOptions{}, catalog) if err != nil { t.Fatalf("ResolvePipeline() error = %v", err) } materialized, _, err := pipeline.MaterializeReferences(resolved, catalog, pipeline.ReferenceMaterializationOptions{}) if !test.prepare { if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("MaterializeReferences() error = %v, want %q", err, test.want) } return } if err != nil { t.Fatalf("MaterializeReferences() error = %v", err) } if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Prepare() error = %v, want %q", err, test.want) } }) } } func npcInteractionProfile(reference pipeline.ReferenceSource) pipeline.PipelineProfile { profile := pipeline.PipelineProfile{ ID: "dnd-npc-interactions", Input: pipeline.Binding("seriatim"), Chunk: pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}, Output: pipeline.Binding("json"), Steps: []pipeline.PipelineStepProfile{ {ID: "npcs", Artifacts: map[string]pipeline.ArtifactLaneProfile{ "npcs": {Extract: pipeline.Binding(npcextract.Key), Normalize: pipeline.Binding(npcnormalize.Key)}, }}, {ID: "interactions", References: map[string]pipeline.ReferenceSource{"npcs": reference}, Artifacts: map[string]pipeline.ArtifactLaneProfile{ "interactions": {Extract: pipeline.Binding(interactionextract.Key), Normalize: pipeline.Binding(interactionnormalize.Key)}, }}, }, } return profile }