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"
2026-08-05 18:38:22 +00:00
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
2026-07-25 19:23:19 +00:00
scenecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/scenedescriptions"
2026-07-21 05:08:02 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestExtractMapsAndOrdersCombatTurnsBySourcePosition ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse {
{
2026-07-22 19:14:14 +00:00
Actor : "Borin" , TurnKind : "turn" ,
SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 2 , EndUnitID : 2 } } ,
2026-07-21 05:08:02 +00:00
} ,
{
2026-07-22 19:14:14 +00:00
Actor : "Aria" , TurnKind : "reaction" , SourceRefs : [ ] combatSourceRefResponse {
2026-08-28 13:45:21 +00:00
{ StartUnitID : 2 , EndUnitID : 10 } ,
{ StartUnitID : 2 , EndUnitID : 10 } ,
2026-07-21 05:08:02 +00:00
} ,
} ,
{
2026-07-22 19:14:14 +00:00
Actor : "Unknown" , TurnKind : "other" ,
SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 0 , EndUnitID : 0 } } ,
2026-07-21 05:08:02 +00:00
} ,
} } }
2026-07-25 19:23:19 +00:00
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( t ) )
2026-07-21 05:08:02 +00:00
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 )
}
2026-08-28 13:45:21 +00:00
wantRefs := [ ] source . SourceRef { { SourceID : "session-alpha" , StartUnitID : 10 , EndUnitID : 2 } }
2026-07-21 05:08:02 +00:00
if ! reflect . DeepEqual ( result . Value . CombatTurns [ 0 ] . SourceRefs , wantRefs ) {
t . Fatalf ( "canonical refs = %#v, want %#v" , result . Value . CombatTurns [ 0 ] . SourceRefs , wantRefs )
}
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" ]
2026-07-25 19:23:19 +00:00
if transcript . Name != "transcript" || transcript . MediaType != "application/json" || transcript . Digest != "sha256:chunk" || transcript . OriginURI != "file:///session-alpha.json" || ! reflect . DeepEqual ( transcript . Content , extractionRequest ( t ) . Chunk . Content ) {
2026-07-21 05:08:02 +00:00
t . Fatalf ( "transcript input = %#v, want chunk-scoped source input" , transcript )
}
}
func TestExtractPreservesInvalidCandidatesForValidators ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse {
{
2026-08-29 01:24:45 +00:00
Actor : " " , TurnKind : "turn" ,
2026-07-22 19:14:14 +00:00
SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 99 , EndUnitID : 0 } } ,
2026-07-21 05:08:02 +00:00
} ,
} } }
2026-07-25 19:23:19 +00:00
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , extractionRequest ( t ) )
2026-07-21 05:08:02 +00:00
if err != nil {
t . Fatalf ( "Extract() error = %v, want nil for candidate values" , err )
}
turn := result . Value . CombatTurns [ 0 ]
2026-08-29 01:24:45 +00:00
if turn . Actor != " " || turn . TurnKind != "turn" {
2026-07-21 05:08:02 +00:00
t . Fatalf ( "invalid turn fields = %#v, want preserved candidate values" , turn )
}
if turn . SourceRefs [ 0 ] != ( source . SourceRef { SourceID : "session-alpha" , StartUnitID : 99 } ) {
t . Fatalf ( "invalid source ref = %#v, want invalid range preserved" , turn . SourceRefs [ 0 ] )
}
}
2026-07-24 14:33:34 +00:00
func TestExtractUsesDocumentOrderForReferencesAndTurns ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse {
{ Actor : "Later" , TurnKind : "turn" , SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 10 , EndUnitID : 10 } } } ,
{ Actor : "First" , TurnKind : "reaction" , SourceRefs : [ ] combatSourceRefResponse {
{ StartUnitID : 10 , EndUnitID : 10 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 30 , EndUnitID : 30 } ,
{ StartUnitID : 999 , EndUnitID : 0 } ,
} } ,
{ Actor : "Second" , TurnKind : "other" , SourceRefs : [ ] combatSourceRefResponse { { StartUnitID : 30 , EndUnitID : 30 } } } ,
} } }
2026-07-25 19:23:19 +00:00
req := extractionRequest ( t )
2026-07-24 14:33:34 +00:00
req . Source . Units = [ ] source . SourceUnit { { ID : 30 } , { ID : 10 } }
req . Chunk . Units = append ( [ ] source . SourceUnit ( nil ) , req . Source . Units ... )
2026-08-09 02:03:27 +00:00
req . Chunk . Ref = source . SourceRef { SourceID : req . Source . ID , StartUnitID : 10 , EndUnitID : 30 }
2026-07-25 19:23:19 +00:00
req . References = sceneReferences ( t , req . Chunk , dnd . SceneKindCombat )
2026-07-24 14:33:34 +00:00
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , req )
if err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if got := [ ] string { result . Value . CombatTurns [ 0 ] . Actor , result . Value . CombatTurns [ 1 ] . Actor , result . Value . CombatTurns [ 2 ] . Actor } ; ! reflect . DeepEqual ( got , [ ] string { "First" , "Second" , "Later" } ) {
t . Fatalf ( "turn order = %#v, want document chronology with stable equal-evidence ties" , got )
}
refs := result . Value . CombatTurns [ 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 _ , turn := range client . response . CombatTurns {
for _ , ref := range turn . SourceRefs {
if ref . StartUnitID == 777 {
t . Fatal ( "result source references alias the model response" )
}
}
}
}
2026-07-21 05:08:02 +00:00
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 ) } } } ,
} }
2026-07-25 19:23:19 +00:00
req := extractionRequest ( t )
for name , slot := range req . References . Slots {
references . Slots [ name ] = slot
}
2026-07-21 05:08:02 +00:00
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" )
}
2026-07-25 19:23:19 +00:00
if _ , ok := inputs [ SceneDescriptionReferenceSlot ] ; ok {
t . Fatalf ( "prompt inputs include scene descriptions: %#v" , inputs )
}
2026-07-21 05:08:02 +00:00
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 )
}
2026-07-25 19:23:19 +00:00
if metadata [ "scene_description_count" ] != 1 || ! strings . HasPrefix ( metadata [ "scene_eligibility_digest" ] . ( string ) , "sha256:" ) {
t . Fatalf ( "bound scene metadata = %#v, want digest and count" , metadata )
}
2026-07-21 05:08:02 +00:00
fingerprints := newExtractor ( t , & fakeCombatTurnsLLMClient { } , references ) . CheckpointFingerprints ( )
2026-07-25 19:23:19 +00:00
if len ( fingerprints ) != 6 || fingerprints [ 3 ] . Name != "scene_gate_policy" || fingerprints [ 4 ] . Name != "npc_registry" || fingerprints [ 5 ] . Name != "scene_eligibility" {
t . Fatalf ( "bound fingerprints = %#v, want local identities plus scene gate and NPC registry" , fingerprints )
2026-07-21 05:08:02 +00:00
}
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 )
2026-07-25 19:23:19 +00:00
if _ , err := extractor . Extract ( context . Background ( ) , extractionRequest ( t ) ) ; err != nil {
2026-07-21 05:08:02 +00:00
t . Fatalf ( "Extract() error = %v, want nil" , err )
}
input := client . requests [ 0 ] . Inputs [ NPCRegistryReferenceSlot ]
2026-07-22 18:51:26 +00:00
if string ( input . Content ) != ` { "npcs":[]} ` || input . Digest == "" || input . OriginURI != "" {
2026-07-21 05:08:02 +00:00
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 )
}
2026-07-25 19:23:19 +00:00
if fingerprints := extractor . CheckpointFingerprints ( ) ; len ( fingerprints ) != 6 || fingerprints [ 4 ] . Name != "npc_registry" || fingerprints [ 4 ] . Value != input . Digest || fingerprints [ 5 ] . Name != "scene_eligibility" || fingerprints [ 5 ] . Value == "" {
2026-07-22 18:51:26 +00:00
t . Fatalf ( "unbound fingerprints = %#v, want empty-projection identity" , fingerprints )
2026-07-21 05:08:02 +00:00
}
}
2026-07-21 22:12:01 +00:00
func TestExtractorResolvesOperationNPCOverrideWithoutSingletonMetadata ( t * testing . T ) {
content := npcRegistryJSON ( t )
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } }
extractor := newExtractor ( t , client )
2026-07-25 19:23:19 +00:00
request := extractionRequest ( t )
request . References . Slots [ NPCRegistryReferenceSlot ] = contracts . ResolvedReferenceSlot {
Slot : contracts . ReferenceSlot { Name : NPCRegistryReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : NPCRegistryReferenceSlot , MediaType : npccodec . MediaType , Content : append ( [ ] byte ( nil ) , content ... ) , Origin : contracts . ReferenceOrigin { Type : "generated" } } } ,
}
2026-07-21 22:12:01 +00:00
if _ , err := extractor . Extract ( context . Background ( ) , request ) ; err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
input := client . requests [ 0 ] . Inputs [ NPCRegistryReferenceSlot ]
2026-07-22 18:51:26 +00:00
if input . Digest == "" || string ( input . Content ) != ` { "npcs":[ { "name":"Mira Thorn"}]} ` || input . OriginURI != "" {
2026-07-21 22:12:01 +00:00
t . Fatalf ( "operation NPC input = %#v, want generated canonical grounding without provenance" , input )
}
2026-07-25 19:23:19 +00:00
if metadata := extractor . ManifestMetadata ( ) ; metadata [ "npc_registry_digest" ] != nil || metadata [ "npc_count" ] != nil || metadata [ "scene_eligibility_digest" ] != nil || metadata [ "scene_description_count" ] != nil {
2026-07-21 22:12:01 +00:00
t . Fatalf ( "singleton metadata = %#v, want no operation-varying NPC identity" , metadata )
}
}
2026-07-25 19:23:19 +00:00
func TestExtractAppliesSceneEligibilityBeforePromptConstruction ( t * testing . T ) {
for _ , test := range [ ] struct {
name string
scenes [ ] dnd . SceneDescription
wantWarning string
wantError string
wantCalls int
wantEmptyList bool
} {
{
name : "exact combat" ,
scenes : [ ] dnd . SceneDescription { sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindCombat ) } ,
wantCalls : 1 ,
} ,
{
name : "exact narrative" ,
scenes : [ ] dnd . SceneDescription { sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindNarrative ) } ,
wantEmptyList : true ,
} ,
{
name : "exact recap" ,
scenes : [ ] dnd . SceneDescription { sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindRecap ) } ,
wantEmptyList : true ,
} ,
{
name : "exact meta" ,
scenes : [ ] dnd . SceneDescription { sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindMeta ) } ,
wantEmptyList : true ,
} ,
{
name : "missing ID" ,
scenes : [ ] dnd . SceneDescription { func ( ) dnd . SceneDescription {
value := sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindCombat )
value . ID = "other-chunk"
return value
} ( ) } ,
wantWarning : "scene_classification_unavailable" ,
wantEmptyList : true ,
} ,
{
name : "source mismatch" ,
scenes : [ ] dnd . SceneDescription { func ( ) dnd . SceneDescription {
value := sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindCombat )
value . SourceRef . SourceID = "other-source"
return value
} ( ) } ,
wantWarning : "scene_classification_unavailable" ,
wantEmptyList : true ,
} ,
{
name : "start mismatch" ,
scenes : [ ] dnd . SceneDescription { func ( ) dnd . SceneDescription {
value := sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindCombat )
2026-08-09 02:03:27 +00:00
value . SourceRef . StartUnitID = 3
2026-07-25 19:23:19 +00:00
return value
} ( ) } ,
wantWarning : "scene_classification_unavailable" ,
wantEmptyList : true ,
} ,
{
name : "end mismatch" ,
scenes : [ ] dnd . SceneDescription { func ( ) dnd . SceneDescription {
value := sceneDescription ( extractionRequest ( t ) . Chunk , dnd . SceneKindCombat )
2026-08-09 02:03:27 +00:00
value . SourceRef . EndUnitID = 9
2026-07-25 19:23:19 +00:00
return value
} ( ) } ,
wantWarning : "scene_classification_unavailable" ,
wantEmptyList : true ,
} ,
{
name : "missing required reference" ,
wantError : "required scene descriptions reference is missing" ,
} ,
} {
t . Run ( test . name , func ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } }
req := extractionRequest ( t )
if test . wantError != "" {
req . References = contracts . ReferenceSet { }
} else {
req . References = sceneListReferences ( t , test . scenes ... )
}
result , err := newExtractor ( t , client ) . Extract ( context . Background ( ) , req )
if test . wantError != "" {
if err == nil || ! strings . Contains ( err . Error ( ) , test . wantError ) {
t . Fatalf ( "Extract() error = %v, want %q" , err , test . wantError )
}
} else if err != nil {
t . Fatalf ( "Extract() error = %v" , err )
}
if len ( client . requests ) != test . wantCalls {
t . Fatalf ( "LLM calls = %d, want %d" , len ( client . requests ) , test . wantCalls )
}
if test . wantEmptyList && result . Value . CombatTurns == nil {
t . Fatal ( "CombatTurns = nil, want accepted non-nil empty list" )
}
if test . wantWarning != "" {
2026-08-27 16:40:17 +00:00
if len ( result . Diagnostics ) != 1 || result . Diagnostics [ 0 ] . Samples [ 0 ] . Scope != SceneDescriptionReferenceSlot || result . Diagnostics [ 0 ] . ReasonCode != test . wantWarning || result . Diagnostics [ 0 ] . Disposition != contracts . DiagnosticDispositionWarning || result . Diagnostics [ 0 ] . Category != contracts . DiagnosticCategoryDegradation {
2026-08-27 16:04:32 +00:00
t . Fatalf ( "diagnostics = %#v" , result . Diagnostics )
2026-07-25 19:23:19 +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, want none" , result . Diagnostics )
2026-07-25 19:23:19 +00:00
}
} )
}
}
func TestSceneEligibilityMetadataAndFingerprintsTrackGatingValues ( t * testing . T ) {
chunk := extractionRequest ( t ) . Chunk
base := sceneDescription ( chunk , dnd . SceneKindCombat )
baseExtractor := newExtractor ( t , & fakeCombatTurnsLLMClient { } , sceneListReferences ( t , base ) )
metadata := baseExtractor . ManifestMetadata ( )
if metadata [ "scene_gate_policy" ] != sceneGatePolicy || metadata [ "scene_description_count" ] != 1 {
t . Fatalf ( "metadata = %#v, want scene gate policy and count" , metadata )
}
baseDigest := fingerprintValue ( t , baseExtractor . CheckpointFingerprints ( ) , "scene_eligibility" )
proseChanged := base
proseChanged . Title = "Different title"
proseChanged . Summary = "Different summary."
if got := fingerprintValue ( t , newExtractor ( t , & fakeCombatTurnsLLMClient { } , sceneListReferences ( t , proseChanged ) ) . CheckpointFingerprints ( ) , "scene_eligibility" ) ; got != baseDigest {
t . Fatalf ( "prose-only eligibility digest = %q, want %q" , got , baseDigest )
}
for _ , test := range [ ] struct {
name string
change func ( * dnd . SceneDescription )
} {
{ "kind" , func ( value * dnd . SceneDescription ) { value . Kind = dnd . SceneKindNarrative } } ,
2026-08-09 02:03:27 +00:00
{ "range" , func ( value * dnd . SceneDescription ) { value . SourceRef . StartUnitID = 3 } } ,
2026-07-25 19:23:19 +00:00
} {
t . Run ( test . name , func ( t * testing . T ) {
changed := base
test . change ( & changed )
if got := fingerprintValue ( t , newExtractor ( t , & fakeCombatTurnsLLMClient { } , sceneListReferences ( t , changed ) ) . CheckpointFingerprints ( ) , "scene_eligibility" ) ; got == baseDigest {
t . Fatal ( "eligibility digest did not change" )
}
} )
}
}
2026-07-21 05:08:02 +00:00
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 ) )
}
}
2026-07-25 19:23:19 +00:00
func TestNewRejectsInvalidSceneReferenceBeforeLLMCallWithoutContent ( t * testing . T ) {
client := & fakeCombatTurnsLLMClient { }
references := contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
SceneDescriptionReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : SceneDescriptionReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : SceneDescriptionReferenceSlot , MediaType : scenecodec . MediaType , Content : [ ] byte ( ` { "scenes":[]} ` ) } } ,
} ,
} }
_ , err := New ( client , Options { } , references )
if err == nil || ! strings . Contains ( err . Error ( ) , "prepare scene eligibility" ) || strings . Contains ( err . Error ( ) , ` { "scenes":[]} ` ) {
t . Fatalf ( "New() error = %v, want bounded content-free scene failure" , err )
}
if len ( client . requests ) != 0 {
t . Fatalf ( "LLM calls = %d, want none during failed construction" , len ( client . requests ) )
}
}
2026-07-21 05:08:02 +00:00
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures ( t * testing . T ) {
2026-07-25 19:23:19 +00:00
validReq := extractionRequest ( t )
2026-07-21 05:08:02 +00:00
validExtractor := newExtractor ( t , & fakeCombatTurnsLLMClient { response : extractionResponse { CombatTurns : [ ] combatTurnResponse { } } } )
2026-07-24 14:38:42 +00:00
var nilExtractor * Extractor
2026-07-21 05:08:02 +00:00
tests := [ ] struct {
name string
extractor * Extractor
ctx context . Context
req contracts . TypedExtractionRequest
want string
} {
2026-07-24 14:38:42 +00:00
{ name : "nil extractor" , extractor : nilExtractor , ctx : context . Background ( ) , req : validReq , want : "extractor" } ,
{ name : "nil LLM client" , extractor : & Extractor { } , ctx : context . Background ( ) , req : validReq , want : "LLM client" } ,
{ name : "wrapped preflight failure" , extractor : validExtractor , ctx : context . Background ( ) , req : mismatchedSourceInputRequest ( validReq ) , want : "must match chunk" } ,
2026-07-21 05:08:02 +00:00
}
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 )
}
} )
}
2026-07-25 19:23:19 +00:00
_ , err := newExtractor ( t , & fakeCombatTurnsLLMClient { err : errors . New ( "provider unavailable" ) } ) . Extract ( context . Background ( ) , extractionRequest ( t ) )
2026-07-21 05:08:02 +00:00
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 ] )
}
}
2026-07-25 19:23:19 +00:00
wantNames := map [ string ] struct { } { "prompt" : { } , "response_schema" : { } , "mapping_policy" : { } , "scene_gate_policy" : { } , "npc_registry" : { } , "scene_eligibility" : { } }
2026-07-21 05:08:02 +00:00
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 ( )
2026-08-26 23:59:05 +00:00
if got . Key != Key || got . Stage != pipeline . StageExtract || got . CorrectionProtocol != contracts . CorrectionProtocolSingleResponseV1 || 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 ) {
2026-07-21 05:08:02 +00:00
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 )
}
}
2026-07-25 19:23:19 +00:00
func extractionRequest ( t * testing . T ) contracts . TypedExtractionRequest {
t . Helper ( )
2026-07-21 05:08:02 +00:00
doc := combatSourceDocument ( )
chunk := & source . Chunk {
ID : "session-alpha:chunk:0" ,
SourceID : doc . ID ,
Index : 0 ,
2026-08-09 02:03:27 +00:00
Ref : source . SourceRef { SourceID : doc . ID , StartUnitID : 2 , EndUnitID : 10 } ,
2026-07-21 05:08:02 +00:00
Content : [ ] byte ( ` { "units":[10,2]} ` ) ,
MediaType : "application/json" ,
Units : append ( [ ] source . SourceUnit ( nil ) , doc . Units ... ) ,
}
2026-07-25 19:23:19 +00:00
request := contracts . TypedExtractionRequest {
2026-07-21 05:08:02 +00:00
Source : doc ,
Chunk : chunk ,
SourceInput : contracts . NewLLMInputMaterial ( "source" , chunk . MediaType , chunk . Content , "sha256:chunk" , "file:///session-alpha.json" ) ,
SessionID : "session-123" ,
LLMProfile : "profile-combat" ,
}
2026-07-25 19:23:19 +00:00
request . References = sceneReferences ( t , chunk , dnd . SceneKindCombat )
return request
2026-07-21 05:08:02 +00:00
}
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 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 ( )
2026-08-05 18:25:04 +00:00
value := dnd . NPCRegistry { NPCs : [ ] dnd . NPC { { ID : identity . DeriveID ( "Mira Thorn" ) , Name : "Mira Thorn" , SourceRefs : [ ] source . SourceRef { { SourceID : "other-session" , StartUnitID : 1 , EndUnitID : 1 } } } } }
2026-07-21 05:08:02 +00:00
content , err := npccodec . New ( ) . Encode ( value )
if err != nil {
t . Fatalf ( "encode NPC registry: %v" , err )
}
return content
}
2026-07-25 19:23:19 +00:00
func sceneReferences ( t * testing . T , chunk * source . Chunk , kind dnd . SceneKind ) contracts . ReferenceSet {
t . Helper ( )
return sceneListReferences ( t , sceneDescription ( chunk , kind ) )
}
func sceneListReferences ( t * testing . T , scenes ... dnd . SceneDescription ) contracts . ReferenceSet {
t . Helper ( )
content , err := scenecodec . New ( ) . Encode ( dnd . SceneDescriptionList { Scenes : scenes } )
if err != nil {
t . Fatalf ( "encode scene descriptions: %v" , err )
}
return contracts . ReferenceSet { Slots : map [ string ] contracts . ResolvedReferenceSlot {
SceneDescriptionReferenceSlot : {
Slot : contracts . ReferenceSlot { Name : SceneDescriptionReferenceSlot } ,
Items : [ ] contracts . ReferenceItem { { SlotName : SceneDescriptionReferenceSlot , MediaType : scenecodec . MediaType , Content : content } } ,
} ,
} }
}
func sceneDescription ( chunk * source . Chunk , kind dnd . SceneKind ) dnd . SceneDescription {
return dnd . SceneDescription {
ID : chunk . ID ,
SourceRef : chunk . Ref ,
Kind : kind ,
Title : "Combat eligibility" ,
Summary : "A valid scene description for combat extraction." ,
}
}
func fingerprintValue ( t * testing . T , fingerprints [ ] pipeline . CheckpointFingerprint , name string ) string {
t . Helper ( )
for _ , fingerprint := range fingerprints {
if fingerprint . Name == name {
return fingerprint . Value
}
}
t . Fatalf ( "fingerprints = %#v, missing %q" , fingerprints , name )
return ""
}
2026-07-21 05:08:02 +00:00
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 ( )
2026-08-26 23:24:20 +00:00
correction , err := contracts . CloneSemanticCorrection ( req . Correction )
if err != nil {
panic ( err )
}
req . Correction = correction
2026-07-21 05:08:02 +00:00
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
}