2026-07-20 20:52:27 +00:00
package spells
import (
"context"
"fmt"
"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"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
)
func TestModuleContractAndStrictOptions ( 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 {
2026-08-03 16:49:51 +00:00
Key : Key ,
Stage : pipeline . StageNormalize ,
ExecutionClass : contracts . ExecutionClassDeterministic ,
Requires : [ ] string { "merged" } ,
Provides : [ ] string { "normalized" } ,
ArtifactKind : dnd . SpellListKind ,
2026-07-20 20:52:27 +00:00
ReferenceSlots : [ ] contracts . ReferenceSlot { {
Name : spellcatalog . SpellCatalogReferenceSlot ,
2026-07-20 19:44:35 -05:00
Description : "Optional canonical spell-name catalog used for normalization and duplicate identity." ,
2026-07-20 20:52:27 +00:00
AcceptedMediaTypes : [ ] string { "application/json" } ,
MaxBytes : 1048576 ,
} } ,
}
if got := ModuleSpec ( ) ; ! reflect . DeepEqual ( got , want ) {
t . Fatalf ( "ModuleSpec() = %#v, want %#v" , got , want )
}
registry := pipeline . NewNormalizerRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatalf ( "Register() error = %v, want nil" , err )
}
registered , ok := registry . SpecForArtifact ( Key , dnd . SpellListKind )
if ! 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 )
}
}
func TestNewBuildsEmbeddedAndOverlayCatalogs ( t * testing . T ) {
base , err := New ( Options { } )
if err != nil {
t . Fatalf ( "New() error = %v, want nil" , err )
}
if base . effectiveCatalog . BaseID ( ) != spellcatalog . SRD5E2014ID || base . effectiveCatalog . Digest ( ) == "" || len ( base . effectiveCatalog . OverlayIDs ( ) ) != 0 {
t . Fatalf ( "base catalog identity = %#v, want embedded catalog identity" , base . effectiveCatalog )
}
if canonical , ok := base . effectiveCatalog . Lookup ( " cure wounds " ) ; ! ok || canonical != "Cure Wounds" {
t . Fatalf ( "base catalog lookup = %q, %t, want Cure Wounds" , canonical , ok )
}
if canonical , ok := base . effectiveCatalog . Lookup ( "Arcanist's Magic Aura" ) ; ! ok || canonical != "Arcanist’ s Magic Aura" {
t . Fatalf ( "apostrophe lookup = %q, %t, want canonical curly apostrophe spelling" , canonical , ok )
}
overlay , err := New ( Options { } , overlayReference ( ) )
if err != nil {
t . Fatalf ( "New(overlay) error = %v, want nil" , err )
}
if got := overlay . effectiveCatalog . OverlayIDs ( ) ; ! reflect . DeepEqual ( got , [ ] string { "campaign.example" } ) {
t . Fatalf ( "overlay IDs = %#v, want campaign.example" , got )
}
if canonical , ok := overlay . effectiveCatalog . Lookup ( " emberfall aegis " ) ; ! ok || canonical != "Aegis of Emberfall" {
t . Fatalf ( "overlay alias lookup = %q, %t, want Aegis of Emberfall" , canonical , ok )
}
}
func TestNewRejectsInvalidCatalogReferencesDuringConstruction ( t * testing . T ) {
invalid := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
spellcatalog . SpellCatalogReferenceSlot : {
Items : [ ] contracts . ReferenceItem { { MediaType : "text/plain" , Content : [ ] byte ( "not a catalog" ) } } ,
} ,
} }
if _ , err := New ( Options { } , invalid ) ; err == nil || ! strings . Contains ( err . Error ( ) , "application/json" ) {
t . Fatalf ( "New(invalid reference) error = %v, want media type failure" , err )
}
tooMany := overlayReference ( )
slot := tooMany . Slots [ spellcatalog . SpellCatalogReferenceSlot ]
slot . Items = append ( slot . Items , slot . Items [ 0 ] )
tooMany . Slots [ spellcatalog . SpellCatalogReferenceSlot ] = slot
if _ , err := New ( Options { } , tooMany ) ; err == nil || ! strings . Contains ( err . Error ( ) , "zero or one item" ) {
t . Fatalf ( "New(duplicated reference) error = %v, want multiplicity failure" , err )
}
}
func TestIdentityAndMetadataAreDefensive ( t * testing . T ) {
normalizer , err := New ( Options { } , overlayReference ( ) )
if err != nil {
t . Fatal ( err )
}
2026-07-25 12:30:42 +00:00
if NormalizationPolicy != "dnd.spells.normalize.v2" {
t . Fatalf ( "NormalizationPolicy = %q, want v2 policy" , NormalizationPolicy )
}
2026-07-20 20:52:27 +00:00
fingerprints := normalizer . CheckpointFingerprints ( )
2026-07-24 14:20:10 +00:00
if len ( fingerprints ) != 2 || fingerprints [ 0 ] . Name != "effective_catalog" || fingerprints [ 0 ] . Value != normalizer . effectiveCatalog . Digest ( ) || fingerprints [ 1 ] != ( pipeline . CheckpointFingerprint { Name : "normalization_policy" , Value : normalizationPolicy } ) {
t . Fatalf ( "fingerprints = %#v, want catalog and normalization policy fingerprints" , fingerprints )
2026-07-20 20:52:27 +00:00
}
fingerprints [ 0 ] . Name = "changed"
fingerprints [ 0 ] . Value = "changed"
2026-07-24 14:20:10 +00:00
if got := normalizer . CheckpointFingerprints ( ) ; len ( got ) != 2 || got [ 0 ] . Name != "effective_catalog" || got [ 0 ] . Value != normalizer . effectiveCatalog . Digest ( ) || got [ 1 ] != ( pipeline . CheckpointFingerprint { Name : "normalization_policy" , Value : normalizationPolicy } ) {
2026-07-20 20:52:27 +00:00
t . Fatalf ( "fingerprints were not defensive: %#v" , got )
}
metadata := normalizer . ManifestMetadata ( )
2026-07-25 12:30:42 +00:00
if metadata [ "catalog_base_id" ] != spellcatalog . SRD5E2014ID || metadata [ "catalog_digest" ] != normalizer . effectiveCatalog . Digest ( ) || metadata [ "normalization_policy" ] != normalizationPolicy {
2026-07-20 20:52:27 +00:00
t . Fatalf ( "metadata = %#v, want catalog identity" , metadata )
}
metadata [ "catalog_overlay_ids" ] . ( [ ] string ) [ 0 ] = "changed"
if got := normalizer . ManifestMetadata ( ) [ "catalog_overlay_ids" ] . ( [ ] string ) ; ! reflect . DeepEqual ( got , [ ] string { "campaign.example" } ) {
t . Fatalf ( "metadata overlay IDs were not defensive: %#v" , got )
}
}
func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames ( t * testing . T ) {
normalizer := newNormalizer ( t )
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast {
{ Spell : " cure wounds " } ,
{ Spell : "Arcanist's Magic Aura" } ,
{ Spell : "Emberfall Aegis" } ,
{ Spell : "Mystery\nSpell\tName" } ,
{ Spell : "Cure Wounds" } ,
} }
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
wantNames := [ ] string { "Cure Wounds" , "Arcanist’ s Magic Aura" , "Aegis of Emberfall" , "Mystery\nSpell\tName" , "Cure Wounds" }
for index , want := range wantNames {
if result . Value . SpellCasts [ index ] . Spell != want {
t . Fatalf ( "spell[%d] = %q, want %q" , index , result . Value . SpellCasts [ index ] . Spell , want )
}
}
2026-08-27 16:04:32 +00:00
canonicalized := diagnosticByReason ( result . Diagnostics , ReasonCodeSpellNameCanonicalized )
unresolved := diagnosticByReason ( result . Diagnostics , ReasonCodeSpellNameUnresolved )
2026-08-27 16:40:17 +00:00
if canonicalized == nil || canonicalized . Disposition != contracts . DiagnosticDispositionObservation || canonicalized . OccurrenceCount != 3 || unresolved == nil || unresolved . Disposition != contracts . DiagnosticDispositionAdvisory || unresolved . OccurrenceCount != 1 || unresolved . Samples [ 0 ] . Scope != "spell_casts[3]" || strings . Contains ( unresolved . Samples [ 0 ] . Message , "Mystery\nSpell" ) || ! strings . Contains ( unresolved . Samples [ 0 ] . Message , ` Mystery\nSpell\tName ` ) {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v, want grouped canonicalization and unresolved-quality diagnostics" , result . Diagnostics )
2026-07-20 20:52:27 +00:00
}
}
2026-08-27 18:41:59 +00:00
func TestNormalizeLimitsFindingsWithoutChangingSpellValues ( t * testing . T ) {
2026-08-27 16:40:17 +00:00
input := dnd . SpellList { SpellCasts : make ( [ ] dnd . SpellCast , contracts . MaxDiagnosticSamples + 1 ) }
2026-08-09 02:22:23 +00:00
for index := range input . SpellCasts {
input . SpellCasts [ index ] . Spell = fmt . Sprintf ( "Unknown Spell %d" , index )
}
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v" , err )
}
if ! reflect . DeepEqual ( result . Value , input ) {
t . Fatalf ( "normalized value = %#v, want unresolved spell values preserved" , result . Value )
}
2026-08-27 16:40:17 +00:00
if len ( result . Diagnostics ) != 1 || result . Diagnostics [ 0 ] . ReasonCode != ReasonCodeSpellNameUnresolved || result . Diagnostics [ 0 ] . Disposition != contracts . DiagnosticDispositionAdvisory || result . Diagnostics [ 0 ] . OccurrenceCount != len ( input . SpellCasts ) || len ( result . Diagnostics [ 0 ] . Samples ) != contracts . MaxDiagnosticSamples {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v" , result . Diagnostics )
2026-08-09 02:22:23 +00:00
}
}
2026-07-20 20:52:27 +00:00
func TestNormalizeBoundsUnicodeNamesAndQuotesCanonicalReplacement ( t * testing . T ) {
longName := strings . Repeat ( "火" , 140 )
reference := spellCatalogReference ( fmt . Sprintf ( ` { "schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[ { "id":"campaign.long","ruleset":"dnd-5e-2014","source": { "title":"Private campaign source"},"spells":[ { "name":%q,"aliases":["long alias"]}]}]} ` , longName ) )
normalizer , err := New ( Options { } , reference )
if err != nil {
t . Fatalf ( "New() error = %v, want nil" , err )
}
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequest ( dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { { Spell : "long alias" } } } ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
2026-08-27 16:04:32 +00:00
diagnostic := diagnosticByReason ( result . Diagnostics , ReasonCodeSpellNameCanonicalized )
if diagnostic == nil || ! utf8 . ValidString ( diagnostic . Samples [ 0 ] . Message ) || ! strings . Contains ( diagnostic . Samples [ 0 ] . Message , "…" ) || strings . Contains ( diagnostic . Samples [ 0 ] . Message , longName ) {
t . Fatalf ( "diagnostic = %#v, want valid bounded Unicode diagnostic" , diagnostic )
2026-07-20 20:52:27 +00:00
}
if got := result . Value . SpellCasts [ 0 ] . Spell ; got != longName {
t . Fatalf ( "canonical value = %q, want full catalog name" , got )
}
}
func TestNormalizeSortsAndDeduplicatesExactSourceReferences ( t * testing . T ) {
normalizer := newNormalizer ( t )
inputRefs := [ ] source . SourceRef {
{ SourceID : "source-b" , StartUnitID : 4 , EndUnitID : 5 } ,
{ SourceID : "source-a" , StartUnitID : 3 , EndUnitID : 4 } ,
{ SourceID : "source-a" , StartUnitID : 3 , EndUnitID : 4 } ,
{ SourceID : "source-a" , StartUnitID : 1 , EndUnitID : 2 } ,
{ SourceID : "source-a" , StartUnitID : 2 , EndUnitID : 3 } ,
{ SourceID : "source-a" , StartUnitID : 1 , EndUnitID : 4 } ,
}
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { { Spell : "Cure Wounds" , SourceRefs : inputRefs } } }
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
wantRefs := [ ] source . SourceRef {
{ SourceID : "source-a" , StartUnitID : 1 , EndUnitID : 2 } ,
{ SourceID : "source-a" , StartUnitID : 1 , EndUnitID : 4 } ,
{ SourceID : "source-a" , StartUnitID : 2 , EndUnitID : 3 } ,
{ SourceID : "source-a" , StartUnitID : 3 , EndUnitID : 4 } ,
{ SourceID : "source-b" , StartUnitID : 4 , EndUnitID : 5 } ,
}
if ! reflect . DeepEqual ( result . Value . SpellCasts [ 0 ] . SourceRefs , wantRefs ) {
t . Fatalf ( "source refs = %#v, want %#v" , result . Value . SpellCasts [ 0 ] . SourceRefs , wantRefs )
}
2026-08-27 16:04:32 +00:00
diagnostic := diagnosticByReason ( result . Diagnostics , ReasonCodeSourceReferencesNormalized )
if diagnostic == nil || ! strings . Contains ( diagnostic . Samples [ 0 ] . Message , "original count 6" ) || ! strings . Contains ( diagnostic . Samples [ 0 ] . Message , "final count 5" ) || ! strings . Contains ( diagnostic . Samples [ 0 ] . Message , "order changed true" ) || ! strings . Contains ( diagnostic . Samples [ 0 ] . Message , "duplicates removed 1" ) {
t . Fatalf ( "diagnostics = %#v, want source normalization observation" , result . Diagnostics )
2026-07-20 20:52:27 +00:00
}
}
2026-07-25 12:30:42 +00:00
func TestNormalizeReportsDuplicateRemovalWithoutOrderChange ( t * testing . T ) {
ref1 := source . SourceRef { SourceID : "source" , StartUnitID : 1 , EndUnitID : 1 }
ref2 := source . SourceRef { SourceID : "source" , StartUnitID : 2 , EndUnitID : 2 }
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { {
Spell : "Cure Wounds" ,
SourceRefs : [ ] source . SourceRef { ref1 , ref1 , ref2 } ,
} } }
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
2026-08-27 16:04:32 +00:00
diagnostic := diagnosticByReason ( result . Diagnostics , ReasonCodeSourceReferencesNormalized )
if diagnostic == nil {
t . Fatalf ( "diagnostics = %#v, want one source normalization observation" , result . Diagnostics )
2026-07-25 12:30:42 +00:00
}
2026-08-27 16:04:32 +00:00
message := diagnostic . Samples [ 0 ] . Message
2026-07-25 12:30:42 +00:00
if ! strings . Contains ( message , "order changed false" ) || ! strings . Contains ( message , "duplicates removed 1" ) {
2026-08-27 18:41:59 +00:00
t . Fatalf ( "finding = %q, want duplicate-only repair without order change" , message )
2026-07-25 12:30:42 +00:00
}
}
2026-07-24 14:20:10 +00:00
func TestNormalizeOrdersReferencesBySourceDocumentPosition ( t * testing . T ) {
doc := & source . SourceDocument { ID : "source" , Units : [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } } }
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { {
Spell : "Cure Wounds" ,
SourceRefs : [ ] source . SourceRef {
{ SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 10 } ,
{ SourceID : doc . ID , StartUnitID : 30 , EndUnitID : 30 } ,
{ SourceID : doc . ID , StartUnitID : 999 , EndUnitID : 999 } ,
} ,
} } }
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
got := result . Value . SpellCasts [ 0 ] . SourceRefs
if got [ 0 ] . StartUnitID != 30 || got [ 1 ] . StartUnitID != 10 || got [ 2 ] . StartUnitID != 999 {
t . Fatalf ( "normalized refs = %#v, want source-document order followed by invalid reference" , got )
}
}
2026-07-20 20:52:27 +00:00
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences ( t * testing . T ) {
normalizer := newNormalizer ( t )
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast {
{ Spell : "Cure Wounds" , SourceRefs : nil } ,
{ Spell : "Cure Wounds" , SourceRefs : [ ] source . SourceRef { } } ,
{ Spell : "Cure Wounds" , SourceRefs : [ ] source . SourceRef {
{ SourceID : "source" , StartUnitID : 3 , EndUnitID : 4 } ,
{ SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 } ,
{ SourceID : "source" , StartUnitID : 2 , EndUnitID : 5 } ,
} } ,
} }
before := input
before . SpellCasts = append ( [ ] dnd . SpellCast ( nil ) , input . SpellCasts ... )
before . SpellCasts [ 2 ] . SourceRefs = append ( [ ] source . SourceRef ( nil ) , input . SpellCasts [ 2 ] . SourceRefs ... )
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequest ( input ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
if result . Value . SpellCasts [ 0 ] . SourceRefs != nil || result . Value . SpellCasts [ 1 ] . SourceRefs == nil {
t . Fatalf ( "nil/empty source refs were not preserved: %#v" , result . Value . SpellCasts )
}
if len ( result . Value . SpellCasts [ 2 ] . SourceRefs ) != 3 {
t . Fatalf ( "adjacent/overlapping references = %#v, want all three retained" , result . Value . SpellCasts [ 2 ] . SourceRefs )
}
if ! reflect . DeepEqual ( input , before ) {
t . Fatalf ( "Normalize() mutated input: got %#v, before %#v" , input , before )
}
result . Value . SpellCasts [ 2 ] . SourceRefs [ 0 ] . SourceID = "changed"
if input . SpellCasts [ 2 ] . SourceRefs [ 0 ] . SourceID == "changed" {
t . Fatal ( "normalized references share input storage" )
}
}
func TestNormalizeHandlesNilAndCanceledCalls ( t * testing . T ) {
request := normalizeRequest ( dnd . SpellList { } )
var normalizer * Normalizer
if _ , err := normalizer . Normalize ( context . Background ( ) , request ) ; err == nil || ! strings . Contains ( err . Error ( ) , "normalizer must not be nil" ) {
t . Fatalf ( "nil receiver error = %v, want nil receiver error" , err )
}
normalizer = newNormalizer ( t )
if _ , err := normalizer . Normalize ( nil , request ) ; err == nil || ! strings . Contains ( err . Error ( ) , "context must not be nil" ) {
t . Fatalf ( "nil context error = %v, want nil context error" , err )
}
ctx , cancel := context . WithCancel ( context . Background ( ) )
cancel ( )
if _ , err := normalizer . Normalize ( ctx , request ) ; err == nil || ! strings . Contains ( err . Error ( ) , "context error before normalize" ) {
t . Fatalf ( "canceled context error = %v, want canceled context error" , err )
}
}
2026-07-20 21:00:22 +00:00
func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization ( t * testing . T ) {
normalizer := newNormalizer ( t )
doc := sourceDocument ( 6 )
firstEvidence := source . SourceRef { SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 }
secondEvidence := source . SourceRef { SourceID : "source" , StartUnitID : 3 , EndUnitID : 4 }
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast {
2026-07-22 19:01:58 +00:00
{ Caster : " Aria \t" , Spell : " cure wounds " , SourceRefs : [ ] source . SourceRef { secondEvidence , firstEvidence , firstEvidence } } ,
{ Caster : "Borin" , Spell : "Healing Word" , SourceRefs : [ ] source . SourceRef { firstEvidence } } ,
{ Caster : " K yle " , Spell : "Cure Wounds" , SourceRefs : [ ] source . SourceRef { firstEvidence } } ,
{ Caster : "aria" , Spell : " cure wounds " , SourceRefs : [ ] source . SourceRef { firstEvidence , secondEvidence } } ,
{ Caster : "KYLE" , Spell : " cure wounds " , SourceRefs : [ ] source . SourceRef { firstEvidence } } ,
{ Caster : " kyle " , Spell : "Cure Wounds" , SourceRefs : [ ] source . SourceRef { firstEvidence } } ,
2026-07-20 21:00:22 +00:00
} }
result , err := normalizer . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
if len ( result . Value . SpellCasts ) != 3 {
t . Fatalf ( "normalized casts = %#v, want first occurrences plus distinct cast" , result . Value . SpellCasts )
}
2026-07-22 19:01:58 +00:00
if got := result . Value . SpellCasts [ 0 ] ; got . Caster != " Aria \t" {
t . Fatalf ( "retained first cast = %#v, want first occurrence caster unchanged" , got )
2026-07-20 21:00:22 +00:00
}
if got := result . Value . SpellCasts [ 0 ] . SourceRefs ; ! reflect . DeepEqual ( got , [ ] source . SourceRef { firstEvidence , secondEvidence } ) {
t . Fatalf ( "retained first evidence = %#v, want canonical first evidence only" , got )
}
if got := result . Value . SpellCasts [ 2 ] . Caster ; got != " K yle " {
t . Fatalf ( "Unicode caster output = %q, want first occurrence text unchanged" , got )
}
2026-08-27 16:04:32 +00:00
canonicalized := diagnosticByReason ( result . Diagnostics , ReasonCodeSpellNameCanonicalized )
refsNormalized := diagnosticByReason ( result . Diagnostics , ReasonCodeSourceReferencesNormalized )
collapsed := diagnosticByReason ( result . Diagnostics , ReasonCodeDuplicateSpellCastCollapsed )
2026-08-27 16:40:17 +00:00
if canonicalized == nil || canonicalized . OccurrenceCount != 3 || refsNormalized == nil || refsNormalized . OccurrenceCount != 1 || collapsed == nil || collapsed . OccurrenceCount != 2 || ! strings . Contains ( collapsed . Samples [ 0 ] . Message , "retained input index 0" ) {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v, want grouped normalization observations" , result . Diagnostics )
2026-07-20 21:00:22 +00:00
}
}
func TestNormalizeKeepsDistinctAndIneligibleCastsSeparate ( t * testing . T ) {
doc := sourceDocument ( 6 )
ref := source . SourceRef { SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 }
otherRef := source . SourceRef { SourceID : "source" , StartUnitID : 3 , EndUnitID : 4 }
tests := [ ] struct {
name string
variant dnd . SpellCast
} {
{ name : "distinct canonical spell" , variant : dnd . SpellCast { Spell : "Healing Word" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { ref } } } ,
{ name : "distinct caster" , variant : dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Borin" , SourceRefs : [ ] source . SourceRef { ref } } } ,
{ name : "distinct evidence" , variant : dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { otherRef } } } ,
{ name : "unknown spell" , variant : dnd . SpellCast { Spell : "Unknown Spell" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { ref } } } ,
{ name : "empty evidence" , variant : dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Aria" } } ,
{ name : "invalid evidence" , variant : dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { { SourceID : "other" , StartUnitID : 1 , EndUnitID : 2 } } } } ,
}
base := dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { ref } }
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { base , test . variant } } , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
if len ( result . Value . SpellCasts ) != 2 {
t . Fatalf ( "normalized casts = %#v, want both casts retained" , result . Value . SpellCasts )
}
2026-08-27 16:40:17 +00:00
if diagnosticByReason ( result . Diagnostics , ReasonCodeDuplicateSpellCastCollapsed ) != nil {
t . Fatalf ( "diagnostics = %#v, want no duplicate collapse" , result . Diagnostics )
2026-07-20 21:00:22 +00:00
}
} )
}
}
func TestNormalizeDoesNotCollapseAdjacentOrOverlappingEvidence ( t * testing . T ) {
doc := sourceDocument ( 8 )
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast {
{ Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 } } } ,
{ Spell : "Cure Wounds" , Caster : "aria" , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 3 , EndUnitID : 4 } } } ,
{ Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 1 , EndUnitID : 3 } } } ,
{ Spell : "Cure Wounds" , Caster : "aria" , SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 2 , EndUnitID : 4 } } } ,
} }
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
if len ( result . Value . SpellCasts ) != len ( input . SpellCasts ) {
t . Fatalf ( "normalized casts = %#v, want adjacent and overlapping evidence retained" , result . Value . SpellCasts )
}
2026-08-27 16:40:17 +00:00
if diagnosticByReason ( result . Diagnostics , ReasonCodeDuplicateSpellCastCollapsed ) != nil {
t . Fatalf ( "diagnostics = %#v, want no duplicate collapse" , result . Diagnostics )
2026-07-20 21:00:22 +00:00
}
}
2026-08-27 18:41:59 +00:00
func TestNormalizeBoundsDuplicateFindingIndices ( t * testing . T ) {
2026-07-20 21:00:22 +00:00
doc := sourceDocument ( 2 )
ref := source . SourceRef { SourceID : "source" , StartUnitID : 1 , EndUnitID : 1 }
casts := make ( [ ] dnd . SpellCast , 22 )
for index := range casts {
casts [ index ] = dnd . SpellCast { Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { ref } }
}
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( dnd . SpellList { SpellCasts : casts } , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
2026-08-27 16:04:32 +00:00
diagnostic := diagnosticByReason ( result . Diagnostics , ReasonCodeDuplicateSpellCastCollapsed )
if len ( result . Value . SpellCasts ) != 1 || diagnostic == nil || diagnostic . OccurrenceCount != 1 {
t . Fatalf ( "result = %#v, diagnostics = %#v, want one retained cast and one grouped observation" , result . Value , result . Diagnostics )
2026-07-20 21:00:22 +00:00
}
2026-08-27 16:04:32 +00:00
message := diagnostic . Samples [ 0 ] . Message
2026-07-20 21:00:22 +00:00
if ! strings . Contains ( message , "removed input indices [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]" ) || strings . Contains ( message , ", 21]" ) || ! strings . Contains ( message , "1 additional removed input indices omitted" ) {
2026-08-27 18:41:59 +00:00
t . Fatalf ( "finding message = %q, want 20 displayed indices and exact omitted count" , message )
2026-07-20 21:00:22 +00:00
}
}
func TestNormalizeIsIdempotentForAlreadyNormalizedInput ( t * testing . T ) {
doc := sourceDocument ( 3 )
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast { {
2026-07-22 19:01:58 +00:00
Spell : "Cure Wounds" ,
Caster : "Aria" ,
SourceRefs : [ ] source . SourceRef { { SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 } } ,
2026-07-20 21:00:22 +00:00
} } }
normalizer := newNormalizer ( t )
first , err := normalizer . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil {
t . Fatalf ( "first Normalize() error = %v, want nil" , err )
}
second , err := normalizer . Normalize ( context . Background ( ) , normalizeRequestWithSource ( first . Value , doc ) )
if err != nil {
t . Fatalf ( "second Normalize() error = %v, want nil" , err )
}
2026-08-27 16:40:17 +00:00
if ! reflect . DeepEqual ( second . Value , first . Value ) {
t . Fatalf ( "first = %#v, second = %#v, want identical artifacts" , first . Value , second . Value )
2026-07-20 21:00:22 +00:00
}
}
func TestNormalizeDuplicateOutputDoesNotShareInputSlices ( t * testing . T ) {
doc := sourceDocument ( 3 )
ref := source . SourceRef { SourceID : "source" , StartUnitID : 1 , EndUnitID : 2 }
input := dnd . SpellList { SpellCasts : [ ] dnd . SpellCast {
{ Spell : "Cure Wounds" , Caster : "Aria" , SourceRefs : [ ] source . SourceRef { ref } } ,
{ Spell : "Cure Wounds" , Caster : "aria" , SourceRefs : [ ] source . SourceRef { ref } } ,
} }
result , err := newNormalizer ( t ) . Normalize ( context . Background ( ) , normalizeRequestWithSource ( input , doc ) )
if err != nil {
t . Fatalf ( "Normalize() error = %v, want nil" , err )
}
result . Value . SpellCasts [ 0 ] . SourceRefs [ 0 ] . SourceID = "changed"
if input . SpellCasts [ 0 ] . SourceRefs [ 0 ] . SourceID != "source" || input . SpellCasts [ 1 ] . SourceRefs [ 0 ] . SourceID != "source" {
t . Fatalf ( "normalized output shares input references: input = %#v" , input )
}
}
2026-07-20 20:52:27 +00:00
func newNormalizer ( t * testing . T ) * Normalizer {
t . Helper ( )
normalizer , err := New ( Options { } , overlayReference ( ) )
if err != nil {
t . Fatalf ( "New() error = %v, want nil" , err )
}
return normalizer
}
func normalizeRequest ( value dnd . SpellList ) contracts . TypedNormalizeRequest [ dnd . SpellList ] {
return contracts . TypedNormalizeRequest [ dnd . SpellList ] {
LaneID : "spells" ,
MergeOutput : contracts . MergeArtifact [ dnd . SpellList ] {
LaneID : "spells" ,
MergerKey : "appendorder" ,
SourceID : "source" ,
Value : value ,
} ,
}
}
2026-07-20 21:00:22 +00:00
func normalizeRequestWithSource ( value dnd . SpellList , doc * source . SourceDocument ) contracts . TypedNormalizeRequest [ dnd . SpellList ] {
request := normalizeRequest ( value )
request . Source = doc
return request
}
func sourceDocument ( unitCount int ) * source . SourceDocument {
units := make ( [ ] source . SourceUnit , unitCount )
for index := range units {
units [ index ] = source . SourceUnit { ID : index + 1 }
}
return & source . SourceDocument { ID : "source" , Units : units }
}
2026-08-27 16:04:32 +00:00
func diagnosticByReason ( diagnostics [ ] contracts . ProducerDiagnostic , reason string ) * contracts . ProducerDiagnostic {
for index := range diagnostics {
if diagnostics [ index ] . ReasonCode == reason {
return & diagnostics [ index ]
}
}
return nil
}
2026-07-20 20:52:27 +00:00
func overlayReference ( ) contracts . ReferenceSet {
return spellCatalogReference ( ` { "schema_version":"notarius.dnd.spell-catalog-overlay.v1","catalogs":[ { "id":"campaign.example","ruleset":"dnd-5e-2014","source": { "title":"Private campaign source","version":"1","url":"file:///private-source.json","license":"private"},"spells":[ { "name":"Aegis of Emberfall","aliases":["Emberfall Aegis"]}]}]} ` )
}
func spellCatalogReference ( content string ) contracts . ReferenceSet {
return contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
spellcatalog . SpellCatalogReferenceSlot : {
Items : [ ] contracts . ReferenceItem { {
SlotName : spellcatalog . SpellCatalogReferenceSlot ,
MediaType : "application/json" ,
Content : [ ] byte ( content ) ,
} } ,
} ,
} }
}