Files
notarius/internal/modules/dnd/normalize/combatturns/normalizer_test.go

324 lines
14 KiB
Go

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 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.NPCList{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
}