mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
前几版 harness 先后报过「4 个变异存活」「11 个一个没跑」「9 个存活」, 查清后**全部是判据或变异本身失效,没有一个是代码问题**。这一版把三类 失效都变成显式失败,而不是静默给出错误结论。 **防线一:先证明 filter 选中了东西** `go test -run <完全不存在>` **不打印** `[no tests to run]`(那句话只在 「包内有其他匹配项但目标没跑」时出现)。我原先据此写的检查方向是反的, harness 第一步就自我否决。现在改成对比数字:空 filter 必须选中 0 个测试, 真实 filter 必须选中 >= 25 个。本次运行:32 / 0。 **防线二:编译失败 ≠ 变异被杀** 嵌套调用漏 `-tags luajit` ⇒ 纯 Lua 5.1 ⇒ billing.lua 的 goto 编译失败 ⇒ 整包 build failed ⇒ **每个变异都「被杀」,给出满分**。这比假绿更隐蔽。 现在 [build failed] 直接 t.Fatalf。 **防线三:变异没改变行为 ≠ 判据漏放** 两次「存活」查清后都是**恒等变换**: `&& out.BaseURL != ""` — 判据用零值结构体,BaseURL 为空,条件恒假 `&& len(out.BaseURL) >= 0` — len 恒 >= 0,条件恒真 正确写法是把赋值换成 `_ =`:读了变量让编译器满意,行为真的变了。 检测方式:变异后若结果行(剥掉测试名、排序后)与 baseline 完全一致, 报 no-op 而不是记成存活。 no-op 检测本身也修过:原来保留 === RUN 行(含测试名),两次运行测试顺序 不同 ⇒ 每个变异都「看起来变了行为」,检测形同虚设。 11 个变异覆盖:kind 判定、metadata 透传、非目录拒绝、URL 回退、 版本段搬移(chat 与 image 两条)、版本段识别、nil 切片、鉴权分类、 候选去重。 ★ 判据的期望值不写死数字:候选去重修好后 len(cands) 从 3 变 2, 连改两次判据都是在追当前列表。改成断言「该覆盖的 URL 都在」。
172 lines
6.6 KiB
Go
172 lines
6.6 KiB
Go
package gateway
|
|
|
|
// Mutation harness for the discovery code. Kept in the tree (not in a shell
|
|
// script) so the -run filter is a Go string literal rather than something the
|
|
// shell can mangle into "[no tests to run]" — which is exactly how an earlier
|
|
// version of this harness reported four surviving mutants that were simply
|
|
// never executed.
|
|
//
|
|
// Each mutant flips one behaviour; TestMutationHarness_Fails proves the
|
|
// harness itself is armed.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// discoveryTestFilter must cover every assertion that guards the behaviour
|
|
// below. Narrowing it is how mutants get to "survive" for free.
|
|
const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)"
|
|
|
|
func TestDiscoveryFilterRunsRealTests(t *testing.T) {
|
|
if discoveryTestFilter == "" {
|
|
t.Fatal("filter is empty; every mutant would survive trivially")
|
|
}
|
|
// The whole point: the catalog parser test must be inside the filter, or a
|
|
// mutant that stops reading nested metadata survives with a green run.
|
|
for _, group := range []string{
|
|
"ParseModelCatalog", // nested metadata + non-catalog rejection
|
|
"DiscoveredModelKind", // kind must follow upstream metadata
|
|
"Discover", // URL fallback + endpoint composition
|
|
"ComposeEndpoint", // version segment exactly once
|
|
"IsVersionSegment", // "v1beta" vs "vision"
|
|
} {
|
|
if !strings.Contains(discoveryTestFilter, group) {
|
|
t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMutantsAreDetectable is the harness's own regression guard: it runs the
|
|
// filter's worth of assertions against deliberately broken inputs and requires
|
|
// each to be rejected. If a future edit makes one of these pass, the
|
|
// corresponding protection is gone.
|
|
func TestMutantsAreDetectable(t *testing.T) {
|
|
t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) {
|
|
// The mutant: drop the output-modality check, so every model is chat.
|
|
m := upstreamModel{ID: "gpt-image-2"}
|
|
m.Architecture.OutputModalities = []string{"image"}
|
|
if got := discoveredModelKind(m); got != "image" {
|
|
t.Errorf("mutant survived: got %q, want \"image\"", got)
|
|
}
|
|
})
|
|
|
|
t.Run("nested metadata is read", func(t *testing.T) {
|
|
var ms []upstreamModel
|
|
if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil {
|
|
t.Fatal("fixture must be a wrapper object, not a bare array")
|
|
}
|
|
var wrapper struct {
|
|
Data []upstreamModel `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wrapper.Data) == 0 {
|
|
t.Fatal("fixture parsed to zero models")
|
|
}
|
|
// The mutant: skip copyFields(m.Metadata) entirely.
|
|
meta := modelMeta(wrapper.Data[0])
|
|
if meta == nil {
|
|
t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost")
|
|
}
|
|
for _, k := range []string{"context_limit", "cost", "context_budget"} {
|
|
if _, ok := meta[k]; !ok {
|
|
t.Errorf("mutant survived: %s dropped from nested metadata", k)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("non-catalog responses are rejected", func(t *testing.T) {
|
|
for _, body := range []string{`<html>login</html>`, `null`, `{"error":{"message":"x"}}`, `{}`} {
|
|
if _, err := parseModelCatalog([]byte(body)); err == nil {
|
|
t.Errorf("mutant survived: %q accepted as a catalog", body)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("version segments are distinguished from real path segments", func(t *testing.T) {
|
|
if !isVersionSegment("v1beta") {
|
|
t.Error("mutant survived: v1beta not treated as a version")
|
|
}
|
|
for _, seg := range []string{"vision", "vertex", "v1x"} {
|
|
if isVersionSegment(seg) {
|
|
t.Errorf("mutant survived: %q wrongly treated as a version", seg)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) {
|
|
// The mutant: never move the segment onto the endpoint.
|
|
if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" {
|
|
t.Errorf("mutant survived: endpoint = %q", got)
|
|
}
|
|
// The other mutant: always move it, even when the base keeps it.
|
|
if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" {
|
|
t.Errorf("mutant survived: endpoint = %q would double /v1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("image_models is never nil", func(t *testing.T) {
|
|
res := &discoveryResult{Models: []string{"a"}, ImageModels: nil}
|
|
b, _ := json.Marshal(res)
|
|
if strings.Contains(string(b), `"image_models":null`) {
|
|
t.Errorf("mutant survived: %s", b)
|
|
}
|
|
})
|
|
|
|
t.Run("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
|
|
// The mutant: collapse every failure into one generic error, which is
|
|
// what made a typo'd key look like the vendor being down.
|
|
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
|
|
t.Error("mutant survived: 401 is not classified")
|
|
}
|
|
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
|
|
t.Error("a 404 must not be reported as an auth problem")
|
|
}
|
|
if classifyAuthStatus(http.StatusOK, nil) != "" {
|
|
t.Error("a 200 must not be reported as an auth problem")
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for _, c := range discoveryCandidates("https://h/v1") {
|
|
u := c.baseURL + c.modelsPath
|
|
if seen[u] {
|
|
t.Errorf("mutant survived: %s requested twice", u)
|
|
}
|
|
seen[u] = true
|
|
}
|
|
})
|
|
|
|
t.Run("discovery falls back across URL forms", func(t *testing.T) {
|
|
// The coverage requirement is that BOTH catalog paths are reachable for
|
|
// a typed base, and both for a bare host. Asserting an exact count kept
|
|
// drifting as the candidate list was fixed, and each time it measured
|
|
// the current list rather than the property that matters.
|
|
for _, base := range []string{"https://h/v1", "https://h"} {
|
|
distinct := map[string]bool{}
|
|
for _, c := range discoveryCandidates(base) {
|
|
distinct[c.baseURL+c.modelsPath] = true
|
|
}
|
|
for _, want := range []string{base + "/v1/models", base + "/models"} {
|
|
if !distinct[want] {
|
|
t.Errorf("mutant survived: %s never requests %s", base, want)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("only the catalog is fetched, never each model", func(t *testing.T) {
|
|
// Guarding a NON-mutation: the design decision that discovery must not
|
|
// spend money. If a future change adds per-model probes, the counting
|
|
// test below catches it; this assertion documents the intent.
|
|
if discoveryModelsPath != "/v1/models" {
|
|
t.Errorf("catalog path changed to %q", discoveryModelsPath)
|
|
}
|
|
})
|
|
}
|