textmachine/backend/internal/pipeline/runner_pairpack_test.go

91 lines
3 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-keyed prompt seam (D39 слой 2): a book
// whose LangPair() is NOT in a stage's pair pack FAILS LOUD at loadTemplates (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 stages use a pair-keyed `prompts` pack carrying
// ONLY zh-ru, with the book's source_lang set to 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", "translator.md"), "Переводи.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"), "Редактируй.\n---USER---\n{{text}}\n{{draft}}")
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")))
// Pair-keyed pack — ONLY zh-ru is populated.
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
prompts: { zh-ru: prompts/translator.md }
prompt_version: v-test
reasoning: "off"
- name: edit
role: editor
model: fake-model
prompts: { zh-ru: prompts/editor.md }
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 against a zh-ru-only pack must STOP at loadTemplates, not silently run zh prompts.
_, err := NewRunner(setupPairPackProject(t, "ja"), obs.NewLogger())
if err == nil {
t.Fatal("a ja book against a zh-ru-only prompt pack must fail loud (D39 слой 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))
}
}