package npcinteractions import ( "context" "reflect" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeCanonicalizesAndClones(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatalf("New() error = %v", err) } input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, }}} original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...) result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) if err != nil { t.Fatalf("Normalize() error = %v", err) } got := result.Value.Interactions[0] if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { t.Fatalf("normalized interaction = %#v", got) } if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { t.Fatalf("warnings = %#v", result.Warnings) } if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) { t.Fatalf("Normalize() mutated input: %#v", input) } second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}}) if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err) } result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999 if input.Interactions[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("normalized source refs share input storage") } } func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *testing.T) { normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil { t.Fatal("Normalize() accepted an unbound NPC registry") } for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} { result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t), }) if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) { t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input) } } if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { t.Fatalf("operation registry leaked into metadata: %#v", metadata) } } func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) { doc := testDocument() normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}} result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) { t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err) } } func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} ref := func(unit int) source.SourceRef { return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} } first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50)) input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)), first, first, interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)), interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)), interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)), }} normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) if err != nil { t.Fatal(err) } got := result.Value.Interactions if len(got) != 5 { t.Fatalf("interaction count = %d, want 5: %#v", len(got), got) } if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 { t.Fatalf("canonical order = %#v", got) } if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { t.Fatalf("warnings = %#v", result.Warnings) } } func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 { t.Fatalf("ModuleSpec() = %#v", spec) } if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { t.Fatalf("metadata = %#v", metadata) } if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" { t.Fatalf("fingerprints = %#v", fingerprints) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted an unknown option") } } func TestNormalizeBoundsWarnings(t *testing.T) { count := diagnostics.MaxWarnings + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)} for index := range doc.Units { doc.Units[index].ID = index + 1 unitID := count - index input.Interactions[index] = interaction( "Ária", dnd.NPCInteractionKindDialogue, source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}, ) } normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, }) if err != nil { t.Fatal(err) } if len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted { t.Fatalf("warnings = %#v", result.Warnings) } } func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction { return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} } func testDocument() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}} } func npcReferences(t *testing.T) contracts.ReferenceSet { t.Helper() value := dnd.NPCList{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Ária"), Name: "Ária", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Borin"), Name: "Borin", SourceRefs: []source.SourceRef{{SourceID: "registry", StartUnitID: 1, EndUnitID: 1}}}, }} content, err := npccodec.New().Encode(value) if err != nil { t.Fatalf("encode registry: %v", err) } return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: { Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, Required: true, AcceptedMediaTypes: []string{npccodec.MediaType}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind}, MaxBytes: NPCRegistryMaxBytes}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, }}} } func hasWarning(warnings []contracts.Warning, reason string) bool { for _, warning := range warnings { if warning.ReasonCode == reason { return true } } return false }