textmachine/backend/internal/pipeline/render_test.go

196 lines
7.2 KiB
Go
Raw Permalink 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"
"textmachine/backend/internal/text"
)
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
}
// Invariant §3.1: render is a pure function; two renders are byte-for-byte identical.
// Without this the request-hash drifts between runs, resume re-translates and
// re-pays for the chapter, and the DeepSeek byte-prefix cache misses.
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(baseRequest(m1))
h2 := RequestHash(baseRequest(m1))
if h1 != h2 {
t.Fatal("request hash is not stable")
}
}
// baseRequest is the reference call every request-hash fixture varies ONE field of — the shape the
// Request struct exists for: a variant now names the field it changes instead of moving a value between
// two of thirteen positional arguments.
func baseRequest(msgs []llm.Message) Request {
return Request{BookID: "b", Chapter: 1, ChunkIdx: 0, Attempt: 0, Stage: "draft", Role: "translator",
Model: "m", Temperature: 0.3, Reasoning: "off", JSONOnly: false, MaxTokens: 1024, SnapshotID: "snap",
Messages: msgs}
}
func TestRequestHashSensitivity(t *testing.T) {
msgs := []llm.Message{{Role: "system", Content: "s", CacheBoundary: true}, {Role: "user", Content: "u"}}
base := RequestHash(baseRequest(msgs))
vary := func(f func(*Request)) string {
req := baseRequest(msgs)
f(&req)
return RequestHash(req)
}
variants := map[string]string{
"model": vary(func(q *Request) { q.Model = "m2" }),
"temp": vary(func(q *Request) { q.Temperature = 0.4 }),
"snapshot": vary(func(q *Request) { q.SnapshotID = "snap2" }),
"maxTokens": vary(func(q *Request) { q.MaxTokens = 2048 }),
"chunk": vary(func(q *Request) { q.ChunkIdx = 1 }),
"content": vary(func(q *Request) {
q.Messages = []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)
}
}
// Concatenation with a separator does not merge: ("ab","c") != ("a","bc").
a := RequestHash(baseRequest([]llm.Message{{Role: "user", Content: "ab"}, {Role: "user", Content: "c"}}))
b := RequestHash(baseRequest([]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")
}
}
// Literal «{{…}}» in the source/draft are just text: values
// are inserted verbatim and not re-scanned (single-pass render).
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 and decomposed Unicode form → canonical; hash is stable across
// OSes/editors.
crlf := "\uFEFFпервая\r\nвторая\rтретья"
if got := text.NormalizeSource(crlf); got != "первая\nвторая\nтретья" {
t.Fatalf("BOM/CRLF/CR not normalized: %q", got)
}
// Explicitly build NFD (e + U+0301) and NFC (U+00E9) so the forms definitely differ.
decomposed := "cafe\u0301"
precomposed := "caf\u00e9"
if decomposed == precomposed {
t.Fatal("test setup: the two forms must differ before normalization")
}
if text.NormalizeSource(decomposed) != text.NormalizeSource(precomposed) {
t.Fatal("NFD and NFC forms must normalize to the same string")
}
// Different representations of the same text → one request-hash.
tpl := writeTemplate(t, "S.\n---USER---\n{{text}}")
m1, _ := Messages(tpl, RenderVars{Book: testBook(), Text: text.NormalizeSource(decomposed)})
m2, _ := Messages(tpl, RenderVars{Book: testBook(), Text: text.NormalizeSource(precomposed)})
h1 := RequestHash(baseRequest(m1))
h2 := RequestHash(baseRequest(m2))
if h1 != h2 {
t.Fatal("normalized equivalent sources must yield the same request hash")
}
}
// Length-prefix eliminates the NUL collision: content with \x00 does not shift field
// boundaries and does not fake extra messages.
func TestRequestHashNulRobust(t *testing.T) {
a := RequestHash(baseRequest([]llm.Message{{Role: "user", Content: "a\x00b"}}))
bb := RequestHash(baseRequest([]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) {
// Order of magnitude per the eval calibration: an ideograph ≈1 token, Cyrillic ≈3
// characters per token. The estimate is for reserves, not for billing.
ja := EstimateTokens("図書館は静寂に包まれていた") // 13 CJK runes
if ja < 10 || ja > 20 {
t.Errorf("ja estimate out of range: %d", ja)
}
ru := EstimateTokens("Библиотека была окутана тишиной") // ~30 non-whitespace
if ru < 8 || ru > 16 {
t.Errorf("ru estimate out of range: %d", ru)
}
}