package npcs import ( "context" "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/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" identity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) func TestModuleContractAndIdentity(t *testing.T) { if _, err := DecodeOptions(nil); err != nil { t.Fatalf("DecodeOptions(nil) error = %v, want nil", err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) } want := pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: dnd.NPCListKind, } if got := ModuleSpec(); !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } if slots := New(Options{}).ReferenceSlots(); slots != nil { t.Fatalf("ReferenceSlots() = %#v, want nil", slots) } registry := pipeline.NewNormalizerRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } if registered, ok := registry.SpecForArtifact(Key, dnd.NPCListKind); !ok || !reflect.DeepEqual(registered, want) { t.Fatalf("registered spec = %#v, ok = %t, want %#v", registered, ok, want) } if err := Register(nil); err == nil || !strings.Contains(err.Error(), "normalizer registry") { t.Fatalf("Register(nil) error = %v, want registry error", err) } normalizer := New(Options{}) metadata := normalizer.ManifestMetadata() if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy { t.Fatalf("metadata = %#v, want identity and normalization policies", metadata) } fingerprints := normalizer.CheckpointFingerprints() wantFingerprints := []pipeline.CheckpointFingerprint{ {Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, } if !reflect.DeepEqual(fingerprints, wantFingerprints) { t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints) } fingerprints[0].Value = "changed" if got := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(got, wantFingerprints) { t.Fatalf("fingerprints were not defensive: %#v", got) } } func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) { input := dnd.NPCList{NPCs: []dnd.NPC{{ ID: "wrong", Name: " Lady\tAsh ", Aliases: []string{" Ash ", " L.A. ", "l.a.", " Lady Ash "}, Description: " first description\n", Relationships: []dnd.NPCRelationship{ {Target: " Lord\nOak ", Relationship: " friend "}, {Target: "lord oak", Relationship: "friend"}, }, SourceRefs: []source.SourceRef{ {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, {SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, }, }}} result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) } got := result.Value.NPCs[0] if got.Name != "Lady Ash" || got.Description != "first description" { t.Fatalf("normalized fields = %#v, want normalized display and description", got) } if !reflect.DeepEqual(got.Aliases, []string{"Ash", "L.A."}) { t.Fatalf("aliases = %#v, want canonical alias removal and deduplication", got.Aliases) } wantRelationships := []dnd.NPCRelationship{{Target: "Lord Oak", Relationship: "friend"}} if !reflect.DeepEqual(got.Relationships, wantRelationships) { t.Fatalf("relationships = %#v, want %#v", got.Relationships, wantRelationships) } wantRefs := []source.SourceRef{ {SourceID: "a", StartUnitID: 4, EndUnitID: 4}, {SourceID: "b", StartUnitID: 2, EndUnitID: 3}, } if !reflect.DeepEqual(got.SourceRefs, wantRefs) { t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs) } if got.ID != identity.DeriveID("Lady Ash") { t.Fatalf("ID = %q, want derived ID", got.ID) } if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") || !hasWarning(result.Warnings, ReasonCodeSourceReferencesNormalized, "npcs[0]") || !hasWarning(result.Warnings, ReasonCodeNPCIDRecomputed, "npcs[0]") { t.Fatalf("warnings = %#v, want field, source, and ID warnings", result.Warnings) } } func TestNormalizeConsolidatesIdentityComponentsInStableOrder(t *testing.T) { input := dnd.NPCList{NPCs: []dnd.NPC{ { Name: " Captain Vale ", Description: "first description", Aliases: []string{"Vale"}, Relationships: []dnd.NPCRelationship{{Target: "Archivist", Relationship: "knows"}}, SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "Captain Vale", Description: "second description", Aliases: []string{"CV"}, SourceRefs: []source.SourceRef{{SourceID: "b", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "The Sage", Description: "sage description", Aliases: []string{"Archivist"}, SourceRefs: []source.SourceRef{{SourceID: "c", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "Archivist", Description: "later sage description", Aliases: []string{"Chronicler"}, SourceRefs: []source.SourceRef{{SourceID: "d", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "North", Description: "north description", SourceRefs: []source.SourceRef{{SourceID: "e", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "North Old", Description: "old north description", Aliases: []string{"North"}, SourceRefs: []source.SourceRef{{SourceID: "f", StartUnitID: 1, EndUnitID: 1}}, }, { Name: "North Renamed", Description: "renamed north description", Aliases: []string{"North Old"}, SourceRefs: []source.SourceRef{{SourceID: "g", StartUnitID: 1, EndUnitID: 1}}, }, {Name: "Red", Description: "red", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "h", StartUnitID: 1, EndUnitID: 1}}}, {Name: "Blue", Description: "blue", Aliases: []string{"Shared"}, SourceRefs: []source.SourceRef{{SourceID: "i", StartUnitID: 1, EndUnitID: 1}}}, }} result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input)) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) } if got := len(result.Value.NPCs); got != 5 { t.Fatalf("normalized NPC count = %d, want five components", got) } first := result.Value.NPCs[0] if first.Name != "Captain Vale" || first.Description != "first description" || !reflect.DeepEqual(first.Aliases, []string{"Vale", "CV"}) { t.Fatalf("first component = %#v, want first description and ordered aliases", first) } if !reflect.DeepEqual(first.SourceRefs, []source.SourceRef{ {SourceID: "a", StartUnitID: 1, EndUnitID: 1}, {SourceID: "b", StartUnitID: 1, EndUnitID: 1}, }) { t.Fatalf("first provenance = %#v, want unioned refs", first.SourceRefs) } second := result.Value.NPCs[1] if second.Name != "The Sage" || !reflect.DeepEqual(second.Aliases, []string{"Archivist", "Chronicler"}) || second.Description != "sage description" { t.Fatalf("canonical-to-alias component = %#v, want consolidated sage", second) } if first.Relationships[0].Target != "The Sage" { t.Fatalf("relationship target = %q, want The Sage", first.Relationships[0].Target) } if !hasWarning(result.Warnings, ReasonCodeRelationshipTargetCanonicalized, "npcs[0]") || !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") || !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[2]") { t.Fatalf("warnings = %#v, want collapse and target warnings", result.Warnings) } if got := result.Value.NPCs[2].Aliases; !reflect.DeepEqual(got, []string{"North Old", "North Renamed"}) { t.Fatalf("transitive aliases = %#v, want ordered canonical members", got) } if result.Value.NPCs[3].Name != "Red" || result.Value.NPCs[4].Name != "Blue" { t.Fatalf("shared-alias ordering = %#v, want Red then Blue", result.Value.NPCs[3:]) } } func TestNormalizePreservesInvalidEvidenceAndDoesNotAliasInput(t *testing.T) { invalid := dnd.NPCList{NPCs: []dnd.NPC{{ Name: " ", Aliases: []string{""}, Description: " ", Relationships: []dnd.NPCRelationship{{Target: " ", Relationship: " "}}, SourceRefs: []source.SourceRef{{SourceID: "source", StartUnitID: 9, EndUnitID: 9}}, }}} original := cloneNPCListForTest(invalid) result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(invalid)) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) } if !reflect.DeepEqual(invalid, original) { t.Fatalf("normalizer mutated input: got %#v, want %#v", invalid, original) } if result.Value.NPCs[0].Name != "" || result.Value.NPCs[0].Aliases[0] != "" || result.Value.NPCs[0].Relationships[0].Target != "" { t.Fatalf("invalid evidence was unexpectedly removed: %#v", result.Value.NPCs[0]) } result.Value.NPCs[0].Aliases[0] = "changed" result.Value.NPCs[0].Relationships[0].Target = "changed" result.Value.NPCs[0].SourceRefs[0].SourceID = "changed" if invalid.NPCs[0].Aliases[0] != "" || invalid.NPCs[0].Relationships[0].Target != " " || invalid.NPCs[0].SourceRefs[0].SourceID != "source" { t.Fatalf("output aliases input storage: input = %#v", invalid) } } func TestNormalizeHandlesNilAndCanceledCalls(t *testing.T) { normalizer := New(Options{}) result, err := normalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: nil})) if err != nil || result.Value.NPCs != nil { t.Fatalf("nil list result = %#v, error = %v, want nil NPC slice", result.Value, err) } canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := normalizer.Normalize(canceled, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context error") { t.Fatalf("canceled Normalize() error = %v, want context error", err) } if _, err := normalizer.Normalize(nil, normalizeRequest(dnd.NPCList{})); err == nil || !strings.Contains(err.Error(), "context must not be nil") { t.Fatalf("nil context Normalize() error = %v, want context error", err) } var nilNormalizer *Normalizer if _, err := nilNormalizer.Normalize(context.Background(), normalizeRequest(dnd.NPCList{})); err == nil { t.Fatal("nil normalizer Normalize() error = nil, want error") } } func TestNormalizePreservesPresentEmptyAliases(t *testing.T) { result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(dnd.NPCList{NPCs: []dnd.NPC{{ Name: "Hooded Guard", Aliases: []string{}, Description: "A distinguishable sentry.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, }}})) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) } if result.Value.NPCs[0].Aliases == nil || len(result.Value.NPCs[0].Aliases) != 0 { t.Fatalf("aliases = %#v, want present empty array", result.Value.NPCs[0].Aliases) } } func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPCList] { return contracts.TypedNormalizeRequest[dnd.NPCList]{ MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}, } } func cloneNPCListForTest(input dnd.NPCList) dnd.NPCList { output := dnd.NPCList{} if input.NPCs != nil { output.NPCs = make([]dnd.NPC, len(input.NPCs)) for index, npc := range input.NPCs { output.NPCs[index] = cloneNPC(npc) } } return output } func hasWarning(warnings []contracts.Warning, reason, scope string) bool { for _, warning := range warnings { if warning.ReasonCode == reason && warning.Scope == scope { return true } } return false }