textmachine/backend/internal/config/config_test.go

91 lines
4 KiB
Go

package config
import (
"strings"
"testing"
)
func TestCheckRunnableRejectsPhase2Mechanics(t *testing.T) {
cases := []struct {
name string
p Pipeline
want string // substring the error must mention
}{
{"c1 ok", Pipeline{Core: "C1", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""},
{"c0 ok", Pipeline{Core: "C0", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""},
{"c2 rejected", Pipeline{Core: "C2", Stages: []Stage{{Name: "draft", Role: "translator"}}}, "C2"},
{"fanout rejected", Pipeline{Core: "C1", Fanout: Fanout{Candidates: 3}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, "fanout"},
{"judge rejected", Pipeline{Core: "C1", Stages: []Stage{{Name: "select", Role: "judge"}}}, "judge"},
// The coverage gate is EXECUTABLE from Milestone 2.5 — CheckRunnable no longer
// rejects it (threshold sanity is LoadPipeline's job, not a mechanics gate).
{"enabled gate runnable", Pipeline{Core: "C1", Gates: Gates{Coverage: CoverageGate{Enabled: true}}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""},
}
for _, c := range cases {
err := c.p.CheckRunnable()
if c.want == "" {
if err != nil {
t.Errorf("%s: expected runnable, got %v", c.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: expected error mentioning %q, got %v", c.name, c.want, err)
}
}
}
// (TestCheckAdultChannel — the D20.2-Q3 "adult:true needs a channel:adult stage" lint — was DELETED
// with the mechanism it covered: the engine no longer knows a content type, and its successor invariant
// is stronger and generic (every model a labelled run can call must accept every label of the book).
// Its replacement lives in content_labels_test.go, exercised on a SYNTHETIC label value.)
func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) {
m := &Models{
Providers: map[string]Provider{
"cloud": {Kind: "openai", BaseURL: "http://x", APIKeyEnv: "TEST_CLOUD_KEY"},
"local": {Kind: "local", BaseURL: "http://127.0.0.1", Model: "q"},
},
Models: map[string]Model{
"cloud-model": {Provider: "cloud"},
"local-model": {Provider: "local"},
"unused-cloud": {Provider: "cloud"},
},
}
// local-only pipeline must not fail on a cloud key.
localPipe := &Pipeline{Stages: []Stage{{Model: "local-model"}}}
if err := m.CheckKeys(localPipe); err != nil {
t.Fatalf("local-only pipeline must not require cloud keys: %v", err)
}
// Pipeline with a cloud model but no key — fail-fast.
t.Setenv("TEST_CLOUD_KEY", "")
cloudPipe := &Pipeline{Stages: []Stage{{Model: "cloud-model"}}}
if err := m.CheckKeys(cloudPipe); err == nil || !strings.Contains(err.Error(), "TEST_CLOUD_KEY") {
t.Fatalf("missing cloud key must fail-fast mentioning the env var, got %v", err)
}
// Key is set — passes.
t.Setenv("TEST_CLOUD_KEY", "sk-xxx")
if err := m.CheckKeys(cloudPipe); err != nil {
t.Fatalf("present key must pass: %v", err)
}
// Escalation keys are NOT required while escalation is off (budget_usd=0,
// opt-in). An enabled coverage gate does NOT make escalation reachable — the gate
// does not call models (self-review: otherwise enabling the gate would block a valid
// deepseek+glm run because of the stub chain's missing Kimi key).
t.Setenv("TEST_CLOUD_KEY", "")
escOffPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
Gates: Gates{Coverage: CoverageGate{Enabled: true}}, // gate ON, budget 0
}
if err := m.CheckKeys(escOffPipe); err != nil {
t.Fatalf("enabling the coverage gate must NOT require escalation keys at budget 0: %v", err)
}
// At budget_usd>0 escalation is reachable → the chain key is required.
escOnPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}, BudgetUSD: 1.0},
}
if err := m.CheckKeys(escOnPipe); err == nil {
t.Fatal("escalation chain model without a key must fail-fast when budget_usd>0")
}
}