package config import ( "os" "path/filepath" "strings" "testing" "time" ) // writeTmp writes a file under a temp path and returns it. func writeTmp(t *testing.T, path, body string) string { t.Helper() if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } return path } // miniModels loads a minimal valid models.yaml with a single `fake` model. func miniModels(t *testing.T) *Models { t.Helper() dir := t.TempDir() body := "prices_checked: " + time.Now().UTC().Format("2006-01-02") + ` default_model: fake providers: p: { kind: openai, base_url: http://x } models: fake: { provider: p, price: { input_per_m: 1, output_per_m: 2 } } ` m, err := LoadModels(writeTmp(t, filepath.Join(dir, "models.yaml"), body)) if err != nil { t.Fatalf("miniModels: %v", err) } return m } // prompt_pack_test.go pins the pair-keyed prompt seam (D39 слой 2, L4-prompts-not-per-pair-zh-baked): // a stage resolves its prompt from a pair-keyed pack by the book's LangPair(), FAIL-LOUD on a missing // pair; a legacy single `prompt` stays pair-agnostic; prod zh→ru resolves the same file (SHA-neutral). func TestPromptPathForResolvesPair(t *testing.T) { st := Stage{Name: "edit", Prompts: map[string]string{"zh-ru": "/x/editor.md", "ja-ru": "/y/editor.md"}} got, err := st.PromptPathFor("zh-ru") if err != nil || got != "/x/editor.md" { t.Fatalf("PromptPathFor(zh-ru) = %q, %v; want /x/editor.md, nil", got, err) } } func TestPromptPathForFailsLoudOnMissingPair(t *testing.T) { st := Stage{Name: "edit", Prompts: map[string]string{"zh-ru": "/x/editor.md"}} _, err := st.PromptPathFor("ja-ru") if err == nil { t.Fatal("PromptPathFor(ja-ru) must FAIL LOUD when the pack has no ja-ru (the latent ja-runs-zh-prompt bug)") } // The error must name the missing pair AND list what the pack has (operator-actionable). if !strings.Contains(err.Error(), "ja-ru") || !strings.Contains(err.Error(), "zh-ru") { t.Errorf("fail-loud error must name the missing pair and the pack's pairs, got: %v", err) } } func TestPromptPathForLegacySingleIsPairAgnostic(t *testing.T) { st := Stage{Name: "draft", Prompt: "/x/translator.md"} for _, pair := range []string{"zh-ru", "ja-ru", "en-ru"} { got, err := st.PromptPathFor(pair) if err != nil || got != "/x/translator.md" { t.Errorf("legacy single prompt must resolve for %q: got %q, %v", pair, got, err) } } } func TestLoadPipelineRejectsAmbiguousPromptForms(t *testing.T) { dir := t.TempDir() writeTmp(t, filepath.Join(dir, "translator.md"), "sys\n---USER---\n{{text}}") models := miniModels(t) // Both `prompt` and `prompts` set → rejected. both := writeTmp(t, filepath.Join(dir, "both.yaml"), ` core: C1 stages: - name: draft role: translator model: fake prompt: translator.md prompts: { zh-ru: translator.md } prompt_version: v1 `) if _, err := LoadPipeline(both, models); err == nil || !strings.Contains(err.Error(), "not both") { t.Errorf("both prompt+prompts must be rejected, got: %v", err) } // Neither set → rejected. neither := writeTmp(t, filepath.Join(dir, "neither.yaml"), ` core: C1 stages: - name: draft role: translator model: fake prompt_version: v1 `) if _, err := LoadPipeline(neither, models); err == nil || !strings.Contains(err.Error(), "prompt is required") { t.Errorf("neither prompt nor prompts must be rejected, got: %v", err) } } // TestBoevoyPipelineC1ResolvesZhRu proves the migrated prod config is behaviour-neutral for zh→ru: // the draft/edit stages resolve the same translator.md/editor.md the legacy path did (same file → // same PromptSHA256), and a NON-zh pair fails loud. func TestBoevoyPipelineC1ResolvesZhRu(t *testing.T) { models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load models: %v", err) } p, err := LoadPipeline(filepath.Join("..", "..", "configs", "pipeline-c1.yaml"), models) if err != nil { t.Fatalf("load pipeline-c1: %v", err) } for _, st := range p.Stages { zh, err := st.PromptPathFor("zh-ru") if err != nil { t.Errorf("stage %q must resolve zh-ru: %v", st.Name, err) } if !strings.HasSuffix(zh, ".md") { t.Errorf("stage %q zh-ru prompt should be a .md template, got %q", st.Name, zh) } // A book of a pair the pack does not carry must fail loud (closes the ja-runs-zh-prompt bug). if _, err := st.PromptPathFor("ja-ru"); err == nil { t.Errorf("stage %q must fail loud for ja-ru (pack is zh-ru only)", st.Name) } } // The draft stage must resolve to the shared translator.md (the zh→ru pack content is unchanged). for _, st := range p.Stages { if st.Role == "translator" { zh, _ := st.PromptPathFor("zh-ru") if !strings.HasSuffix(zh, filepath.Join("prompts", "translator.md")) { t.Errorf("draft zh-ru must be prompts/translator.md, got %q", zh) } } } }