package register import ( "io/fs" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns" enemyeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/enemyevents" itemoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemoccurrences" itemregistryextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemregistry" locationoccurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationoccurrences" locationextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/locationregistry" occurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry" scenedescriptionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/scenedescriptions" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns" enemyeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/enemyevents" itemoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemoccurrences" itemregistrynormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemregistry" locationoccurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationoccurrences" locationnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/locationregistry" occurrencenormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcoccurrences" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry" scenedescriptionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/scenedescriptions" spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) func TestRegisterAddsDNDFamily(t *testing.T) { registries := completeRegistries() assets := llm.NewAssetRegistry() if err := Register(registries, assets); err != nil { t.Fatalf("Register() error = %v, want nil", err) } promptFS, err := assets.PromptFS() if err != nil { t.Fatalf("PromptFS() error = %v", err) } if _, err := fs.ReadFile(promptFS, "dnd.npc_registry.normalize/prompt.yaml"); err != nil { t.Fatalf("normalization prompt asset = %v, want registered private prompt", err) } for _, name := range []string{ "dnd.scenes/prompt.yaml", "dnd.spells/prompt.yaml", "dnd.npc_registry/prompt.yaml", "dnd.combat_turns/prompt.yaml", "dnd.enemy_events/prompt.yaml", "dnd.item_occurrences/prompt.yaml", "dnd.item_registry/prompt.yaml", "dnd.item_registry.normalize/prompt.yaml", "dnd.npc_occurrences/prompt.yaml", "dnd.scene_descriptions/prompt.yaml", "dnd.npc_registry.normalize/prompt.yaml", "dnd.location_registry/prompt.yaml", "dnd.location_registry.normalize/prompt.yaml", "dnd.location_occurrences/prompt.yaml", } { content, err := fs.ReadFile(promptFS, name) if err != nil { t.Fatalf("read prompt asset %q: %v", name, err) } if !strings.Contains(string(content), "default_profile: dnd-extraction") { t.Fatalf("prompt asset %q does not select dnd-extraction", name) } } fallbackFS, err := assets.FallbackProfileFS() if err != nil { t.Fatalf("FallbackProfileFS() error = %v", err) } if _, err := fs.ReadFile(fallbackFS, "dnd-extraction.yaml"); err != nil { t.Fatalf("fallback profile asset = %v, want registered D&D profile", err) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemoccurrenceextract.Key, itemregistryextract.Key, occurrenceextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key}) assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemoccurrencenormalize.Key, itemregistrynormalize.Key, occurrencenormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemOccurrenceListKind, dnd.ItemRegistryKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationRegistryKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemoccurrencenormalize.Key), []contracts.ArtifactKind{dnd.ItemOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemregistrynormalize.Key), []contracts.ArtifactKind{dnd.ItemRegistryKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(occurrencenormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/location-registry/shape", "normalize/dnd/location-registry/identity", "extract/dnd/location-registry/source_refs", "extract/dnd/location-registry/source_relatedness", "extract/dnd/location-occurrences/shape", "extract/dnd/location-occurrences/registry", "normalize/dnd/location-occurrences/invariants", "extract/dnd/location-occurrences/source_refs", "extract/dnd/location-occurrences/source_relatedness", "extract/dnd/npc-registry/shape", "extract/dnd/npc-registry/source_refs", "extract/dnd/npc-registry/source_relatedness", "normalize/dnd/npc-registry/identity", "extract/dnd/spells/catalog", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness", "extract/dnd/combat-turns/shape", "extract/dnd/combat-turns/source_refs", "extract/dnd/combat-turns/source_relatedness", "normalize/dnd/combat-turns/invariants", "extract/dnd/enemy-events/shape", "extract/dnd/enemy-events/engagements", "extract/dnd/enemy-events/source_refs", "extract/dnd/enemy-events/source_relatedness", "normalize/dnd/enemy-events/invariants", "extract/dnd/item-occurrences/shape", "extract/dnd/item-occurrences/source_refs", "extract/dnd/item-occurrences/source_relatedness", "normalize/dnd/item-occurrences/invariants", "extract/dnd/item-registry/shape", "normalize/dnd/item-registry/identity", "extract/dnd/item-registry/source_refs", "extract/dnd/item-registry/source_relatedness", "extract/dnd/npc-occurrences/shape", "extract/dnd/npc-occurrences/registry", "extract/dnd/npc-occurrences/source_refs", "extract/dnd/npc-occurrences/source_relatedness", "normalize/dnd/npc-occurrences/invariants", "extract/dnd/scene-descriptions/shape", "extract/dnd/scene-descriptions/source_refs", "extract/dnd/scene-descriptions/source_relatedness", "normalize/dnd/scene-descriptions/invariants", "generic/always_accept", "generic/always_reject", }) locationExtractChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-registry/shape"), pipeline.Binding("extract/dnd/location-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-registry/source_relatedness")} locationNormalizeChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-registry/shape"), pipeline.Binding("normalize/dnd/location-registry/identity"), pipeline.Binding("extract/dnd/location-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-registry/source_relatedness")} occurrenceExtractChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-occurrences/shape"), pipeline.Binding("extract/dnd/location-occurrences/registry"), pipeline.Binding("extract/dnd/location-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-occurrences/source_relatedness")} occurrenceNormalizeChain := []pipeline.ModuleBinding{pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/location-occurrences/shape"), pipeline.Binding("extract/dnd/location-occurrences/registry"), pipeline.Binding("normalize/dnd/location-occurrences/invariants"), pipeline.Binding("extract/dnd/location-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/location-occurrences/source_relatedness")} for _, test := range []struct { stage pipeline.ModuleStage key string want []pipeline.ModuleBinding }{ {pipeline.StageExtract, locationextract.Key, locationExtractChain}, {pipeline.StageNormalize, locationnormalize.Key, locationNormalizeChain}, {pipeline.StageExtract, locationoccurrenceextract.Key, occurrenceExtractChain}, {pipeline.StageNormalize, locationoccurrencenormalize.Key, occurrenceNormalizeChain}, } { if got := registries.ValidatorChains.Validators(test.stage, test.key); !reflect.DeepEqual(got, test.want) { t.Fatalf("validator chain for %s/%s = %#v, want %#v", test.stage, test.key, got, test.want) } } wantChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/spells/shape"), pipeline.Binding("extract/dnd/spells/catalog"), pipeline.Binding("extract/dnd/spells/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/spells/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain) } npcExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-registry/shape"), pipeline.Binding("extract/dnd/npc-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-registry/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, npcextract.Key); !reflect.DeepEqual(got, npcExtractChain) { t.Fatalf("NPC extract validator chain = %#v, want %#v", got, npcExtractChain) } npcNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-registry/shape"), pipeline.Binding("normalize/dnd/npc-registry/identity"), pipeline.Binding("extract/dnd/npc-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-registry/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, npcnormalize.Key); !reflect.DeepEqual(got, npcNormalizeChain) { t.Fatalf("NPC normalize validator chain = %#v, want %#v", got, npcNormalizeChain) } combatExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, combatextract.Key); !reflect.DeepEqual(got, combatExtractChain) { t.Fatalf("combat extract validator chain = %#v, want %#v", got, combatExtractChain) } combatNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/combat-turns/shape"), pipeline.Binding("normalize/dnd/combat-turns/invariants"), pipeline.Binding("extract/dnd/combat-turns/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/combat-turns/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, combatnormalize.Key); !reflect.DeepEqual(got, combatNormalizeChain) { t.Fatalf("combat normalize validator chain = %#v, want %#v", got, combatNormalizeChain) } enemyEventExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/enemy-events/shape"), pipeline.Binding("extract/dnd/enemy-events/engagements"), pipeline.Binding("extract/dnd/enemy-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/enemy-events/source_relatedness"), } enemyEventNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/enemy-events/shape"), pipeline.Binding("normalize/dnd/enemy-events/invariants"), pipeline.Binding("extract/dnd/enemy-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/enemy-events/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, enemyeventextract.Key); !reflect.DeepEqual(got, enemyEventExtractChain) { t.Fatalf("enemy event extract validator chain = %#v, want %#v", got, enemyEventExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, enemyeventnormalize.Key); !reflect.DeepEqual(got, enemyEventNormalizeChain) { t.Fatalf("enemy event normalize validator chain = %#v, want %#v", got, enemyEventNormalizeChain) } itemOccurrenceExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-occurrences/shape"), pipeline.Binding("extract/dnd/item-occurrences/registry"), pipeline.Binding("extract/dnd/item-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-occurrences/source_relatedness"), } itemOccurrenceNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-occurrences/shape"), pipeline.Binding("extract/dnd/item-occurrences/registry"), pipeline.Binding("normalize/dnd/item-occurrences/invariants"), pipeline.Binding("extract/dnd/item-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-occurrences/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemoccurrenceextract.Key); !reflect.DeepEqual(got, itemOccurrenceExtractChain) { t.Fatalf("item occurrence extract validator chain = %#v, want %#v", got, itemOccurrenceExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemoccurrencenormalize.Key); !reflect.DeepEqual(got, itemOccurrenceNormalizeChain) { t.Fatalf("item occurrence normalize validator chain = %#v, want %#v", got, itemOccurrenceNormalizeChain) } itemRegistryExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-registry/shape"), pipeline.Binding("extract/dnd/item-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-registry/source_relatedness"), } itemRegistryNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-registry/shape"), pipeline.Binding("normalize/dnd/item-registry/identity"), pipeline.Binding("extract/dnd/item-registry/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-registry/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemregistryextract.Key); !reflect.DeepEqual(got, itemRegistryExtractChain) { t.Fatalf("item registry extract validator chain = %#v, want %#v", got, itemRegistryExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemregistrynormalize.Key); !reflect.DeepEqual(got, itemRegistryNormalizeChain) { t.Fatalf("item registry normalize validator chain = %#v, want %#v", got, itemRegistryNormalizeChain) } npcOccurrenceExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-occurrences/shape"), pipeline.Binding("extract/dnd/npc-occurrences/registry"), pipeline.Binding("extract/dnd/npc-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-occurrences/source_relatedness"), } npcOccurrenceNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-occurrences/shape"), pipeline.Binding("extract/dnd/npc-occurrences/registry"), pipeline.Binding("normalize/dnd/npc-occurrences/invariants"), pipeline.Binding("extract/dnd/npc-occurrences/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-occurrences/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, occurrenceextract.Key); !reflect.DeepEqual(got, npcOccurrenceExtractChain) { t.Fatalf("NPC occurrence extract validator chain = %#v, want %#v", got, npcOccurrenceExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, occurrencenormalize.Key); !reflect.DeepEqual(got, npcOccurrenceNormalizeChain) { t.Fatalf("NPC occurrence normalize validator chain = %#v, want %#v", got, npcOccurrenceNormalizeChain) } sceneExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/scene-descriptions/shape"), pipeline.Binding("extract/dnd/scene-descriptions/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, scenedescriptionextract.Key); !reflect.DeepEqual(got, sceneExtractChain) { t.Fatalf("scene description extract validator chain = %#v, want %#v", got, sceneExtractChain) } sceneNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/scene-descriptions/shape"), pipeline.Binding("normalize/dnd/scene-descriptions/invariants"), pipeline.Binding("extract/dnd/scene-descriptions/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/scene-descriptions/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, scenedescriptionnormalize.Key); !reflect.DeepEqual(got, sceneNormalizeChain) { t.Fatalf("scene description normalize validator chain = %#v, want %#v", got, sceneNormalizeChain) } if got := registries.ValidatorChains.Validators(pipeline.StageMerge, npcextract.Key); got != nil { t.Fatalf("NPC merge validator chain = %#v, want absent", got) } if got := registries.ValidatorChains.Validators(pipeline.StageMerge, occurrenceextract.Key); got != nil { t.Fatalf("NPC occurrence merge validator chain = %#v, want absent", got) } if spec, ok := registries.Chunkers.Spec("dnd/scenes"); !ok || spec.Key != "dnd/scenes" { t.Fatalf("scene chunker spec = %#v, present = %t; want family-owned spec", spec, ok) } if spec, ok := registries.Extractors.Spec(spells.Key); !ok || spec.ArtifactKind != dnd.SpellListKind { t.Fatalf("spell extractor spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) } if spec, ok := registries.Normalizers.Spec(spellnormalize.Key); !ok || spec.ArtifactKind != dnd.SpellListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("spell normalizer spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) } if spec, ok := registries.Extractors.Spec(npcextract.Key); !ok || spec.ArtifactKind != dnd.NPCRegistryKind { t.Fatalf("NPC extractor spec = %#v, present = %t; want D&D NPC registry artifact", spec, ok) } if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCRegistryKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("NPC normalizer spec = %#v, present = %t; want D&D NPC registry artifact", spec, ok) } if spec, ok := registries.Extractors.Spec(combatextract.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind { t.Fatalf("combat extractor spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok) } if spec, ok := registries.Normalizers.Spec(combatnormalize.Key); !ok || spec.ArtifactKind != dnd.CombatTurnListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("combat normalizer spec = %#v, present = %t; want dnd combat-turn-list artifact", spec, ok) } enemyEventExtractSpec, enemyEventExtractOK := registries.Extractors.Spec(enemyeventextract.Key) enemyEventNormalizeSpec, enemyEventNormalizeOK := registries.Normalizers.Spec(enemyeventnormalize.Key) if !enemyEventExtractOK || enemyEventExtractSpec.ArtifactKind != dnd.EnemyEventListKind || !enemyEventNormalizeOK || enemyEventNormalizeSpec.ArtifactKind != dnd.EnemyEventListKind || enemyEventNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("enemy event specs = %#v / %#v, present = %t / %t", enemyEventExtractSpec, enemyEventNormalizeSpec, enemyEventExtractOK, enemyEventNormalizeOK) } if len(enemyEventExtractSpec.ReferenceSlots) != 8 || len(enemyEventNormalizeSpec.ReferenceSlots) != 1 { t.Fatalf("enemy event reference slots = %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots) } npcRegistrySlot := referenceSlot(enemyEventExtractSpec.ReferenceSlots, "npc_registry") enemyNormalizeRegistrySlot := referenceSlot(enemyEventNormalizeSpec.ReferenceSlots, "npc_registry") if !npcRegistrySlot.Required || !enemyNormalizeRegistrySlot.Required || npcRegistrySlot.MaxBytes != enemyNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(npcRegistrySlot.AcceptedMediaTypes, enemyNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(npcRegistrySlot.AcceptedArtifactKinds, enemyNormalizeRegistrySlot.AcceptedArtifactKinds) { t.Fatalf("enemy-event NPC registry slots disagree: %#v / %#v", enemyEventExtractSpec.ReferenceSlots, enemyEventNormalizeSpec.ReferenceSlots) } itemOccurrenceExtractSpec, itemOccurrenceExtractOK := registries.Extractors.Spec(itemoccurrenceextract.Key) itemOccurrenceNormalizeSpec, itemOccurrenceNormalizeOK := registries.Normalizers.Spec(itemoccurrencenormalize.Key) if !itemOccurrenceExtractOK || itemOccurrenceExtractSpec.ArtifactKind != dnd.ItemOccurrenceListKind || !itemOccurrenceNormalizeOK || itemOccurrenceNormalizeSpec.ArtifactKind != dnd.ItemOccurrenceListKind || itemOccurrenceNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("item occurrence specs = %#v / %#v, present = %t / %t", itemOccurrenceExtractSpec, itemOccurrenceNormalizeSpec, itemOccurrenceExtractOK, itemOccurrenceNormalizeOK) } itemExtractRegistrySlot := referenceSlot(itemOccurrenceExtractSpec.ReferenceSlots, "item_registry") itemNormalizeRegistrySlot := referenceSlot(itemOccurrenceNormalizeSpec.ReferenceSlots, "item_registry") if !itemExtractRegistrySlot.Required || !itemNormalizeRegistrySlot.Required || itemExtractRegistrySlot.MaxBytes != itemNormalizeRegistrySlot.MaxBytes || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedMediaTypes, itemNormalizeRegistrySlot.AcceptedMediaTypes) || !reflect.DeepEqual(itemExtractRegistrySlot.AcceptedArtifactKinds, itemNormalizeRegistrySlot.AcceptedArtifactKinds) { t.Fatalf("item occurrence registry slots disagree: %#v / %#v", itemOccurrenceExtractSpec.ReferenceSlots, itemOccurrenceNormalizeSpec.ReferenceSlots) } occurrenceExtractSpec, extractOK := registries.Extractors.Spec(occurrenceextract.Key) occurrenceNormalizeSpec, normalizeOK := registries.Normalizers.Spec(occurrencenormalize.Key) if !extractOK || occurrenceExtractSpec.ArtifactKind != dnd.NPCOccurrenceListKind || !normalizeOK || occurrenceNormalizeSpec.ArtifactKind != dnd.NPCOccurrenceListKind || occurrenceNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("NPC occurrence specs = %#v / %#v, present = %t / %t", occurrenceExtractSpec, occurrenceNormalizeSpec, extractOK, normalizeOK) } locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key) locationNormalizeSpec, locationNormalizeOK := registries.Normalizers.Spec(locationnormalize.Key) if !locationExtractOK || locationExtractSpec.ArtifactKind != dnd.LocationRegistryKind || locationExtractSpec.ExecutionClass != contracts.ExecutionClassLLMBacked || !locationNormalizeOK || locationNormalizeSpec.ArtifactKind != dnd.LocationRegistryKind || locationNormalizeSpec.ExecutionClass != contracts.ExecutionClassLLMBacked { t.Fatalf("location specs = %#v / %#v", locationExtractSpec, locationNormalizeSpec) } locationOccurrenceExtractSpec, locationOccurrenceExtractOK := registries.Extractors.Spec(locationoccurrenceextract.Key) locationOccurrenceNormalizeSpec, locationOccurrenceNormalizeOK := registries.Normalizers.Spec(locationoccurrencenormalize.Key) if !locationOccurrenceExtractOK || locationOccurrenceExtractSpec.ArtifactKind != dnd.LocationOccurrenceListKind || locationOccurrenceExtractSpec.ExecutionClass != contracts.ExecutionClassLLMBacked || !locationOccurrenceNormalizeOK || locationOccurrenceNormalizeSpec.ArtifactKind != dnd.LocationOccurrenceListKind || locationOccurrenceNormalizeSpec.ExecutionClass != contracts.ExecutionClassDeterministic { t.Fatalf("location occurrence specs = %#v / %#v", locationOccurrenceExtractSpec, locationOccurrenceNormalizeSpec) } locationRegistrySlot := referenceSlot(locationOccurrenceExtractSpec.ReferenceSlots, "location_registry") occurrenceNormalizeRegistrySlot := referenceSlot(locationOccurrenceNormalizeSpec.ReferenceSlots, "location_registry") if len(locationOccurrenceExtractSpec.ReferenceSlots) != 5 || len(locationOccurrenceNormalizeSpec.ReferenceSlots) != 1 { t.Fatalf("location occurrence reference slots = %#v / %#v, want extractor campaign context and normalizer registry only", locationOccurrenceExtractSpec.ReferenceSlots, locationOccurrenceNormalizeSpec.ReferenceSlots) } if !locationRegistrySlot.Required || !reflect.DeepEqual(locationRegistrySlot.AcceptedMediaTypes, []string{"application/json"}) || !reflect.DeepEqual(locationRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.LocationRegistryKind}) || locationRegistrySlot.MaxBytes != 1048576 || !sameReferenceSlotContract(locationRegistrySlot, occurrenceNormalizeRegistrySlot) { t.Fatalf("location registry slots disagree: %#v / %#v", occurrenceExtractSpec.ReferenceSlots, occurrenceNormalizeSpec.ReferenceSlots) } for _, name := range []string{"party", "roster", "players", "glossary"} { slot := referenceSlot(locationOccurrenceExtractSpec.ReferenceSlots, name) if slot.Name != name || slot.Required || len(slot.AcceptedArtifactKinds) != 0 { t.Fatalf("location occurrence extractor campaign slot %q = %#v, want optional text context", name, slot) } } sceneExtractSpec, sceneExtractOK := registries.Extractors.Spec(scenedescriptionextract.Key) sceneNormalizeSpec, sceneNormalizeOK := registries.Normalizers.Spec(scenedescriptionnormalize.Key) if !sceneExtractOK || sceneExtractSpec.ArtifactKind != dnd.SceneDescriptionListKind || !sceneNormalizeOK || sceneNormalizeSpec.ArtifactKind != dnd.SceneDescriptionListKind || sceneNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("scene description specs = %#v / %#v, present = %t / %t", sceneExtractSpec, sceneNormalizeSpec, sceneExtractOK, sceneNormalizeOK) } if len(sceneExtractSpec.ReferenceSlots) != 3 || len(sceneNormalizeSpec.ReferenceSlots) != 0 { t.Fatalf("scene description reference slots = %#v / %#v, want extractor campaign slots only", sceneExtractSpec.ReferenceSlots, sceneNormalizeSpec.ReferenceSlots) } extractRegistrySlot := referenceSlot(occurrenceExtractSpec.ReferenceSlots, "npc_registry") normalizeRegistrySlot := referenceSlot(occurrenceNormalizeSpec.ReferenceSlots, "npc_registry") if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCRegistryKind}) || !sameReferenceSlotContract(extractRegistrySlot, normalizeRegistrySlot) { t.Fatalf("NPC occurrence registry slots disagree: %#v / %#v", occurrenceExtractSpec.ReferenceSlots, occurrenceNormalizeSpec.ReferenceSlots) } } func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *testing.T) { first := source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1} second := source.SourceRef{SourceID: "session", StartUnitID: 2, EndUnitID: 2} for _, test := range []struct { name string project func() []source.SourceRef want []source.SourceRef }{ {name: "spells", project: func() []source.SourceRef { return spellEvidence(dnd.SpellList{SpellCasts: []dnd.SpellCast{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "npcs", project: func() []source.SourceRef { return npcEvidence(dnd.NPCRegistry{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "combat turns", project: func() []source.SourceRef { return combatTurnEvidence(dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "enemy events", project: func() []source.SourceRef { return enemyEventEvidence(dnd.EnemyEventList{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "item occurrences", project: func() []source.SourceRef { return itemOccurrenceEvidence(dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "item registry", project: func() []source.SourceRef { return itemRegistryEvidence(dnd.ItemRegistry{Items: []dnd.Item{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "npc occurrences", project: func() []source.SourceRef { return npcOccurrenceEvidence(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "scene descriptions", project: func() []source.SourceRef { return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}}) }, want: []source.SourceRef{first, second}}, {name: "locations", project: func() []source.SourceRef { return locationEvidence(dnd.LocationRegistry{Locations: []dnd.Location{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, {name: "location occurrences", project: func() []source.SourceRef { return locationOccurrenceEvidence(dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{SourceRefs: []source.SourceRef{first, second}}}}) }, want: []source.SourceRef{first, second}}, } { t.Run(test.name, func(t *testing.T) { got := test.project() if !reflect.DeepEqual(got, test.want) { t.Fatalf("projected references = %#v, want %#v", got, test.want) } got[0].StartUnitID = 99 if first.StartUnitID != 1 { t.Fatal("projector returned aliased reference storage") } }) } } func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot { for _, slot := range slots { if slot.Name == name { return slot } } return contracts.ReferenceSlot{} } func sameReferenceSlotContract(first, second contracts.ReferenceSlot) bool { first.Description = "" second.Description = "" return reflect.DeepEqual(first, second) } func TestAppendNPCRegistriesPreservesOrderAndArrayPresence(t *testing.T) { tests := []struct { name string in []dnd.NPCRegistry want dnd.NPCRegistry }{ {name: "no values", in: nil, want: dnd.NPCRegistry{}}, {name: "nil values", in: []dnd.NPCRegistry{{}, {}}, want: dnd.NPCRegistry{}}, {name: "present empty", in: []dnd.NPCRegistry{{NPCs: []dnd.NPC{}}}, want: dnd.NPCRegistry{NPCs: []dnd.NPC{}}}, {name: "ordered values", in: []dnd.NPCRegistry{{NPCs: []dnd.NPC{{Name: "first"}}}, {NPCs: []dnd.NPC{{Name: "second"}}}}, want: dnd.NPCRegistry{NPCs: []dnd.NPC{{Name: "first"}, {Name: "second"}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := appendNPCRegistries(tt.in) if err != nil || !reflect.DeepEqual(got, tt.want) { t.Fatalf("appendNPCRegistries() = %#v, error = %v, want %#v", got, err, tt.want) } }) } input := []dnd.NPCRegistry{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}} merged, err := appendNPCRegistries(input) if err != nil { t.Fatalf("appendNPCRegistries() error = %v", err) } merged.NPCs[0].SourceRefs[0].StartUnitID = 999 if input[0].NPCs[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("merged NPCs share source reference storage") } } func TestAppendLocationRegistriesPreserveOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.LocationRegistry{{Locations: []dnd.Location{{ID: "one", Name: "First", SourceRefs: refs}}}, {Locations: []dnd.Location{{ID: "two", Name: "Second", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}} got, err := appendLocationRegistries(input) if err != nil || !reflect.DeepEqual([]string{got.Locations[0].Name, got.Locations[1].Name}, []string{"First", "Second"}) { t.Fatalf("appendLocationRegistries() = %#v, %v", got, err) } got.Locations[0].SourceRefs[0].StartUnitID = 99 if input[0].Locations[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged locations share source-reference storage") } for _, values := range [][]dnd.LocationRegistry{nil, {{}, {}}} { result, err := appendLocationRegistries(values) if err != nil || result.Locations != nil { t.Fatalf("nil-only merge = %#v, %v", result, err) } } } func TestAppendLocationOccurrenceListsPreserveOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.LocationOccurrenceList{{Occurrences: []dnd.LocationOccurrence{{LocationID: "one", Name: "First", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: refs}}}, {Occurrences: []dnd.LocationOccurrence{{LocationID: "two", Name: "Second", Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}} got, err := appendLocationOccurrenceLists(input) if err != nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"First", "Second"}) { t.Fatalf("appendLocationOccurrenceLists() = %#v, %v", got, err) } got.Occurrences[0].SourceRefs[0].StartUnitID = 99 if input[0].Occurrences[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged location occurrences share source-reference storage") } for _, values := range [][]dnd.LocationOccurrenceList{nil, {{}, {}}} { result, err := appendLocationOccurrenceLists(values) if err != nil || result.Occurrences != nil { t.Fatalf("nil-only merge = %#v, %v", result, err) } } } func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) { tests := []struct { name string in []dnd.SpellList want dnd.SpellList }{ {name: "no values", in: nil, want: dnd.SpellList{}}, {name: "nil values", in: []dnd.SpellList{{}, {}}, want: dnd.SpellList{}}, {name: "present empty", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}, {name: "ordered values", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "first"}}}, {SpellCasts: []dnd.SpellCast{{Spell: "second"}}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "first"}, {Spell: "second"}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := appendSpellLists(tt.in) if err != nil || !reflect.DeepEqual(got, tt.want) { t.Fatalf("appendSpellLists() = %#v, error = %v, want %#v", got, err, tt.want) } }) } input := []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "Shield", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}} merged, err := appendSpellLists(input) if err != nil { t.Fatalf("appendSpellLists() error = %v", err) } merged.SpellCasts[0].SourceRefs[0].StartUnitID = 999 if input[0].SpellCasts[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("merged spell casts share source reference storage") } } func TestAppendNPCOccurrenceListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}} input := []dnd.NPCOccurrenceList{ {}, {Occurrences: []dnd.NPCOccurrence{}}, {Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}}, {Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, } got, err := appendNPCOccurrenceLists(input) if err != nil { t.Fatalf("appendNPCOccurrenceLists() error = %v", err) } if got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"Aria", "Borin"}) { t.Fatalf("combined occurrences = %#v", got) } got.Occurrences[0].SourceRefs[0].StartUnitID = 999 if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("merged occurrences share source reference storage") } for _, values := range [][]dnd.NPCOccurrenceList{nil, []dnd.NPCOccurrenceList{{}, {}}} { result, err := appendNPCOccurrenceLists(values) if err != nil || result.Occurrences != nil { t.Fatalf("nil-only merge = %#v, %v; want nil occurrences", result, err) } } } func TestAppendSceneDescriptionListsPreservesOrderAndArrayPresence(t *testing.T) { input := []dnd.SceneDescriptionList{ {}, {Scenes: []dnd.SceneDescription{}}, {Scenes: []dnd.SceneDescription{{ID: "first"}}}, {Scenes: []dnd.SceneDescription{{ID: "second"}}}, } got, err := appendSceneDescriptionLists(input) if err != nil || !reflect.DeepEqual(got.Scenes, []dnd.SceneDescription{{ID: "first"}, {ID: "second"}}) { t.Fatalf("appendSceneDescriptionLists() = %#v, error = %v", got, err) } for _, values := range [][]dnd.SceneDescriptionList{nil, []dnd.SceneDescriptionList{{}, {}}} { result, err := appendSceneDescriptionLists(values) if err != nil || result.Scenes != nil { t.Fatalf("nil-only merge = %#v, %v; want nil scenes", result, err) } } } func TestAppendCombatTurnListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.CombatTurnList{ {CombatTurns: []dnd.CombatTurn{{Actor: "first", SourceRefs: refs}}}, {CombatTurns: []dnd.CombatTurn{{Actor: "second"}}}, } got, err := appendCombatTurnLists(input) if err != nil { t.Fatalf("appendCombatTurnLists() error = %v, want nil", err) } if len(got.CombatTurns) != 2 || got.CombatTurns[0].Actor != "first" || got.CombatTurns[1].Actor != "second" { t.Fatalf("combat turns = %#v, want chunk order", got.CombatTurns) } if &got.CombatTurns[0].SourceRefs[0] == &refs[0] { t.Fatal("appendCombatTurnLists() retained nested input aliases") } tests := []struct { name string in []dnd.CombatTurnList want dnd.CombatTurnList }{ {name: "no values", in: nil, want: dnd.CombatTurnList{}}, {name: "nil values", in: []dnd.CombatTurnList{{}, {}}, want: dnd.CombatTurnList{}}, {name: "present empty", in: []dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{}}}, want: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{}}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := appendCombatTurnLists(test.in) if err != nil || !reflect.DeepEqual(got, test.want) { t.Fatalf("appendCombatTurnLists() = %#v, error = %v, want %#v", got, err, test.want) } }) } } func TestAppendEnemyEventListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.EnemyEventList{ {}, {Events: []dnd.EnemyEvent{}}, {Events: []dnd.EnemyEvent{{Name: "first", Kind: dnd.EnemyEventKindEngaged, SourceRefs: refs}}}, {Events: []dnd.EnemyEvent{{Name: "second", Kind: dnd.EnemyEventKindFled, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, } got, err := appendEnemyEventLists(input) if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) { t.Fatalf("appendEnemyEventLists() = %#v, error = %v", got, err) } got.Events[0].SourceRefs[0].StartUnitID = 999 if input[2].Events[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged enemy events share source-reference storage") } for _, values := range [][]dnd.EnemyEventList{nil, []dnd.EnemyEventList{{}, {}}} { result, err := appendEnemyEventLists(values) if err != nil || result.Events != nil { t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err) } } } func TestAppendItemOccurrenceListsPreservesOrderPresenceAndOwnership(t *testing.T) { quantity := 3 refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.ItemOccurrenceList{ {}, {Occurrences: []dnd.ItemOccurrence{}}, {Occurrences: []dnd.ItemOccurrence{{ItemID: "first", Name: "first", Kind: dnd.ItemOccurrenceKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, {Occurrences: []dnd.ItemOccurrence{{ItemID: "second", Name: "second", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, } got, err := appendItemOccurrenceLists(input) if err != nil || got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"first", "second"}) { t.Fatalf("appendItemOccurrenceLists() = %#v, error = %v", got, err) } *got.Occurrences[0].Quantity = 99 got.Occurrences[0].SourceRefs[0].StartUnitID = 999 if quantity != 3 || input[2].Occurrences[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged item occurrences share quantity or source-reference storage") } for _, values := range [][]dnd.ItemOccurrenceList{nil, []dnd.ItemOccurrenceList{{}, {}}} { result, err := appendItemOccurrenceLists(values) if err != nil || result.Occurrences != nil { t.Fatalf("nil-only merge = %#v, %v; want nil events", result, err) } } } func TestAppendItemRegistriesPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.ItemRegistry{{}, {Items: []dnd.Item{}}, {Items: []dnd.Item{{ID: "one", Name: "First", SourceRefs: refs}}}, {Items: []dnd.Item{{ID: "two", Name: "Second", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}} got, err := appendItemRegistries(input) if err != nil || got.Items == nil || !reflect.DeepEqual([]string{got.Items[0].Name, got.Items[1].Name}, []string{"First", "Second"}) { t.Fatalf("appendItemRegistries() = %#v, %v", got, err) } got.Items[0].SourceRefs[0].StartUnitID = 99 if input[2].Items[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged items share source-reference storage") } for _, values := range [][]dnd.ItemRegistry{nil, {{}, {}}} { result, err := appendItemRegistries(values) if err != nil || result.Items != nil { t.Fatalf("nil-only merge = %#v, %v", result, err) } } } func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) { spells, err := appendSpellLists([]dnd.SpellList{{SpellCasts: []dnd.SpellCast{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || spells.SpellCasts[0].SourceRefs == nil { t.Fatalf("appendSpellLists() = %#v, %v; want present-empty source refs", spells, err) } npcs, err := appendNPCRegistries([]dnd.NPCRegistry{{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || npcs.NPCs[0].SourceRefs == nil { t.Fatalf("appendNPCRegistries() = %#v, %v; want present-empty source refs", npcs, err) } turns, err := appendCombatTurnLists([]dnd.CombatTurnList{{CombatTurns: []dnd.CombatTurn{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || turns.CombatTurns[0].SourceRefs == nil { t.Fatalf("appendCombatTurnLists() = %#v, %v; want present-empty source refs", turns, err) } enemyEvents, err := appendEnemyEventLists([]dnd.EnemyEventList{{Events: []dnd.EnemyEvent{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || enemyEvents.Events[0].SourceRefs == nil { t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err) } occurrences, err := appendNPCOccurrenceLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || occurrences.Occurrences[0].SourceRefs == nil { t.Fatalf("appendNPCOccurrenceLists() = %#v, %v; want present-empty source refs", occurrences, err) } events, err := appendItemOccurrenceLists([]dnd.ItemOccurrenceList{{Occurrences: []dnd.ItemOccurrence{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || events.Occurrences[0].SourceRefs == nil { t.Fatalf("appendItemOccurrenceLists() = %#v, %v; want present-empty source refs", events, err) } items, err := appendItemRegistries([]dnd.ItemRegistry{{Items: []dnd.Item{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || items.Items[0].SourceRefs == nil { t.Fatalf("appendItemRegistries() = %#v, %v; want present-empty source refs", items, err) } } func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) { tests := []struct { name string remove func(*pipeline.Registries, **llm.AssetRegistry) wantErr string }{ {name: "chunkers", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.Chunkers = nil }, wantErr: "chunker registry"}, {name: "artifact codecs", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.ArtifactCodecs = nil }, wantErr: "artifact codec registry"}, {name: "artifact evidence", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.ArtifactEvidence = nil }, wantErr: "artifact evidence registry"}, {name: "extractors", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.Extractors = nil }, wantErr: "extractor registry"}, {name: "mergers", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.Mergers = nil }, wantErr: "merger registry"}, {name: "normalizers", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.Normalizers = nil }, wantErr: "normalizer registry"}, {name: "validators", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.Validators = nil }, wantErr: "validator registry"}, {name: "validator chains", remove: func(r *pipeline.Registries, _ **llm.AssetRegistry) { r.ValidatorChains = nil }, wantErr: "validator chain registry"}, {name: "assets", remove: func(_ *pipeline.Registries, assets **llm.AssetRegistry) { *assets = nil }, wantErr: "asset registry"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { registries := completeRegistries() assets := llm.NewAssetRegistry() test.remove(®istries, &assets) err := Register(registries, assets) if err == nil || !strings.Contains(err.Error(), test.wantErr) { t.Fatalf("Register() error = %v, want %q", err, test.wantErr) } if got := registries.Chunkers; got != nil && len(got.RegisteredKeys()) != 0 { t.Fatalf("chunker keys = %#v, want validation before mutation", got.RegisteredKeys()) } }) } } func TestRegisterReportsDuplicateDNDRegistration(t *testing.T) { registries := completeRegistries() assets := llm.NewAssetRegistry() if err := Register(registries, assets); err != nil { t.Fatalf("first Register() error = %v, want nil", err) } err := Register(registries, assets) if err == nil || !strings.Contains(err.Error(), "register dnd spells codec") || !strings.Contains(err.Error(), "already registered") { t.Fatalf("second Register() error = %v, want contextual duplicate error", err) } } func completeRegistries() pipeline.Registries { return pipeline.Registries{ Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), ArtifactEvidence: pipeline.NewArtifactEvidenceRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry(), } } func assertContainsKeys(t *testing.T, name string, got, want []string) { t.Helper() seen := make(map[string]struct{}, len(got)) for _, key := range got { seen[key] = struct{}{} } for _, key := range want { if _, ok := seen[key]; !ok { t.Fatalf("%s keys = %#v, want required key %q", name, got, key) } } } func assertContainsArtifactKinds(t *testing.T, got, want []contracts.ArtifactKind) { t.Helper() seen := make(map[contracts.ArtifactKind]struct{}, len(got)) for _, kind := range got { seen[kind] = struct{}{} } for _, kind := range want { if _, ok := seen[kind]; !ok { t.Fatalf("artifact codec kinds = %#v, want required kind %q", got, kind) } } }