2026-07-17 05:05:48 +00:00
package integration_test
2026-07-03 23:47:06 +00:00
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-07-17 05:05:48 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
2026-07-22 14:30:20 +00:00
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
2026-07-22 14:38:35 +00:00
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
2026-07-17 04:57:47 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
2026-07-03 23:47:06 +00:00
)
2026-07-17 06:18:46 +00:00
func runPreparedPipeline ( t * testing . T , registries pipeline . Registries , resolved pipeline . ResolvedPipeline , llmClient contracts . StructuredLLMClient , input pipeline . RunInput ) ( pipeline . RunOutput , error ) {
t . Helper ( )
prepared , err := pipeline . Prepare ( resolved , registries , pipeline . ModuleDependencies { LLM : llmClient } )
if err != nil {
return pipeline . RunOutput { } , err
}
input . Prepared = prepared
return pipeline . New ( ) . Run ( context . Background ( ) , input )
}
2026-07-03 23:47:06 +00:00
func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor ( t * testing . T ) {
raw := readDNDSpellsFixture ( t )
expectedDoc := parseDNDSpellsFixture ( t , raw )
resolved := resolveDNDSpellsPipeline ( t )
llmClient := & fakeSpellsLLMClient {
response : extractionResponse {
SpellCasts : [ ] spellCastResponse {
{
Caster : "Aria" ,
Spell : "Cure Wounds" ,
Effect : "Heals an injured ally." ,
NarrativeDescription : "Aria restores the fighter after the fight." ,
2026-07-07 18:34:23 +00:00
SourceRefs : responseSourceRefs ( expectedDoc . ID , 1 , 1 ) ,
2026-07-03 23:47:06 +00:00
} ,
{
Caster : "Borin" ,
Spell : "Fire Bolt" ,
Effect : "Scorches the wight." ,
NarrativeDescription : "Borin hurls fire at the wight." ,
2026-07-07 18:34:23 +00:00
SourceRefs : responseSourceRefs ( expectedDoc . ID , 3 , 3 ) ,
2026-07-03 23:47:06 +00:00
} ,
} ,
} ,
}
2026-07-17 06:18:46 +00:00
output , err := runPreparedPipeline ( t , dndSpellsRunnerRegistries ( t ) , resolved . ResolvedPipeline , llmClient , pipeline . RunInput {
RawInput : raw ,
2026-07-03 23:47:06 +00:00
} )
if err != nil {
t . Fatalf ( "Run() error = %v, want nil" , err )
}
2026-07-07 18:58:23 +00:00
if len ( output . NormalizeOutputs ) != 1 {
t . Fatalf ( "len(NormalizeOutputs) = %d, want 1" , len ( output . NormalizeOutputs ) )
2026-07-03 23:47:06 +00:00
}
2026-07-17 08:13:08 +00:00
serializedOutput := output . NormalizeOutputs [ 0 ]
if serializedOutput . LaneID != "spells" || serializedOutput . Artifact . Schema . ID != spells . ResponseSchemaID || serializedOutput . Artifact . Schema . Version != spells . SchemaVersion {
t . Fatalf ( "serialized output envelope = %#v, want dnd spells schema on spells lane" , serializedOutput )
2026-07-03 23:47:06 +00:00
}
2026-07-17 08:13:08 +00:00
response := decodeRunnerSpellResponse ( t , serializedOutput . Artifact . Content )
2026-07-07 18:58:23 +00:00
if len ( response . SpellCasts ) != 2 {
t . Fatalf ( "len(spell_casts) = %d, want 2" , len ( response . SpellCasts ) )
2026-07-03 23:47:06 +00:00
}
2026-07-07 18:58:23 +00:00
first , second := response . SpellCasts [ 0 ] , response . SpellCasts [ 1 ]
2026-07-03 23:47:06 +00:00
if first . Spell != "Cure Wounds" || second . Spell != "Fire Bolt" {
2026-07-08 10:43:39 -05:00
t . Fatalf ( "spell order = %q, %q; want source-unit order" , first . Spell , second . Spell )
2026-07-03 23:47:06 +00:00
}
if first . Caster != "Aria" || second . Caster != "Borin" {
2026-07-07 18:58:23 +00:00
t . Fatalf ( "casters = %q, %q; want spell data" , first . Caster , second . Caster )
2026-07-03 23:47:06 +00:00
}
2026-07-07 18:58:23 +00:00
for _ , spell := range response . SpellCasts {
if len ( spell . SourceRefs ) != 1 {
t . Fatalf ( "len(SourceRefs) = %d, want 1" , len ( spell . SourceRefs ) )
2026-07-03 23:47:06 +00:00
}
2026-07-07 18:58:23 +00:00
if spell . SourceRefs [ 0 ] . SourceID != expectedDoc . ID {
t . Fatalf ( "SourceID = %q, want fixture document ID" , spell . SourceRefs [ 0 ] . SourceID )
2026-07-03 23:47:06 +00:00
}
}
2026-07-17 04:57:47 +00:00
if output . Manifest . InputModule != transcript . Key {
t . Fatalf ( "manifest input module = %q, want %q" , output . Manifest . InputModule , transcript . Key )
2026-07-03 23:47:06 +00:00
}
if output . Manifest . ValidationStatus != "approved" {
t . Fatalf ( "ValidationStatus = %q, want approved" , output . Manifest . ValidationStatus )
}
if len ( output . Manifest . ArtifactLanes ) != 1 {
t . Fatalf ( "len(ArtifactLanes) = %d, want 1" , len ( output . Manifest . ArtifactLanes ) )
}
lane := output . Manifest . ArtifactLanes [ 0 ]
2026-07-17 05:05:48 +00:00
if lane . ID != "spells" || lane . Extractor != spells . Key {
2026-07-03 23:47:06 +00:00
t . Fatalf ( "manifest lane = %#v, want spells lane with dnd/spells extractor" , lane )
}
2026-07-04 01:15:59 +00:00
extractorMetadata , ok := lane . Metadata [ "extractor" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "manifest lane metadata = %#v, want extractor metadata" , lane . Metadata )
}
2026-07-17 05:05:48 +00:00
if extractorMetadata [ "prompt_id" ] != spells . PromptID ||
extractorMetadata [ "response_schema_key" ] != string ( spells . ResponseSchemaKey ) ||
extractorMetadata [ "response_schema_name" ] != spells . ResponseSchemaName {
2026-07-04 01:15:59 +00:00
t . Fatalf ( "extractor metadata = %#v, want prompt/schema identifiers" , extractorMetadata )
}
2026-07-03 21:26:05 -05:00
if len ( output . OutputFiles ) != 1 {
t . Fatalf ( "len(OutputFiles) = %d, want 1" , len ( output . OutputFiles ) )
}
if output . OutputFiles [ 0 ] . ContentType != "application/json" {
t . Fatalf ( "ContentType = %q, want application/json" , output . OutputFiles [ 0 ] . ContentType )
2026-07-03 23:47:06 +00:00
}
}
2026-07-06 11:26:21 -05:00
func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt ( t * testing . T ) {
2026-07-05 14:49:17 +00:00
raw := readDNDSpellsFixture ( t )
expectedDoc := parseDNDSpellsFixture ( t , raw )
resolved := resolveDNDSpellsPipeline ( t )
2026-07-21 21:05:28 +00:00
resolved . ResolvedPipeline . Steps [ 0 ] . ArtifactLanes [ 0 ] . ExtractReferences . ReferenceSet = dndSpellsReferenceSet (
2026-07-05 14:49:17 +00:00
"Aria: party cleric\nBorin: fighter" ,
"Fire Bolt: evocation cantrip" ,
)
llmClient := & fakeSpellsLLMClient {
response : extractionResponse {
SpellCasts : [ ] spellCastResponse {
{
Caster : "Borin" ,
Spell : "Fire Bolt" ,
Effect : "Scorches the wight." ,
NarrativeDescription : "Borin hurls fire at the wight." ,
2026-07-07 18:34:23 +00:00
SourceRefs : responseSourceRefs ( expectedDoc . ID , 3 , 3 ) ,
2026-07-05 14:49:17 +00:00
} ,
} ,
} ,
}
2026-07-17 06:18:46 +00:00
output , err := runPreparedPipeline ( t , dndSpellsRunnerRegistries ( t ) , resolved . ResolvedPipeline , llmClient , pipeline . RunInput {
RawInput : raw ,
2026-07-05 14:49:17 +00:00
} )
if err != nil {
t . Fatalf ( "Run() error = %v, want nil" , err )
}
2026-07-07 18:58:23 +00:00
if len ( output . NormalizeOutputs ) != 1 {
t . Fatalf ( "len(NormalizeOutputs) = %d, want 1" , len ( output . NormalizeOutputs ) )
2026-07-05 14:49:17 +00:00
}
if len ( output . Manifest . References ) != 2 {
2026-07-06 11:26:21 -05:00
t . Fatalf ( "manifest references = %#v, want party and glossary provenance" , output . Manifest . References )
2026-07-05 14:49:17 +00:00
}
if len ( llmClient . requests ) != 1 {
t . Fatalf ( "LLM calls = %d, want 1" , len ( llmClient . requests ) )
}
2026-07-05 18:27:07 +00:00
request := llmClient . requests [ 0 ]
2026-07-17 05:05:48 +00:00
if request . PromptID != spells . PromptID || request . PromptVersion != spells . SchemaVersion {
t . Fatalf ( "prompt = %q/%q, want %q/%q" , request . PromptID , request . PromptVersion , spells . PromptID , spells . SchemaVersion )
2026-07-05 18:27:07 +00:00
}
2026-07-06 11:26:21 -05:00
if got := string ( request . Inputs [ "party" ] . Content ) ; got != "Aria: party cleric\nBorin: fighter" {
t . Fatalf ( "party input = %q, want reference text" , got )
2026-07-05 18:27:07 +00:00
}
if got := string ( request . Inputs [ "glossary" ] . Content ) ; got != "Fire Bolt: evocation cantrip" {
t . Fatalf ( "glossary input = %q, want reference text" , got )
2026-07-05 14:49:17 +00:00
}
}
2026-07-22 14:38:35 +00:00
func TestProductionSpellPipelineRoutesSemanticCandidatesToDeterministicValidators ( t * testing . T ) {
2026-07-22 14:30:20 +00:00
registries := productionNPCRegistries ( t )
configValue := config . Default ( )
configValue . Pipelines [ "dnd-spells-shape" ] = pipeline . PipelineProfile {
Input : pipeline . Binding ( transcript . Key ) ,
Chunk : pipeline . ModuleBinding { Module : pipeline . DefaultChunkModule , Options : map [ string ] any { "max_units" : 100 } } ,
Artifacts : map [ string ] pipeline . ArtifactLaneProfile {
"spells" : {
Extract : pipeline . ModuleBinding { Module : spells . Key , Retries : 2 } ,
Normalize : pipeline . Binding ( spellnormalize . Key ) ,
} ,
} ,
}
effective , err := configValue . Resolve ( config . ResolveInput {
PipelineID : "dnd-spells-shape" ,
Catalog : moduleCatalog ( registries ) ,
} )
if err != nil {
t . Fatalf ( "Resolve() error = %v, want nil" , err )
}
2026-07-22 14:38:35 +00:00
for _ , test := range [ ] struct {
name string
response [ ] byte
reasonCode string
validatorName string
} {
{
name : "blank string" ,
response : [ ] byte ( ` { "spell_casts":[ { "caster":"Aria","spell":"Cure Wounds","effect":"","narrative_description":"Aria casts the spell.","source_refs":[ { "start_unit_id":1,"end_unit_id":1}]}]} ` ) ,
reasonCode : spellshape . ReasonCode ,
validatorName : spellshape . Key ,
} ,
{
name : "empty evidence" ,
response : [ ] byte ( ` { "spell_casts":[ { "caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[]}]} ` ) ,
reasonCode : spellshape . ReasonCode ,
validatorName : spellshape . Key ,
} ,
{
name : "nonpositive unit candidate" ,
response : [ ] byte ( ` { "spell_casts":[ { "caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[ { "start_unit_id":0,"end_unit_id":1}]}]} ` ) ,
reasonCode : spellsourcerefs . ReasonCode ,
validatorName : spellsourcerefs . Key ,
} ,
{
name : "unknown unit candidate" ,
response : [ ] byte ( ` { "spell_casts":[ { "caster":"Aria","spell":"Cure Wounds","effect":"Heals.","narrative_description":"Aria casts the spell.","source_refs":[ { "start_unit_id":99,"end_unit_id":99}]}]} ` ) ,
reasonCode : spellsourcerefs . ReasonCode ,
validatorName : spellsourcerefs . Key ,
} ,
} {
t . Run ( test . name , func ( t * testing . T ) {
client := & fakeSpellsLLMClient { rawResponses : [ ] [ ] byte { test . response , test . response , test . response } }
output , err := runPreparedPipeline ( t , registries , effective . ResolvedPipeline , client , pipeline . RunInput { RawInput : readDNDSpellsFixture ( t ) } )
if err != nil {
t . Fatalf ( "Run() error = %v, want non-fatal rejected output" , err )
}
if len ( client . requests ) != 3 || len ( output . Rejected ) != 1 || len ( output . NormalizeOutputs ) != 0 {
t . Fatalf ( "LLM requests = %d rejected = %#v normalized = %#v, want exhausted rejection" , len ( client . requests ) , output . Rejected , output . NormalizeOutputs )
}
rejection := output . Rejected [ 0 ]
if rejection . ReasonCode != test . reasonCode || rejection . ValidatorName != test . validatorName || rejection . AttemptCount != 3 {
t . Fatalf ( "rejection = %#v, want exhausted %s rejection" , rejection , test . validatorName )
}
} )
2026-07-22 14:30:20 +00:00
}
}
2026-07-06 11:26:21 -05:00
func dndSpellsReferenceSet ( party string , glossary string ) contracts . ReferenceSet {
2026-07-05 14:49:17 +00:00
slots := make ( map [ string ] contracts . ResolvedReferenceSlot )
2026-07-06 11:26:21 -05:00
if strings . TrimSpace ( party ) != "" {
slots [ "party" ] = contracts . ResolvedReferenceSlot {
Slot : contracts . ReferenceSlot { Name : "party" } ,
2026-07-05 14:49:17 +00:00
Items : [ ] contracts . ReferenceItem {
{
2026-07-06 11:26:21 -05:00
SlotName : "party" ,
2026-07-05 14:49:17 +00:00
MediaType : "text/plain; charset=utf-8" ,
2026-07-06 11:26:21 -05:00
Content : [ ] byte ( party ) ,
Digest : "sha256:party" ,
Origin : contracts . ReferenceOrigin { Type : "file" , URI : "file:///tmp/party.txt" } ,
SizeBytes : int64 ( len ( party ) ) ,
2026-07-05 14:49:17 +00:00
BindingSource : contracts . ReferenceBindingSourceConfig ,
} ,
} ,
}
}
if strings . TrimSpace ( glossary ) != "" {
slots [ "glossary" ] = contracts . ResolvedReferenceSlot {
Slot : contracts . ReferenceSlot { Name : "glossary" } ,
Items : [ ] contracts . ReferenceItem {
{
SlotName : "glossary" ,
MediaType : "text/plain; charset=utf-8" ,
Content : [ ] byte ( glossary ) ,
Digest : "sha256:glossary" ,
Origin : contracts . ReferenceOrigin { Type : "file" , URI : "file:///tmp/glossary.txt" } ,
SizeBytes : int64 ( len ( glossary ) ) ,
BindingSource : contracts . ReferenceBindingSourceConfig ,
} ,
} ,
}
}
return contracts . ReferenceSet { Slots : slots }
}
2026-07-03 23:47:06 +00:00
func resolveDNDSpellsPipeline ( t * testing . T ) config . EffectiveConfig {
t . Helper ( )
resolved , err := loadDNDSpellsPipelineConfig ( t ) . Resolve ( config . ResolveInput {
PipelineID : "dnd-spells-fixture" ,
Catalog : dndSpellsTestCatalog ( t , dndSpellsCatalogSpecs { } ) ,
} )
if err != nil {
t . Fatalf ( "Resolve() error = %v, want nil" , err )
}
return resolved
}
func dndSpellsRunnerRegistries ( t * testing . T ) pipeline . Registries {
t . Helper ( )
catalog := dndSpellsTestCatalog ( t , dndSpellsCatalogSpecs { } )
return pipeline . Registries {
2026-07-17 05:41:20 +00:00
Inputs : catalog . Inputs ,
Chunkers : catalog . Chunkers ,
ArtifactCodecs : catalog . ArtifactCodecs ,
Extractors : catalog . Extractors ,
Mergers : catalog . Mergers ,
Normalizers : catalog . Normalizers ,
Outputs : catalog . Outputs ,
2026-07-03 23:47:06 +00:00
}
}
func readDNDSpellsFixture ( t * testing . T ) [ ] byte {
t . Helper ( )
raw , err := os . ReadFile ( "testdata/seriatim_spell_session.json" )
if err != nil {
t . Fatalf ( "ReadFile(seriatim_spell_session.json) error = %v, want nil" , err )
}
return raw
}
func parseDNDSpellsFixture ( t * testing . T , raw [ ] byte ) * source . SourceDocument {
t . Helper ( )
2026-07-17 04:57:47 +00:00
doc , err := transcript . New ( ) . Parse ( context . Background ( ) , contracts . ParseRequest { Raw : raw } )
2026-07-03 23:47:06 +00:00
if err != nil {
t . Fatalf ( "Parse() error = %v, want nil" , err )
}
return doc
}
2026-07-07 18:58:23 +00:00
func decodeRunnerSpellResponse ( t * testing . T , raw [ ] byte ) extractionResponse {
t . Helper ( )
var response extractionResponse
if err := json . Unmarshal ( raw , & response ) ; err != nil {
t . Fatalf ( "Unmarshal(raw output) error = %v, want nil" , err )
}
return response
}