2026-08-03 20:37:35 +00:00
package enemyevents
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
combatturncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
2026-08-05 19:00:55 +00:00
occurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcoccurrences"
2026-08-05 18:38:22 +00:00
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
2026-08-03 20:37:35 +00:00
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
)
func TestReferenceSlotsDescribeRequiredTypedArtifacts ( t * testing . T ) {
slots := referenceSlots ( )
if len ( slots ) != 8 {
t . Fatalf ( "ReferenceSlots() count = %d, want 8" , len ( slots ) )
}
byName := make ( map [ string ] contracts . ReferenceSlot , len ( slots ) )
for index , slot := range slots {
if index > 0 && slots [ index - 1 ] . Name > slot . Name {
t . Fatalf ( "ReferenceSlots() is not sorted: %#v" , slots )
}
byName [ slot . Name ] = slot
}
for _ , want := range [ ] struct {
name string
kind contracts . ArtifactKind
} {
2026-08-05 18:25:04 +00:00
{ NPCRegistryReferenceSlot , dnd . NPCRegistryKind } ,
2026-08-03 20:37:35 +00:00
{ SceneDescriptionReferenceSlot , dnd . SceneDescriptionListKind } ,
{ CombatTurnReferenceSlot , dnd . CombatTurnListKind } ,
2026-08-05 19:00:55 +00:00
{ NPCOccurrenceReferenceSlot , dnd . NPCOccurrenceListKind } ,
2026-08-03 20:37:35 +00:00
} {
slot , ok := byName [ want . name ]
if ! ok || ! slot . Required || slot . MaxBytes != ReferenceMaxBytes || len ( slot . AcceptedMediaTypes ) != 1 || slot . AcceptedMediaTypes [ 0 ] != "application/json" || len ( slot . AcceptedArtifactKinds ) != 1 || slot . AcceptedArtifactKinds [ 0 ] != want . kind {
t . Fatalf ( "slot %q = %#v" , want . name , slot )
}
}
slots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
if referenceSlots ( ) [ 0 ] . AcceptedMediaTypes [ 0 ] == "changed" {
t . Fatal ( "ReferenceSlots() returned caller-owned storage" )
}
}
func TestGroundingProducesExactSourceFreePromptInputs ( t * testing . T ) {
resolver , err := newGroundingResolver ( groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat ) )
if err != nil {
t . Fatal ( err )
}
resolved , err := resolver . Resolve ( contracts . ReferenceSet { } )
if err != nil {
t . Fatal ( err )
}
inputs := resolved . PromptInputs ( )
want := map [ string ] string {
2026-08-05 19:00:55 +00:00
NPCRegistryReferenceSlot : ` { "npcs":[ { "name":"Ashfang"}]} ` ,
CombatTurnReferenceSlot : ` { "combat_turns":[ { "actor":"Ashfang","turn_kind":"turn"}, { "actor":"Aria","turn_kind":"reaction"}]} ` ,
NPCOccurrenceReferenceSlot : ` { "npc_occurrences":[ { "name":"Ashfang","kind":"combat_opponent"}]} ` ,
2026-08-03 20:37:35 +00:00
}
if len ( inputs ) != len ( want ) {
t . Fatalf ( "PromptInputs() = %#v" , inputs )
}
for name , content := range want {
input , ok := inputs [ name ]
if ! ok || string ( input . Content ) != content || input . Digest != digest ( [ ] byte ( content ) ) || input . OriginURI != "" || input . MediaType != "application/json" {
t . Fatalf ( "PromptInputs()[%q] = %#v, want %q" , name , input , content )
}
for _ , forbidden := range [ ] string { "source_ref" , "npc-ashfang" , "origin" , "summary" , "combat-session" } {
if strings . Contains ( string ( input . Content ) , forbidden ) {
t . Fatalf ( "PromptInputs()[%q] leaked %q: %s" , name , forbidden , input . Content )
}
}
}
if _ , ok := inputs [ SceneDescriptionReferenceSlot ] ; ok {
t . Fatal ( "scene descriptions were rendered as prompt grounding" )
}
inputs [ CombatTurnReferenceSlot ] = contracts . NewLLMInputMaterial ( "changed" , "text/plain" , [ ] byte ( "changed" ) , "" , "" )
if next := resolved . PromptInputs ( ) [ CombatTurnReferenceSlot ] ; string ( next . Content ) != want [ CombatTurnReferenceSlot ] {
t . Fatal ( "PromptInputs() did not return a defensive copy" )
}
}
func TestGroundingResolvesGeneratedReferencesAndSceneEligibility ( t * testing . T ) {
prepared := groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat )
resolver , err := newGroundingResolver ( prepared )
if err != nil {
t . Fatal ( err )
}
generated := groundingReferences ( t , "Grimjaw" , dnd . SceneKindNarrative )
resolved , err := resolver . Resolve ( generated )
if err != nil {
t . Fatal ( err )
}
if got := string ( resolved . PromptInputs ( ) [ NPCRegistryReferenceSlot ] . Content ) ; got != ` { "npcs":[ { "name":"Grimjaw"}]} ` {
t . Fatalf ( "generated NPC projection = %s" , got )
}
2026-08-03 22:54:11 +00:00
match , err := resolver . SceneMatch ( generated , combatChunk ( ) )
if err != nil {
t . Fatal ( err )
2026-08-03 20:37:35 +00:00
}
2026-08-03 22:54:11 +00:00
if match != ( sceneregistry . ChunkMatch { State : sceneregistry . MatchExact , Kind : dnd . SceneKindNarrative } ) {
t . Fatalf ( "generated scene match = %#v" , match )
}
match , err = resolver . SceneMatch ( generated , & source . Chunk { ID : "other" , Ref : combatChunk ( ) . Ref } )
if err != nil {
t . Fatal ( err )
}
if match . State != sceneregistry . MatchMissing {
2026-08-03 20:37:35 +00:00
t . Fatal ( "missing scene was not reported" )
}
mismatched := combatChunk ( )
mismatched . Ref . EndUnitID ++
2026-08-03 22:54:11 +00:00
match , err = resolver . SceneMatch ( generated , mismatched )
if err != nil {
t . Fatal ( err )
}
if match . State != sceneregistry . MatchMismatched {
2026-08-03 20:37:35 +00:00
t . Fatal ( "mismatched scene was not reported" )
}
static , err := resolver . Resolve ( contracts . ReferenceSet { } )
if err != nil {
t . Fatal ( err )
}
if got := string ( static . PromptInputs ( ) [ NPCRegistryReferenceSlot ] . Content ) ; got != ` { "npcs":[ { "name":"Ashfang"}]} ` {
t . Fatalf ( "static NPC projection changed after generated resolution: %s" , got )
}
2026-08-03 22:54:11 +00:00
match , err = resolver . SceneMatch ( contracts . ReferenceSet { } , combatChunk ( ) )
if err != nil {
t . Fatal ( err )
}
if match != ( sceneregistry . ChunkMatch { State : sceneregistry . MatchExact , Kind : dnd . SceneKindCombat } ) {
t . Fatalf ( "static scene match = %#v" , match )
2026-08-03 20:37:35 +00:00
}
}
func TestGroundingRejectsMissingAndInvalidReferences ( t * testing . T ) {
valid := groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat )
for _ , test := range [ ] struct {
slot string
want string
} {
{ NPCRegistryReferenceSlot , "NPC registry" } ,
{ SceneDescriptionReferenceSlot , "scene descriptions" } ,
{ CombatTurnReferenceSlot , CombatTurnReferenceSlot } ,
2026-08-05 19:00:55 +00:00
{ NPCOccurrenceReferenceSlot , NPCOccurrenceReferenceSlot } ,
2026-08-03 20:37:35 +00:00
} {
t . Run ( "missing " + test . slot , func ( t * testing . T ) {
resolver , err := newGroundingResolver ( withoutSlot ( valid , test . slot ) )
if err != nil {
t . Fatal ( err )
}
2026-08-03 22:54:11 +00:00
if test . slot == SceneDescriptionReferenceSlot {
_ , err = resolver . SceneMatch ( contracts . ReferenceSet { } , combatChunk ( ) )
} else {
_ , err = resolver . Resolve ( contracts . ReferenceSet { } )
}
if err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "grounding error = %v, want missing %q reference" , err , test . slot )
2026-08-03 20:37:35 +00:00
}
} )
}
tests := [ ] struct {
name string
set contracts . ReferenceSet
} {
{ "multiple items" , replaceSlot ( valid , CombatTurnReferenceSlot , contracts . ResolvedReferenceSlot { Items : [ ] contracts . ReferenceItem { { MediaType : "application/json" } , { MediaType : "application/json" } } } ) } ,
2026-08-05 19:00:55 +00:00
{ "malformed durable content" , replaceItem ( valid , NPCOccurrenceReferenceSlot , contracts . ReferenceItem { MediaType : "application/json" , Content : [ ] byte ( ` { } ` ) } ) } ,
2026-08-03 20:37:35 +00:00
{ "wrong media type" , replaceItem ( valid , CombatTurnReferenceSlot , contracts . ReferenceItem { MediaType : "text/plain" , Content : valid . Slots [ CombatTurnReferenceSlot ] . Items [ 0 ] . Content } ) } ,
{ "oversize" , replaceItem ( valid , CombatTurnReferenceSlot , contracts . ReferenceItem { MediaType : "application/json" , Content : make ( [ ] byte , ReferenceMaxBytes + 1 ) } ) } ,
2026-08-09 02:03:27 +00:00
{ "reversed combat-turn evidence" , reversedCombatTurnReference ( t , valid ) } ,
2026-08-03 20:37:35 +00:00
}
resolver , err := newGroundingResolver ( valid )
if err != nil {
t . Fatal ( err )
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
if _ , err := resolver . Resolve ( test . set ) ; err == nil {
t . Fatal ( "Resolve() error = nil" )
}
} )
}
}
2026-08-09 02:03:27 +00:00
func reversedCombatTurnReference ( t * testing . T , references contracts . ReferenceSet ) contracts . ReferenceSet {
t . Helper ( )
value := dnd . CombatTurnList { CombatTurns : [ ] dnd . CombatTurn { {
Actor : "Ashfang" , TurnKind : dnd . CombatTurnKindTurn ,
SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 2 , EndUnitID : 1 } } ,
} } }
content , err := combatturncodec . New ( ) . EncodeCandidate ( value )
if err != nil {
t . Fatalf ( "EncodeCandidate() error = %v" , err )
}
return replaceItem ( references , CombatTurnReferenceSlot , newReferenceItem ( CombatTurnReferenceSlot , content ) )
}
2026-08-03 20:37:35 +00:00
func groundingReferences ( t * testing . T , enemy string , sceneKind dnd . SceneKind ) contracts . ReferenceSet {
t . Helper ( )
2026-08-05 18:25:04 +00:00
npcContent , err := npccodec . New ( ) . Encode ( dnd . NPCRegistry { NPCs : [ ] dnd . NPC { {
2026-08-03 20:37:35 +00:00
ID : identity . DeriveID ( enemy ) ,
Name : enemy ,
SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 1 } } ,
} } } )
if err != nil {
t . Fatal ( err )
}
sceneContent , err := scenecodec . New ( ) . Encode ( dnd . SceneDescriptionList { Scenes : [ ] dnd . SceneDescription { {
ID : "combat-scene" ,
SourceRef : source . SourceRef { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 4 } ,
Kind : sceneKind ,
Title : "Combat session" ,
Summary : "A detailed scene summary that must not reach the prompt." ,
} } } )
if err != nil {
t . Fatal ( err )
}
turnContent , err := combatturncodec . New ( ) . Encode ( dnd . CombatTurnList { CombatTurns : [ ] dnd . CombatTurn {
{ Actor : enemy , TurnKind : dnd . CombatTurnKindTurn , SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Actor : "Aria" , TurnKind : dnd . CombatTurnKindReaction , SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 2 , EndUnitID : 2 } } } ,
} } )
if err != nil {
t . Fatal ( err )
}
2026-08-06 13:40:42 +00:00
occurrenceContent , err := occurrencecodec . New ( ) . Encode ( dnd . NPCOccurrenceList { Occurrences : [ ] dnd . NPCOccurrence {
2026-08-05 18:55:58 +00:00
{ NPCID : identity . DeriveID ( enemy ) , Name : enemy , Kind : dnd . NPCOccurrenceKindCombatOpponent , SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 1 } } } ,
{ NPCID : identity . DeriveID ( "Aria" ) , Name : "Aria" , Kind : dnd . NPCOccurrenceKindCombatAlly , SourceRefs : [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 2 , EndUnitID : 2 } } } ,
2026-08-03 20:37:35 +00:00
} } )
if err != nil {
t . Fatal ( err )
}
return contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
NPCRegistryReferenceSlot : { Items : [ ] contracts . ReferenceItem { newReferenceItem ( NPCRegistryReferenceSlot , npcContent ) } } ,
SceneDescriptionReferenceSlot : { Items : [ ] contracts . ReferenceItem { newReferenceItem ( SceneDescriptionReferenceSlot , sceneContent ) } } ,
CombatTurnReferenceSlot : { Items : [ ] contracts . ReferenceItem { newReferenceItem ( CombatTurnReferenceSlot , turnContent ) } } ,
2026-08-06 13:40:42 +00:00
NPCOccurrenceReferenceSlot : { Items : [ ] contracts . ReferenceItem { newReferenceItem ( NPCOccurrenceReferenceSlot , occurrenceContent ) } } ,
2026-08-03 20:37:35 +00:00
} }
}
func newReferenceItem ( slot string , content [ ] byte ) contracts . ReferenceItem {
return contracts . ReferenceItem {
SlotName : slot ,
MediaType : "application/json" ,
Content : append ( [ ] byte ( nil ) , content ... ) ,
Digest : digest ( content ) ,
Origin : contracts . ReferenceOrigin { Type : "file" , URI : "file:///private/reference.json" } ,
Producer : contracts . ReferenceProducer { PipelineID : "prior" , StepID : "extract" , LaneID : "lane" , ModuleKey : "dnd/example" } ,
}
}
func withoutSlot ( set contracts . ReferenceSet , name string ) contracts . ReferenceSet {
cloned := contracts . ReferenceSet { Slots : make ( map [ string ] contracts . ResolvedReferenceSlot , len ( set . Slots ) - 1 ) }
for slotName , slot := range set . Slots {
if slotName != name {
cloned . Slots [ slotName ] = slot
}
}
return cloned
}
func replaceSlot ( set contracts . ReferenceSet , name string , value contracts . ResolvedReferenceSlot ) contracts . ReferenceSet {
cloned := contracts . ReferenceSet { Slots : make ( map [ string ] contracts . ResolvedReferenceSlot , len ( set . Slots ) ) }
for slotName , slot := range set . Slots {
cloned . Slots [ slotName ] = slot
}
cloned . Slots [ name ] = value
return cloned
}
func replaceItem ( set contracts . ReferenceSet , name string , item contracts . ReferenceItem ) contracts . ReferenceSet {
return replaceSlot ( set , name , contracts . ResolvedReferenceSlot { Items : [ ] contracts . ReferenceItem { item } } )
}
func combatChunk ( ) * source . Chunk {
return & source . Chunk { ID : "combat-scene" , Ref : source . SourceRef { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 4 } }
}
func digest ( content [ ] byte ) string {
sum := sha256 . Sum256 ( content )
return "sha256:" + hex . EncodeToString ( sum [ : ] )
}