2026-07-21 05:22:12 +00:00
package combatturns
import (
"context"
2026-07-21 07:49:57 -05:00
"fmt"
"math"
2026-07-21 05:22:12 +00:00
"reflect"
2026-07-21 07:49:57 -05:00
"strings"
2026-07-21 05:22:12 +00:00
"testing"
2026-07-21 07:49:57 -05:00
"unicode/utf8"
2026-07-21 05:22:12 +00:00
"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 )
}
2026-07-21 07:49:57 -05:00
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 {
2026-07-21 05:22:12 +00:00
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" )
}
}
2026-07-21 07:49:57 -05:00
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 ) )
}
}
2026-07-21 05:22:12 +00:00
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
}