package register import ( "io/fs" "reflect" "sort" "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" itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents" interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions" npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs" 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" itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents" interactionnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcinteractions" npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs" 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) } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key}) assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, pipeline.DefaultNormalizeModule}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCListKind, dnd.CombatTurnListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/npcs/shape", "extract/dnd/npcs/source_refs", "extract/dnd/npcs/source_relatedness", "normalize/dnd/npcs/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/item-events/shape", "extract/dnd/item-events/source_refs", "extract/dnd/item-events/source_relatedness", "normalize/dnd/item-events/invariants", "extract/dnd/npc-interactions/shape", "extract/dnd/npc-interactions/registry", "extract/dnd/npc-interactions/source_refs", "extract/dnd/npc-interactions/source_relatedness", "normalize/dnd/npc-interactions/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", }) 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/npcs/shape"), pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npcs/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/npcs/shape"), pipeline.Binding("normalize/dnd/npcs/identity"), pipeline.Binding("extract/dnd/npcs/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npcs/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) } itemEventExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-events/source_relatedness"), } itemEventNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/item-events/shape"), pipeline.Binding("normalize/dnd/item-events/invariants"), pipeline.Binding("extract/dnd/item-events/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/item-events/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, itemeventextract.Key); !reflect.DeepEqual(got, itemEventExtractChain) { t.Fatalf("item event extract validator chain = %#v, want %#v", got, itemEventExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, itemeventnormalize.Key); !reflect.DeepEqual(got, itemEventNormalizeChain) { t.Fatalf("item event normalize validator chain = %#v, want %#v", got, itemEventNormalizeChain) } interactionExtractChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-interactions/shape"), pipeline.Binding("extract/dnd/npc-interactions/registry"), pipeline.Binding("extract/dnd/npc-interactions/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"), } interactionNormalizeChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), pipeline.Binding("extract/dnd/npc-interactions/shape"), pipeline.Binding("extract/dnd/npc-interactions/registry"), pipeline.Binding("normalize/dnd/npc-interactions/invariants"), pipeline.Binding("extract/dnd/npc-interactions/source_refs"), pipeline.Binding("generic/valid_json_schema"), pipeline.Binding("extract/dnd/npc-interactions/source_relatedness"), } if got := registries.ValidatorChains.Validators(pipeline.StageExtract, interactionextract.Key); !reflect.DeepEqual(got, interactionExtractChain) { t.Fatalf("NPC interaction extract validator chain = %#v, want %#v", got, interactionExtractChain) } if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, interactionnormalize.Key); !reflect.DeepEqual(got, interactionNormalizeChain) { t.Fatalf("NPC interaction normalize validator chain = %#v, want %#v", got, interactionNormalizeChain) } 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, interactionextract.Key); got != nil { t.Fatalf("NPC interaction merge validator chain = %#v, want absent", got) } assertAssetNamesContain(t, assets.PromptFS, []string{ "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/instructions.md", "dnd.scenes/sharedassets/common-dnd-references.md", "dnd.scenes/sharedassets/common-dnd-system.md", "dnd.scenes/sharedassets/common-dnd-transcript.md", "dnd.scenes/task.md", "dnd.spells/dnd.spells.yaml", "dnd.spells/instructions.md", "dnd.spells/sharedassets/common-dnd-references.md", "dnd.spells/sharedassets/common-dnd-system.md", "dnd.spells/sharedassets/common-dnd-transcript.md", "dnd.spells/task.md", "dnd.npcs/dnd.npcs.yaml", "dnd.npcs/instructions.md", "dnd.npcs/sharedassets/common-dnd-references.md", "dnd.npcs/sharedassets/common-dnd-system.md", "dnd.npcs/sharedassets/common-dnd-transcript.md", "dnd.npcs/task.md", "dnd.combat_turns/dnd.combat_turns.yaml", "dnd.combat_turns/instructions.md", "dnd.combat_turns/sharedassets/common-dnd-references.md", "dnd.combat_turns/sharedassets/common-dnd-system.md", "dnd.combat_turns/sharedassets/common-dnd-transcript.md", "dnd.combat_turns/task.md", "dnd.item_events/dnd.item_events.yaml", "dnd.item_events/instructions.md", "dnd.item_events/sharedassets/common-dnd-extraction-evidence.md", "dnd.item_events/sharedassets/common-dnd-identity.md", "dnd.item_events/sharedassets/common-dnd-references.md", "dnd.item_events/sharedassets/common-dnd-system.md", "dnd.item_events/sharedassets/common-dnd-transcript.md", "dnd.item_events/task.md", "dnd.npc_interactions/dnd.npc_interactions.yaml", "dnd.npc_interactions/instructions.md", "dnd.npc_interactions/sharedassets/common-dnd-npcs.md", "dnd.npc_interactions/task.md", "dnd.scene_descriptions/dnd.scene_descriptions.yaml", "dnd.scene_descriptions/instructions.md", "dnd.scene_descriptions/sharedassets/common-dnd-identity.md", "dnd.scene_descriptions/sharedassets/common-dnd-references.md", "dnd.scene_descriptions/sharedassets/common-dnd-system.md", "dnd.scene_descriptions/sharedassets/common-dnd-transcript.md", "dnd.scene_descriptions/task.md", }) assertAssetNamesContain(t, assets.SchemaFS, []string{ "dnd_scenes.v1.json", "dnd_spells_llm.v1.json", "dnd_npcs_llm.v1.json", "dnd_combat_turns_llm.v1.json", "dnd_item_events_llm.v1.json", "dnd_npc_interactions_llm.v1.json", "dnd_scene_descriptions_llm.v1.json", }) 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.NPCListKind { t.Fatalf("NPC extractor spec = %#v, present = %t; want dnd NPC-list artifact", spec, ok) } if spec, ok := registries.Normalizers.Spec(npcnormalize.Key); !ok || spec.ArtifactKind != dnd.NPCListKind || spec.Stage != pipeline.StageNormalize { t.Fatalf("NPC normalizer spec = %#v, present = %t; want dnd NPC-list 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) } itemEventExtractSpec, itemEventExtractOK := registries.Extractors.Spec(itemeventextract.Key) itemEventNormalizeSpec, itemEventNormalizeOK := registries.Normalizers.Spec(itemeventnormalize.Key) if !itemEventExtractOK || itemEventExtractSpec.ArtifactKind != dnd.ItemEventListKind || !itemEventNormalizeOK || itemEventNormalizeSpec.ArtifactKind != dnd.ItemEventListKind || itemEventNormalizeSpec.Stage != pipeline.StageNormalize || len(itemEventNormalizeSpec.ReferenceSlots) != 0 { t.Fatalf("item event specs = %#v / %#v, present = %t / %t", itemEventExtractSpec, itemEventNormalizeSpec, itemEventExtractOK, itemEventNormalizeOK) } for _, slot := range itemEventExtractSpec.ReferenceSlots { if slot.Required || len(slot.AcceptedArtifactKinds) != 0 { t.Fatalf("item event extractor has a generated-reference dependency: %#v", slot) } } interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key) interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key) if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK) } 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(interactionExtractSpec.ReferenceSlots, "npcs") normalizeRegistrySlot := referenceSlot(interactionNormalizeSpec.ReferenceSlots, "npcs") if !extractRegistrySlot.Required || !reflect.DeepEqual(extractRegistrySlot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.NPCListKind}) || !reflect.DeepEqual(extractRegistrySlot, normalizeRegistrySlot) { t.Fatalf("NPC interaction registry slots disagree: %#v / %#v", interactionExtractSpec.ReferenceSlots, interactionNormalizeSpec.ReferenceSlots) } } func referenceSlot(slots []contracts.ReferenceSlot, name string) contracts.ReferenceSlot { for _, slot := range slots { if slot.Name == name { return slot } } return contracts.ReferenceSlot{} } func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) { tests := []struct { name string in []dnd.NPCList want dnd.NPCList }{ {name: "no values", in: nil, want: dnd.NPCList{}}, {name: "nil values", in: []dnd.NPCList{{}, {}}, want: dnd.NPCList{}}, {name: "present empty", in: []dnd.NPCList{{NPCs: []dnd.NPC{}}}, want: dnd.NPCList{NPCs: []dnd.NPC{}}}, {name: "ordered values", in: []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first"}}}, {NPCs: []dnd.NPC{{Name: "second"}}}}, want: dnd.NPCList{NPCs: []dnd.NPC{{Name: "first"}, {Name: "second"}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := appendNPCLists(tt.in) if err != nil || !reflect.DeepEqual(got, tt.want) { t.Fatalf("appendNPCLists() = %#v, error = %v, want %#v", got, err, tt.want) } }) } input := []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}} merged, err := appendNPCLists(input) if err != nil { t.Fatalf("appendNPCLists() 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 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 TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) { refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}} input := []dnd.NPCInteractionList{ {}, {Interactions: []dnd.NPCInteraction{}}, {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}}, {Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, } got, err := appendNPCInteractionLists(input) if err != nil { t.Fatalf("appendNPCInteractionLists() error = %v", err) } if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) { t.Fatalf("combined interactions = %#v", got) } got.Interactions[0].SourceRefs[0].StartUnitID = 999 if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("merged interactions share source reference storage") } for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} { result, err := appendNPCInteractionLists(values) if err != nil || result.Interactions != nil { t.Fatalf("nil-only merge = %#v, %v; want nil interactions", 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 TestAppendItemEventListsPreservesOrderPresenceAndOwnership(t *testing.T) { quantity := 3 refs := []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}} input := []dnd.ItemEventList{ {}, {Events: []dnd.ItemEvent{}}, {Events: []dnd.ItemEvent{{Name: "first", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: refs}}}, {Events: []dnd.ItemEvent{{Name: "second", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}}}, } got, err := appendItemEventLists(input) if err != nil || got.Events == nil || !reflect.DeepEqual([]string{got.Events[0].Name, got.Events[1].Name}, []string{"first", "second"}) { t.Fatalf("appendItemEventLists() = %#v, error = %v", got, err) } *got.Events[0].Quantity = 99 got.Events[0].SourceRefs[0].StartUnitID = 999 if quantity != 3 || input[2].Events[0].SourceRefs[0].StartUnitID != 1 { t.Fatal("merged item events share quantity or source-reference storage") } for _, values := range [][]dnd.ItemEventList{nil, []dnd.ItemEventList{{}, {}}} { result, err := appendItemEventLists(values) if err != nil || result.Events != nil { t.Fatalf("nil-only merge = %#v, %v; want nil events", 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 := appendNPCLists([]dnd.NPCList{{NPCs: []dnd.NPC{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || npcs.NPCs[0].SourceRefs == nil { t.Fatalf("appendNPCLists() = %#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) } interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || interactions.Interactions[0].SourceRefs == nil { t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err) } events, err := appendItemEventLists([]dnd.ItemEventList{{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{}}}}}) if err != nil || events.Events[0].SourceRefs == nil { t.Fatalf("appendItemEventLists() = %#v, %v; want present-empty source refs", events, 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: "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(), 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) } } } func assertAssetNamesContain(t *testing.T, getFS func() (fs.FS, error), want []string) { t.Helper() fSys, err := getFS() if err != nil { t.Fatalf("asset filesystem error = %v, want nil", err) } var got []string if err := fs.WalkDir(fSys, ".", func(path string, entry fs.DirEntry, err error) error { if err == nil && !entry.IsDir() { got = append(got, path) } return err }); err != nil { t.Fatalf("walk assets: %v", err) } sort.Strings(got) seen := make(map[string]struct{}, len(got)) for _, name := range got { seen[name] = struct{}{} } for _, name := range want { if _, ok := seen[name]; !ok { t.Fatalf("asset names = %#v, want required asset %q", got, name) } } }