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