369 lines
17 KiB
Go
369 lines
17 KiB
Go
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)
|
|
if got, want := catalog.Families["character_meta"].Source, absolutePath(t, pipelinePath); got != want {
|
|
t.Fatalf("family source = %q, want %q", got, want)
|
|
}
|
|
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
|
|
}{
|
|
{name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"},
|
|
{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}},
|
|
}
|
|
err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical})
|
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "outside an artifact family") {
|
|
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
|
|
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "malformed member artifact source") {
|
|
t.Fatalf("malformed concrete member source error = %v", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|