2026-07-23 13:18:38 +00:00
package npcinteractions
import (
"bytes"
"encoding/json"
"errors"
"os"
"reflect"
"strings"
"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"
)
2026-08-05 18:55:58 +00:00
func validList ( ) dnd . NPCOccurrenceList {
return dnd . NPCOccurrenceList { Occurrences : [ ] dnd . NPCOccurrence {
2026-07-23 13:18:38 +00:00
{
2026-08-05 18:55:58 +00:00
NPCID : "npc:test-mira" , Name : "Mira Thorn" , Kind : dnd . NPCOccurrenceKindDialogue ,
2026-07-23 13:18:38 +00:00
SourceRefs : [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 1 , EndUnitID : 2 } } ,
} ,
{
2026-08-05 18:55:58 +00:00
NPCID : "npc:test-guard" , Name : "Hooded Guard" , Kind : dnd . NPCOccurrenceKindCombatOpponent ,
2026-07-23 13:18:38 +00:00
SourceRefs : [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 3 , EndUnitID : 3 } } ,
} ,
} }
}
func TestCodecMatchesMaintainedDurableFixture ( t * testing . T ) {
2026-08-05 18:55:58 +00:00
raw , err := os . ReadFile ( "testdata/dnd_npc_occurrences.v1.json" )
2026-07-23 13:18:38 +00:00
if err != nil {
t . Fatal ( err )
}
codec := New ( )
value , err := codec . Decode ( raw )
if err != nil {
t . Fatalf ( "Decode() error = %v" , err )
}
if want := validList ( ) ; ! reflect . DeepEqual ( value , want ) {
t . Fatalf ( "Decode() = %#v, want %#v" , value , want )
}
encoded , err := codec . Encode ( value )
if err != nil {
t . Fatalf ( "Encode() error = %v" , err )
}
var compact bytes . Buffer
if err := json . Compact ( & compact , raw ) ; err != nil {
t . Fatal ( err )
}
if ! bytes . Equal ( encoded , compact . Bytes ( ) ) {
t . Fatalf ( "Encode() = %s, want %s" , encoded , compact . Bytes ( ) )
}
}
func TestCodecOwnsDurableSchemaAndRegistersExactType ( t * testing . T ) {
codec := New ( )
schema := codec . Schema ( )
2026-08-05 18:55:58 +00:00
if codec . Kind ( ) != dnd . NPCOccurrenceListKind || codec . MediaType ( ) != MediaType {
2026-07-23 13:18:38 +00:00
t . Fatalf ( "codec identity = %q/%q" , codec . Kind ( ) , codec . MediaType ( ) )
}
if schema . ID != SchemaID || schema . Name != SchemaName || schema . Version != SchemaVersion || ! json . Valid ( schema . JSONSchema ) {
t . Fatalf ( "schema = %#v" , schema )
}
registry := pipeline . NewArtifactCodecRegistry ( )
if err := pipeline . RegisterArtifactCodec ( registry , codec ) ; err != nil {
t . Fatal ( err )
}
2026-08-05 18:55:58 +00:00
spec , ok := registry . Spec ( dnd . NPCOccurrenceListKind )
2026-07-23 13:18:38 +00:00
if ! ok || spec . SchemaDigest != contracts . DigestArtifactSchema ( schema ) {
t . Fatalf ( "registered spec = %#v, %t" , spec , ok )
}
2026-08-05 18:55:58 +00:00
if _ , err := registry . Encode ( dnd . NPCOccurrenceListKind , dnd . NPCRegistry { } ) ; err == nil {
2026-07-23 13:18:38 +00:00
t . Fatal ( "Encode() error = nil, want exact type rejection" )
} else {
var typeErr * pipeline . ArtifactCodecTypeError
if ! errors . As ( err , & typeErr ) {
t . Fatalf ( "Encode() error = %T, want ArtifactCodecTypeError" , err )
}
}
}
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates ( t * testing . T ) {
codec := New ( )
2026-08-05 18:55:58 +00:00
empty := dnd . NPCOccurrenceList { Occurrences : [ ] dnd . NPCOccurrence { } }
2026-07-23 13:18:38 +00:00
content , err := codec . Encode ( empty )
2026-08-05 18:55:58 +00:00
if err != nil || string ( content ) != ` { "occurrences":[]} ` {
2026-07-23 13:18:38 +00:00
t . Fatalf ( "Encode() = %s, %v" , content , err )
}
2026-08-05 18:55:58 +00:00
for _ , candidate := range [ ] dnd . NPCOccurrenceList {
2026-07-23 13:18:38 +00:00
{ } ,
empty ,
2026-08-05 18:55:58 +00:00
{ Occurrences : [ ] dnd . NPCOccurrence { { NPCID : "npc:test" , Name : " " , Kind : "unsupported" , SourceRefs : nil } } } ,
{ Occurrences : [ ] dnd . NPCOccurrence { { NPCID : "npc:test" , Name : " " , Kind : "unsupported" , SourceRefs : [ ] source . SourceRef { } } } } ,
{ Occurrences : [ ] dnd . NPCOccurrence { { NPCID : "npc:test" , Name : " " , Kind : "unsupported" , SourceRefs : [ ] source . SourceRef { { StartUnitID : 0 , EndUnitID : - 1 } } } } } ,
2026-07-23 13:18:38 +00:00
} {
content , err := codec . EncodeCandidate ( candidate )
if err != nil || ! json . Valid ( content ) {
t . Fatalf ( "EncodeCandidate() = %s, %v" , content , err )
}
decoded , err := codec . DecodeCandidate ( content )
if err != nil || ! reflect . DeepEqual ( decoded , candidate ) {
t . Fatalf ( "DecodeCandidate() = %#v, %v; want %#v" , decoded , err , candidate )
}
}
}
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON ( t * testing . T ) {
2026-08-05 18:55:58 +00:00
validJSON := ` { "occurrences":[ { "npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[ { "source_id":"session","start_unit_id":1,"end_unit_id":1}]}]} `
2026-07-23 13:18:38 +00:00
for _ , test := range [ ] struct { name , raw , want string } {
2026-08-05 18:55:58 +00:00
{ "malformed" , ` { ` , "decode dnd npc occurrence list" } ,
{ "unknown top-level" , ` { "occurrences":[],"unexpected":true} ` , "unknown field" } ,
2026-07-23 13:18:38 +00:00
{ "unknown interaction field" , strings . Replace ( validJSON , ` "kind":"dialogue" ` , ` "kind":"dialogue","unexpected":true ` , 1 ) , "unknown field" } ,
{ "unknown source reference field" , strings . Replace ( validJSON , ` "end_unit_id":1 ` , ` "end_unit_id":1,"unexpected":true ` , 1 ) , "unknown field" } ,
2026-08-05 18:55:58 +00:00
{ "trailing" , ` { "occurrences":[]} { } ` , "multiple JSON values" } ,
2026-07-23 13:18:38 +00:00
} {
t . Run ( test . name , func ( t * testing . T ) {
if _ , err := New ( ) . Decode ( [ ] byte ( test . raw ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Decode() error = %v, want %q" , err , test . want )
}
} )
}
}
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries ( t * testing . T ) {
tests := [ ] struct {
name string
2026-08-05 18:55:58 +00:00
value dnd . NPCOccurrenceList
2026-07-23 13:18:38 +00:00
want string
} {
2026-08-05 18:55:58 +00:00
{ "nil occurrences" , dnd . NPCOccurrenceList { } , "occurrences must be present" } ,
{ "blank NPC ID" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . NPCID = " " } ) , "npc_id must not be empty" } ,
{ "blank name" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . Name = " " } ) , "name must not be empty" } ,
{ "unsupported kind" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . Kind = "unsupported" } ) , "kind must be supported" } ,
{ "nil source refs" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . SourceRefs = nil } ) , "source_refs must contain" } ,
{ "empty source ID" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . SourceRefs [ 0 ] . SourceID = " " } ) , "source_id must not be empty" } ,
{ "non-positive start" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . SourceRefs [ 0 ] . StartUnitID = 0 } ) , "start_unit_id must be positive" } ,
{ "non-positive end" , mutate ( validList ( ) , func ( v * dnd . NPCOccurrenceList ) { v . Occurrences [ 0 ] . SourceRefs [ 0 ] . EndUnitID = 0 } ) , "end_unit_id must be positive" } ,
2026-07-23 13:18:38 +00:00
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
if _ , err := New ( ) . Encode ( test . value ) ; err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Encode() error = %v, want %q" , err , test . want )
}
} )
}
}
func TestCodecAcceptsEveryInteractionKind ( t * testing . T ) {
2026-08-05 18:55:58 +00:00
for _ , kind := range [ ] dnd . NPCOccurrenceKind {
dnd . NPCOccurrenceKindMentioned ,
dnd . NPCOccurrenceKindNoncombatPresence ,
dnd . NPCOccurrenceKindDialogue ,
dnd . NPCOccurrenceKindCombatAlly ,
dnd . NPCOccurrenceKindCombatOpponent ,
dnd . NPCOccurrenceKindOther ,
2026-07-23 13:18:38 +00:00
} {
value := validList ( )
2026-08-05 18:55:58 +00:00
value . Occurrences [ 0 ] . Kind = kind
2026-07-23 13:18:38 +00:00
if _ , err := New ( ) . Encode ( value ) ; err != nil {
t . Fatalf ( "Encode(%q) error = %v" , kind , err )
}
}
}
func TestCodecSchemaAndMetadataAreDefensive ( t * testing . T ) {
codec := New ( )
first := codec . Schema ( )
first . JSONSchema [ 0 ] = '['
if second := codec . Schema ( ) ; ! json . Valid ( second . JSONSchema ) || second . JSONSchema [ 0 ] == '[' {
t . Fatal ( "Schema() returned shared bytes" )
}
metadata := codec . Metadata ( validList ( ) )
metadata [ "other" ] = true
2026-08-05 18:55:58 +00:00
if next := codec . Metadata ( validList ( ) ) ; len ( next ) != 1 || next [ "occurrence_count" ] != 2 {
2026-07-23 13:18:38 +00:00
t . Fatalf ( "Metadata() = %#v" , next )
}
}
2026-08-05 18:55:58 +00:00
func mutate ( value dnd . NPCOccurrenceList , change func ( * dnd . NPCOccurrenceList ) ) dnd . NPCOccurrenceList {
2026-07-23 13:18:38 +00:00
change ( & value )
return value
}