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