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 := 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) } }) } } 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 }