2026-08-30 14:15:57 +00:00
package config
import (
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
func TestArtifactFamiliesExpandCanonicalCharactersDeterministically ( t * testing . T ) {
dir := t . TempDir ( )
writePartyResolutionFile ( t , dir + "/party.yml" , ` schema_version : narratio . party . v1
characters :
zeta :
player : { name : Zara }
character :
name : Zeta
alias : [ Z ]
classes : [ { name : wizard , level : 8 } , { name : fighter } ]
alpha :
player : { name : Ada }
character :
name : Alpha
alias : [ A , The First ]
classes : [ { name : ranger } ]
` )
pipelinePath , campaignPath , sessionPath := writeArtifactFamilyConfig ( t , dir , artifactFamilyPipelineYAML ( `
character_items :
enabled : false
for_each : party . characters
prompt_id : dnd . character_items
profile_id : production
output_path_pattern : artifacts / characters / { character_id } / items . md
inputs :
transcript : { source : narratio . transcript . final_trimmed , required : true }
member_vars :
id : character_id
player : player . name
name : character . name
classes : character . class_summary
aliases : character . alias_summary
vars :
static_flag : true
character_meta :
enabled : true
for_each : party . characters
prompt_id : dnd . character_meta
profile_id : production
output_path_pattern : artifacts / characters / { character_id } / meta . md
inputs :
transcript : { source : narratio . transcript . final_trimmed , required : true }
` ) )
cfg , err := LoadWithSessionOptions ( pipelinePath , campaignPath , sessionPath , SessionLoadOptions { } )
if err != nil {
t . Fatalf ( "LoadWithSessionOptions() error = %v" , err )
}
if err := Validate ( cfg ) ; err != nil {
t . Fatalf ( "Validate() error = %v" , err )
}
if len ( cfg . Pipeline . Scriptorium . ArtifactFamilies ) != 0 {
t . Fatalf ( "adapter-facing artifact families = %#v, want none" , cfg . Pipeline . Scriptorium . ArtifactFamilies )
}
keys := sortedArtifactKeys ( cfg . Pipeline . Scriptorium . Artifacts )
wantKeys := [ ] string { "character_items_alpha" , "character_items_zeta" , "character_meta_alpha" , "character_meta_zeta" }
if ! reflect . DeepEqual ( keys , wantKeys ) {
t . Fatalf ( "artifact keys = %#v, want %#v" , keys , wantKeys )
}
alpha := cfg . Pipeline . Scriptorium . Artifacts [ "character_items_alpha" ]
if alpha . Enabled || alpha . OutputPath != "artifacts/characters/alpha/items.md" {
t . Fatalf ( "alpha artifact = %#v" , alpha )
}
if ! reflect . DeepEqual ( alpha . Vars , map [ string ] any {
"static_flag" : true , "id" : "alpha" , "player" : "Ada" , "name" : "Alpha" , "classes" : "ranger" , "aliases" : "A, The First" ,
} ) {
t . Fatalf ( "alpha vars = %#v" , alpha . Vars )
}
zeta := cfg . Pipeline . Scriptorium . Artifacts [ "character_items_zeta" ]
if got := zeta . Vars [ "classes" ] ; got != "wizard 8 / fighter" {
t . Fatalf ( "zeta class summary = %#v" , got )
}
catalog := ArtifactFamilies ( cfg . Pipeline )
2026-08-30 14:56:22 +00:00
if got , want := catalog . Families [ "character_meta" ] . Source , absolutePath ( t , pipelinePath ) ; got != want {
t . Fatalf ( "family source = %q, want %q" , got , want )
}
2026-08-30 14:15:57 +00:00
if got := catalog . Families [ "character_meta" ] . Members ; ! reflect . DeepEqual ( got , [ ] string { "character_meta_alpha" , "character_meta_zeta" } ) {
t . Fatalf ( "meta members = %#v" , got )
}
if got := catalog . Members [ "character_items_zeta" ] ; got . Family != "character_items" || got . CharacterID != "zeta" || got . Source == "" {
t . Fatalf ( "zeta origin = %#v" , got )
}
firstDigest := EffectivePipelineDigest ( cfg . Pipeline )
writePartyResolutionFile ( t , dir + "/party.yml" , ` schema_version : narratio . party . v1
characters :
alpha :
player : { name : Ada }
character :
name : Alpha
alias : [ A , The First ]
classes : [ { name : ranger } ]
zeta :
player : { name : Zara }
character :
name : Zeta
alias : [ Z ]
classes : [ { name : wizard , level : 8 } , { name : fighter } ]
` )
again , err := LoadWithSessionOptions ( pipelinePath , campaignPath , sessionPath , SessionLoadOptions { } )
if err != nil {
t . Fatalf ( "reordered LoadWithSessionOptions() error = %v" , err )
}
if ! reflect . DeepEqual ( cfg . Pipeline . Scriptorium . Artifacts , again . Pipeline . Scriptorium . Artifacts ) || ! reflect . DeepEqual ( ArtifactFamilies ( cfg . Pipeline ) , ArtifactFamilies ( again . Pipeline ) ) || firstDigest != EffectivePipelineDigest ( again . Pipeline ) {
t . Fatalf ( "reordered source maps changed expansion" )
}
}
func TestArtifactFamiliesRejectInvalidExpansionDeclarations ( t * testing . T ) {
tests := [ ] struct {
name string
party string
families string
wantError string
} {
2026-09-02 11:57:51 +00:00
{ name : "unversioned party" , party : "characters: {}\n" , families : validFamilyYAML , wantError : "schema_version is required" } ,
2026-08-30 14:15:57 +00:00
{ name : "invalid iteration" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "party.characters" , "party.players" , 1 ) , wantError : ".for_each must be" } ,
{ name : "invalid token" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "{character_id}" , "{character_name}" , 1 ) , wantError : "must contain exactly one" } ,
{ name : "repeated token" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "meta.md" , "{character_id}.md" , 1 ) , wantError : "must contain exactly one" } ,
{ name : "member selector" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "character.name" , "character.unknown" , 1 ) , wantError : "selector" } ,
{ name : "static variable collision" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "member_vars:\n character_name" , "vars:\n character_name: static\n member_vars:\n character_name" , 1 ) , wantError : "conflicts with static vars" } ,
{ name : "unsafe output" , party : canonicalPartyFixture , families : strings . Replace ( validFamilyYAML , "artifacts/characters/{character_id}/meta.md" , "../{character_id}.md" , 1 ) , wantError : "must not contain path traversal" } ,
}
for _ , test := range tests {
t . Run ( test . name , func ( t * testing . T ) {
dir := t . TempDir ( )
writePartyResolutionFile ( t , dir + "/party.yml" , test . party )
pipelinePath , campaignPath , sessionPath := writeArtifactFamilyConfig ( t , dir , artifactFamilyPipelineYAML ( test . families ) )
_ , err := LoadWithSessionOptions ( pipelinePath , campaignPath , sessionPath , SessionLoadOptions { } )
if err == nil || ! strings . Contains ( err . Error ( ) , test . wantError ) {
t . Fatalf ( "LoadWithSessionOptions() error = %v, want %q" , err , test . wantError )
}
} )
}
}
func TestArtifactFamilyStrictDecodeRejectsUnknownFields ( t * testing . T ) {
dir := t . TempDir ( )
pipelinePath , _ , _ := writeArtifactFamilyConfig ( t , dir , artifactFamilyPipelineYAML ( validFamilyYAML + " unsupported: true\n" ) )
_ , err := LoadPipeline ( pipelinePath )
if err == nil || ! strings . Contains ( err . Error ( ) , "field unsupported not found" ) {
t . Fatalf ( "LoadPipeline() error = %v" , err )
}
}
func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions ( t * testing . T ) {
document , err := ParseParty ( [ ] byte ( canonicalPartyFixture ) )
if err != nil {
t . Fatal ( err )
}
base := ScriptoriumArtifactFamilyConfig {
ForEach : partyCharactersFamilySource , PromptID : "dnd.character_meta" ,
OutputPathPattern : "artifacts/characters/{character_id}/meta.md" ,
}
for _ , test := range [ ] struct {
name string
artifacts map [ string ] ScriptoriumArtifactConfig
wantError string
} {
{
name : "generated key" , artifacts : map [ string ] ScriptoriumArtifactConfig {
"character_meta_arannis" : { OutputPath : "artifacts/other.md" } ,
} , wantError : "generates artifact key" ,
} ,
{
name : "generated output" , artifacts : map [ string ] ScriptoriumArtifactConfig {
"existing" : { OutputPath : "artifacts/characters/arannis/meta.md" } ,
} , wantError : "generates output path" ,
} ,
} {
t . Run ( test . name , func ( t * testing . T ) {
pipeline := & PipelineConfig {
Scriptorium : & ScriptoriumConfig { Artifacts : test . artifacts } ,
resolution : & pipelineResolutionMetadata { artifactFamilies : map [ string ] ScriptoriumArtifactFamilyConfig { "character_meta" : base } } ,
}
2026-09-02 12:04:35 +00:00
err := expandPipelineArtifactFamilies ( pipeline , ResolvedParty { Canonical : document } )
2026-08-30 14:15:57 +00:00
if err == nil || ! strings . Contains ( err . Error ( ) , test . wantError ) {
t . Fatalf ( "expandPipelineArtifactFamilies() error = %v, want %q" , err , test . wantError )
}
} )
}
}
2026-08-30 14:20:03 +00:00
func TestArtifactFamiliesExpandSameMemberDependenciesAndInputs ( t * testing . T ) {
dir := t . TempDir ( )
writePartyResolutionFile ( t , filepath . Join ( dir , "party.yml" ) , ` schema_version : narratio . party . v1
characters :
arannis :
player : { name : Eric }
character : { name : Arannis , classes : [ { name : wizard } ] }
bryn :
player : { name : Bri }
character : { name : Bryn , classes : [ { name : fighter } ] }
` )
families := `
character_meta :
enabled : true
for_each : party . characters
prompt_id : dnd . character_meta
output_path_pattern : artifacts / characters / { character_id } / meta . md
character_items :
enabled : true
for_each : party . characters
prompt_id : dnd . character_items
output_path_pattern : artifacts / characters / { character_id } / items . md
depends_on : [ session_recap ]
member_dependencies : [ character_meta ]
inputs :
transcript : { source : narratio . transcript . final_trimmed , required : true }
prior_meta : { source : narratio . member_artifact . character_meta , required : true }
`
pipelinePath , campaignPath , sessionPath := writeArtifactFamilyConfig ( t , dir , strings . Replace ( artifactFamilyPipelineYAML ( families ) , " artifact_families:" , " artifacts:\n session_recap:\n enabled: false\n output_path: artifacts/session_recap.md\n artifact_families:" , 1 ) )
cfg , err := LoadWithSessionOptions ( pipelinePath , campaignPath , sessionPath , SessionLoadOptions { } )
if err != nil {
t . Fatal ( err )
}
items := cfg . Pipeline . Scriptorium . Artifacts [ "character_items_arannis" ]
if ! reflect . DeepEqual ( items . DependsOn , [ ] string { "session_recap" , "character_meta_arannis" } ) {
t . Fatalf ( "member dependencies = %#v" , items . DependsOn )
}
if got := items . Inputs [ "prior_meta" ] . Source ; got != "narratio.artifact.character_meta_arannis" {
t . Fatalf ( "rewritten member source = %q" , got )
}
catalog := ArtifactFamilies ( cfg . Pipeline )
if got := catalog . Members [ "character_items_arannis" ] ; ! reflect . DeepEqual ( got . Dependencies , items . DependsOn ) || got . Inputs [ "prior_meta" ] != "narratio.artifact.character_meta_arannis" {
t . Fatalf ( "member provenance = %#v" , got )
}
for key , artifact := range cfg . Pipeline . Scriptorium . Artifacts {
for name , input := range artifact . Inputs {
if strings . Contains ( input . Source , "narratio.member_artifact." ) {
t . Fatalf ( "unresolved source at %s.%s = %q" , key , name , input . Source )
}
}
}
}
func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources ( t * testing . T ) {
for _ , test := range [ ] struct { name , families , want string } {
{ name : "missing family" , families : strings . Replace ( validFamilyYAML , "member_vars:" , "member_dependencies: [missing]\n member_vars:" , 1 ) , want : "unknown family" } ,
{ name : "self dependency" , families : strings . Replace ( validFamilyYAML , "member_vars:" , "member_dependencies: [character_meta]\n member_vars:" , 1 ) , want : "must not reference its own family" } ,
{ name : "duplicate dependency" , families : strings . Replace ( validFamilyYAML , "member_vars:" , "member_dependencies: [other, other]\n member_vars:" , 1 ) + `
other :
for_each : party . characters
output_path_pattern : artifacts / characters / { character_id } / other . md
` , want : "duplicates family" } ,
{ name : "undeclared source dependency" , families : strings . Replace ( validFamilyYAML , "member_vars:" , "inputs:\n prior: {source: narratio.member_artifact.other, required: true}\n member_vars:" , 1 ) + `
other :
for_each : party . characters
output_path_pattern : artifacts / characters / { character_id } / other . md
` , want : "requires member_dependencies" } ,
{ name : "transitive cycle" , families : strings . Replace ( validFamilyYAML , "member_vars:" , "member_dependencies: [other]\n member_vars:" , 1 ) + `
other :
for_each : party . characters
output_path_pattern : artifacts / characters / { character_id } / other . md
member_dependencies : [ character_meta ]
` , want : "dependencies must not contain cycles" } ,
} {
t . Run ( test . name , func ( t * testing . T ) {
dir := t . TempDir ( )
writePartyResolutionFile ( t , filepath . Join ( dir , "party.yml" ) , canonicalPartyFixture )
pipelinePath , campaignPath , sessionPath := writeArtifactFamilyConfig ( t , dir , artifactFamilyPipelineYAML ( test . families ) )
_ , err := LoadWithSessionOptions ( pipelinePath , campaignPath , sessionPath , SessionLoadOptions { } )
if err == nil || ! strings . Contains ( err . Error ( ) , test . want ) {
t . Fatalf ( "error = %v, want %q" , err , test . want )
}
} )
}
pipeline := & PipelineConfig {
Scriptorium : & ScriptoriumConfig { Artifacts : map [ string ] ScriptoriumArtifactConfig {
"explicit" : { Inputs : map [ string ] ScriptoriumInputConfig {
"bad" : { Source : "narratio.member_artifact.character_meta" } ,
} } ,
} } ,
resolution : & pipelineResolutionMetadata { artifactFamilies : map [ string ] ScriptoriumArtifactFamilyConfig {
"character_meta" : { ForEach : partyCharactersFamilySource , OutputPathPattern : "artifacts/{character_id}.md" } ,
} } ,
}
document , err := ParseParty ( [ ] byte ( canonicalPartyFixture ) )
if err != nil {
t . Fatal ( err )
}
2026-09-02 12:04:35 +00:00
if err := expandPipelineArtifactFamilies ( pipeline , ResolvedParty { Canonical : document } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "outside an artifact family" ) {
2026-08-30 14:20:03 +00:00
t . Fatalf ( "concrete member source error = %v" , err )
}
pipeline . Scriptorium . Artifacts [ "explicit" ] = ScriptoriumArtifactConfig { Inputs : map [ string ] ScriptoriumInputConfig { "bad" : { Source : "narratio.member_artifact." } } }
pipeline . resolution . artifactFamiliesExpanded = false
2026-09-02 12:04:35 +00:00
if err := expandPipelineArtifactFamilies ( pipeline , ResolvedParty { Canonical : document } ) ; err == nil || ! strings . Contains ( err . Error ( ) , "malformed member artifact source" ) {
2026-08-30 14:20:03 +00:00
t . Fatalf ( "malformed concrete member source error = %v" , err )
}
}
2026-08-30 14:30:07 +00:00
func TestFamilyPublishRulesExpandToConcreteArtifacts ( t * testing . T ) {
required := true
cfg := & PipelineConfig {
Scriptorium : & ScriptoriumConfig { Artifacts : map [ string ] ScriptoriumArtifactConfig {
"character_meta_alpha" : { OutputPath : "artifacts/characters/alpha/meta.md" } ,
"character_meta_zeta" : { OutputPath : "artifacts/characters/zeta/meta.md" } ,
} } ,
Publish : & PublishConfig { } ,
resolution : & pipelineResolutionMetadata { publishDeclared : true } ,
}
catalog := ArtifactFamilyCatalog { Families : map [ string ] ArtifactFamilyOrigin {
"character_meta" : { Members : [ ] string { "character_meta_alpha" , "character_meta_zeta" } , Publish : & ScriptoriumArtifactFamilyPublishConfig { Enabled : true , Required : required , DestPattern : "published/{character_id}.md" } } ,
} , Members : map [ string ] ArtifactFamilyMemberOrigin {
"character_meta_alpha" : { CharacterID : "alpha" } , "character_meta_zeta" : { CharacterID : "zeta" } ,
} }
if err := expandFamilyPublishRules ( cfg , catalog ) ; err != nil {
t . Fatal ( err )
}
if got := cfg . Publish . Outputs ; len ( got ) != 2 || got [ 0 ] . Source != "narratio.artifact.character_meta_alpha" || got [ 0 ] . Dest != "published/alpha.md" || got [ 0 ] . Required == nil || ! * got [ 0 ] . Required {
t . Fatalf ( "generated publish rules = %#v" , got )
}
cfg . Publish . Outputs = nil
catalog . Families [ "character_meta" ] = ArtifactFamilyOrigin { Members : [ ] string { "character_meta_alpha" } , Publish : & ScriptoriumArtifactFamilyPublishConfig { Enabled : true } }
cfg . resolution . publishDeclared = false
if err := expandFamilyPublishRules ( cfg , catalog ) ; err == nil || ! strings . Contains ( err . Error ( ) , "requires pipeline.publish" ) {
t . Fatalf ( "missing publish error = %v" , err )
}
}
2026-08-30 14:15:57 +00:00
const validFamilyYAML = `
character_meta :
enabled : true
for_each : party . characters
prompt_id : dnd . character_meta
profile_id : production
output_path_pattern : artifacts / characters / { character_id } / meta . md
member_vars :
character_name : character . name
`
func artifactFamilyPipelineYAML ( families string ) string {
lines := strings . Split ( strings . TrimPrefix ( families , "\n" ) , "\n" )
for index , line := range lines {
if line != "" {
lines [ index ] = " " + line
}
}
return "workspace:\n root: /tmp/narratio-work\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\nscriptorium:\n binary: scriptorium\n artifact_families:\n" + strings . Join ( lines , "\n" )
}
func writeArtifactFamilyConfig ( t * testing . T , dir , pipeline string ) ( string , string , string ) {
t . Helper ( )
pipelinePath := writePartyResolutionFile ( t , filepath . Join ( dir , "pipeline.yml" ) , pipeline )
campaignPath := writePartyResolutionFile ( t , filepath . Join ( dir , "campaign.yml" ) , "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n" )
sessionPath := writePartyResolutionFile ( t , filepath . Join ( dir , "session.yml" ) , "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n" )
return pipelinePath , campaignPath , sessionPath
}
func sortedArtifactKeys ( artifacts map [ string ] ScriptoriumArtifactConfig ) [ ] string {
keys := make ( [ ] string , 0 , len ( artifacts ) )
for key := range artifacts {
keys = append ( keys , key )
}
sort . Strings ( keys )
return keys
}