textmachine/backend/internal/config/prompt_pack_test.go

140 lines
4.8 KiB
Go

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