2026-07-04 13:02:02 +00:00
package scenes
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"
2026-07-17 05:05:48 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
2026-07-04 13:02:02 +00:00
)
func TestNewModuleSpecAndRegister ( t * testing . T ) {
2026-07-17 06:30:56 +00:00
client := & fakeScenesLLMClient { }
chunker := newChunker ( t , client )
2026-07-04 13:02:02 +00:00
if chunker . Key ( ) != Key {
t . Fatalf ( "Key() = %q, want %q" , chunker . Key ( ) , Key )
}
want := pipeline . ModuleSpec {
2026-07-05 18:48:45 -05:00
Key : Key ,
Stage : pipeline . StageChunk ,
Requires : [ ] string { "source.transcript" } ,
2026-07-17 23:57:59 +00:00
Provides : [ ] string { "chunks" } ,
2026-07-05 18:48:45 -05:00
ReferenceSlots : wantReferenceSlots ( ) ,
2026-07-04 13:02:02 +00:00
}
if got := ModuleSpec ( ) ; ! reflect . DeepEqual ( got , want ) {
t . Fatalf ( "ModuleSpec() = %#v, want %#v" , got , want )
}
got := ModuleSpec ( )
got . Requires [ 0 ] = "changed"
got . Provides [ 0 ] = "changed"
2026-07-05 18:48:45 -05:00
got . ReferenceSlots [ 0 ] . AcceptedMediaTypes [ 0 ] = "changed"
2026-07-04 13:02:02 +00:00
if again := ModuleSpec ( ) ; ! reflect . DeepEqual ( again , want ) {
t . Fatalf ( "ModuleSpec() after caller mutation = %#v, want %#v" , again , want )
}
registry := pipeline . NewChunkerRegistry ( )
if err := Register ( registry ) ; err != nil {
t . Fatalf ( "Register() error = %v, want nil" , err )
}
registered , ok := registry . Spec ( Key )
if ! ok {
t . Fatalf ( "Spec(%q) ok = false, want true" , Key )
}
if ! reflect . DeepEqual ( registered , want ) {
t . Fatalf ( "registered spec = %#v, want %#v" , registered , want )
}
2026-07-17 06:30:56 +00:00
built , err := registry . BuildWithRequest ( Key , pipeline . BuildRequest { Dependencies : pipeline . ModuleDependencies { LLM : client } } )
2026-07-04 13:02:02 +00:00
if err != nil {
t . Fatalf ( "Build(%q) error = %v, want nil" , Key , err )
}
if built . Key ( ) != Key {
t . Fatalf ( "built Key() = %q, want %q" , built . Key ( ) , Key )
}
2026-07-05 18:48:45 -05:00
if slots := built . ReferenceSlots ( ) ; ! reflect . DeepEqual ( slots , want . ReferenceSlots ) {
t . Fatalf ( "ReferenceSlots() = %#v, want %#v" , slots , want . ReferenceSlots )
2026-07-05 16:06:47 +00:00
}
2026-07-17 06:30:56 +00:00
if err := registry . ValidateOptions ( Key , map [ string ] any { "unexpected" : true } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unknown option" ) {
t . Fatalf ( "ValidateOptions() error = %v, want unknown option error" , err )
}
}
func TestConstructionRejectsMissingDependencyAndUnknownOptions ( t * testing . T ) {
if _ , err := New ( nil , Options { } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "LLM client" ) {
t . Fatalf ( "New(nil) error = %v, want LLM client error" , err )
}
if _ , err := DecodeOptions ( map [ string ] any { "unexpected" : true } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "unknown option" ) {
t . Fatalf ( "DecodeOptions() error = %v, want unknown option error" , err )
}
2026-07-04 13:02:02 +00:00
}
func TestRegisterNilRegistryReturnsError ( t * testing . T ) {
err := Register ( nil )
if err == nil {
t . Fatal ( "Register(nil) error = nil, want error" )
}
if ! strings . Contains ( err . Error ( ) , "chunker registry" ) {
t . Fatalf ( "Register(nil) error = %q, want registry context" , err . Error ( ) )
}
}
2026-07-05 18:48:45 -05:00
func wantReferenceSlots ( ) [ ] contracts . ReferenceSlot {
accepted := [ ] string { "application/json" , "application/x-yaml" , "application/yaml" , "text/markdown" , "text/plain" }
return [ ] contracts . ReferenceSlot {
{
Name : "glossary" ,
Description : "Optional campaign glossary reference material used only for scene disambiguation." ,
AcceptedMediaTypes : append ( [ ] string ( nil ) , accepted ... ) ,
} ,
2026-07-06 11:26:21 -05:00
{
Name : "party" ,
Description : "Optional party roster reference material used only for scene disambiguation." ,
AcceptedMediaTypes : append ( [ ] string ( nil ) , accepted ... ) ,
} ,
{
Name : "players" ,
Description : "Optional player list reference material used only for scene disambiguation." ,
AcceptedMediaTypes : append ( [ ] string ( nil ) , accepted ... ) ,
} ,
2026-07-05 18:48:45 -05:00
{
Name : "roster" ,
2026-07-06 11:26:21 -05:00
Description : "Deprecated alias for party roster reference material used only for scene disambiguation." ,
2026-07-05 18:48:45 -05:00
AcceptedMediaTypes : append ( [ ] string ( nil ) , accepted ... ) ,
} ,
}
}
2026-07-17 23:57:59 +00:00
func TestPlanReturnsSceneRangesAndAnnotationsFromStructuredOutput ( t * testing . T ) {
2026-07-04 13:02:02 +00:00
client := & fakeScenesLLMClient {
response : chunkResponse {
Scenes : [ ] sceneResponse {
{
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( 1 ) ,
EndUnitID : shared . UnitRefFromInt ( 2 ) ,
2026-07-04 13:02:02 +00:00
ShortTitle : " Goblin parley " ,
PrimaryMode : "Discussion" ,
MainParticipants : [ ] string { " Aria " , "Goblin scout" } ,
Summary : " The party negotiates with a scout. " ,
BoundaryNote : " The scene covers the discussion before fighting starts. " ,
BoundaryConfidence : "High" ,
} ,
{
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( 3 ) ,
EndUnitID : shared . UnitRefFromInt ( 4 ) ,
2026-07-04 13:02:02 +00:00
ShortTitle : "Ambush at the gate" ,
PrimaryMode : "Combat" ,
MainParticipants : [ ] string { "Aria" , "Goblin ambushers" } ,
Summary : "The goblins attack at the gate." ,
BoundaryNote : "Combat begins and resolves the immediate threat." ,
BoundaryConfidence : "Medium" ,
} ,
} ,
2026-07-05 13:30:32 +00:00
BoundaryCaveats : [ ] string { " The transition into combat is gradual. " } ,
2026-07-04 13:02:02 +00:00
} ,
}
2026-07-17 23:57:59 +00:00
result , err := newChunker ( t , client ) . Plan ( context . Background ( ) , chunkRequest ( ) )
2026-07-04 13:02:02 +00:00
if err != nil {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %v, want nil" , err )
2026-07-04 13:02:02 +00:00
}
if len ( client . requests ) != 1 {
t . Fatalf ( "LLM calls = %d, want 1" , len ( client . requests ) )
}
req := client . requests [ 0 ]
if req . StageName != Key {
t . Fatalf ( "StageName = %q, want %q" , req . StageName , Key )
}
2026-07-05 18:27:07 +00:00
if req . PromptID != PromptID || req . PromptVersion != ResponseSchemaVersion {
t . Fatalf ( "prompt = %q/%q, want %q/%q" , req . PromptID , req . PromptVersion , PromptID , ResponseSchemaVersion )
2026-07-04 13:02:02 +00:00
}
2026-07-05 18:27:07 +00:00
if req . SessionID != "session-123" || req . ProfileID != "profile-scenes" {
t . Fatalf ( "session/profile = %q/%q, want session-123/profile-scenes" , req . SessionID , req . ProfileID )
2026-07-04 13:02:02 +00:00
}
2026-07-05 18:27:07 +00:00
transcript , ok := req . Inputs [ "transcript" ]
if ! ok {
t . Fatalf ( "transcript input missing from %#v" , req . Inputs )
2026-07-04 13:02:02 +00:00
}
2026-07-05 18:27:07 +00:00
if transcript . Name != "transcript" || transcript . MediaType != "application/json" || transcript . Digest != "sha256:transcript" || transcript . OriginURI != "file:///session-alpha.json" {
t . Fatalf ( "transcript metadata = %#v" , transcript )
}
if got := string ( transcript . Content ) ; got != sceneTranscriptJSON {
t . Fatalf ( "transcript content = %q, want original source input" , got )
2026-07-04 13:02:02 +00:00
}
2026-07-06 11:26:21 -05:00
if got := string ( req . Inputs [ "players" ] . Content ) ; got != " " {
t . Fatalf ( "players input = %q, want empty reference placeholder" , got )
}
if got := string ( req . Inputs [ "party" ] . Content ) ; got != " " {
t . Fatalf ( "party input = %q, want empty reference placeholder" , got )
2026-07-05 18:48:45 -05:00
}
if got := string ( req . Inputs [ "glossary" ] . Content ) ; got != " " {
t . Fatalf ( "glossary input = %q, want empty reference placeholder" , got )
}
2026-07-04 13:02:02 +00:00
2026-07-17 23:57:59 +00:00
if result . Plan . SourceDigest != "sha256:source" {
t . Fatalf ( "SourceDigest = %q, want source digest" , result . Plan . SourceDigest )
2026-07-04 13:02:02 +00:00
}
2026-07-17 23:57:59 +00:00
wantRanges := [ ] source . ChunkRange { { StartUnitID : 1 , EndUnitID : 2 } , { StartUnitID : 3 , EndUnitID : 4 } }
if len ( result . Plan . Ranges ) != len ( wantRanges ) {
t . Fatalf ( "ranges = %#v, want two" , result . Plan . Ranges )
2026-07-04 13:02:02 +00:00
}
2026-07-17 23:57:59 +00:00
for i , want := range wantRanges {
if result . Plan . Ranges [ i ] . StartUnitID != want . StartUnitID || result . Plan . Ranges [ i ] . EndUnitID != want . EndUnitID {
t . Fatalf ( "range[%d] = %#v, want %#v" , i , result . Plan . Ranges [ i ] , want )
}
}
var firstAnnotation map [ string ] any
if err := json . Unmarshal ( result . Plan . Ranges [ 0 ] . Annotations [ annotationNamespace ] , & firstAnnotation ) ; err != nil {
t . Fatalf ( "decode first scene annotation: %v" , err )
}
wantFirst := map [ string ] any {
"short_title" : "Goblin parley" , "primary_mode" : "Discussion" ,
"main_participants" : [ ] any { "Aria" , "Goblin scout" } ,
"summary" : "The party negotiates with a scout." ,
"boundary_note" : "The scene covers the discussion before fighting starts." ,
"boundary_confidence" : "High" ,
2026-07-04 13:02:02 +00:00
}
2026-07-17 23:57:59 +00:00
if ! reflect . DeepEqual ( firstAnnotation , wantFirst ) {
t . Fatalf ( "first scene annotation = %#v, want %#v" , firstAnnotation , wantFirst )
2026-07-07 18:34:23 +00:00
}
2026-07-17 23:57:59 +00:00
if len ( firstAnnotation ) != 6 {
t . Fatalf ( "first scene annotation keys = %#v, want exact six fields" , firstAnnotation )
2026-07-07 18:34:23 +00:00
}
2026-07-17 23:57:59 +00:00
var planAnnotation map [ string ] any
if err := json . Unmarshal ( result . Plan . Annotations [ annotationNamespace ] , & planAnnotation ) ; err != nil {
t . Fatalf ( "decode plan annotation: %v" , err )
2026-07-04 13:02:02 +00:00
}
2026-07-17 23:57:59 +00:00
if ! reflect . DeepEqual ( planAnnotation , map [ string ] any { "boundary_caveats" : [ ] any { "The transition into combat is gradual." } } ) || len ( planAnnotation ) != 1 {
t . Fatalf ( "plan annotation = %#v, want normalized caveats only" , planAnnotation )
2026-07-04 13:02:02 +00:00
}
if got := result . Warnings ; len ( got ) != 1 ||
got [ 0 ] . Scope != Key ||
got [ 0 ] . ReasonCode != "scene_boundary_caveat" ||
got [ 0 ] . Message != "The transition into combat is gradual." {
t . Fatalf ( "Warnings = %#v, want boundary caveat warning" , got )
}
}
2026-07-17 23:57:59 +00:00
func TestPlanPassesReferencesAsPromptInputs ( t * testing . T ) {
2026-07-05 18:48:45 -05:00
client := & fakeScenesLLMClient { response : chunkResponse {
Scenes : [ ] sceneResponse {
{
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( 1 ) ,
EndUnitID : shared . UnitRefFromInt ( 4 ) ,
2026-07-05 18:48:45 -05:00
ShortTitle : "Ambush" ,
PrimaryMode : "Combat" ,
MainParticipants : [ ] string { "Aria" } ,
Summary : "The party is ambushed." ,
BoundaryNote : "One scene covers the short fixture." ,
BoundaryConfidence : "High" ,
} ,
} ,
} }
2026-07-17 06:30:56 +00:00
req := chunkRequest ( )
2026-07-05 18:48:45 -05:00
req . References = contracts . ReferenceSet {
Slots : map [ string ] contracts . ResolvedReferenceSlot {
2026-07-06 11:26:21 -05:00
"players" : {
Slot : contracts . ReferenceSlot { Name : "players" } ,
Items : [ ] contracts . ReferenceItem {
{ SlotName : "players" , Content : [ ] byte ( "Alice: Aria" ) } ,
} ,
} ,
"party" : {
Slot : contracts . ReferenceSlot { Name : "party" } ,
2026-07-05 18:48:45 -05:00
Items : [ ] contracts . ReferenceItem {
2026-07-06 11:26:21 -05:00
{ SlotName : "party" , Content : [ ] byte ( "Aria: cleric" ) } ,
2026-07-05 18:48:45 -05:00
} ,
} ,
"glossary" : {
Slot : contracts . ReferenceSlot { Name : "glossary" } ,
Items : [ ] contracts . ReferenceItem {
{ SlotName : "glossary" , Content : [ ] byte ( "Brightmantle: local temple" ) } ,
} ,
} ,
} ,
}
2026-07-17 23:57:59 +00:00
if _ , err := newChunker ( t , client ) . Plan ( context . Background ( ) , req ) ; err != nil {
t . Fatalf ( "Plan() error = %v, want nil" , err )
2026-07-05 18:48:45 -05:00
}
request := client . requests [ 0 ]
2026-07-06 11:26:21 -05:00
if got := string ( request . Inputs [ "players" ] . Content ) ; got != "Alice: Aria" {
t . Fatalf ( "players input = %q, want reference content" , got )
}
if got := string ( request . Inputs [ "party" ] . Content ) ; got != "Aria: cleric" {
t . Fatalf ( "party input = %q, want reference content" , got )
2026-07-05 18:48:45 -05:00
}
if got := string ( request . Inputs [ "glossary" ] . Content ) ; got != "Brightmantle: local temple" {
t . Fatalf ( "glossary input = %q, want reference content" , got )
}
if strings . Contains ( string ( request . Inputs [ "transcript" ] . Content ) , "Aria: cleric" ) {
t . Fatalf ( "transcript input contains reference content" )
}
}
2026-07-06 11:26:21 -05:00
func TestPromptInputsMapLegacyRosterReferenceToParty ( t * testing . T ) {
2026-07-17 05:05:48 +00:00
inputs := shared . PromptInputs ( sceneSourceInput ( ) , contracts . ReferenceSet {
2026-07-06 17:22:21 +00:00
Slots : map [ string ] contracts . ResolvedReferenceSlot {
"roster" : {
Slot : contracts . ReferenceSlot { Name : "roster" } ,
Items : [ ] contracts . ReferenceItem {
{ SlotName : "roster" , Content : [ ] byte ( "Legacy roster text" ) } ,
2026-07-06 11:26:21 -05:00
} ,
} ,
} ,
} )
if got := string ( inputs [ "party" ] . Content ) ; got != "Legacy roster text" {
t . Fatalf ( "party input = %q, want legacy roster content" , got )
}
if _ , ok := inputs [ "roster" ] ; ok {
t . Fatalf ( "roster prompt input was present; want only party input" )
}
}
2026-07-17 23:57:59 +00:00
func TestPlanRejectsWhitespaceOnlyBoundaryCaveats ( t * testing . T ) {
2026-07-05 13:30:32 +00:00
client := & fakeScenesLLMClient {
response : chunkResponse {
Scenes : validSceneResponse ( ) . Scenes ,
BoundaryCaveats : [ ] string {
" " ,
} ,
} ,
}
2026-07-17 23:57:59 +00:00
_ , err := newChunker ( t , client ) . Plan ( context . Background ( ) , chunkRequest ( ) )
2026-07-05 13:30:32 +00:00
if err == nil {
2026-07-17 23:57:59 +00:00
t . Fatal ( "Plan() error = nil, want malformed structured output error" )
2026-07-05 13:30:32 +00:00
}
if ! strings . Contains ( err . Error ( ) , "dnd scenes chunker" ) || ! strings . Contains ( err . Error ( ) , "malformed structured output" ) || ! strings . Contains ( err . Error ( ) , "boundary_caveats[0]" ) {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %q, want malformed boundary caveat context" , err . Error ( ) )
2026-07-05 13:30:32 +00:00
}
}
2026-07-17 23:57:59 +00:00
func TestPlanDefensivelyCopiesAnnotationValues ( t * testing . T ) {
2026-07-04 13:02:02 +00:00
doc := sceneSourceDocument ( )
client := & fakeScenesLLMClient { response : validSceneResponse ( ) }
2026-07-17 23:57:59 +00:00
result , err := newChunker ( t , client ) . Plan ( context . Background ( ) , contracts . ChunkRequest {
2026-07-05 18:27:07 +00:00
Source : doc ,
SourceInput : sceneSourceInput ( ) ,
SessionID : "session-123" ,
LLMProfile : "profile-scenes" ,
2026-07-04 13:02:02 +00:00
} )
if err != nil {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %v, want nil" , err )
2026-07-04 13:02:02 +00:00
}
client . response . Scenes [ 0 ] . MainParticipants [ 0 ] = "mutated"
2026-07-17 23:57:59 +00:00
var annotation struct {
MainParticipants [ ] string ` json:"main_participants" `
2026-07-17 05:19:57 +00:00
}
2026-07-17 23:57:59 +00:00
if err := json . Unmarshal ( result . Plan . Ranges [ 0 ] . Annotations [ annotationNamespace ] , & annotation ) ; err != nil {
t . Fatalf ( "decode annotation: %v" , err )
2026-07-04 13:02:02 +00:00
}
2026-07-17 23:57:59 +00:00
if ! reflect . DeepEqual ( annotation . MainParticipants , [ ] string { "Aria" } ) {
t . Fatalf ( "participants = %#v, want defensive copy" , annotation . MainParticipants )
2026-07-04 13:02:02 +00:00
}
}
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance ( t * testing . T ) {
2026-07-17 06:30:56 +00:00
metadata := newChunker ( t , & fakeScenesLLMClient { } ) . ManifestMetadata ( )
2026-07-04 13:02:02 +00:00
tests := map [ string ] string {
"prompt_id" : PromptID ,
"prompt_version" : ResponseSchemaVersion ,
"response_schema_key" : string ( ResponseSchemaKey ) ,
"response_schema_id" : ResponseSchemaID ,
"response_schema_name" : ResponseSchemaName ,
"response_schema_version" : ResponseSchemaVersion ,
}
for key , want := range tests {
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 sha256 value" , key , metadata [ key ] )
}
}
for _ , forbidden := range [ ] string { "prompt" , "schema" , "source" , "text" } {
if _ , ok := metadata [ forbidden ] ; ok {
t . Fatalf ( "metadata includes raw %q field: %#v" , forbidden , metadata )
}
}
}
2026-07-17 23:57:59 +00:00
func TestPlanRejectsInvalidRequests ( t * testing . T ) {
2026-07-04 13:02:02 +00:00
validClient := & fakeScenesLLMClient { response : validSceneResponse ( ) }
2026-07-17 06:30:56 +00:00
validReq := chunkRequest ( )
2026-07-04 13:02:02 +00:00
canceledCtx , cancel := context . WithCancel ( context . Background ( ) )
cancel ( )
invalidDoc := sceneSourceDocument ( )
2026-07-07 18:34:23 +00:00
invalidDoc . Units [ 0 ] . ID = 0
2026-07-04 13:02:02 +00:00
emptyDoc := sceneSourceDocument ( )
emptyDoc . Units = nil
tests := [ ] struct {
name string
chunker * Chunker
ctx context . Context
req contracts . ChunkRequest
want string
} {
{ name : "nil chunker" , chunker : nil , ctx : context . Background ( ) , req : validReq , want : "chunker" } ,
2026-07-17 06:30:56 +00:00
{ name : "nil context" , chunker : newChunker ( t , validClient ) , ctx : nil , req : validReq , want : "context" } ,
{ name : "canceled context" , chunker : newChunker ( t , validClient ) , ctx : canceledCtx , req : validReq , want : "context" } ,
{ name : "nil source" , chunker : newChunker ( t , validClient ) , ctx : context . Background ( ) , req : contracts . ChunkRequest { } , want : "source" } ,
{ name : "empty source units" , chunker : newChunker ( t , validClient ) , ctx : context . Background ( ) , req : contracts . ChunkRequest { Source : emptyDoc } , want : "units" } ,
{ name : "invalid source" , chunker : newChunker ( t , validClient ) , ctx : context . Background ( ) , req : contracts . ChunkRequest { Source : invalidDoc } , want : "validate source document" } ,
2026-07-04 13:02:02 +00:00
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
2026-07-17 23:57:59 +00:00
_ , err := tt . chunker . Plan ( tt . ctx , tt . req )
2026-07-04 13:02:02 +00:00
if err == nil {
2026-07-17 23:57:59 +00:00
t . Fatal ( "Plan() error = nil, want error" )
2026-07-04 13:02:02 +00:00
}
if ! strings . Contains ( err . Error ( ) , "dnd scenes" ) || ! strings . Contains ( err . Error ( ) , tt . want ) {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %q, want module context and %q" , err . Error ( ) , tt . want )
2026-07-04 13:02:02 +00:00
}
} )
}
}
2026-07-17 23:57:59 +00:00
func TestPlanRejectsMalformedStructuredOutput ( t * testing . T ) {
2026-07-04 13:02:02 +00:00
tests := [ ] struct {
name string
response chunkResponse
want string
} {
{ name : "missing scenes" , response : chunkResponse { } , want : "scenes" } ,
{ name : "empty scenes" , response : chunkResponse { Scenes : [ ] sceneResponse { } } , want : "scenes" } ,
{
name : "unknown boundary id" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 1 , 999 ) ,
2026-07-04 13:02:02 +00:00
} ) ,
want : "was not found" ,
} ,
{
name : "out of order boundaries" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 3 , 2 ) ,
2026-07-04 13:02:02 +00:00
} ) ,
want : "appears after" ,
} ,
{
name : "gap" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 1 , 1 ) ,
scene ( 3 , 4 ) ,
2026-07-04 13:02:02 +00:00
} ) ,
want : "gap" ,
} ,
{
name : "overlap" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 1 , 2 ) ,
scene ( 2 , 4 ) ,
2026-07-04 13:02:02 +00:00
} ) ,
want : "overlap" ,
} ,
{
name : "incomplete coverage" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 1 , 3 ) ,
2026-07-04 13:02:02 +00:00
} ) ,
want : "final scene" ,
} ,
{
name : "empty metadata field" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
{
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( 1 ) ,
EndUnitID : shared . UnitRefFromInt ( 4 ) ,
2026-07-04 13:02:02 +00:00
ShortTitle : " " ,
PrimaryMode : "Narrative" ,
MainParticipants : [ ] string { "Aria" } ,
Summary : "Summary." ,
BoundaryNote : "Note." ,
BoundaryConfidence : "High" ,
} ,
} ) ,
want : "short_title" ,
} ,
{
name : "empty participant" ,
response : replaceScenes ( validSceneResponse ( ) , [ ] sceneResponse {
{
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( 1 ) ,
EndUnitID : shared . UnitRefFromInt ( 4 ) ,
2026-07-04 13:02:02 +00:00
ShortTitle : "Title" ,
PrimaryMode : "Narrative" ,
MainParticipants : [ ] string { "Aria" , " " } ,
Summary : "Summary." ,
BoundaryNote : "Note." ,
BoundaryConfidence : "High" ,
} ,
} ) ,
want : "main_participants" ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
client := & fakeScenesLLMClient { response : tt . response }
2026-07-17 23:57:59 +00:00
_ , err := newChunker ( t , client ) . Plan ( context . Background ( ) , chunkRequest ( ) )
2026-07-04 13:02:02 +00:00
if err == nil {
2026-07-17 23:57:59 +00:00
t . Fatal ( "Plan() error = nil, want error" )
2026-07-04 13:02:02 +00:00
}
if ! strings . Contains ( err . Error ( ) , "dnd scenes" ) || ! strings . Contains ( err . Error ( ) , tt . want ) {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %q, want module context and %q" , err . Error ( ) , tt . want )
2026-07-04 13:02:02 +00:00
}
} )
}
}
2026-07-17 23:57:59 +00:00
func TestPlanWrapsLLMClientError ( t * testing . T ) {
2026-07-04 13:02:02 +00:00
client := & fakeScenesLLMClient { err : errors . New ( "provider unavailable" ) }
2026-07-17 23:57:59 +00:00
_ , err := newChunker ( t , client ) . Plan ( context . Background ( ) , chunkRequest ( ) )
2026-07-04 13:02:02 +00:00
if err == nil {
2026-07-17 23:57:59 +00:00
t . Fatal ( "Plan() error = nil, want LLM error" )
2026-07-04 13:02:02 +00:00
}
if ! strings . Contains ( err . Error ( ) , "dnd scenes" ) || ! strings . Contains ( err . Error ( ) , "provider unavailable" ) {
2026-07-17 23:57:59 +00:00
t . Fatalf ( "Plan() error = %q, want wrapped LLM context" , err . Error ( ) )
2026-07-04 13:02:02 +00:00
}
}
2026-07-17 06:30:56 +00:00
func chunkRequest ( ) contracts . ChunkRequest {
2026-07-04 13:02:02 +00:00
return contracts . ChunkRequest {
2026-07-05 18:27:07 +00:00
Source : sceneSourceDocument ( ) ,
SourceInput : sceneSourceInput ( ) ,
SessionID : "session-123" ,
LLMProfile : "profile-scenes" ,
2026-07-04 13:02:02 +00:00
}
}
2026-07-07 18:34:23 +00:00
const sceneTranscriptJSON = ` { "id":"session-alpha","segments":[ { "id":1,"text":"Aria asks whether the goblin will parley."}]} `
2026-07-05 18:27:07 +00:00
func sceneSourceInput ( ) contracts . LLMInputMaterial {
return contracts . NewLLMInputMaterial ( "source" , "application/json" , [ ] byte ( sceneTranscriptJSON ) , "sha256:transcript" , "file:///session-alpha.json" )
}
2026-07-17 06:30:56 +00:00
func newChunker ( t * testing . T , client contracts . StructuredLLMClient ) * Chunker {
t . Helper ( )
chunker , err := New ( client , Options { } )
if err != nil {
t . Fatalf ( "New() error = %v, want nil" , err )
}
return chunker
2026-07-04 13:02:02 +00:00
}
func sceneSourceDocument ( ) * source . SourceDocument {
return & source . SourceDocument {
ID : "session-alpha" ,
Kind : "transcript" ,
Format : "application/vnd.seriatim.minimal+json" ,
Digest : "sha256:source" ,
Units : [ ] source . SourceUnit {
2026-07-17 05:19:57 +00:00
{ ID : 1 , Kind : "transcript_segment" , Text : "Aria asks whether the goblin will parley." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 1 , EndUnitID : 1 } , Metadata : map [ string ] any { "speaker" : "Alice" } } ,
{ ID : 2 , Kind : "transcript_segment" , Text : "The goblin scout describes the gate guards." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 2 , EndUnitID : 2 } } ,
{ ID : 3 , Kind : "transcript_segment" , Text : "The guards rush out with blades drawn." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 3 , EndUnitID : 3 } } ,
{ ID : 4 , Kind : "transcript_segment" , Text : "The party defeats the ambushers." , Ref : source . SourceRef { SourceID : "session-alpha" , StartUnitID : 4 , EndUnitID : 4 } } ,
2026-07-04 13:02:02 +00:00
} ,
}
}
func validSceneResponse ( ) chunkResponse {
return chunkResponse {
Scenes : [ ] sceneResponse {
2026-07-07 18:34:23 +00:00
scene ( 1 , 4 ) ,
2026-07-04 13:02:02 +00:00
} ,
BoundaryCaveats : [ ] string { } ,
}
}
func replaceScenes ( response chunkResponse , scenes [ ] sceneResponse ) chunkResponse {
response . Scenes = scenes
return response
}
2026-07-07 18:34:23 +00:00
func scene ( startUnitID int , endUnitID int ) sceneResponse {
2026-07-04 13:02:02 +00:00
return sceneResponse {
2026-07-17 05:05:48 +00:00
StartUnitID : shared . UnitRefFromInt ( startUnitID ) ,
EndUnitID : shared . UnitRefFromInt ( endUnitID ) ,
2026-07-04 13:02:02 +00:00
ShortTitle : "Scene title" ,
PrimaryMode : "Narrative" ,
MainParticipants : [ ] string { "Aria" } ,
Summary : "A compact summary." ,
BoundaryNote : "The source units form one coherent scene." ,
BoundaryConfidence : "High" ,
}
}
2026-07-17 05:32:11 +00:00
func chunkIDs ( chunks [ ] source . Chunk ) [ ] string {
2026-07-04 13:02:02 +00:00
ids := make ( [ ] string , 0 , len ( chunks ) )
for _ , chunk := range chunks {
ids = append ( ids , chunk . ID )
}
return ids
}
2026-07-07 18:34:23 +00:00
func unitIDs ( units [ ] source . SourceUnit ) [ ] int {
ids := make ( [ ] int , 0 , len ( units ) )
2026-07-04 13:02:02 +00:00
for _ , unit := range units {
ids = append ( ids , unit . ID )
}
return ids
}
type fakeScenesLLMClient struct {
response chunkResponse
err error
requests [ ] contracts . StructuredCompletionRequest
}
func ( client * fakeScenesLLMClient ) CompleteStructured ( ctx context . Context , req contracts . StructuredCompletionRequest , out any ) ( contracts . StructuredCompletionResponse , error ) {
2026-07-05 18:27:07 +00:00
client . requests = append ( client . requests , cloneStructuredCompletionRequest ( req ) )
2026-07-04 13:02:02 +00:00
if client . err != nil {
return contracts . StructuredCompletionResponse { } , client . err
}
target , ok := out . ( * chunkResponse )
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
}
2026-07-05 18:27:07 +00:00
func cloneStructuredCompletionRequest ( req contracts . StructuredCompletionRequest ) contracts . StructuredCompletionRequest {
req . Inputs = req . Inputs . Clone ( )
2026-07-05 18:26:31 -05:00
req . Vars = cloneVars ( req . Vars )
2026-07-05 18:27:07 +00:00
return req
}
2026-07-05 18:26:31 -05:00
func cloneVars ( in map [ string ] any ) map [ string ] any {
if len ( in ) == 0 {
return nil
}
out := make ( map [ string ] any , len ( in ) )
for key , value := range in {
out [ key ] = value
}
return out
}