package pipeline import ( "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/config" "textmachine/backend/internal/llm" ) func testBook() *config.Book { return &config.Book{ BookID: "b", Title: "T", SourceLang: "ja", TargetLang: "ru", Genre: "ранобэ", Audience: "взрослые", Venuti: 0.6, Honorifics: "keep", Transcription: "polivanov", Footnotes: "minimal", } } func writeTemplate(t *testing.T, content string) *PromptTemplate { t.Helper() path := filepath.Join(t.TempDir(), "tpl.md") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } tpl, err := LoadPromptTemplate(path) if err != nil { t.Fatal(err) } return tpl } // Инвариант §3.1: рендер — чистая функция; два рендера байт-в-байт идентичны. // Без этого request-hash плывёт между запусками, resume пере-переводит и // пере-оплачивает главу, а byte-prefix кэш DeepSeek промахивается. func TestRenderIsDeterministic(t *testing.T) { tpl := writeTemplate(t, "Перевод с {{source_lang}} на {{target_lang}}, жанр {{genre}}, venuti {{venuti}}.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}") v := RenderVars{Book: testBook(), Text: "исходник", Draft: "черновик"} m1, err := Messages(tpl, v) if err != nil { t.Fatal(err) } for i := 0; i < 100; i++ { m2, err := Messages(tpl, v) if err != nil { t.Fatal(err) } if len(m1) != len(m2) { t.Fatal("length differs") } for j := range m1 { if m1[j] != m2[j] { t.Fatalf("render %d differs at message %d:\n%+v\nvs\n%+v", i, j, m1[j], m2[j]) } } } h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1) h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1) if h1 != h2 { t.Fatal("request hash is not stable") } } func TestRequestHashSensitivity(t *testing.T) { msgs := []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u"}} base := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs) variants := map[string]string{ "model": RequestHash("b", 1, 0, 0, "draft", "translator", "m2", 0.3, "off", false, 1024, "snap", msgs), "temp": RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.4, "off", false, 1024, "snap", msgs), "snapshot": RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap2", msgs), "maxTokens": RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 2048, "snap", msgs), "chunk": RequestHash("b", 1, 1, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", msgs), "content": RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u2"}}), } for name, h := range variants { if h == base { t.Errorf("hash must change when %s changes", name) } } // Конкатенация с разделителем не склеивается: ("ab","c") != ("a","bc"). a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "user", Content: "ab"}, {Role: "user", Content: "c"}}) b := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "user", Content: "a"}, {Role: "user", Content: "bc"}}) if a == b { t.Fatal("field separator is not injective") } } func TestRenderRejectsUnknownPlaceholder(t *testing.T) { tpl := writeTemplate(t, "Привет {{nonexistent}}\n---USER---\n{{text}}") _, err := Messages(tpl, RenderVars{Book: testBook(), Text: "x"}) if err == nil { t.Fatal("unknown placeholder must fail loud, not render empty") } } // Литеральные «{{…}}» в ИСХОДНИКЕ/черновике — просто текст: значения // вставляются verbatim и не пере-сканируются (однопроходный рендер). func TestRenderValuesAreNotRescanned(t *testing.T) { tpl := writeTemplate(t, "Система.\n---USER---\nТекст: {{text}}\nЧерновик: {{draft}}") v := RenderVars{ Book: testBook(), Text: "герой сказал {{draft}} и ушёл", // literal braces in source Draft: "примечание {{TN: сноска}} осталось", // literal braces in prior LLM output } msgs, err := Messages(tpl, v) if err != nil { t.Fatalf("literal braces inside values must not fail: %v", err) } user := msgs[1].Content for _, want := range []string{"герой сказал {{draft}} и ушёл", "примечание {{TN: сноска}} осталось"} { if !strings.Contains(user, want) { t.Fatalf("value %q must survive verbatim, got:\n%s", want, user) } } } func TestTemplateRequiresUserSeparator(t *testing.T) { path := filepath.Join(t.TempDir(), "bad.md") if err := os.WriteFile(path, []byte("только системная часть"), 0o644); err != nil { t.Fatal(err) } if _, err := LoadPromptTemplate(path); err == nil { t.Fatal("template without ---USER--- must be rejected") } } func TestNormalizeSource(t *testing.T) { // BOM, CRLF/CR и разложенная форма Unicode → канон; хеш стабилен между // ОС/редакторами. crlf := "\uFEFFпервая\r\nвторая\rтретья" if got := NormalizeSource(crlf); got != "первая\nвторая\nтретья" { t.Fatalf("BOM/CRLF/CR not normalized: %q", got) } // Явно строим NFD (e + U+0301) и NFC (U+00E9), чтобы формы точно различались. decomposed := "cafe\u0301" precomposed := "caf\u00e9" if decomposed == precomposed { t.Fatal("test setup: the two forms must differ before normalization") } if NormalizeSource(decomposed) != NormalizeSource(precomposed) { t.Fatal("NFD and NFC forms must normalize to the same string") } // Разные представления одного текста → один request-hash. tpl := writeTemplate(t, "S.\n---USER---\n{{text}}") m1, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(decomposed)}) m2, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(precomposed)}) h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1) h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m2) if h1 != h2 { t.Fatal("normalized equivalent sources must yield the same request hash") } } // Length-prefix устраняет NUL-коллизию: контент с \x00 не сдвигает границы // полей и не подделывает лишние сообщения. func TestRequestHashNulRobust(t *testing.T) { a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "user", Content: "a\x00b"}}) bb := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", []llm.Message{{Role: "user", Content: "a"}, {Role: "\x00b", Content: ""}}) if a == bb { t.Fatal("NUL byte in content must not collide with an extra message boundary") } } func TestEstimateTokens(t *testing.T) { // Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3 // символа на токен. Оценка — для резервов, не для биллинга. ja := EstimateTokens("図書館は静寂に包まれていた") // 13 CJK-рун if ja < 10 || ja > 20 { t.Errorf("ja estimate out of range: %d", ja) } ru := EstimateTokens("Библиотека была окутана тишиной") // ~30 не-пробельных if ru < 8 || ru > 16 { t.Errorf("ru estimate out of range: %d", ru) } }