textmachine/backend/internal/pipeline/runner_pairpack_test.go

92 lines
3.2 KiB
Go

package pipeline
import (
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/backend/internal/obs"
)
// runner_pairpack_test.go is the end-to-end proof of the pair prompt seam (D39 layer 2, pack-15 form):
// a book whose LangPair() has NO prompt pack FAILS LOUD when the runner opens (closing the latent bug
// where a ja book rode the zh-parataxis prompt), while a book of the populated pair resolves cleanly.
// setupPairPackProject writes a minimal project whose prompt pack carries ONLY zh-ru — the stages take
// no prompt path at all, they resolve prompts/<pair>/<role>.md by convention. The book's source_lang
// is srcLang. Returns the book.yaml path.
func setupPairPackProject(t *testing.T, srcLang string) string {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "translator.md"), "Переводи.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "editor.md"), "Редактируй.\n---USER---\n{{text}}\n{{draft}}")
// The pair layer points the convention at THIS project's prompts dir (repo default: ../../prompts).
writeFile(t, filepath.Join(dir, "pairs", "zh-ru.yaml"), "pair: zh-ru\nprompts_root: ../prompts\n")
writeFile(t, filepath.Join(dir, "pairs", "ja-ru.yaml"), "pair: ja-ru\nprompts_root: ../prompts\n")
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake: { kind: openai, base_url: http://127.0.0.1:0 }
models:
fake-model: { provider: fake, price: { input_per_m: 1.0, output_per_m: 2.0 } }
`, time.Now().UTC().Format("2006-01-02")))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
stages:
- name: draft
role: translator
model: fake-model
prompt_version: v-test
reasoning: "off"
- name: edit
role: editor
model: fake-model
prompt_version: v-test
reasoning: "off"
`)
writeFile(t, filepath.Join(dir, "source.txt"), "四代族长 вошёл в зал.")
writeFile(t, filepath.Join(dir, "book.yaml"), fmt.Sprintf(`
book_id: pairpack
title: Тест
source_lang: %s
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`, srcLang))
return filepath.Join(dir, "book.yaml")
}
func TestPairPackJaBookFailsLoud(t *testing.T) {
// A ja→ru book with no ja-ru prompt pack must STOP at load, not silently run zh prompts.
_, err := NewRunner(setupPairPackProject(t, "ja"), obs.NewLogger())
if err == nil {
t.Fatal("a ja book with no ja-ru prompt pack must fail loud (D39 layer 2)")
}
if !strings.Contains(err.Error(), "ja-ru") {
t.Errorf("fail-loud error must name the missing pair ja-ru, got: %v", err)
}
}
func TestPairPackZhBookResolves(t *testing.T) {
// A zh→ru book of the populated pair resolves the pack cleanly (prod path, behaviour-neutral).
r, err := NewRunner(setupPairPackProject(t, "zh"), obs.NewLogger())
if err != nil {
t.Fatalf("a zh book must resolve the zh-ru pack: %v", err)
}
defer r.Close()
if len(r.templates) != 2 {
t.Errorf("expected both stage templates loaded, got %d", len(r.templates))
}
}