486 lines
21 KiB
Go
486 lines
21 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// writeTmp writes a file under a temp path and returns it.
|
|
func writeTmp(t *testing.T, path, body string) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// miniModels loads a minimal valid models.yaml with a single `fake` model.
|
|
func miniModels(t *testing.T) *Models {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
body := "prices_checked: " + time.Now().UTC().Format("2006-01-02") + `
|
|
default_model: fake
|
|
providers:
|
|
p: { kind: openai, base_url: http://x }
|
|
models:
|
|
fake: { provider: p, price: { input_per_m: 1, output_per_m: 2 } }
|
|
`
|
|
m, err := LoadModels(writeTmp(t, filepath.Join(dir, "models.yaml"), body))
|
|
if err != nil {
|
|
t.Fatalf("miniModels: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// prompt_pack_test.go pins the pair seam (D39 layer 2, L4-prompts-not-per-pair-zh-baked) at its
|
|
// pack-15 form: a stage's prompt is resolved by CONVENTION from the book's pair and the stage's role
|
|
// (prompts/<pair>/<role>.md), a pair with no pack fails LOUD at load naming the path it looked for,
|
|
// and prompt_override is the deliberate pair-agnostic exception.
|
|
|
|
// promptProject writes a config dir with a pair's prompt pack and returns (dir, promptsRoot).
|
|
func promptProject(t *testing.T, pairs ...string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, pair := range pairs {
|
|
pdir := filepath.Join(dir, "prompts", pair)
|
|
if err := os.MkdirAll(pdir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, role := range []string{"translator", "editor"} {
|
|
writeTmp(t, filepath.Join(pdir, role+".md"), "sys\n---USER---\n{{text}}")
|
|
}
|
|
}
|
|
// The pair layer points at this project's prompts dir (the repo default is ../../prompts).
|
|
pairsDir := filepath.Join(dir, "cfg", pairsDirName)
|
|
if err := os.MkdirAll(pairsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pair := range pairs {
|
|
writeTmp(t, filepath.Join(pairsDir, pair+".yaml"), "pair: "+pair+"\nprompts_root: ../../prompts\n")
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func draftOnlyConfig(t *testing.T, dir, extra string) string {
|
|
t.Helper()
|
|
cfgDir := filepath.Join(dir, "cfg")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return writeTmp(t, filepath.Join(cfgDir, "pipeline.yaml"), `
|
|
core: C1
|
|
stages:
|
|
- name: draft
|
|
role: translator
|
|
model: fake
|
|
prompt_version: v1
|
|
`+extra)
|
|
}
|
|
|
|
func TestPromptResolvesByPairAndRoleConvention(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru", "ja-ru")
|
|
cfg := draftOnlyConfig(t, dir, "")
|
|
p, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("zh-ru must load: %v", err)
|
|
}
|
|
want := filepath.Join(dir, "prompts", "zh-ru", "translator.md")
|
|
if p.Stages[0].PromptPath != want {
|
|
t.Fatalf("resolved prompt = %q, want %q (prompts/<pair>/<role>.md)", p.Stages[0].PromptPath, want)
|
|
}
|
|
// The SAME config resolves a DIFFERENT pair's conventions — the pair, not the config, selects them.
|
|
p2, err := LoadPipeline(cfg, miniModels(t), "ja-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("ja-ru must load (its pack exists): %v", err)
|
|
}
|
|
if p2.Stages[0].PromptPath != filepath.Join(dir, "prompts", "ja-ru", "translator.md") {
|
|
t.Fatalf("ja-ru resolved %q — a pair must get its OWN conventions", p2.Stages[0].PromptPath)
|
|
}
|
|
}
|
|
|
|
func TestPromptMissingPairPackFailsLoud(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
cfg := draftOnlyConfig(t, dir, "")
|
|
_, err := LoadPipeline(cfg, miniModels(t), "ja-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("a pair with no prompt pack must FAIL LOUD (the latent ja-runs-zh-prompt bug)")
|
|
}
|
|
// The error must name the missing pair, the role AND the path it looked for (operator-actionable).
|
|
for _, want := range []string{"ja-ru", "translator", filepath.Join("prompts", "ja-ru", "translator.md")} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("fail-loud error must mention %q, got: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPromptOverrideIsPairAgnostic(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
own := writeTmp(t, filepath.Join(dir, "own.md"), "sys\n---USER---\n{{text}}")
|
|
cfg := draftOnlyConfig(t, dir, " prompt_override: "+own+"\n")
|
|
for _, pair := range []string{"zh-ru", "ja-ru", "en-ru"} {
|
|
p, err := LoadPipeline(cfg, miniModels(t), pair, nil)
|
|
if err != nil {
|
|
t.Fatalf("prompt_override must resolve for %q: %v", pair, err)
|
|
}
|
|
if p.Stages[0].PromptPath != own {
|
|
t.Errorf("pair %q: override resolved to %q, want %q", pair, p.Stages[0].PromptPath, own)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPromptOverrideMustBeReadable(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
cfg := draftOnlyConfig(t, dir, " prompt_override: nope.md\n")
|
|
if _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil); err == nil || !strings.Contains(err.Error(), "not readable") {
|
|
t.Errorf("an unreadable prompt_override must be rejected, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestBoevoyPipelineC1ResolvesZhRu proves the migrated prod config resolves its prompts BY PAIR for zh→ru,
|
|
// and that a pair with no pack fails loud.
|
|
//
|
|
// ⚠ The draft stage is the ONE deliberate exception to the role convention and has been since backlog row
|
|
// 140 turned the bank contour on: the banknote channel is two coordinated changes, and the second is the
|
|
// prompt that instructs the model to emit the ⟦TM-BANK-v1⟧ block (D39.10). It is declared with
|
|
// `prompt_override`, which is exactly what the convention keeps that key for. The assertion follows the
|
|
// override rather than being dropped, so an override that appears somewhere nobody meant it still reds.
|
|
func TestBoevoyPipelineC1ResolvesZhRu(t *testing.T) {
|
|
models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("load models: %v", err)
|
|
}
|
|
cfg := filepath.Join("..", "..", "configs", "pipeline-c1.yaml")
|
|
p, err := LoadPipeline(cfg, models, "zh-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("load pipeline-c1: %v", err)
|
|
}
|
|
overrides := map[string]string{"draft": "translator-banknote.md"}
|
|
for _, st := range p.Stages {
|
|
file := st.Role + ".md"
|
|
if o, ok := overrides[st.Name]; ok {
|
|
file = o
|
|
}
|
|
want := filepath.Join("prompts", "zh-ru", file)
|
|
if !strings.HasSuffix(st.PromptPath, want) {
|
|
t.Errorf("stage %q resolved %q, want a path ending in %q", st.Name, st.PromptPath, want)
|
|
}
|
|
if _, err := os.Stat(st.PromptPath); err != nil {
|
|
t.Errorf("stage %q: resolved prompt %q must exist: %v", st.Name, st.PromptPath, err)
|
|
}
|
|
}
|
|
// A book of a pair the repo does not carry must fail loud (closes the ja-runs-zh-prompt bug).
|
|
if _, err := LoadPipeline(cfg, models, "ja-ru", nil); err == nil {
|
|
t.Error("pipeline-c1 must fail loud for ja-ru (only prompts/zh-ru exists)")
|
|
}
|
|
}
|
|
|
|
// TestPairLayerCarriesCalibration pins the pack-15 config split: the SEGMENTATION calibration and the
|
|
// excision corridor come from configs/pairs/<pair>.yaml, not from the run config, and a run config
|
|
// that sets the block anyway keeps its deliberate override.
|
|
func TestPairLayerCarriesCalibration(t *testing.T) {
|
|
models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("load models: %v", err)
|
|
}
|
|
p, err := LoadPipeline(filepath.Join("..", "..", "configs", "pipeline-c1.yaml"), models, "zh-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("load pipeline-c1: %v", err)
|
|
}
|
|
if p.Segmentation.DraftBudgetOut != 1797 || p.Segmentation.EditCeilingOut != 3200 {
|
|
t.Errorf("zh-ru budgets = %d/%d, want 1797/3200 from the pair layer",
|
|
p.Segmentation.DraftBudgetOut, p.Segmentation.EditCeilingOut)
|
|
}
|
|
if p.Segmentation.Fertility.CJK != 1.1978 || p.Segmentation.Fertility.Other != 0.3852 {
|
|
t.Errorf("zh-ru fertility = %v/%v, want 1.1978/0.3852 from the pair layer",
|
|
p.Segmentation.Fertility.CJK, p.Segmentation.Fertility.Other)
|
|
}
|
|
if got := p.Gates.Coverage.LenRatio["zh-ru"]; len(got) != 2 || got[0] != 2.2 || got[1] != 4.2 {
|
|
t.Errorf("zh-ru corridor = %v, want [2.2 4.2] from the pair layer", got)
|
|
}
|
|
// A pair whose file carries only a corridor keeps the generic segmentation fallback.
|
|
ja, err := LoadPipeline(filepath.Join("..", "..", "configs", "pipeline-c1.yaml"), models, "ja-ru", nil)
|
|
if err == nil {
|
|
if got := ja.Gates.Coverage.LenRatio["ja-ru"]; len(got) != 2 || got[0] != 1.4 {
|
|
t.Errorf("ja-ru corridor = %v, want [1.4 2.6]", got)
|
|
}
|
|
} // (a ja-ru load fails on the missing prompt pack — asserted above; the corridor check is best-effort)
|
|
}
|
|
|
|
func TestPairConfigMismatchFailsLoud(t *testing.T) {
|
|
dir := t.TempDir()
|
|
pairsDir := filepath.Join(dir, pairsDirName)
|
|
if err := os.MkdirAll(pairsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTmp(t, filepath.Join(pairsDir, "ja-ru.yaml"), "pair: zh-ru\n")
|
|
if _, err := LoadPair(dir, "ja-ru"); err == nil || !strings.Contains(err.Error(), "zh-ru") {
|
|
t.Errorf("a pair file whose `pair` disagrees with its name must fail loud, got: %v", err)
|
|
}
|
|
// A missing pair file is NOT an error — the layer is optional (generic fallbacks apply).
|
|
if pc, err := LoadPair(dir, "en-ru"); err != nil || pc != nil {
|
|
t.Errorf("a missing pair file must be (nil, nil), got %v, %v", pc, err)
|
|
}
|
|
}
|
|
|
|
// TestEveryShippingConfigLoadsUnderThePairLayer is the pack-15 acceptance check by EXECUTION: every
|
|
// config the stand actually runs (c1, the reserve/candidate arms, the c2 skeleton) must still load for
|
|
// the prod pair after the calibration and the prompt paths moved out of them — and must resolve to the
|
|
// SAME effective numbers and prompt files they carried inline before the split.
|
|
func TestEveryShippingConfigLoadsUnderThePairLayer(t *testing.T) {
|
|
models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("load models: %v", err)
|
|
}
|
|
for _, name := range []string{
|
|
"pipeline-c1.yaml", "pipeline-c2.yaml",
|
|
"pipeline-arm-deepseek-pro.yaml", "pipeline-arm-glm.yaml", "pipeline-arm-mistral.yaml",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
p, err := LoadPipeline(filepath.Join("..", "..", "configs", name), models, "zh-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if p.Segmentation.DraftBudgetOut != 1797 || p.Segmentation.EditCeilingOut != 3200 ||
|
|
p.Segmentation.Fertility.CJK != 1.1978 || p.Segmentation.Fertility.Other != 0.3852 {
|
|
t.Errorf("segmentation = %+v, want the zh-ru pair calibration 1797/3200/1.1978/0.3852", p.Segmentation)
|
|
}
|
|
if got := p.Gates.Coverage.LenRatio["zh-ru"]; len(got) != 2 || got[0] != 2.2 || got[1] != 4.2 {
|
|
t.Errorf("zh-ru corridor = %v, want [2.2 4.2]", got)
|
|
}
|
|
for _, st := range p.Stages {
|
|
if _, err := os.Stat(st.PromptPath); err != nil {
|
|
t.Errorf("stage %q: resolved prompt %q must exist: %v", st.Name, st.PromptPath, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRetiredPromptKeysAreRejected pins the pack-15 delta guard: yaml.v3 ignores unknown fields, so a
|
|
// config still carrying the retired `prompt:` / `prompts:` keys would parse to an EMPTY override and the
|
|
// convention would then resolve the role's BASE prompt — a fresh book or an arm silently running a
|
|
// prompt nobody chose (the D37 class). Both keys must stop the load with the migration named.
|
|
func TestRetiredPromptKeysAreRejected(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
for _, tc := range []struct{ name, extra, want string }{
|
|
{"legacy single prompt", " prompt: prompts/zh-ru/translator.md\n", "`prompt:` is retired"},
|
|
{"legacy pair-keyed map", " prompts: { zh-ru: prompts/zh-ru/translator.md }\n", "`prompts:` map is retired"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := draftOnlyConfig(t, dir, tc.extra)
|
|
_, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("a retired prompt key must fail loud (it is silently ignored otherwise, and the convention resolves the base prompt)")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("error must name the retired key, got: %v", err)
|
|
}
|
|
// The migration must be actionable: name both the convention and the override.
|
|
for _, hint := range []string{"<pair>/<role>.md", "prompt_override"} {
|
|
if !strings.Contains(err.Error(), hint) {
|
|
t.Errorf("error must point at %q, got: %v", hint, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDefaultPromptsRootMatchesPairFileLayout pins delta item (3): with NO pair file the convention root
|
|
// must resolve to the same directory a pair file's default would name, so the "expected …" path in the
|
|
// fail-loud is the path an operator should actually create.
|
|
func TestDefaultPromptsRootMatchesPairFileLayout(t *testing.T) {
|
|
dir := t.TempDir() // a config dir with NO pairs/ directory at all
|
|
cfgDir := filepath.Join(dir, "configs")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := writeTmp(t, filepath.Join(cfgDir, "pipeline.yaml"), `
|
|
core: C1
|
|
stages:
|
|
- name: draft
|
|
role: translator
|
|
model: fake
|
|
prompt_version: v1
|
|
`)
|
|
_, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("no prompt pack anywhere must fail loud")
|
|
}
|
|
// <config dir>/pairs/<pair>.yaml + ../../prompts ⇒ <dir>/prompts/zh-ru/translator.md
|
|
want := filepath.Join(dir, "prompts", "zh-ru", "translator.md")
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("expected path must be %q (the documented layout), got: %v", want, err)
|
|
}
|
|
}
|
|
|
|
// TestUnknownConfigKeyIsRejected pins the other half of the silent-substitution class: yaml.v3 drops
|
|
// unknown fields, so a TYPO reads as "not set". `promt_override:` would leave the stage on the
|
|
// convention (silently running the role's BASE prompt — an arm experiment measuring the wrong thing),
|
|
// `segmantation:` would leave the pair calibration untouched. Strict decoding makes both loud.
|
|
func TestUnknownConfigKeyIsRejected(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
for _, tc := range []struct{ name, extra string }{
|
|
{"typo'd override key", " promt_override: whatever.md\n"},
|
|
{"typo'd top-level block", "\nsegmantation:\n draft_budget_out: 999\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := draftOnlyConfig(t, dir, tc.extra)
|
|
_, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("an unknown config key must fail loud — silently ignored, it is a behaviour change nobody chose")
|
|
}
|
|
if !strings.Contains(err.Error(), "field") {
|
|
t.Errorf("error should name the offending field, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnknownKeyErrorNamesTheKey tightens the assertion pack-15 left loose: it is not enough that a typo
|
|
// FAILS — the message must name the offending key, or an operator staring at a 200-line config learns only
|
|
// that "something" is wrong. yaml.v3 renders the key in its "field X not found" text; this pins it.
|
|
func TestUnknownKeyErrorNamesTheKey(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
for _, tc := range []struct{ name, extra, key string }{
|
|
{"stage key", " promt_override: whatever.md\n", "promt_override"},
|
|
{"top-level block", "\nsegmantation:\n draft_budget_out: 999\n", "segmantation"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := draftOnlyConfig(t, dir, tc.extra)
|
|
_, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("an unknown config key must fail loud")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.key) {
|
|
t.Fatalf("the error must NAME the offending key %q, got: %v", tc.key, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStrictDecodeRejectsUnknownKeysInBookAndPair closes the other half of the silent-substitution class
|
|
// (pack-16, sanctioned after a stand audit found zero unknown keys anywhere): a mistyped book key such as
|
|
// `langpack_extned:` used to leave the book's private canon quietly absent, and a mistyped pair key left the
|
|
// calibration silently not applied.
|
|
func TestStrictDecodeRejectsUnknownKeysInBookAndPair(t *testing.T) {
|
|
dir := t.TempDir()
|
|
book := filepath.Join(dir, "book.yaml")
|
|
const base = `book_id: b
|
|
title: T
|
|
source_lang: zh
|
|
target_lang: ru
|
|
pipeline: pipeline.yaml
|
|
models: models.yaml
|
|
source_file: source.txt
|
|
ceilings: { book_usd: 1.0 }
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("текст"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(book, []byte(base+"langpack_extned: /tmp/nope\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadBook(book); err == nil {
|
|
t.Fatal("a mistyped book key must fail loud (it silently disabled the book overlay before)")
|
|
} else if !strings.Contains(err.Error(), "langpack_extned") {
|
|
t.Fatalf("the error must name the offending key, got: %v", err)
|
|
}
|
|
// A CLEAN book must still load — strictness may not cost a legitimate config.
|
|
if err := os.WriteFile(book, []byte(base), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadBook(book); err != nil {
|
|
t.Fatalf("a clean book.yaml must still load: %v", err)
|
|
}
|
|
|
|
pairDir := filepath.Join(dir, "pairs")
|
|
if err := os.MkdirAll(pairDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(pairDir, "zh-ru.yaml"), []byte("pair: zh-ru\nsegmentaion:\n draft_budget_out: 1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := LoadPair(dir, "zh-ru"); err == nil {
|
|
t.Fatal("a mistyped pair key must fail loud")
|
|
} else if !strings.Contains(err.Error(), "segmentaion") {
|
|
t.Fatalf("the error must name the offending key, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRepairGateValidation pins the load-time contract of the repair gate (pack-16): an enabled gate that
|
|
// cannot ever fire is a config error, not a silent no-op, and the repair model may not sit on a provider
|
|
// whose reasoning bills additively (this block has no reasoning buffer to reserve).
|
|
func TestRepairGateValidation(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
cases := []struct{ name, gates, want string }{
|
|
{"zero budget", "\ngates:\n repair:\n enabled: true\n model: mini\n max_calls_per_unit: 1\n budget_usd: 0\n", "budget_usd"},
|
|
{"zero call cap", "\ngates:\n repair:\n enabled: true\n model: mini\n max_calls_per_unit: 0\n budget_usd: 1\n", "max_calls_per_unit"},
|
|
{"unknown model", "\ngates:\n repair:\n enabled: true\n model: nope\n max_calls_per_unit: 1\n budget_usd: 1\n", "not defined in models.yaml"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
cfg := draftOnlyConfig(t, dir, c.gates)
|
|
_, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("an unusable repair gate must fail loud")
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Fatalf("error must explain %q, got: %v", c.want, err)
|
|
}
|
|
})
|
|
}
|
|
// A DISABLED gate is never validated — the default config path must stay silent.
|
|
if _, err := LoadPipeline(draftOnlyConfig(t, dir, "\ngates:\n repair:\n enabled: false\n"), miniModels(t), "zh-ru", nil); err != nil {
|
|
t.Fatalf("a disabled repair gate must not be validated: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestTerminologyGateValidatesItsTargetScript pins the load-time contract of the answer-language screen:
|
|
// an enabled gate must declare the target language's script, and the name must be one Unicode defines.
|
|
// The failure it guards is silent at runtime, so it has to be loud before any money moves.
|
|
func TestTerminologyGateValidatesItsTargetScript(t *testing.T) {
|
|
dir := promptProject(t, "zh-ru")
|
|
if err := os.WriteFile(filepath.Join(dir, "prompts", "zh-ru", "terminologist.md"), []byte("role\n---USER---\n{{text}}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// `mining.contrast_path` rides every case: an enabled gate with no contrast artifact is refused on its
|
|
// own (backlog row 140 — both bank roles live inside the bank-mining stop), and these cases are about
|
|
// the TARGET SCRIPT, not about that.
|
|
gate := func(extra string) string {
|
|
return "\nmining:\n contrast_path: contrast.txt\ngates:\n terminology:\n enabled: true\n model: fake\n budget_usd: 1\n" + extra
|
|
}
|
|
cases := []struct{ name, gates, want string }{
|
|
{"missing script", gate(""), "target_script is required"},
|
|
{"unknown script", gate(" target_script: Cyrilic\n"), "not a Unicode script name"},
|
|
{"case must be exact", gate(" target_script: cyrillic\n"), "not a Unicode script name"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := LoadPipeline(draftOnlyConfig(t, dir, c.gates), miniModels(t), "zh-ru", nil)
|
|
if err == nil {
|
|
t.Fatal("a terminology gate that cannot screen the answer language must fail loud")
|
|
}
|
|
if !strings.Contains(err.Error(), c.want) {
|
|
t.Fatalf("error must explain %q, got: %v", c.want, err)
|
|
}
|
|
})
|
|
}
|
|
p, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Cyrillic\n")), miniModels(t), "zh-ru", nil)
|
|
if err != nil {
|
|
t.Fatalf("a declared script must load: %v", err)
|
|
}
|
|
if p.Gates.Terminology.TargetScript != "Cyrillic" {
|
|
t.Fatalf("the declaration must reach the gate, got %q", p.Gates.Terminology.TargetScript)
|
|
}
|
|
// A pair that is not in the repo declares another script the same way — no Go edit involved.
|
|
if _, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Hiragana\n")), miniModels(t), "zh-ru", nil); err != nil {
|
|
t.Fatalf("any Unicode script must be declarable: %v", err)
|
|
}
|
|
// A DISABLED gate is never validated.
|
|
if _, err := LoadPipeline(draftOnlyConfig(t, dir, "\ngates:\n terminology:\n enabled: false\n"), miniModels(t), "zh-ru", nil); err != nil {
|
|
t.Fatalf("a disabled terminology gate must not be validated: %v", err)
|
|
}
|
|
}
|