2026-08-05 20:00:20 +00:00
package itemoccurrences
2026-07-25 21:41:17 +00:00
import (
"bytes"
"encoding/json"
"errors"
"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 19:55:34 +00:00
func validList ( ) dnd . ItemOccurrenceList {
2026-07-25 21:41:17 +00:00
quantity := 12
2026-08-05 19:55:34 +00:00
return dnd . ItemOccurrenceList { Occurrences : [ ] dnd . ItemOccurrence { { ItemID : "item" , Name : "Hidden Cache" , Kind : dnd . ItemOccurrenceKindDiscovered , SourceRefs : refs ( 1 , 1 ) } ,
{ ItemID : "item" , Name : "Gold Pieces" , Kind : dnd . ItemOccurrenceKindAcquired , Quantity : & quantity , To : "party" , SourceRefs : refs ( 2 , 2 ) } ,
{ ItemID : "item" , Name : "Torch" , Kind : dnd . ItemOccurrenceKindLost , From : "party" , SourceRefs : refs ( 3 , 3 ) } ,
{ ItemID : "item" , Name : "Healing Potion" , Kind : dnd . ItemOccurrenceKindConsumed , From : "party" , SourceRefs : refs ( 4 , 4 ) } ,
{ ItemID : "item" , Name : "Moonblade" , Kind : dnd . ItemOccurrenceKindTransferred , From : "Aria" , To : "Borin" , SourceRefs : refs ( 5 , 5 ) } ,
2026-07-25 21:41:17 +00:00
} }
}
func refs ( start , end int ) [ ] source . SourceRef {
return [ ] source . SourceRef { { SourceID : "session" , StartUnitID : start , EndUnitID : end } }
}
func TestCodecRoundTripAndIdentities ( t * testing . T ) {
codec := New ( )
value := validList ( )
content , err := codec . Encode ( value )
if err != nil {
t . Fatalf ( "Encode() error = %v" , err )
}
decoded , err := codec . Decode ( content )
if err != nil || ! reflect . DeepEqual ( decoded , value ) {
t . Fatalf ( "Decode() = %#v, %v; want %#v" , decoded , err , value )
}
schema := codec . Schema ( )
2026-08-05 19:55:34 +00:00
if codec . Kind ( ) != dnd . ItemOccurrenceListKind || codec . MediaType ( ) != MediaType || schema . ID != SchemaID || schema . Name != SchemaName || schema . Version != SchemaVersion || ! json . Valid ( schema . JSONSchema ) {
2026-07-25 21:41:17 +00:00
t . Fatalf ( "codec identity/schema = %q/%q %#v" , codec . Kind ( ) , codec . MediaType ( ) , schema )
}
registry := pipeline . NewArtifactCodecRegistry ( )
if err := pipeline . RegisterArtifactCodec ( registry , codec ) ; err != nil {
t . Fatal ( err )
}
2026-08-05 19:55:34 +00:00
spec , ok := registry . Spec ( dnd . ItemOccurrenceListKind )
2026-07-25 21:41:17 +00:00
if ! ok || spec . SchemaDigest != contracts . DigestArtifactSchema ( schema ) {
t . Fatalf ( "registered spec = %#v, %t" , spec , ok )
}
2026-08-05 19:55:34 +00:00
if _ , err := registry . Encode ( dnd . ItemOccurrenceListKind , dnd . NPCRegistry { } ) ; err == nil {
2026-07-25 21:41:17 +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 TestCodecSupportsEmptyListAndPreservesInvalidCandidates ( t * testing . T ) {
codec := New ( )
2026-08-05 19:55:34 +00:00
empty := dnd . ItemOccurrenceList { Occurrences : [ ] dnd . ItemOccurrence { } }
if content , err := codec . Encode ( empty ) ; err != nil || string ( content ) != ` { "occurrences":[]} ` {
2026-07-25 21:41:17 +00:00
t . Fatalf ( "Encode() = %s, %v" , content , err )
}
zero := 0
2026-08-05 19:55:34 +00:00
candidate := dnd . ItemOccurrenceList { Occurrences : [ ] dnd . ItemOccurrence { {
Name : " " , Kind : dnd . ItemOccurrenceKindTransferred , Quantity : & zero , From : "party" , To : "Party" ,
2026-07-25 21:41:17 +00:00
SourceRefs : [ ] source . SourceRef { { SourceID : "" , StartUnitID : 0 , EndUnitID : - 1 } } ,
} } }
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 )
}
if _ , err := codec . Decode ( content ) ; err == nil {
t . Fatal ( "Decode() error = nil, want semantic candidate rejection" )
}
}
2026-08-09 01:54:43 +00:00
func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem ( t * testing . T ) {
codec := New ( )
for _ , test := range [ ] struct {
name string
mutate func ( * dnd . ItemOccurrenceList )
want string
} {
{ name : "nonpositive start" , mutate : func ( value * dnd . ItemOccurrenceList ) { value . Occurrences [ 0 ] . SourceRefs [ 0 ] . StartUnitID = 0 } , want : "start_unit_id" } ,
{ name : "nonpositive end" , mutate : func ( value * dnd . ItemOccurrenceList ) { value . Occurrences [ 0 ] . SourceRefs [ 0 ] . EndUnitID = 0 } , want : "end_unit_id" } ,
{ name : "reversed" , mutate : func ( value * dnd . ItemOccurrenceList ) {
value . Occurrences [ 0 ] . SourceRefs [ 0 ] . StartUnitID = 2
value . Occurrences [ 0 ] . SourceRefs [ 0 ] . EndUnitID = 1
} , want : "must not exceed" } ,
} {
t . Run ( test . name , func ( t * testing . T ) {
candidate := validList ( )
test . mutate ( & candidate )
content , err := codec . EncodeCandidate ( candidate )
if err != nil {
t . Fatalf ( "EncodeCandidate() error = %v" , 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 ( ) , test . want ) {
t . Fatalf ( "Encode() error = %v, want %q" , err , test . want )
}
if _ , err := codec . Decode ( content ) ; err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Decode() error = %v, want %q" , err , test . want )
}
} )
}
}
2026-07-25 21:41:17 +00:00
func TestCodecRejectsStrictJSONAndApprovedBoundaries ( t * testing . T ) {
2026-08-05 19:55:34 +00:00
validJSON := ` { "occurrences":[ { "item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[ { "source_id":"session","start_unit_id":1,"end_unit_id":1}]}]} `
2026-07-25 21:41:17 +00:00
tests := [ ] struct {
name , raw , want string
} {
2026-08-05 19:55:34 +00:00
{ "malformed" , ` { ` , "decode dnd item occurrence list" } ,
{ "unknown top level" , ` { "occurrences":[],"unexpected":true} ` , "unknown field" } ,
2026-08-05 20:00:20 +00:00
{ "unknown occurrence field" , strings . Replace ( validJSON , ` "to":"party" ` , ` "to":"party","unexpected":true ` , 1 ) , "unknown field" } ,
2026-07-25 21:41:17 +00:00
{ "unknown reference field" , strings . Replace ( validJSON , ` "end_unit_id":1 ` , ` "end_unit_id":1,"unexpected":true ` , 1 ) , "unknown field" } ,
2026-08-05 19:55:34 +00:00
{ "trailing" , ` { "occurrences":[]} { } ` , "multiple JSON values" } ,
2026-08-05 20:00:20 +00:00
{ "missing list" , ` { } ` , "occurrences must be present" } ,
2026-07-25 21:41:17 +00:00
{ "zero quantity" , strings . Replace ( validJSON , ` "to":"party" ` , ` "quantity":0,"to":"party" ` , 1 ) , "quantity must be positive" } ,
{ "negative quantity" , strings . Replace ( validJSON , ` "to":"party" ` , ` "quantity":-1,"to":"party" ` , 1 ) , "quantity must be positive" } ,
{ "party transfer" , strings . Replace ( validJSON , ` "kind":"acquired","to":"party" ` , ` "kind":"transferred","from":"party","to":"Borin" ` , 1 ) , "holders are incompatible" } ,
{ "self transfer" , strings . Replace ( validJSON , ` "kind":"acquired","to":"party" ` , ` "kind":"transferred","from":"Aria","to":"aria" ` , 1 ) , "holders are incompatible" } ,
{ "missing source refs" , strings . Replace ( validJSON , ` ,"source_refs":[ { "source_id":"session","start_unit_id":1,"end_unit_id":1}] ` , "" , 1 ) , "source_refs must contain" } ,
2026-07-25 21:49:24 +00:00
{ "empty source refs" , strings . Replace ( validJSON , ` [ { "source_id":"session","start_unit_id":1,"end_unit_id":1}] ` , ` [] ` , 1 ) , "source_refs must contain" } ,
2026-07-25 21:41:17 +00:00
}
for _ , test := range tests {
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 TestCodecDeepCopiesBoundaryValuesAndMetadata ( t * testing . T ) {
codec := New ( )
value := validList ( )
content , err := codec . EncodeCandidate ( value )
if err != nil {
t . Fatal ( err )
}
decoded , err := codec . DecodeCandidate ( content )
if err != nil {
t . Fatal ( err )
}
2026-08-09 00:57:03 +00:00
sibling , err := codec . DecodeCandidate ( content )
if err != nil {
t . Fatal ( err )
}
2026-08-05 19:55:34 +00:00
if decoded . Occurrences [ 1 ] . Quantity == value . Occurrences [ 1 ] . Quantity || & decoded . Occurrences [ 1 ] . SourceRefs [ 0 ] == & value . Occurrences [ 1 ] . SourceRefs [ 0 ] {
2026-08-05 20:00:20 +00:00
t . Fatal ( "DecodeCandidate() retained caller-owned occurrence fields" )
2026-07-25 21:41:17 +00:00
}
2026-08-09 00:57:03 +00:00
nameOffset := bytes . Index ( content , [ ] byte ( "Gold Pieces" ) )
if nameOffset < 0 {
t . Fatal ( "candidate JSON does not contain item name" )
}
content [ nameOffset ] = 'X'
if decoded . Occurrences [ 1 ] . Name != "Gold Pieces" {
t . Fatal ( "DecodeCandidate() retained input bytes" )
}
2026-08-05 19:55:34 +00:00
* decoded . Occurrences [ 1 ] . Quantity = 99
decoded . Occurrences [ 1 ] . SourceRefs [ 0 ] . SourceID = "changed"
if * value . Occurrences [ 1 ] . Quantity != 12 || value . Occurrences [ 1 ] . SourceRefs [ 0 ] . SourceID != "session" {
t . Fatal ( "decoded item occurrence aliases input" )
2026-07-25 21:41:17 +00:00
}
2026-08-09 00:57:03 +00:00
if * sibling . Occurrences [ 1 ] . Quantity != 12 || sibling . Occurrences [ 1 ] . SourceRefs [ 0 ] . SourceID != "session" {
t . Fatal ( "decoded item occurrence aliases a sibling decode" )
}
2026-07-25 21:41:17 +00:00
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 ( value )
metadata [ "payload" ] = bytes . Repeat ( [ ] byte ( "x" ) , 10 )
2026-08-05 19:55:34 +00:00
if next := codec . Metadata ( value ) ; len ( next ) != 1 || next [ "occurrence_count" ] != len ( value . Occurrences ) {
2026-07-25 21:41:17 +00:00
t . Fatalf ( "Metadata() = %#v" , next )
}
}