textmachine/backend/internal/pipeline/placeholderplacement_test.go

84 lines
4.4 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"
)
// placeholderplacement_test.go pins WHERE the load gate looks.
//
// ⚠ LoadPromptTemplate's own comment says «the whole canonical text is scanned, not the parts, so a marker
// in a few-shot block or in the user tail is caught too». Nothing held that. Planted — the scan narrowed to
// the system part — the whole pipeline package stayed green, because every existing fixture puts its bad
// marker in the system half. Two independent adversarial passes reached this same square from different
// directions. A retired placeholder sitting after `---USER---` therefore loaded clean and failed inside
// Render, mid-run, after the wave that stage sits behind had been bought — which is the one failure this
// gate exists to prevent, and the placement an author is MOST likely to produce: the user tail is where the
// per-chunk variables live.
func TestTheLoadGateScansEveryPartOfTheFile(t *testing.T) {
for _, c := range []struct {
name, tpl, want string
}{
{"in the system part", "Роль {{chapter_title}}.\n---USER---\n{{text}}", "{{chapter_title}}"},
{"in the user tail", "Роль.\n---USER---\n{{chapter_title}}\n{{text}}", "{{chapter_title}}"},
{"in the few-shot block", "Роль.\n---FEWSHOT---\nПример: {{chapter_title}}\n---USER---\n{{text}}", "{{chapter_title}}"},
// The unterminated class, which the renderer refuses and the load gate must refuse FIRST — same
// reason, same moment. Planted separately, removing it also survived everything.
{"unterminated in the system part", "Роль {{chapter_title.\n---USER---\n{{text}}", "unterminated"},
{"unterminated in the user tail", "Роль.\n---USER---\n{{text}} и {{хвост", "unterminated"},
} {
t.Run(c.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.md")
if err := os.WriteFile(path, []byte(c.tpl), 0o644); err != nil {
t.Fatal(err)
}
_, err := LoadPromptTemplate(path)
if err == nil {
t.Fatalf("a marker the engine cannot render must be refused at LOAD wherever it sits in the "+
"file — this one loaded clean and would have failed inside Render, after the wave: %q", c.tpl)
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("the refusal must name what is wrong (%q), got: %v", c.want, err)
}
})
}
// The control: a template using only real placeholders, in all three parts, must LOAD. Without it the
// assertions above would also pass on a gate that refused every file.
good := "Роль для {{title}}.\n---FEWSHOT---\nПример на {{target_lang}}\n---USER---\n{{text}}\n{{draft}}"
path := filepath.Join(t.TempDir(), "ok.md")
if err := os.WriteFile(path, []byte(good), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPromptTemplate(path); err != nil {
t.Fatalf("a template using only real placeholders must load, in every part of the file: %v", err)
}
}
// TestTheLoadGateAcceptsExactlyWhatTheRendererDoes is the direction that costs money, and it is asserted by
// CONSTRUCTION rather than by example. The old validator kept its own copy of the accepted set, so the two
// could disagree; the set-equality test that was supposed to catch that walked the RENDERER's table and
// could only ever prove renderer ⊆ validator. The expensive direction is the other one — a name the gate
// blesses and the renderer refuses loads clean and dies mid-run — and it was pinned against four literal
// words, so a fifth word (planted: «chapter») walked straight through.
//
// The check below cannot be satisfied by a list: it asks the gate about a name the renderer does not have,
// and the only way to pass is for the gate to consult the renderer.
func TestTheLoadGateAcceptsExactlyWhatTheRendererDoes(t *testing.T) {
for _, name := range []string{"chapter", "chapter_title", "genre", "book", "Text", "TEXT", "tex", "текст"} {
if err := CheckPlaceholders("{{" + name + "}}"); err == nil {
t.Errorf("{{%s}} is not a placeholder the renderer substitutes, and the load gate accepted it: "+
"the pack loads and the run dies inside Render, after the wave", name)
}
}
// And every name the renderer DOES substitute must load — the gate must not be strict by being wrong.
for name := range renderVals(RenderVars{Book: &config.Book{}}) {
if err := CheckPlaceholders("{{" + name + "}}"); err != nil {
t.Errorf("{{%s}} is substituted by the renderer and the load gate refused it: %v", name, err)
}
}
}