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