2026-08-03 20:44:44 +00:00
package enemyevents
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"
)
func TestExtractMapsEnemyEventsInSourceOrder ( t * testing . T ) {
client := & fakeEnemyEventsLLMClient { response : extractionResponse { Events : [ ] enemyEventResponse {
2026-08-28 13:45:21 +00:00
{ Name : "Ashfang" , Kind : "killed" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 4 , EndUnitID : 3 } } } ,
2026-08-03 20:44:44 +00:00
{ Name : "Ashfang" , Kind : "engaged" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 1 , EndUnitID : 1 } , { StartUnitID : 1 , EndUnitID : 1 } } } ,
{ Name : "Orcs" , Kind : "fled" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 3 , EndUnitID : 3 } } } ,
{ Name : "One orc" , Kind : "captured" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 2 , EndUnitID : 2 } } } ,
{ Name : "Ashfang" , Kind : "incapacitated" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 2 , EndUnitID : 2 } } } ,
} } }
result , err := newEnemyExtractor ( t , client ) . Extract ( context . Background ( ) , enemyExtractionRequest ( t ) )
if err != nil {
t . Fatal ( err )
}
if got := [ ] dnd . EnemyEventKind { result . Value . Events [ 0 ] . Kind , result . Value . Events [ 1 ] . Kind , result . Value . Events [ 2 ] . Kind , result . Value . Events [ 3 ] . Kind , result . Value . Events [ 4 ] . Kind } ; ! reflect . DeepEqual ( got , [ ] dnd . EnemyEventKind {
dnd . EnemyEventKindEngaged ,
dnd . EnemyEventKindCaptured ,
dnd . EnemyEventKindIncapacitated ,
dnd . EnemyEventKindKilled ,
2026-08-28 13:45:21 +00:00
dnd . EnemyEventKindFled ,
2026-08-03 20:44:44 +00:00
} ) {
t . Fatalf ( "event order = %#v" , got )
}
if refs := result . Value . Events [ 0 ] . SourceRefs ; ! reflect . DeepEqual ( refs , [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 1 , EndUnitID : 1 } } ) {
t . Fatalf ( "canonical source refs = %#v" , refs )
}
2026-08-28 13:45:21 +00:00
if refs := result . Value . Events [ 3 ] . SourceRefs ; ! reflect . DeepEqual ( refs , [ ] source . SourceRef { { SourceID : "combat-session" , StartUnitID : 3 , EndUnitID : 4 } } ) {
t . Fatalf ( "reversed source refs = %#v" , refs )
}
2026-08-03 20:44:44 +00:00
if len ( client . requests ) != 1 {
t . Fatalf ( "LLM calls = %d, want 1" , len ( client . requests ) )
}
request := client . requests [ 0 ]
if request . StageName != Key || request . PromptID != PromptID || request . PromptVersion != SchemaVersion || request . ProfileID != "enemy-profile" || request . SessionID != "session-123" {
t . Fatalf ( "LLM request identity = %#v" , request )
}
if got := string ( request . Inputs [ CombatTurnReferenceSlot ] . Content ) ; ! strings . Contains ( got , ` "actor":"Ashfang" ` ) || strings . Contains ( got , "source_ref" ) {
t . Fatalf ( "combat grounding = %s" , got )
}
2026-08-05 19:00:55 +00:00
if got := string ( request . Inputs [ NPCOccurrenceReferenceSlot ] . Content ) ; ! strings . Contains ( got , ` "kind":"combat_opponent" ` ) || strings . Contains ( got , "Aria" ) {
t . Fatalf ( "occurrence grounding = %s" , got )
2026-08-03 20:44:44 +00:00
}
}
func TestExtractPreservesSemanticCandidatesAndResponseOwnership ( t * testing . T ) {
client := & fakeEnemyEventsLLMClient { response : extractionResponse { Events : [ ] enemyEventResponse { {
2026-08-29 01:24:45 +00:00
Name : " " , Kind : "engaged" , SourceRefs : [ ] enemySourceRefResponse { { StartUnitID : 99 , EndUnitID : 0 } } ,
2026-08-03 20:44:44 +00:00
} } } }
result , err := newEnemyExtractor ( t , client ) . Extract ( context . Background ( ) , enemyExtractionRequest ( t ) )
if err != nil {
t . Fatal ( err )
}
event := result . Value . Events [ 0 ]
2026-08-29 01:24:45 +00:00
if event . Name != " " || event . Kind != "engaged" || event . SourceRefs [ 0 ] != ( source . SourceRef { SourceID : "combat-session" , StartUnitID : 99 } ) {
2026-08-03 20:44:44 +00:00
t . Fatalf ( "semantic candidate = %#v" , event )
}
result . Value . Events [ 0 ] . SourceRefs [ 0 ] . StartUnitID = 7
if client . response . Events [ 0 ] . SourceRefs [ 0 ] . StartUnitID != 99 {
t . Fatal ( "mapped event aliases model-owned source references" )
}
}
func TestExtractSkipsModelForIneligibleScenes ( t * testing . T ) {
for _ , test := range [ ] struct {
name string
kind dnd . SceneKind
change func ( * source . Chunk )
wantWarning bool
} {
{ name : "non-combat" , kind : dnd . SceneKindNarrative } ,
{ name : "missing" , kind : dnd . SceneKindCombat , change : func ( chunk * source . Chunk ) { chunk . ID = "other" } , wantWarning : true } ,
{ name : "mismatched" , kind : dnd . SceneKindCombat , change : func ( chunk * source . Chunk ) { chunk . Ref . EndUnitID ++ } , wantWarning : true } ,
} {
t . Run ( test . name , func ( t * testing . T ) {
client := & fakeEnemyEventsLLMClient { }
request := enemyExtractionRequest ( t )
request . References = groundingReferences ( t , "Ashfang" , test . kind )
if test . change != nil {
test . change ( request . Chunk )
}
result , err := newEnemyExtractor ( t , client ) . Extract ( context . Background ( ) , request )
if err != nil {
t . Fatal ( err )
}
if len ( client . requests ) != 0 || result . Value . Events == nil || len ( result . Value . Events ) != 0 {
t . Fatalf ( "result = %#v, calls = %d" , result , len ( client . requests ) )
}
if test . wantWarning {
2026-08-27 16:40:17 +00:00
if len ( result . Diagnostics ) != 1 || result . Diagnostics [ 0 ] . ReasonCode != "scene_classification_unavailable" || result . Diagnostics [ 0 ] . Disposition != contracts . DiagnosticDispositionWarning || result . Diagnostics [ 0 ] . Category != contracts . DiagnosticCategoryDegradation || result . Diagnostics [ 0 ] . Samples [ 0 ] . Scope != SceneDescriptionReferenceSlot {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v" , result . Diagnostics )
2026-08-03 20:44:44 +00:00
}
2026-08-27 16:40:17 +00:00
} else if len ( result . Diagnostics ) != 0 {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v" , result . Diagnostics )
2026-08-03 20:44:44 +00:00
}
} )
}
}
func TestExtractReportsRequiredGroundingAndProviderFailures ( t * testing . T ) {
client := & fakeEnemyEventsLLMClient { }
request := enemyExtractionRequest ( t )
request . References = withoutSlot ( request . References , NPCRegistryReferenceSlot )
if _ , err := ( & Extractor { llm : client , grounding : mustGroundingResolver ( t , request . References ) } ) . Extract ( context . Background ( ) , request ) ; err == nil || ! strings . Contains ( err . Error ( ) , "NPC registry" ) {
t . Fatalf ( "Extract() error = %v, want required grounding context" , err )
}
provider := & fakeEnemyEventsLLMClient { err : errors . New ( "provider unavailable" ) }
if _ , err := newEnemyExtractor ( t , provider ) . Extract ( context . Background ( ) , enemyExtractionRequest ( t ) ) ; err == nil || ! strings . Contains ( err . Error ( ) , "complete structured output" ) || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
t . Fatalf ( "Extract() error = %v, want provider context" , err )
}
}
func TestConstructorSpecOptionsAndSafeMetadata ( t * testing . T ) {
if _ , err := New ( nil , Options { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "LLM client" ) {
t . Fatalf ( "New(nil) error = %v" , err )
}
if _ , err := New ( & fakeEnemyEventsLLMClient { } , Options { } , contracts . ReferenceSet { } , contracts . ReferenceSet { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "at most one" ) {
t . Fatalf ( "New() error = %v" , err )
}
if _ , err := DecodeOptions ( map [ string ] any { "unexpected" : true } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unknown option" ) {
t . Fatalf ( "DecodeOptions() error = %v" , err )
}
first := ModuleSpec ( )
first . Requires [ 0 ] = "changed"
first . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
second := ModuleSpec ( )
2026-08-26 23:59:05 +00:00
if second . Requires [ 0 ] != "chunks" || second . ArtifactKind != dnd . EnemyEventListKind || second . ExecutionClass != contracts . ExecutionClassLLMBacked || second . CorrectionProtocol != contracts . CorrectionProtocolSingleResponseV1 || second . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] == "changed" {
2026-08-03 20:44:44 +00:00
t . Fatalf ( "ModuleSpec() reused mutable state: %#v" , second )
}
registry := pipeline . NewExtractorRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatal ( err )
}
if spec , ok := registry . Spec ( Key ) ; ! ok || spec . Key != Key || spec . ArtifactKind != dnd . EnemyEventListKind {
t . Fatalf ( "registered spec = %#v, %t" , spec , ok )
}
extractor := newEnemyExtractor ( t , & fakeEnemyEventsLLMClient { } , groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat ) )
metadata := extractor . ManifestMetadata ( )
encoded , err := json . Marshal ( metadata )
if err != nil {
t . Fatal ( err )
}
if strings . Contains ( string ( encoded ) , "Ashfang" ) || metadata [ "mapping_policy" ] != mappingPolicy || metadata [ "scene_gate_policy" ] != sceneGatePolicy {
t . Fatalf ( "unsafe or incomplete metadata = %s" , encoded )
}
fingerprints := extractor . CheckpointFingerprints ( )
if len ( fingerprints ) != 4 || fingerprints [ 0 ] . Value != metadata [ "prompt_sha256" ] || fingerprints [ 1 ] . Value != metadata [ "response_schema_sha256" ] || fingerprints [ 2 ] . Value != mappingPolicy || fingerprints [ 3 ] . Value != sceneGatePolicy {
t . Fatalf ( "fingerprints = %#v" , fingerprints )
}
}
func newEnemyExtractor ( t * testing . T , client contracts . StructuredLLMClient , references ... contracts . ReferenceSet ) * Extractor {
t . Helper ( )
if len ( references ) == 0 {
references = [ ] contracts . ReferenceSet { groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat ) }
}
extractor , err := New ( client , Options { } , references ... )
if err != nil {
t . Fatal ( err )
}
return extractor
}
func mustGroundingResolver ( t * testing . T , references contracts . ReferenceSet ) * groundingResolver {
t . Helper ( )
resolver , err := newGroundingResolver ( references )
if err != nil {
t . Fatal ( err )
}
return resolver
}
func enemyExtractionRequest ( t * testing . T ) contracts . TypedExtractionRequest {
t . Helper ( )
chunk := combatChunk ( )
chunk . Units = [ ] source . SourceUnit { { ID : 1 } , { ID : 2 } , { ID : 3 } , { ID : 4 } }
chunk . MediaType = "application/json"
chunk . Content = [ ] byte ( ` { "id":"combat-scene","units":[1,2,3,4]} ` )
return contracts . TypedExtractionRequest {
Source : & source . SourceDocument { ID : "combat-session" , Units : append ( [ ] source . SourceUnit ( nil ) , chunk . Units ... ) } ,
Chunk : chunk ,
SourceInput : contracts . NewLLMInputMaterial ( "source" , "application/json" , chunk . Content , digest ( chunk . Content ) , "file:///combat-session.json" ) ,
References : groundingReferences ( t , "Ashfang" , dnd . SceneKindCombat ) ,
LLMProfile : "enemy-profile" ,
SessionID : "session-123" ,
}
}
type fakeEnemyEventsLLMClient struct {
response extractionResponse
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( client * fakeEnemyEventsLLMClient ) CompleteStructured ( _ context . Context , request contracts . StructuredCompletionRequest , out any ) ( contracts . StructuredCompletionResponse , error ) {
client . requests = append ( client . requests , cloneEnemyRequest ( request ) )
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 cloneEnemyRequest ( request contracts . StructuredCompletionRequest ) contracts . StructuredCompletionRequest {
request . Inputs = request . Inputs . Clone ( )
return request
}