303 lines
13 KiB
Go
303 lines
13 KiB
Go
package combatturns
|
|
|
|
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"
|
|
)
|
|
|
|
func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
|
doc := testDocument()
|
|
references := npcReferences(t)
|
|
normalizer, err := New(Options{}, references)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
resolution := " the target is hit "
|
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
|
Actor: " storm ",
|
|
TurnKind: dnd.CombatTurnKindTurn,
|
|
Actions: []dnd.CombatAction{{
|
|
Category: dnd.CombatActionCategoryAttack,
|
|
Declaration: " attacks\n with a sword ",
|
|
Targets: []string{" minion ", "goblin", " unknown combatant "},
|
|
Resolution: &resolution,
|
|
}},
|
|
Summary: " Aria\n attacks ",
|
|
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" || got.Summary != "Aria attacks" || got.Actions[0].Declaration != "attacks with a sword" || got.Actions[0].Resolution == nil || *got.Actions[0].Resolution != "the target is hit" {
|
|
t.Fatalf("normalized turn = %#v, want display-normalized fields", got)
|
|
}
|
|
if got := result.Value.CombatTurns[0].Actions[0].Targets; !reflect.DeepEqual(got, []string{"Goblin", "unknown combatant"}) {
|
|
t.Fatalf("normalized targets = %#v, want canonical deduplicated target and preserved unmatched target", 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{ReasonCodeFieldsNormalized, ReasonCodeActorCanonicalized, ReasonCodeTargetCanonicalized, 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].Actions[0].Targets[0] = "changed"
|
|
if input.CombatTurns[0].Actions[0].Targets[0] == "changed" {
|
|
t.Fatal("normalized targets share input storage")
|
|
}
|
|
}
|
|
|
|
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})
|
|
first.Summary = "first record"
|
|
second := validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90})
|
|
second.Summary = "later record"
|
|
duplicate := cloneCombatTurn(first)
|
|
duplicate.Summary = "must not replace first"
|
|
duplicate.Actions[0].Declaration = "replacement action"
|
|
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].Summary != "first record" || result.Value.CombatTurns[0].Actions[0].Declaration != "Aria attacks" || result.Value.CombatTurns[1].Summary != "later record" || 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})
|
|
base.Round = nil
|
|
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 round", other: withRound(base, 2)},
|
|
{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) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {
|
|
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) != 5 {
|
|
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 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,
|
|
Round: intPointer(1),
|
|
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: actor + " attacks", Targets: []string{}, Resolution: nil}},
|
|
Summary: actor + " attacks",
|
|
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", Aliases: []string{"Storm"}, Description: "a fighter", Relationships: []dnd.NPCRelationship{{Target: "Goblin", Relationship: "fights"}}, SourceRefs: []source.SourceRef{{SourceID: "npc-run", StartUnitID: 1, EndUnitID: 1}}},
|
|
{ID: identity.DeriveID("Goblin"), Name: "Goblin", Aliases: []string{"Minion"}, Description: "a goblin", Relationships: []dnd.NPCRelationship{{Target: "Aria", Relationship: "fights"}}, 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 intPointer(value int) *int { return &value }
|
|
|
|
func withActor(turn dnd.CombatTurn, actor string) dnd.CombatTurn {
|
|
turn.Actor = actor
|
|
turn.Actions = cloneCombatTurn(turn).Actions
|
|
return turn
|
|
}
|
|
|
|
func withKind(turn dnd.CombatTurn, kind dnd.CombatTurnKind) dnd.CombatTurn {
|
|
turn.TurnKind = kind
|
|
turn.Actions = cloneCombatTurn(turn).Actions
|
|
return turn
|
|
}
|
|
|
|
func withRound(turn dnd.CombatTurn, round int) dnd.CombatTurn {
|
|
turn.Round = intPointer(round)
|
|
turn.Actions = cloneCombatTurn(turn).Actions
|
|
return turn
|
|
}
|
|
|
|
func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn {
|
|
turn.SourceRefs = []source.SourceRef{ref}
|
|
turn.Actions = cloneCombatTurn(turn).Actions
|
|
return turn
|
|
}
|