package combatturns import ( "context" "fmt" "math" "reflect" "strings" "testing" "unicode/utf8" "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" ) func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) { doc := testDocument() references := npcReferences(t) normalizer, err := New(Options{}, references) if err != nil { t.Fatalf("New() error = %v", err) } input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ Actor: " aria ", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, }}} original := cloneCombatTurn(input.CombatTurns[0]) result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}, }) if err != nil { t.Fatalf("Normalize() error = %v", err) } if got := result.Value.CombatTurns[0]; got.Actor != "Aria" { t.Fatalf("normalized turn = %#v, want canonical actor", got) } wantRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}} if !reflect.DeepEqual(result.Value.CombatTurns[0].SourceRefs, wantRefs) { t.Fatalf("normalized refs = %#v, want %#v", result.Value.CombatTurns[0].SourceRefs, wantRefs) } for _, reason := range []string{ReasonCodeActorCanonicalized, ReasonCodeSourceRefsNormalized} { if !hasWarningReason(result.Warnings, reason) { t.Fatalf("warnings = %#v, missing reason %q", result.Warnings, reason) } } if !reflect.DeepEqual(input.CombatTurns[0], original) { t.Fatalf("Normalize() mutated input: got %#v, want %#v", input.CombatTurns[0], original) } result.Value.CombatTurns[0].SourceRefs[0].StartUnitID = 999 if input.CombatTurns[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("normalized source refs share input storage") } } func TestNormalizeResolvesOperationNPCOverrideWithoutSingletonMetadata(t *testing.T) { doc := testDocument() normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ Actor: "aria", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}, }}} result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}, References: npcReferences(t), }) if err != nil { t.Fatalf("Normalize() error = %v", err) } turn := result.Value.CombatTurns[0] if turn.Actor != "Aria" { t.Fatalf("operation-normalized turn = %#v, want Aria", turn) } if metadata := normalizer.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil { t.Fatalf("singleton metadata = %#v, want no operation-varying NPC identity", metadata) } } func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} first := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}) second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90}) duplicate := cloneCombatTurn(first) invalid := validTurn("Unknown", source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}) input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{second, first, duplicate, invalid}} normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}, }) if err != nil { t.Fatalf("Normalize() error = %v", err) } if len(result.Value.CombatTurns) != 3 { t.Fatalf("normalized turn count = %d, want 3", len(result.Value.CombatTurns)) } if result.Value.CombatTurns[0].SourceRefs[0].StartUnitID != 50 || result.Value.CombatTurns[1].SourceRefs[0].StartUnitID != 90 || result.Value.CombatTurns[2].Actor != "Unknown" { t.Fatalf("normalized order/value = %#v, want chronology then invalid evidence", result.Value.CombatTurns) } if !hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) || !hasWarningReason(result.Warnings, ReasonCodeDuplicateCollapsed) { t.Fatalf("warnings = %#v, want reorder and duplicate warnings", result.Warnings) } for _, warning := range result.Warnings { if warning.ReasonCode == ReasonCodeDuplicateCollapsed && warning.Scope != "combat_turns[1]" { t.Fatalf("duplicate warning = %#v, want retained input scope combat_turns[1]", warning) } } } func TestNormalizeUsesDocumentOrderForReferencesAndChronology(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{ validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}), validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}), {Actor: "Goblin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{ {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}, }}, }} normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}}) if err != nil { t.Fatalf("Normalize() error = %v", err) } if got := result.Value.CombatTurns; got[0].Actor != "Aria" || got[1].Actor != "Goblin" || got[2].Actor != "Borin" { t.Fatalf("turn order = %#v, want source chronology", got) } refs := result.Value.CombatTurns[1].SourceRefs if refs[0].StartUnitID != 30 || refs[1].StartUnitID != 10 || refs[2].StartUnitID != 999 { t.Fatalf("turn source refs = %#v, want source-document order and invalid fallback", refs) } } func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}} input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{ validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}), validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 50, EndUnitID: 50}), }} normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}}) if err != nil { t.Fatalf("Normalize() error = %v", err) } if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor}; !reflect.DeepEqual(got, []string{"Aria", "Borin"}) { t.Fatalf("equal-position order = %#v, want stable input order", got) } if hasWarningReason(result.Warnings, ReasonCodeTurnsReordered) { t.Fatalf("warnings = %#v, equal-position stable sort should not warn", result.Warnings) } } func TestNormalizeDoesNotCollapseDifferentIdentityDimensions(t *testing.T) { doc := testDocument() base := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}) tests := []struct { name string other dnd.CombatTurn }{ {name: "different actor", other: withActor(base, "Borin")}, {name: "different turn kind", other: withKind(base, dnd.CombatTurnKindReaction)}, {name: "different evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20})}, {name: "invalid evidence", other: withRef(base, source.SourceRef{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999})}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{ Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{base, test.other}}}, }) if err != nil { t.Fatalf("Normalize() error = %v", err) } if len(result.Value.CombatTurns) != 2 { t.Fatalf("normalized turn count = %d, want distinct records", len(result.Value.CombatTurns)) } }) } } func TestNormalizePreservesNilAndPresentEmptyStorage(t *testing.T) { normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } for _, input := range []dnd.CombatTurnList{ {}, {CombatTurns: []dnd.CombatTurn{}}, } { result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}}) if err != nil { t.Fatalf("Normalize() error = %v", err) } if (result.Value.CombatTurns == nil) != (input.CombatTurns == nil) { t.Fatalf("nil/present-empty distinction lost: input %#v output %#v", input.CombatTurns, result.Value.CombatTurns) } } } func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T) { unbound, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy { t.Fatalf("unbound metadata = %#v", metadata) } if got := unbound.CheckpointFingerprints(); len(got) != 3 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" || got[2].Name != "npc_registry" || got[2].Value == "" { t.Fatalf("unbound fingerprints = %#v", got) } bound, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatalf("bound New() error = %v", err) } metadata := bound.ManifestMetadata() if metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { t.Fatalf("bound metadata = %#v", metadata) } if got := bound.CheckpointFingerprints(); len(got) != 3 || got[2].Name != "npc_registry" || got[2].Value == "" { t.Fatalf("bound fingerprints = %#v", got) } if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.CombatTurnListKind || !reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) || len(spec.ReferenceSlots) != 1 || spec.ReferenceSlots[0].Name != NPCRegistryReferenceSlot { t.Fatalf("ModuleSpec() = %#v", spec) } registry := pipeline.NewNormalizerRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v", err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown option") } if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil { t.Fatal("New() accepted multiple reference sets") } } func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) { removed := make([]int, 25) for index := range removed { removed[index] = math.MaxInt - index } warning := duplicateWarning(7, removed) if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed { t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning) } if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) { t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message) } if !strings.Contains(warning.Message, "5 additional issue(s) omitted") { t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message) } if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 { t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message)) } } func TestNormalizerRejectsNilAndCanceledCalls(t *testing.T) { normalizer, err := New(Options{}) if err != nil { t.Fatalf("New() error = %v", err) } if _, err := normalizer.Normalize(nil, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil { t.Fatal("Normalize() accepted nil context") } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := normalizer.Normalize(ctx, contracts.TypedNormalizeRequest[dnd.CombatTurnList]{}); err == nil { t.Fatal("Normalize() accepted canceled context") } } func validTurn(actor string, ref source.SourceRef) dnd.CombatTurn { return dnd.CombatTurn{ Actor: actor, TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{ref}, } } func testDocument() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10, Text: "Aria attacks the goblin."}, {ID: 20, Text: "The goblin is hit."}, {ID: 30, Text: "The goblin falls."}}} } func npcReferences(t *testing.T) contracts.ReferenceSet { t.Helper() npcs := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Aria"), Name: "Aria", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, {ID: identity.DeriveID("Goblin"), Name: "Goblin", SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}}, }} content, err := npccodec.New().Encode(npcs) if err != nil { t.Fatalf("encode NPC references: %v", err) } return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ NPCRegistryReferenceSlot: { Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot, AcceptedMediaTypes: []string{npccodec.MediaType}, MaxBytes: NPCRegistryMaxBytes}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, }, }} } func hasWarningReason(warnings []contracts.Warning, reason string) bool { for _, warning := range warnings { if warning.ReasonCode == reason { return true } } return false } func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn { turn.Actor = actor return turn } func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn { turn.TurnKind = kind return turn } func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { turn.SourceRefs = []source.SourceRef{ref} return turn }