2026-07-21 02:24:26 +00:00
package npcs
import (
"context"
"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/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractReturnsCanonicalNPCListFromPrivateResponse ( t * testing . T ) {
client := & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse {
{
2026-07-22 18:51:26 +00:00
Name : "Captain Vale" , SourceRefs : responseSourceRefs ( 3 , 3 ) ,
2026-07-21 02:24:26 +00:00
} ,
{
2026-07-22 18:51:26 +00:00
Name : "Mira Thorn" ,
2026-07-21 02:24:26 +00:00
SourceRefs : [ ] npcSourceRefResponse {
2026-07-22 14:38:35 +00:00
{ StartUnitID : 2 , EndUnitID : 2 } ,
{ StartUnitID : 1 , EndUnitID : 2 } ,
{ StartUnitID : 1 , EndUnitID : 2 } ,
2026-07-21 02:24:26 +00:00
} ,
} ,
} } }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
want := dnd . NPCList { NPCs : [ ] dnd . NPC {
2026-07-22 18:51:26 +00:00
{ ID : identity . DeriveID ( "Mira Thorn" ) , Name : "Mira Thorn" , SourceRefs : [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 1 , EndUnitID : 2 } , { SourceID : "session-alpha" , StartUnitID : 2 , EndUnitID : 2 } } } ,
{ ID : identity . DeriveID ( "Captain Vale" ) , Name : "Captain Vale" , SourceRefs : [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 3 , EndUnitID : 3 } } } ,
2026-07-21 02:24:26 +00:00
} }
if ! reflect . DeepEqual ( result . Value , want ) {
t . Fatalf ( "Value = %#v, want %#v" , result . Value , want )
}
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 != "profile-npcs" || request . SessionID != "session-123" {
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" || string ( transcript . Content ) != string ( extractionRequest ( ) . Chunk . Content ) {
t . Fatalf ( "transcript input = %#v, want chunk-scoped material" , transcript )
}
}
2026-07-20 23:10:25 -05:00
func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID ( t * testing . T ) {
client := & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse {
{
2026-07-22 18:51:26 +00:00
Name : "Later NPC" ,
2026-07-20 23:10:25 -05:00
SourceRefs : responseSourceRefs ( 10 , 10 ) ,
} ,
{
2026-07-22 18:51:26 +00:00
Name : "Earlier NPC" ,
2026-07-20 23:10:25 -05:00
SourceRefs : [ ] npcSourceRefResponse {
2026-07-22 14:38:35 +00:00
{ StartUnitID : 50 , EndUnitID : 50 } ,
{ StartUnitID : 100 , EndUnitID : 100 } ,
2026-07-20 23:10:25 -05:00
} ,
} ,
} } }
req := extractionRequest ( )
req . Source . Units = [ ] source . SourceUnit {
{ ID : 100 , Kind : "transcript_segment" , Text : "Earlier NPC appears." } ,
{ ID : 10 , Kind : "transcript_segment" , Text : "Later NPC appears." } ,
{ ID : 50 , Kind : "transcript_segment" , Text : "Earlier NPC appears again." } ,
}
req . Chunk . Units = append ( [ ] source . SourceUnit ( nil ) , req . Source . Units ... )
req . Chunk . Ref = source . SourceRef { SourceID : req . Source . ID , StartUnitID : 100 , EndUnitID : 50 }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , req )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
if len ( result . Value . NPCs ) != 2 || result . Value . NPCs [ 0 ] . Name != "Earlier NPC" || result . Value . NPCs [ 1 ] . Name != "Later NPC" {
t . Fatalf ( "NPC order = %#v, want source-document order" , result . Value . NPCs )
}
}
2026-07-24 14:29:19 +00:00
func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies ( t * testing . T ) {
client := & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse {
{ Name : "Later" , SourceRefs : responseSourceRefs ( 10 , 10 ) } ,
{ Name : "First" , SourceRefs : [ ] npcSourceRefResponse {
{ StartUnitID : 10 , EndUnitID : 10 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 999 , EndUnitID : 0 } ,
} } ,
{ Name : "Second" , SourceRefs : responseSourceRefs ( 30 , 30 ) } ,
} } }
req := extractionRequest ( )
req . Source . Units = [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } }
req . Chunk . Units = append ( [ ] source . SourceUnit ( nil ) , req . Source . Units ... )
req . Chunk . Ref = source . SourceRef { SourceID : req . Source . ID , StartUnitID : 30 , EndUnitID : 10 }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , req )
if err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if got := [ ] string { result . Value . NPCs [ 0 ] . Name , result . Value . NPCs [ 1 ] . Name , result . Value . NPCs [ 2 ] . Name } ; ! reflect . DeepEqual ( got , [ ] string { "First" , "Second" , "Later" } ) {
t . Fatalf ( "NPC order = %#v, want document chronology with stable equal-evidence ties" , got )
}
refs := result . Value . NPCs [ 0 ] . SourceRefs
if got := [ ] int { refs [ 0 ] . StartUnitID , refs [ 1 ] . StartUnitID , refs [ 2 ] . StartUnitID } ; ! reflect . DeepEqual ( got , [ ] int { 30 , 10 , 999 } ) {
t . Fatalf ( "source refs = %#v, want document order with exact duplicate removed" , refs )
}
refs [ 0 ] . StartUnitID = 777
for _ , npc := range client . response . NPCs {
for _ , ref := range npc . SourceRefs {
if ref . StartUnitID == 777 {
t . Fatal ( "result source references alias the model response" )
}
}
}
}
2026-07-21 02:24:26 +00:00
func TestExtractPassesCampaignReferencesAsPromptInputs ( t * testing . T ) {
client := & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse { } } }
req := extractionRequest ( )
req . References = contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
"players" : { Slot : contracts . ReferenceSlot { Name : "players" } , Items : [ ] contracts . ReferenceItem { { SlotName : "players" , Content : [ ] byte ( "Dana: Mira" ) } } } ,
"party" : { Slot : contracts . ReferenceSlot { Name : "party" } , Items : [ ] contracts . ReferenceItem { { SlotName : "party" , Content : [ ] byte ( "Mira: ranger" ) } } } ,
"glossary" : { Slot : contracts . ReferenceSlot { Name : "glossary" } , Items : [ ] contracts . ReferenceItem { { SlotName : "glossary" , Content : [ ] byte ( "Greencloak: local title" ) } } } ,
} }
if _ , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , req ) ; err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
inputs := client . requests [ 0 ] . Inputs
if string ( inputs [ "players" ] . Content ) != "Dana: Mira" || string ( inputs [ "party" ] . Content ) != "Mira: ranger" || string ( inputs [ "glossary" ] . Content ) != "Greencloak: local title" {
t . Fatalf ( "reference inputs = %#v" , inputs )
}
if strings . Contains ( string ( inputs [ "transcript" ] . Content ) , "local title" ) {
t . Fatal ( "transcript input contains reference content" )
}
}
func TestExtractPreservesMalformedCandidatesForValidators ( t * testing . T ) {
client := & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse { {
2026-07-22 18:51:26 +00:00
Name : "" ,
2026-07-22 14:38:35 +00:00
SourceRefs : [ ] npcSourceRefResponse { { StartUnitID : 99 , EndUnitID : 0 } } ,
2026-07-21 02:24:26 +00:00
} } } }
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
2026-07-22 18:51:26 +00:00
if len ( result . Value . NPCs ) != 1 || result . Value . NPCs [ 0 ] . ID != "" || result . Value . NPCs [ 0 ] . Name != "" {
2026-07-21 02:24:26 +00:00
t . Fatalf ( "malformed candidate = %#v, want invalid values preserved" , result . Value )
}
if refs := result . Value . NPCs [ 0 ] . SourceRefs ; len ( refs ) != 1 || refs [ 0 ] . SourceID != "session-alpha" || refs [ 0 ] . StartUnitID != 99 || refs [ 0 ] . EndUnitID != 0 {
t . Fatalf ( "malformed source refs = %#v, want invalid range preserved after exact deduplication" , refs )
}
}
2026-07-22 14:38:35 +00:00
func TestExtractMapsRawSemanticCandidatesWithoutRepair ( t * testing . T ) {
2026-07-22 18:51:26 +00:00
client := & fakeNPCsLLMClient { content : [ ] byte ( ` { "npcs":[ { "name":"","source_refs":[ { "start_unit_id":0,"end_unit_id":-1}]}]} ` ) }
2026-07-22 14:38:35 +00:00
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( ) )
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
npc := result . Value . NPCs [ 0 ]
2026-07-22 18:51:26 +00:00
if npc . ID != "" || npc . Name != "" {
2026-07-22 14:38:35 +00:00
t . Fatalf ( "NPC = %#v, want blank semantic values preserved" , npc )
}
if refs := npc . SourceRefs ; len ( refs ) != 1 || refs [ 0 ] != ( source . SourceRef { SourceID : "session-alpha" , StartUnitID : 0 , EndUnitID : - 1 } ) {
t . Fatalf ( "source refs = %#v, want raw nonpositive candidates preserved" , refs )
}
}
2026-07-24 14:38:42 +00:00
func TestExtractRetainsLocalErrorContextAndProviderFailures ( t * testing . T ) {
2026-07-21 02:24:26 +00:00
request := extractionRequest ( )
extractor := newExtractor ( t , & fakeNPCsLLMClient { response : extractionResponse { NPCs : [ ] npcResponse { } } } )
2026-07-24 14:38:42 +00:00
var nilExtractor * Extractor
for _ , test := range [ ] struct {
name string
extractor * Extractor
req contracts . TypedExtractionRequest
want string
} {
{ name : "nil extractor" , extractor : nilExtractor , req : request , want : "extractor" } ,
{ name : "nil LLM client" , extractor : & Extractor { } , req : request , want : "LLM client" } ,
{ name : "wrapped preflight failure" , extractor : extractor , req : mismatchedSourceInputRequest ( request ) , want : "must match chunk" } ,
} {
t . Run ( test . name , func ( t * testing . T ) {
if _ , err := test . extractor . Extract ( context . Background ( ) , test . req ) ; err == nil || ! strings . Contains ( err . Error ( ) , "dnd npcs" ) || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "Extract() error = %v, want contextual local error" , err )
}
} )
2026-07-21 14:27:39 +00:00
}
2026-07-24 14:38:42 +00:00
_ , err := newExtractor ( t , & fakeNPCsLLMClient { err : errors . New ( "provider unavailable" ) } ) . Extract ( context . Background ( ) , request )
2026-07-21 02:24:26 +00:00
if err == nil || ! strings . Contains ( err . Error ( ) , "dnd npcs" ) || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
t . Fatalf ( "provider Extract() error = %v, want contextual provider error" , err )
}
}