textmachine/backend/internal/pipeline/render_test.go

183 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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