2026-07-21 05:08:02 +00:00
package combatturns
import (
"context"
"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"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition ( t * testing . T ) {
round := 3
resolution := "The ogre falls back."
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse {
{
Actor : "Borin" , TurnKind : "turn" , Round : & round ,
Actions : [ ] combatActionResponse { { Category : "movement" , Declaration : "Borin retreats" , Targets : [ ] string { "ogre" } , Resolution : nil } } ,
2026-07-21 07:49:57 -05:00
Summary : "Borin retreats." , SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 2 , EndUnitID : 2 } } ,
2026-07-21 05:08:02 +00:00
} ,
{
Actor : "Aria" , TurnKind : "reaction" , Round : nil ,
Actions : [ ] combatActionResponse { { Category : "attack" , Declaration : "Aria strikes" , Targets : [ ] string { "ogre" } , Resolution : & resolution } } ,
Summary : "Aria reacts." , SourceRefs : [ ] combatSourceRefResponse {
2026-07-21 07:49:57 -05:00
{ StartUnitID : 10 , EndUnitID : 10 } ,
{ StartUnitID : 10 , EndUnitID : 10 } ,
2026-07-21 05:08:02 +00:00
} ,
} ,
{
Actor : "Unknown" , TurnKind : "other" , Round : nil ,
Actions : [ ] combatActionResponse { { Category : "other" , Declaration : "something" , Targets : [ ] string { } , Resolution : nil } } ,
2026-07-21 07:49:57 -05:00
Summary : "Uncited event." , SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 0 , EndUnitID : 0 } } ,
2026-07-21 05:08:02 +00:00
} ,
} } }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
if got := [ ] string { result . Value . CombatTurns [ 0 ] . Actor , result . Value . CombatTurns [ 1 ] . Actor , result . Value . CombatTurns [ 2 ] . Actor } ; ! reflect . DeepEqual ( got , [ ] string { "Aria" , "Borin" , "Unknown" } ) {
t . Fatalf ( "actor order = %#v, want source-position order with invalid evidence last" , got )
}
wantRefs := [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 10 , EndUnitID : 10 } }
if ! reflect . DeepEqual ( result . Value . CombatTurns [ 0 ] . SourceRefs , wantRefs ) {
t . Fatalf ( "canonical refs = %#v, want %#v" , result . Value . CombatTurns [ 0 ] . SourceRefs , wantRefs )
}
if result . Value . CombatTurns [ 0 ] . Round != nil || result . Value . CombatTurns [ 0 ] . Actions [ 0 ] . Resolution == nil || * result . Value . CombatTurns [ 0 ] . Actions [ 0 ] . Resolution != resolution {
t . Fatalf ( "nullable fields = %#v, want nil round and preserved resolution" , result . Value . CombatTurns [ 0 ] )
}
if ref := result . Value . CombatTurns [ 2 ] . SourceRefs [ 0 ] ; ref != ( source . SourceRef { SourceID : "session-alpha" } ) {
t . Fatalf ( "invalid evidence = %#v, want source identity and invalid range preserved" , ref )
}
if len ( client . requests ) != 1 {
t . Fatalf ( "LLM calls = %d, want one call per chunk" , len ( client . requests ) )
}
request := client . requests [ 0 ]
if request . StageName != Key || request . PromptID != PromptID || request . PromptVersion != SchemaVersion || request . SessionID != "session-123" || request . ProfileID != "profile-combat" {
t . Fatalf ( "LLM request identity = %#v" , request )
}
transcript := request . Inputs [ "transcript" ]
if transcript . Name != "transcript" || transcript . MediaType != "application/json" || transcript . Digest != "sha256:chunk" || transcript . OriginURI != "file:///session-alpha.json" || ! reflect . DeepEqual ( transcript . Content , extractionRequest ( ) . Chunk . Content ) {
t . Fatalf ( "transcript input = %#v, want chunk-scoped source input" , transcript )
}
}
func TestExtractPreservesInvalidCandidatesForValidators ( t * testing . T ) {
negativeRound := - 1
emptyResolution := " "
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse {
{
Actor : " " , TurnKind : "unsupported" , Round : & negativeRound ,
Actions : [ ] combatActionResponse { { Category : "unsupported" , Declaration : " " , Targets : nil , Resolution : & emptyResolution } } ,
2026-07-21 07:49:57 -05:00
Summary : " " , SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 99 , EndUnitID : 0 } } ,
2026-07-21 05:08:02 +00:00
} ,
} } }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil for candidate values" , err )
}
turn := result . Value . CombatTurns [ 0 ]
if turn . Actor != " " || turn . TurnKind != "unsupported" || turn . Round == nil || * turn . Round != negativeRound || turn . Summary != " " {
t . Fatalf ( "invalid turn fields = %#v, want preserved candidate values" , turn )
}
if turn . Actions == nil || turn . Actions [ 0 ] . Targets != nil || turn . Actions [ 0 ] . Resolution == nil || * turn . Actions [ 0 ] . Resolution != emptyResolution {
t . Fatalf ( "invalid action fields = %#v, want preserved candidate values" , turn . Actions [ 0 ] )
}
if turn . SourceRefs [ 0 ] != ( source . SourceRef { SourceID : "session-alpha" , StartUnitID : 99 } ) {
t . Fatalf ( "invalid source ref = %#v, want invalid range preserved" , turn . SourceRefs [ 0 ] )
}
}
func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } }
references := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
"players" : { Slot : contracts . ReferenceSlot { Name : "players" } , Items : [ ] contracts . ReferenceItem { { SlotName : "players" , Content : [ ] byte ( "Alice: Aria" ) } } } ,
"party" : { Slot : contracts . ReferenceSlot { Name : "party" } , Items : [ ] contracts . ReferenceItem { { SlotName : "party" , Content : [ ] byte ( "Aria: cleric" ) } } } ,
"glossary" : { Slot : contracts . ReferenceSlot { Name : "glossary" } , Items : [ ] contracts . ReferenceItem { { SlotName : "glossary" , Content : [ ] byte ( "ogre: a large foe" ) } } } ,
NPCRegistryReferenceSlot : { Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } , Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : "application/json" , Content : npcRegistryJSON ( t ) } } } ,
} }
req := extractionRequest ( )
req . References = references
if _ , err := newExtractor ( t , client , references ) . Extract ( context . Background ( ) , req ) ; err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
inputs := client . requests [ 0 ] . Inputs
if string ( inputs [ "players" ] . Content ) != "Alice: Aria" || string ( inputs [ "party" ] . Content ) != "Aria: cleric" || string ( inputs [ "glossary" ] . Content ) != "ogre: a large foe" {
t . Fatalf ( "reference inputs = %#v, want configured references" , inputs )
}
registryInput := inputs [ NPCRegistryReferenceSlot ]
if registryInput . Name != NPCRegistryReferenceSlot || registryInput . MediaType != "application/json" || ! strings . Contains ( string ( registryInput . Content ) , "Mira Thorn" ) {
t . Fatalf ( "NPC registry input = %#v, want canonical registry grounding" , registryInput )
}
if strings . Contains ( string ( inputs [ "transcript" ] . Content ) , "Aria: cleric" ) {
t . Fatal ( "transcript input contains reference content" )
}
metadata := newExtractor ( t , & fakeCombatTurnsLLMClient { } , references ) . ManifestMetadata ( )
if metadata [ "npc_count" ] != 1 || ! strings . HasPrefix ( metadata [ "npc_registry_digest" ] . ( string ) , "sha256:" ) {
t . Fatalf ( "bound registry metadata = %#v, want digest and count" , metadata )
}
fingerprints := newExtractor ( t , & fakeCombatTurnsLLMClient { } , references ) . CheckpointFingerprints ( )
if len ( fingerprints ) != 4 || fingerprints [ 3 ] . Name != "npc_registry" {
t . Fatalf ( "bound fingerprints = %#v, want local identities plus NPC registry" , fingerprints )
}
encoded , err := json . Marshal ( metadata )
if err != nil {
t . Fatal ( err )
}
if strings . Contains ( string ( encoded ) , "Mira Thorn" ) || strings . Contains ( string ( encoded ) , "session-alpha" ) {
t . Fatalf ( "metadata leaked content: %s" , encoded )
}
}
func TestExtractUnboundRegistryUsesExactEmptyPromptAndOmitsIdentity ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } }
extractor := newExtractor ( t , client )
if _ , err := extractor . Extract ( context . Background ( ) , extractionRequest ( ) ) ; err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
input := client . requests [ 0 ] . Inputs [ NPCRegistryReferenceSlot ]
if string ( input . Content ) != ` { "npcs":[]} ` || input . Digest != "" || input . OriginURI != "" {
t . Fatalf ( "unbound registry input = %#v, want exact empty prompt without identity" , input )
}
metadata := extractor . ManifestMetadata ( )
if _ , ok := metadata [ "npc_registry_digest" ] ; ok {
t . Fatalf ( "unbound metadata has registry digest: %#v" , metadata )
}
for _ , fingerprint := range extractor . CheckpointFingerprints ( ) {
if fingerprint . Name == "npc_registry" {
t . Fatalf ( "unbound fingerprints include registry identity: %#v" , extractor . CheckpointFingerprints ( ) )
}
}
}
func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { }
references := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
NPCRegistryReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : "application/json" , Content : [ ] byte ( ` { "secret":"private transcript detail"} ` ) } } ,
} ,
} }
_ , err := New ( client , Options { } , references )
if err == nil || ! strings . Contains ( err . Error ( ) , "prepare NPC registry" ) || strings . Contains ( err . Error ( ) , "private transcript detail" ) {
t . Fatalf ( "New() error = %v, want bounded content-free registry failure" , err )
}
if len ( client . requests ) != 0 {
t . Fatalf ( "LLM calls = %d, want none during failed construction" , len ( client . requests ) )
}
}
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures ( t * testing . T ) {
validReq := extractionRequest ( )
canceledCtx , cancel := context . WithCancel ( context . Background ( ) )
cancel ( )
validExtractor := newExtractor ( t , & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } } )
tests := [ ] struct {
name string
extractor * Extractor
ctx context . Context
req contracts . TypedExtractionRequest
want string
} {
{ name : "nil extractor" , ctx : context . Background ( ) , req : validReq , want : "extractor" } ,
{ name : "nil context" , extractor : validExtractor , req : validReq , want : "context" } ,
{ name : "canceled context" , extractor : validExtractor , ctx : canceledCtx , req : validReq , want : "context" } ,
{ name : "nil source" , extractor : validExtractor , ctx : context . Background ( ) , req : contracts . TypedExtractionRequest { Chunk : validReq . Chunk } , want : "source" } ,
{ name : "nil chunk" , extractor : validExtractor , ctx : context . Background ( ) , req : contracts . TypedExtractionRequest { Source : validReq . Source } , want : "chunk" } ,
{ name : "empty chunk units" , extractor : validExtractor , ctx : context . Background ( ) , req : emptyChunkRequest ( validReq ) , want : "units" } ,
{ name : "source input mismatch" , extractor : validExtractor , ctx : context . Background ( ) , req : mismatchedSourceInputRequest ( validReq ) , want : "must match chunk" } ,
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
_ , err := test . extractor . Extract ( test . ctx , test . req )
if err == nil || ! strings . Contains ( err . Error ( ) , "dnd combat turns" ) || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Extract() error = %v, want %q context" , err , test . want )
}
} )
}
_ , err := newExtractor ( t , & fakeCombatTurnsLLMClient { err : errors . New ( "provider unavailable" ) } ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err == nil || ! strings . Contains ( err . Error ( ) , "dnd combat turns" ) || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
t . Fatalf ( "provider error = %v, want contextual wrapped error" , err )
}
}
func TestExtractorManifestMetadataAndFingerprints ( t * testing . T ) {
extractor := newExtractor ( t , & fakeCombatTurnsLLMClient { } )
metadata := extractor . ManifestMetadata ( )
for key , want := range map [ string ] string {
"prompt_id" : PromptID , "prompt_version" : SchemaVersion , "mapping_policy" : mappingPolicy ,
"response_schema_key" : string ( ResponseSchemaKey ) , "response_schema_id" : ResponseSchemaID ,
"response_schema_name" : ResponseSchemaName , "response_schema_version" : SchemaVersion ,
} {
if metadata [ key ] != want {
t . Fatalf ( "metadata[%q] = %#v, want %q" , key , metadata [ key ] , want )
}
}
for _ , key := range [ ] string { "prompt_sha256" , "response_schema_sha256" } {
value , ok := metadata [ key ] . ( string )
if ! ok || ! strings . HasPrefix ( value , "sha256:" ) {
t . Fatalf ( "metadata[%q] = %#v, want digest" , key , metadata [ key ] )
}
}
wantNames := map [ string ] struct { } { "prompt" : { } , "response_schema" : { } , "mapping_policy" : { } }
for _ , fingerprint := range extractor . CheckpointFingerprints ( ) {
if _ , ok := wantNames [ fingerprint . Name ] ; ! ok {
t . Fatalf ( "unexpected fingerprint = %#v" , fingerprint )
}
delete ( wantNames , fingerprint . Name )
}
if len ( wantNames ) != 0 {
t . Fatalf ( "missing fingerprints = %#v" , wantNames )
}
}
func TestModuleSpecAndRegistration ( t * testing . T ) {
wantSlots := referenceSlots ( )
got := ModuleSpec ( )
if got . Key != Key || got . Stage != pipeline . StageExtract || got . ArtifactKind != dnd . CombatTurnListKind || ! reflect . DeepEqual ( got . Requires , [ ] string { "chunks" , "source.transcript" } ) || ! reflect . DeepEqual ( got . Provides , [ ] string { "dnd.combat_turns" } ) || ! reflect . DeepEqual ( got . ReferenceSlots , wantSlots ) {
t . Fatalf ( "ModuleSpec() = %#v, want combat extractor contract" , got )
}
got . Requires [ 0 ] = "changed"
got . Provides [ 0 ] = "changed"
got . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
again := ModuleSpec ( )
if again . Requires [ 0 ] == "changed" || again . Provides [ 0 ] == "changed" || again . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] == "changed" {
t . Fatalf ( "ModuleSpec() returned shared mutable values: %#v" , again )
}
registry := pipeline . NewExtractorRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatalf ( "Register() error = %v" , err )
}
if _ , err := DecodeOptions ( map [ string ] any { "unexpected" : true } ) ; err == nil {
t . Fatal ( "DecodeOptions() accepted unknown option" )
}
if _ , ok := registry . Spec ( Key ) ; ! ok {
t . Fatalf ( "registry missing spec for %q" , Key )
}
}
func extractionRequest ( ) contracts . TypedExtractionRequest {
doc := combatSourceDocument ( )
chunk := & source . Chunk {
ID : "session-alpha:chunk:0" ,
SourceID : doc . ID ,
Index : 0 ,
Ref : source . SourceRef { SourceID : doc . ID , StartUnitID : 10 , EndUnitID : 2 } ,
Content : [ ] byte ( ` { "units":[10,2]} ` ) ,
MediaType : "application/json" ,
Units : append ( [ ] source . SourceUnit ( nil ) , doc . Units ... ) ,
}
return contracts . TypedExtractionRequest {
Source : doc ,
Chunk : chunk ,
SourceInput : contracts . NewLLMInputMaterial ( "source" , chunk . MediaType , chunk . Content , "sha256:chunk" , "file:///session-alpha.json" ) ,
SessionID : "session-123" ,
LLMProfile : "profile-combat" ,
}
}
func combatSourceDocument ( ) * source . SourceDocument {
return & source . SourceDocument {
ID : "session-alpha" , Kind : "transcript" , Format : "application/vnd.seriatim.minimal+json" , Digest : "sha256:source" ,
Units : [ ] source . SourceUnit {
{ ID : 10 , Kind : "transcript_segment" , Text : "Aria reacts and strikes the ogre." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 10 , EndUnitID : 10 } } ,
{ ID : 2 , Kind : "transcript_segment" , Text : "Borin retreats from the ogre." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 2 , EndUnitID : 2 } } ,
} ,
}
}
func emptyChunkRequest ( req contracts . TypedExtractionRequest ) contracts . TypedExtractionRequest {
req . Chunk = & source . Chunk { ID : req . Chunk . ID , SourceID : req . Chunk . SourceID , Index : req . Chunk . Index }
return req
}
func mismatchedSourceInputRequest ( req contracts . TypedExtractionRequest ) contracts . TypedExtractionRequest {
req . SourceInput = contracts . NewLLMInputMaterial ( "source" , "application/json" , [ ] byte ( ` { "other":true} ` ) , "sha256:other" , "" )
return req
}
func newExtractor ( t * testing . T , client contracts . StructuredLLMClient , references ... contracts . ReferenceSet ) * Extractor {
t . Helper ( )
extractor , err := New ( client , Options { } , references ... )
if err != nil {
t . Fatalf ( "New() error = %v" , err )
}
return extractor
}
func npcRegistryJSON ( t * testing . T ) [ ] byte {
t . Helper ( )
value := dnd . NPCList { NPCs : [ ] dnd . NPC { {
ID : identity . DeriveID ( "Mira Thorn" ) , Name : "Mira Thorn" , Aliases : [ ] string { "The Greencloak" } ,
Description : "A ranger." , Relationships : [ ] dnd . NPCRelationship { } , SourceRefs : [ ] source . SourceRef { { SourceID : "other-session" , StartUnitID : 1 , EndUnitID : 1 } } ,
} } }
content , err := npccodec . New ( ) . Encode ( value )
if err != nil {
t . Fatalf ( "encode NPC registry: %v" , err )
}
return content
}
type fakeCombatTurnsLLMClient struct {
response extractionResponse
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( client * fakeCombatTurnsLLMClient ) CompleteStructured ( _ context . Context , req contracts . StructuredCompletionRequest , out any ) ( contracts . StructuredCompletionResponse , error ) {
client . requests = append ( client . requests , cloneStructuredCompletionRequest ( req ) )
if client . err != nil {
return contracts . StructuredCompletionResponse { } , client . err
}
target , ok := out . ( * extractionResponse )
if ! ok {
return contracts . StructuredCompletionResponse { } , errors . New ( "unexpected output target" )
}
* target = client . response
content , err := json . Marshal ( client . response )
if err != nil {
return contracts . StructuredCompletionResponse { } , err
}
return contracts . StructuredCompletionResponse { Content : content } , nil
}
func cloneStructuredCompletionRequest ( req contracts . StructuredCompletionRequest ) contracts . StructuredCompletionRequest {
req . Inputs = req . Inputs . Clone ( )
if len ( req . Vars ) == 0 {
req . Vars = nil
return req
}
vars := make ( map [ string ] any , len ( req . Vars ) )
for key , value := range req . Vars {
vars [ key ] = value
}
req . Vars = vars
return req
}