2026-07-21 02:08:46 +00:00
package npcs
import (
"bytes"
"encoding/json"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func validList ( ) dnd . NPCList {
return dnd . NPCList { NPCs : [ ] dnd . NPC { {
2026-07-22 18:51:26 +00:00
ID : identity . DeriveID ( "Mira Thorn" ) ,
Name : "Mira Thorn" ,
2026-07-21 02:08:46 +00:00
SourceRefs : [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 1 , EndUnitID : 2 } } ,
} } }
}
func TestCodecMatchesMaintainedDurableFixture ( t * testing . T ) {
raw , err := os . ReadFile ( "testdata/dnd_npcs.v1.json" )
if err != nil {
t . Fatalf ( "read durable fixture: %v" , err )
}
codec := New ( )
value , err := codec . Decode ( raw )
if err != nil {
t . Fatalf ( "Decode() error = %v, want nil" , err )
}
want := validList ( )
if ! 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, want nil" , err )
}
var compact bytes . Buffer
if err := json . Compact ( & compact , raw ) ; err != nil {
t . Fatalf ( "compact durable fixture: %v" , err )
}
if ! bytes . Equal ( encoded , compact . Bytes ( ) ) {
t . Fatalf ( "Encode() = %s, want stable durable JSON %s" , encoded , compact . Bytes ( ) )
}
}
func TestCodecOwnsDurableSchemaAndRegistersExactType ( t * testing . T ) {
codec := New ( )
schema := codec . Schema ( )
if codec . Kind ( ) != dnd . NPCListKind || codec . MediaType ( ) != MediaType {
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, want durable NPC schema" , schema )
}
var document map [ string ] any
if err := json . Unmarshal ( schema . JSONSchema , & document ) ; err != nil || document [ "$id" ] != SchemaID {
t . Fatalf ( "durable schema document = %#v, %v" , document , err )
}
registry := pipeline . NewArtifactCodecRegistry ( )
if err := pipeline . RegisterArtifactCodec ( registry , codec ) ; err != nil {
t . Fatalf ( "RegisterArtifactCodec() error = %v" , err )
}
spec , ok := registry . Spec ( dnd . NPCListKind )
if ! ok || spec . SchemaDigest != contracts . DigestArtifactSchema ( schema ) {
t . Fatalf ( "registered spec = %#v, %t" , spec , ok )
}
}
func TestCodecStrictlyRejectsMalformedOrUnknownJSON ( t * testing . T ) {
codec := New ( )
tests := [ ] struct {
name string
raw string
want string
} {
{ name : "unknown top-level" , raw : ` { "npcs":[],"unexpected":true} ` , want : "unknown field" } ,
2026-07-22 18:51:26 +00:00
{ name : "unknown nested" , raw : ` { "npcs":[ { "id":"x","name":"Mira","source_refs":[ { "source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]} ` , want : "unknown field" } ,
2026-07-21 02:08:46 +00:00
{ name : "trailing" , raw : ` { "npcs":[]} { } ` , want : "multiple JSON values" } ,
{ name : "missing array" , raw : ` { } ` , want : "npcs must be present" } ,
2026-07-22 18:51:26 +00:00
{ name : "invalid reference" , raw : ` { "npcs":[ { "id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","source_refs":[ { "source_id":"s","start_unit_id":0,"end_unit_id":1}]}]} ` , want : "start_unit_id" } ,
2026-07-21 02:08:46 +00:00
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
_ , err := codec . Decode ( [ ] byte ( test . raw ) )
if err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Decode() error = %v, want %q" , err , test . want )
}
} )
}
}
func TestCodecCandidatePreservesInvalidTypedValues ( t * testing . T ) {
codec := New ( )
2026-07-22 18:51:26 +00:00
candidate := dnd . NPCList { NPCs : [ ] dnd . NPC { { Name : "Mira Thorn" , SourceRefs : [ ] source . SourceRef { } } } }
2026-07-21 02:08:46 +00:00
content , err := codec . EncodeCandidate ( candidate )
if err != nil || ! json . Valid ( content ) {
t . Fatalf ( "EncodeCandidate() = %s, %v; want JSON" , content , err )
}
decoded , err := codec . DecodeCandidate ( content )
if err != nil || ! reflect . DeepEqual ( decoded , candidate ) {
t . Fatalf ( "DecodeCandidate() = %#v, %v; want %#v" , decoded , err , candidate )
}
if _ , err := codec . Encode ( candidate ) ; err == nil || ! strings . Contains ( err . Error ( ) , ".id must match npc ID pattern" ) {
t . Fatalf ( "Encode() error = %v, want strict validation error" , err )
}
}
func TestCodecRejectsEveryRequiredShapeBoundary ( t * testing . T ) {
base := validList ( ) . NPCs [ 0 ]
tests := [ ] struct {
name string
value dnd . NPCList
want string
} {
2026-07-22 18:51:26 +00:00
{ name : "blank name" , value : dnd . NPCList { NPCs : [ ] dnd . NPC { { ID : base . ID , Name : " " , SourceRefs : base . SourceRefs } } } , want : "name must not be empty" } ,
{ name : "empty source refs" , value : dnd . NPCList { NPCs : [ ] dnd . NPC { { ID : base . ID , Name : base . Name , SourceRefs : [ ] source . SourceRef { } } } } , want : "source_refs must not be empty" } ,
2026-07-21 02:08:46 +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 TestCodecSchemaAndMetadataAreDefensive ( t * testing . T ) {
codec := New ( )
first := codec . Schema ( )
first . JSONSchema [ 0 ] = '['
second := codec . Schema ( )
if ! json . Valid ( second . JSONSchema ) || second . JSONSchema [ 0 ] == '[' {
t . Fatal ( "Schema() returned shared bytes" )
}
metadata := codec . Metadata ( validList ( ) )
metadata [ "other" ] = true
if next := codec . Metadata ( validList ( ) ) ; len ( next ) != 1 || next [ "npc_count" ] != 1 {
t . Fatalf ( "Metadata() = %#v, want only npc_count" , next )
}
}