package integration_test import ( "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" ) func TestSequentialNPCOutputCanGroundIndependentSpellRun(t *testing.T) { registries := productionNPCRegistries(t) catalog := moduleCatalog(registries) configValue := loadSequentialPipelineConfig(t) npcEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npcs", Catalog: catalog}) if err != nil { t.Fatalf("resolve NPC pipeline: %v", err) } npcClient := &fakeNPCProductionLLMClient{response: npcProductionResponse{NPCs: []npcProductionRecord{{ Name: "Mira Thorn", Aliases: []string{"The Greencloak"}, Description: "A guarded ranger who watches the northern road.", Relationships: []npcProductionRelationship{{ Target: "Captain Vale", Relationship: "reports to", }}, SourceRefs: []npcProductionSourceRef{{StartUnitID: 1, EndUnitID: 2}}, }}}} npcOutput, err := runPreparedPipeline(t, registries, npcEffective.ResolvedPipeline, npcClient, pipeline.RunInput{RawInput: readNPCFixture(t)}) if err != nil { t.Fatalf("run NPC pipeline: %v", err) } if len(npcOutput.NormalizeOutputs) != 1 || npcOutput.NormalizeOutputs[0].LaneID != "npcs" { t.Fatalf("NPC normalized outputs = %#v, want one npcs lane", npcOutput.NormalizeOutputs) } npcPayload := npcOutput.NormalizeOutputs[0].Artifact.Content if _, err := npccodec.New().Decode(npcPayload); err != nil { t.Fatalf("decode normalized NPC payload: %v", err) } npcRunDir := t.TempDir() npcPath := filepath.Join(npcRunDir, "lanes", "npcs.json") if err := os.MkdirAll(filepath.Dir(npcPath), 0o700); err != nil { t.Fatalf("create NPC output directory: %v", err) } if err := os.WriteFile(npcPath, npcPayload, 0o600); err != nil { t.Fatalf("write NPC output payload: %v", err) } spellEffective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-spells", Catalog: catalog}) if err != nil { t.Fatalf("resolve spell pipeline: %v", err) } spellEffective.ResolvedPipeline.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings = []pipeline.ReferenceBinding{{ Stage: pipeline.StageExtract, LaneID: "spells", SlotName: spells.NPCRegistryReferenceSlot, Source: npcPath, BindingSource: contracts.ReferenceBindingSourceCLI, }} materialized, warnings, err := pipeline.MaterializeReferences(spellEffective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{WorkingDir: npcRunDir}) if err != nil { t.Fatalf("materialize NPC registry reference: %v", err) } if len(warnings) != 0 { t.Fatalf("reference materialization warnings = %#v, want none", warnings) } spellClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{ Caster: "Mira Thorn", Spell: "Cure Wounds", Effect: "Restores the injured ally.", NarrativeDescription: "Mira Thorn restores the ally after the fight.", SourceRefs: responseSourceRefs("spell-session", 1, 1), }}}} spellOutput, err := runPreparedPipeline(t, registries, materialized, spellClient, pipeline.RunInput{RawInput: readDNDSpellsFixture(t)}) if err != nil { t.Fatalf("run spell pipeline: %v", err) } if len(spellOutput.NormalizeOutputs) != 1 || spellOutput.NormalizeOutputs[0].LaneID != "spells" { t.Fatalf("spell normalized outputs = %#v, want one spells lane", spellOutput.NormalizeOutputs) } spellValue := decodeRunnerSpellResponse(t, spellOutput.NormalizeOutputs[0].Artifact.Content) if len(spellValue.SpellCasts) != 1 || spellValue.SpellCasts[0].Caster != "Mira Thorn" { t.Fatalf("spell output = %#v, want one registry-grounded caster", spellValue) } if len(spellValue.SpellCasts[0].SourceRefs) != 1 || spellValue.SpellCasts[0].SourceRefs[0].SourceID != "spell-session" { t.Fatalf("spell source refs = %#v, want current spell session only", spellValue.SpellCasts[0].SourceRefs) } if len(spellClient.requests) != 1 { t.Fatalf("spell LLM requests = %d, want one", len(spellClient.requests)) } registryInput := spellClient.requests[0].Inputs[spells.NPCRegistryReferenceSlot] if string(registryInput.Content) != string(npcPayload) || registryInput.MediaType != npccodec.MediaType || registryInput.OriginURI != "" { t.Fatalf("spell NPC prompt input = %#v, want canonical payload without origin", registryInput) } if len(spellOutput.Manifest.References) != 1 { t.Fatalf("spell manifest references = %#v, want one NPC provenance entry", spellOutput.Manifest.References) } provenance := spellOutput.Manifest.References[0] if provenance.Stage != "extract" || provenance.LaneID != "spells" || provenance.SlotName != spells.NPCRegistryReferenceSlot || provenance.BindingSource != contracts.ReferenceBindingSourceCLI || !strings.Contains(provenance.OriginURI, "npcs.json") { t.Fatalf("spell NPC provenance = %#v, want extract CLI reference provenance", provenance) } metadata, ok := spellOutput.Manifest.ArtifactLanes[0].Metadata["extractor"].(map[string]any) if !ok || metadata["npc_count"] != 1 || metadata["npc_registry_digest"] != registryInput.Digest { t.Fatalf("spell extractor metadata = %#v, want NPC count and semantic digest", spellOutput.Manifest.ArtifactLanes[0].Metadata) } } func loadSequentialPipelineConfig(t *testing.T) config.Config { t.Helper() data, err := os.ReadFile("testdata/dnd_npc_spell_sequential_pipeline.yml") if err != nil { t.Fatalf("read sequential pipeline config: %v", err) } fileConfig, err := config.ParseFileConfigYAML(data) if err != nil { t.Fatalf("parse sequential pipeline config: %v", err) } configValue := config.Default() if err := configValue.ApplyFileConfig(fileConfig); err != nil { t.Fatalf("apply sequential pipeline config: %v", err) } return configValue }