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 Веха 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) } } } 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 не должен падать на облачном ключе. 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) } // Пайплайн с облачной моделью без ключа — 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) } // Ключ задан — проходит. t.Setenv("TEST_CLOUD_KEY", "sk-xxx") if err := m.CheckKeys(cloudPipe); err != nil { t.Fatalf("present key must pass: %v", err) } // Эскалационные ключи НЕ требуются, пока эскалация выключена (budget_usd=0, // opt-in). Включённый coverage-гейт НЕ делает эскалацию достижимой — гейт // моделей не зовёт (self-review: иначе включение гейта блокирует валидный // deepseek+glm прогон из-за отсутствующего Kimi-ключа заглушки-цепочки). 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) } // При budget_usd>0 эскалация достижима → ключ цепочки обязателен. 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") } }