type(relay):修正测试代码里面的函数名为纯英文
This commit is contained in:
1 parent
96bcd3e46f
commit
958f6c4d75
15 files changed
+185
-185
No files matched your search
@@ -786,7 +786,7 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error {
|
||||
|
||||
**原因**:`g.Redis().Do(ctx, "HMGET", key, "f1", "f2")` 对**不存在的 key/字段**,经 gf redis contrib 驱动转换后返回的是 `[]string{"", ""}`(空字符串),`.Vars()` 后每个元素 **`IsNil()==false`**、`Float64()==0`。`ReadRuntime` 用 `!vals[0].IsNil()` 判「有数据才覆盖乐观默认值 1」,判断永远为真 → 默认值被 0 覆盖。调度打分侧 `healthFactor` 恰好有 `succ<=0 && lat==0 视为满分` 的兜底所以转发不受影响,但快照聚合没有兜底,展示值被拖垮。`readBreaker` 同模式(空串 state 恰好 Int()==0==CLOSED 侥幸正确)。
|
||||
|
||||
**修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `Test缺失健康键读默认值`。
|
||||
**修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `TestMissingHealthKey_ReadsDefault`。
|
||||
|
||||
**正确做法(通用规则)**:判断 gf redis `HMGET`/批量读的字段是否存在,**不要依赖 `IsNil()`**(驱动转换后 nil 会变空字符串);用 `String() != ""` 判断,或改用 Lua 脚本内 `HGET ... or '默认值'` 在 Redis 侧兜底。单 key `HGET` 缺失返回的是真 nil(`IsNil()==true`),两者行为不一致,混用时尤其注意。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func testCatalog(rows []catalogRow, keys map[int64][]int64, runtime runtimeReade
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCatalog_快照构建与范围过滤(t *testing.T) {
|
||||
func TestCatalog_SnapshotBuildAndScopeFilter(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 0.8},
|
||||
{ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary", CostRatio: 1.0},
|
||||
@@ -50,7 +50,7 @@ func TestCatalog_快照构建与范围过滤(t *testing.T) {
|
||||
assert.Empty(t, c.Snapshot(context.Background(), 0, "unknown", nil))
|
||||
}
|
||||
|
||||
func TestCatalog_转发元数据(t *testing.T) {
|
||||
func TestCatalog_ForwardMeta(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 2, ChannelName: "B", ChannelType: 3, BaseURL: "https://up.example.com",
|
||||
ModelName: "claude-sonnet", UpstreamModel: "claude-sonnet-4", Weight: 5, Tier: "secondary",
|
||||
@@ -76,7 +76,7 @@ func TestCatalog_转发元数据(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestCatalog_运行时读值合并(t *testing.T) {
|
||||
func TestCatalog_RuntimeReadoutMerge(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", MaxConcurrency: 50},
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestCatalog_运行时读值合并(t *testing.T) {
|
||||
assert.Equal(t, dispatch.BreakerHalfOpen, got[0].Breaker)
|
||||
}
|
||||
|
||||
func TestCatalog_严格容量查询(t *testing.T) {
|
||||
func TestCatalog_StrictCapacityLookup(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", StrictCapacity: true, MaxConcurrency: 30},
|
||||
{ChannelID: 2, ModelName: "m", Weight: 10, Tier: "primary"},
|
||||
@@ -111,7 +111,7 @@ func TestCatalog_严格容量查询(t *testing.T) {
|
||||
assert.False(t, strict)
|
||||
}
|
||||
|
||||
func TestCatalog_加载失败保留上一份快照(t *testing.T) {
|
||||
func TestCatalog_LoadFailureKeepsPreviousSnapshot(t *testing.T) {
|
||||
pol := dispatch.DefaultRoutingPolicy()
|
||||
calls := 0
|
||||
c := NewCatalog(
|
||||
@@ -133,10 +133,10 @@ func TestCatalog_加载失败保留上一份快照(t *testing.T) {
|
||||
assert.Len(t, c.Snapshot(ctx, 0, "m", nil), 1, "last-known 快照必须保留")
|
||||
}
|
||||
|
||||
// TestCatalog_Redis降级沿用上一份健康读值 Redis 读失败时若让乐观默认值 succ=1 落进
|
||||
// TestCatalog_RedisDegradedCarriesForwardHealth Redis 读失败时若让乐观默认值 succ=1 落进
|
||||
// 新快照,一次 Redis 抖动就会把全渠道的健康记忆清零、healthFactor 集体回满分,
|
||||
// 与 Rebuild 声称的 last-known 语义相悖。
|
||||
func TestCatalog_Redis降级沿用上一份健康读值(t *testing.T) {
|
||||
func TestCatalog_RedisDegradedCarriesForwardHealth(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
|
||||
}
|
||||
@@ -160,9 +160,9 @@ func TestCatalog_Redis降级沿用上一份健康读值(t *testing.T) {
|
||||
assert.Equal(t, 4000.0, got[0].LatEwmaMs, "延迟读值同样沿用")
|
||||
}
|
||||
|
||||
// TestCatalog_首次刷新即降级无历史可沿用 新实例启动时 Redis 就不可用:没有 last-known
|
||||
// TestCatalog_DegradedOnFirstRefreshHasNoHistory 新实例启动时 Redis 就不可用:没有 last-known
|
||||
// 可沿用,保持乐观默认满分(不能让新实例启动即把全渠道判死)。
|
||||
func TestCatalog_首次刷新即降级无历史可沿用(t *testing.T) {
|
||||
func TestCatalog_DegradedOnFirstRefreshHasNoHistory(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
|
||||
}
|
||||
@@ -191,9 +191,9 @@ func TestRampElapsed(t *testing.T) {
|
||||
assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0))
|
||||
}
|
||||
|
||||
// TestCatalog_渠道运行状态聚合 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
|
||||
// TestCatalog_ChannelRuntimeStateAggregation 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
|
||||
// 模型级熔断按模型计数。
|
||||
func TestCatalog_渠道运行状态聚合(t *testing.T) {
|
||||
func TestCatalog_ChannelRuntimeStateAggregation(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"},
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"},
|
||||
@@ -227,16 +227,16 @@ func TestCatalog_渠道运行状态聚合(t *testing.T) {
|
||||
assert.Equal(t, 0, got[3].BreakerModels)
|
||||
}
|
||||
|
||||
// TestCatalog_运行状态_未Rebuild返回空 验证目录未构建时返回空 map(而非 nil),
|
||||
// TestCatalog_RuntimeState_EmptyBeforeRebuild 验证目录未构建时返回空 map(而非 nil),
|
||||
// 调用方遍历时安全。
|
||||
func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) {
|
||||
func TestCatalog_RuntimeState_EmptyBeforeRebuild(t *testing.T) {
|
||||
c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil)
|
||||
assert.Empty(t, c.ChannelRuntimeStates())
|
||||
}
|
||||
|
||||
// TestCatalog_最差模型_单模型故障不被均值摊薄 渠道健康分是均值,10 个健康模型 + 1 个
|
||||
// TestCatalog_WorstModel_SingleFailureNotDilutedByAverage 渠道健康分是均值,10 个健康模型 + 1 个
|
||||
// 全挂仍能算出 90 分绿灯;最差项要如实报出那个挂掉的模型。
|
||||
func TestCatalog_最差模型_单模型故障不被均值摊薄(t *testing.T) {
|
||||
func TestCatalog_WorstModel_SingleFailureNotDilutedByAverage(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-a", Weight: 10, Tier: "primary"},
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-b", Weight: 10, Tier: "primary"},
|
||||
@@ -256,9 +256,9 @@ func TestCatalog_最差模型_单模型故障不被均值摊薄(t *testing.T) {
|
||||
assert.InDelta(t, 1.0, got[1].WorstModelScore, 1e-6)
|
||||
}
|
||||
|
||||
// TestCatalog_最差模型_冷模型不被误报 无真实上报的模型读作满分 1.0,取最小值天然
|
||||
// TestCatalog_WorstModel_ColdModelNotReported 无真实上报的模型读作满分 1.0,取最小值天然
|
||||
// 不会把它选成最差项——这正是拖垮渠道均值的缺陷在 min 聚合下不存在的原因。
|
||||
func TestCatalog_最差模型_冷模型不被误报(t *testing.T) {
|
||||
func TestCatalog_WorstModel_ColdModelNotReported(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "cold", Weight: 10, Tier: "primary"},
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "warm", Weight: 10, Tier: "primary"},
|
||||
@@ -275,9 +275,9 @@ func TestCatalog_最差模型_冷模型不被误报(t *testing.T) {
|
||||
assert.InDelta(t, 64.0, got[1].WorstModelScore, 1e-6) // 0.8² × 100
|
||||
}
|
||||
|
||||
// TestCatalog_最差模型_同分时结果稳定 byModel 是 map,遍历顺序随机;同分不做确定性
|
||||
// TestCatalog_WorstModel_StableOnTiedScores byModel 是 map,遍历顺序随机;同分不做确定性
|
||||
// 打破平局的话,全部模型满分时每次请求返回的模型名都会跳变。
|
||||
func TestCatalog_最差模型_同分时结果稳定(t *testing.T) {
|
||||
func TestCatalog_WorstModel_StableOnTiedScores(t *testing.T) {
|
||||
rows := []catalogRow{
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "zeta", Weight: 10, Tier: "primary"},
|
||||
{ChannelID: 1, ChannelName: "A", ModelName: "alpha", Weight: 10, Tier: "primary"},
|
||||
|
||||
@@ -24,7 +24,7 @@ func staticReader(m map[string]RuntimeReadout) func(string) RuntimeReadout {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateChannelHealth_只统计有真实上报的模型(t *testing.T) {
|
||||
func TestAggregateChannelHealth_CountsOnlyModelsWithRealReports(t *testing.T) {
|
||||
// 20 个模型的渠道死掉 1 个:旧实现把 19 个无数据模型按满分计入均值,
|
||||
// avgSucc≈0.95 → 91 分绿灯,完全掩盖故障。
|
||||
models := make([]string, 0, 20)
|
||||
@@ -43,7 +43,7 @@ func TestAggregateChannelHealth_只统计有真实上报的模型(t *testing.T)
|
||||
assert.Contains(t, agg.Detail, "b=无数据")
|
||||
}
|
||||
|
||||
func TestAggregateChannelHealth_多模型均值(t *testing.T) {
|
||||
func TestAggregateChannelHealth_MultiModelAverage(t *testing.T) {
|
||||
agg := aggregateChannelHealth([]string{"m1", "m2", "m3"}, staticReader(map[string]RuntimeReadout{
|
||||
"m1": readoutOf(1.0, 100),
|
||||
"m2": readoutOf(0.6, 300),
|
||||
@@ -55,7 +55,7 @@ func TestAggregateChannelHealth_多模型均值(t *testing.T) {
|
||||
assert.InDelta(t, 200, agg.AvgLat, 1e-9)
|
||||
}
|
||||
|
||||
func TestAggregateChannelHealth_全部无数据不产生均值(t *testing.T) {
|
||||
func TestAggregateChannelHealth_NoAverageWhenAllModelsLackData(t *testing.T) {
|
||||
agg := aggregateChannelHealth([]string{"m1", "m2"}, staticReader(nil))
|
||||
|
||||
assert.False(t, agg.Degraded)
|
||||
@@ -63,7 +63,7 @@ func TestAggregateChannelHealth_全部无数据不产生均值(t *testing.T) {
|
||||
assert.Zero(t, agg.AvgSucc, "不得回落成默认满分 1.0 造成 100 分落盘")
|
||||
}
|
||||
|
||||
func TestAggregateChannelHealth_读失败整体降级(t *testing.T) {
|
||||
func TestAggregateChannelHealth_ReadFailureDegradesWhole(t *testing.T) {
|
||||
// Redis 故障时读值既非实测也非"确实无数据",必须整体作废而非按满分聚合
|
||||
agg := aggregateChannelHealth([]string{"m1", "m2", "m3"}, staticReader(map[string]RuntimeReadout{
|
||||
"m1": readoutOf(0.9, 100),
|
||||
@@ -75,7 +75,7 @@ func TestAggregateChannelHealth_读失败整体降级(t *testing.T) {
|
||||
assert.Zero(t, agg.Counted, "降级时不得给出任何聚合结论")
|
||||
}
|
||||
|
||||
func TestAggregateChannelHealth_首模型读值供自动禁用判定(t *testing.T) {
|
||||
func TestAggregateChannelHealth_FirstModelReadoutFeedsAutoDisable(t *testing.T) {
|
||||
first := readoutOf(0.5, 100)
|
||||
first.FirstOpenedMs = 12345
|
||||
agg := aggregateChannelHealth([]string{"m1", "m2"}, staticReader(map[string]RuntimeReadout{
|
||||
@@ -86,9 +86,9 @@ func TestAggregateChannelHealth_首模型读值供自动禁用判定(t *testing.
|
||||
assert.Equal(t, int64(12345), agg.ChannelRt.FirstOpenedMs, "渠道级熔断读值取首个模型")
|
||||
}
|
||||
|
||||
// TestRequestHealthSnapshot_不阻塞且满时丢弃 调用方是管理后台请求线程(渠道测试 /
|
||||
// TestRequestHealthSnapshot_NonBlockingDropsWhenFull 调用方是管理后台请求线程(渠道测试 /
|
||||
// 重置健康度),队列满时必须丢弃而非阻塞——下一轮定时维护会兜底重算。
|
||||
func TestRequestHealthSnapshot_不阻塞且满时丢弃(t *testing.T) {
|
||||
func TestRequestHealthSnapshot_NonBlockingDropsWhenFull(t *testing.T) {
|
||||
drain := func() {
|
||||
for {
|
||||
select {
|
||||
@@ -119,9 +119,9 @@ func TestRequestHealthSnapshot_不阻塞且满时丢弃(t *testing.T) {
|
||||
assert.Len(t, healthRefresh, cap(healthRefresh), "满队列不应再增长")
|
||||
}
|
||||
|
||||
// TestRefreshChannelHealth_未组装时静默返回 管理后台可能在首次 relay 请求前就触发重算,
|
||||
// TestRefreshChannelHealth_SilentWhenNotWired 管理后台可能在首次 relay 请求前就触发重算,
|
||||
// 此时调度引擎尚未组装(catalog / redisState 为 nil),不得 panic。
|
||||
func TestRefreshChannelHealth_未组装时静默返回(t *testing.T) {
|
||||
func TestRefreshChannelHealth_SilentWhenNotWired(t *testing.T) {
|
||||
require.Nil(t, catalog, "前置条件:本包测试不组装调度引擎")
|
||||
assert.NotPanics(t, func() { refreshChannelHealth(context.Background(), 1) })
|
||||
}
|
||||
@@ -45,9 +45,9 @@ func newTestState(t *testing.T, mutate func(*dispatch.RoutingPolicy)) *RedisStat
|
||||
return NewRedisState(func() *dispatch.RoutingPolicy { return pol }, nil)
|
||||
}
|
||||
|
||||
// Test并发健康EWMA原子性 H1 修复验证:100 并发失败经 Lua 原子读-算-写,
|
||||
// TestHealthEwma_ConcurrentAtomicity H1 修复验证:100 并发失败经 Lua 原子读-算-写,
|
||||
// EWMA 精确等于 0.93^100,熔断窗口计数精确 =100(读改写竞态会导致丢失更新)。
|
||||
func Test并发健康EWMA原子性(t *testing.T) {
|
||||
func TestHealthEwma_ConcurrentAtomicity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
|
||||
p.Breaker.FailThreshold = 1000 // 阈值调高,只验证计数不触发熔断
|
||||
@@ -76,7 +76,7 @@ func Test并发健康EWMA原子性(t *testing.T) {
|
||||
assert.Equal(t, 100, v.Int(), "熔断窗口失败计数必须精确 =100")
|
||||
}
|
||||
|
||||
func Test健康衰减分档(t *testing.T) {
|
||||
func TestHealthDecay_Tiers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -104,10 +104,10 @@ func Test健康衰减分档(t *testing.T) {
|
||||
assert.InEpsilon(t, 800, rt.LatEwmaMs, 1e-9)
|
||||
}
|
||||
|
||||
// Test探测失败不衰减健康EWMA 探测类(Probe)失败只喂熔断窗口,健康分不变——
|
||||
// TestProbeFailure_DoesNotDecayHealthEwma 探测类(Probe)失败只喂熔断窗口,健康分不变——
|
||||
// 防止每 5 分钟的自动探测持续失败把无流量渠道健康分指数拖垮(0.93^N → 个位数);
|
||||
// 探测成功照常回升健康并记录延迟。
|
||||
func Test探测失败不衰减健康EWMA(t *testing.T) {
|
||||
func TestProbeFailure_DoesNotDecayHealthEwma(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
|
||||
p.Breaker.FailThreshold = 1000 // 阈值调高,只验证窗口计数不触发熔断
|
||||
@@ -139,10 +139,10 @@ func Test探测失败不衰减健康EWMA(t *testing.T) {
|
||||
assert.InEpsilon(t, 500, rt.LatEwmaMs, 1e-9)
|
||||
}
|
||||
|
||||
// Test缺失健康键读默认值 gf 驱动对 HMGET 缺失字段返回空字符串而非 nil(IsNil()==false)。
|
||||
// TestMissingHealthKey_ReadsDefault gf 驱动对 HMGET 缺失字段返回空字符串而非 nil(IsNil()==false)。
|
||||
// ReadRuntime 必须按无数据处理保留乐观默认(succ=1):无流量模型或 TTL(24h)过期键
|
||||
// 若读成 succ=0,健康快照取平均会把渠道健康度拖到个位数。
|
||||
func Test缺失健康键读默认值(t *testing.T) {
|
||||
func TestMissingHealthKey_ReadsDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -153,7 +153,7 @@ func Test缺失健康键读默认值(t *testing.T) {
|
||||
assert.Equal(t, dispatch.BreakerClosed, rt.ModelBreaker)
|
||||
}
|
||||
|
||||
func Test限流不喂熔断窗口(t *testing.T) {
|
||||
func TestRateLimit_DoesNotFeedBreakerWindow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -168,7 +168,7 @@ func Test限流不喂熔断窗口(t *testing.T) {
|
||||
assert.Equal(t, dispatch.BreakerClosed, rt.Breaker)
|
||||
}
|
||||
|
||||
func Test熔断转移与探测令牌(t *testing.T) {
|
||||
func TestBreaker_TransitionAndProbeToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
|
||||
p.Breaker.ModelFailThreshold = 100 // 调高模型级阈值,聚焦验证渠道级 8 次语义
|
||||
@@ -201,7 +201,7 @@ func Test熔断转移与探测令牌(t *testing.T) {
|
||||
assert.Greater(t, rt.RecoveredMs, int64(0), "恢复时间必须记录")
|
||||
}
|
||||
|
||||
func Test熔断探测失败冷却翻倍(t *testing.T) {
|
||||
func TestBreaker_ProbeFailureDoublesCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -225,9 +225,9 @@ func Test熔断探测失败冷却翻倍(t *testing.T) {
|
||||
// 模型级隔离(错误作用域分流 + 去重守卫)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Test模型致命一次直达模型级 404/模型不存在:模型级 fatal 直达 OPEN,
|
||||
// TestModelFatal_SingleHitOpensModelBreaker 404/模型不存在:模型级 fatal 直达 OPEN,
|
||||
// 渠道级仅窗口计 1 票;模型级已 OPEN 后同模型后续失败不再污染渠道级。
|
||||
func Test模型致命一次直达模型级(t *testing.T) {
|
||||
func TestModelFatal_SingleHitOpensModelBreaker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -248,9 +248,9 @@ func Test模型致命一次直达模型级(t *testing.T) {
|
||||
assert.Equal(t, 1, v.Int(), "模型 OPEN 后渠道级不再计数")
|
||||
}
|
||||
|
||||
// Test模型级窗口阈值与渠道级隔离 单模型慢性故障(瞬时类):模型级阈值 4 先打开,
|
||||
// TestModelBreaker_WindowThresholdIsolatedFromChannel 单模型慢性故障(瞬时类):模型级阈值 4 先打开,
|
||||
// 渠道级贡献封顶在 4 票(< 8),渠道级永不熔断——单模型故障不误伤其它模型。
|
||||
func Test模型级窗口阈值与渠道级隔离(t *testing.T) {
|
||||
func TestModelBreaker_WindowThresholdIsolatedFromChannel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil) // failThreshold=8, modelFailThreshold=4
|
||||
|
||||
@@ -274,9 +274,9 @@ func Test模型级窗口阈值与渠道级隔离(t *testing.T) {
|
||||
assert.Equal(t, 4, v.Int(), "单模型对渠道级贡献封顶在 modelFailThreshold")
|
||||
}
|
||||
|
||||
// Test跨模型共识渠道级熔断 两个模型各恶化 4 次(4+4=8)→ 渠道级 OPEN:
|
||||
// TestChannelBreaker_CrossModelConsensus 两个模型各恶化 4 次(4+4=8)→ 渠道级 OPEN:
|
||||
// 渠道级熔断保留给真正的多模型同时恶化(整渠道故障)。
|
||||
func Test跨模型共识渠道级熔断(t *testing.T) {
|
||||
func TestChannelBreaker_CrossModelConsensus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -290,9 +290,9 @@ func Test跨模型共识渠道级熔断(t *testing.T) {
|
||||
assert.Equal(t, dispatch.BreakerOpen, rt.ModelBreaker, "两模型各自也达阈值 4")
|
||||
}
|
||||
|
||||
// Test渠道致命不喂模型级 402/Key 耗尽等渠道级信号:fatal 只打渠道级,模型级 key 不被触碰
|
||||
// TestChannelFatal_DoesNotFeedModelBreaker 402/Key 耗尽等渠道级信号:fatal 只打渠道级,模型级 key 不被触碰
|
||||
// (渠道恢复后模型无残留熔断)。
|
||||
func Test渠道致命不喂模型级(t *testing.T) {
|
||||
func TestChannelFatal_DoesNotFeedModelBreaker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -305,9 +305,9 @@ func Test渠道致命不喂模型级(t *testing.T) {
|
||||
assert.Equal(t, 0, v.Int(), "渠道级致命不创建/触碰模型级 key")
|
||||
}
|
||||
|
||||
// Test模型级探测成功复位 模型级 OPEN 冷却期满 → HALF_OPEN → 成功 → CLOSED + recovered_ms
|
||||
// TestModelBreaker_ProbeSuccessResets 模型级 OPEN 冷却期满 → HALF_OPEN → 成功 → CLOSED + recovered_ms
|
||||
// (模型级熔断可靠流量探测恢复,不再死锁到 24h TTL)。
|
||||
func Test模型级探测成功复位(t *testing.T) {
|
||||
func TestModelBreaker_ProbeSuccessResets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -321,9 +321,9 @@ func Test模型级探测成功复位(t *testing.T) {
|
||||
assert.Greater(t, v.Int64(), int64(0), "模型级恢复时间必须记录")
|
||||
}
|
||||
|
||||
// Test模型级探测令牌与探测失败翻倍 模型级令牌按渠道×模型 key 独立限流;
|
||||
// TestModelBreaker_ProbeTokenAndFailureDoublesCooldown 模型级令牌按渠道×模型 key 独立限流;
|
||||
// 冷却期满后的失败走 HALF_OPEN 探测失败分支(模型级回 OPEN 冷却翻倍,渠道级不计数)。
|
||||
func Test模型级探测令牌与探测失败翻倍(t *testing.T) {
|
||||
func TestModelBreaker_ProbeTokenAndFailureDoublesCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -346,7 +346,7 @@ func Test模型级探测令牌与探测失败翻倍(t *testing.T) {
|
||||
assert.Equal(t, 1, v.Int(), "渠道级不因探测失败新增计数")
|
||||
}
|
||||
|
||||
func Test绑定CAS与反向索引(t *testing.T) {
|
||||
func TestBinding_CASAndReverseIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -389,7 +389,7 @@ func Test绑定CAS与反向索引(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func Test绑定续期(t *testing.T) {
|
||||
func TestBinding_Touch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -402,7 +402,7 @@ func Test绑定续期(t *testing.T) {
|
||||
s.TouchBinding(ctx, "sk:test:none", time.Hour)
|
||||
}
|
||||
|
||||
func Test容量租约(t *testing.T) {
|
||||
func TestLease_Capacity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -441,7 +441,7 @@ func Test容量租约(t *testing.T) {
|
||||
assert.Equal(t, 20, rt.Inflight)
|
||||
}
|
||||
|
||||
func Test租约过期自愈(t *testing.T) {
|
||||
func TestLease_ExpirySelfHeals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -459,7 +459,7 @@ func Test租约过期自愈(t *testing.T) {
|
||||
assert.True(t, v.IsNil(), "已释放的租约不得被续租复活")
|
||||
}
|
||||
|
||||
func Test凭证冷却(t *testing.T) {
|
||||
func TestCredentialCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -472,9 +472,9 @@ func Test凭证冷却(t *testing.T) {
|
||||
assert.False(t, s.IsCredentialCooled(ctx, 101), "冷却到期后必须恢复可用")
|
||||
}
|
||||
|
||||
// Test凭证冷却可手动解除 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标,
|
||||
// TestCredentialCooldown_ManualClear 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标,
|
||||
// 而换 Key 是原地改同一行(keyID 不变),不解除的话新 Key 会被整段跳过直到 TTL 到期。
|
||||
func Test凭证冷却可手动解除(t *testing.T) {
|
||||
func TestCredentialCooldown_ManualClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -492,7 +492,7 @@ func Test凭证冷却可手动解除(t *testing.T) {
|
||||
assert.False(t, s.IsCredentialCooled(ctx, 999))
|
||||
}
|
||||
|
||||
func TestReportOutcome异步消费(t *testing.T) {
|
||||
func TestReportOutcome_AsyncConsumption(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
s.Start(ctx)
|
||||
@@ -506,11 +506,11 @@ func TestReportOutcome异步消费(t *testing.T) {
|
||||
}, 3*time.Second, 20*time.Millisecond, "后台 worker 必须消费上报事件")
|
||||
}
|
||||
|
||||
// TestReadRuntime无数据回落默认值 从未被访问过的渠道×模型(Redis key 不存在)必须回落
|
||||
// TestReadRuntime_FallsBackToDefaultWhenNoData 从未被访问过的渠道×模型(Redis key 不存在)必须回落
|
||||
// 默认满分 1.0。HMGET 缺失字段经 gredis 包装成非 nil 空 gvar(IsNil() 恒为 false、
|
||||
// Float64() 静默返回 0),旧代码用 IsNil() 判空失效,把全部冷模型读成 0 分,
|
||||
// 拖垮渠道聚合健康分与调度 healthFactor。
|
||||
func TestReadRuntime无数据回落默认值(t *testing.T) {
|
||||
func TestReadRuntime_FallsBackToDefaultWhenNoData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -527,8 +527,8 @@ func TestReadRuntime无数据回落默认值(t *testing.T) {
|
||||
assert.Equal(t, 100.0, rt.LatEwmaMs)
|
||||
}
|
||||
|
||||
// TestReadRuntime异常值防御 succ_ewma 为空串/非法值/精确 0 时视为无数据(保持默认 1.0)。
|
||||
func TestReadRuntime异常值防御(t *testing.T) {
|
||||
// TestReadRuntime_GuardsAgainstOutOfRangeValues succ_ewma 为空串/非法值/精确 0 时视为无数据(保持默认 1.0)。
|
||||
func TestReadRuntime_GuardsAgainstOutOfRangeValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -553,10 +553,10 @@ func TestReadRuntime异常值防御(t *testing.T) {
|
||||
assert.Equal(t, 0.0, rt.LatEwmaMs)
|
||||
}
|
||||
|
||||
// TestReadRuntime区分无数据与读失败 「没读到」与「读失败」必须可区分:前者是真实的
|
||||
// TestReadRuntime_DistinguishesNoDataFromReadFailure 「没读到」与「读失败」必须可区分:前者是真实的
|
||||
// 「该模型没有流量」,后者是 Redis 不可用。二者都会回落成默认满分,若调用方无法区分,
|
||||
// Redis 抖动期间维护快照会把全渠道健康分刷成 100,事故现场被销毁。
|
||||
func TestReadRuntime区分无数据与读失败(t *testing.T) {
|
||||
func TestReadRuntime_DistinguishesNoDataFromReadFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -583,9 +583,9 @@ func TestReadRuntime区分无数据与读失败(t *testing.T) {
|
||||
assert.Equal(t, 1.0, rt.SuccEwma)
|
||||
}
|
||||
|
||||
// TestReadRuntime脏值不算有效数据 精确 0 / 非法值视为无数据,HasHealth 保持 false,
|
||||
// TestReadRuntime_DirtyValueIsNotValidData 精确 0 / 非法值视为无数据,HasHealth 保持 false,
|
||||
// 避免脏数据以「实测值」身份参与渠道聚合(2026-08-27 全渠道健康分坍缩事故的读侧防线)。
|
||||
func TestReadRuntime脏值不算有效数据(t *testing.T) {
|
||||
func TestReadRuntime_DirtyValueIsNotValidData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -609,7 +609,7 @@ func TestLocalFallbackLimit(t *testing.T) {
|
||||
assert.Equal(t, 10, localFallbackLimit(10, 0), "副本数缺省按 1")
|
||||
}
|
||||
|
||||
func TestLocal严格容量租约(t *testing.T) {
|
||||
func TestLocal_StrictCapacityLease(t *testing.T) {
|
||||
l := newLocalState()
|
||||
require.True(t, l.acquireLease(1, "a", 2, 60_000))
|
||||
require.True(t, l.acquireLease(1, "b", 2, 60_000))
|
||||
@@ -622,7 +622,7 @@ func TestLocal严格容量租约(t *testing.T) {
|
||||
require.False(t, l.acquireLease(2, "x", 0, 60_000))
|
||||
}
|
||||
|
||||
func TestLocal探测限流(t *testing.T) {
|
||||
func TestLocal_ProbeThrottle(t *testing.T) {
|
||||
l := newLocalState()
|
||||
assert.True(t, l.tryProbe(1, "", 10_000))
|
||||
assert.False(t, l.tryProbe(1, "", 10_000), "同窗口本实例只放行一个")
|
||||
@@ -630,7 +630,7 @@ func TestLocal探测限流(t *testing.T) {
|
||||
assert.True(t, l.tryProbe(1, "gpt-4o", 10_000), "同渠道的模型级令牌与渠道级互不影响")
|
||||
}
|
||||
|
||||
func TestLocal凭证冷却过期(t *testing.T) {
|
||||
func TestLocal_CredentialCooldownExpiry(t *testing.T) {
|
||||
l := newLocalState()
|
||||
l.coolCred(9, 50*time.Millisecond)
|
||||
assert.True(t, l.isCredCooled(9))
|
||||
@@ -638,7 +638,7 @@ func TestLocal凭证冷却过期(t *testing.T) {
|
||||
assert.False(t, l.isCredCooled(9))
|
||||
}
|
||||
|
||||
func TestLocal凭证冷却解除(t *testing.T) {
|
||||
func TestLocal_CredentialCooldownClear(t *testing.T) {
|
||||
l := newLocalState()
|
||||
l.coolCred(9, time.Hour)
|
||||
require.True(t, l.isCredCooled(9))
|
||||
@@ -647,7 +647,7 @@ func TestLocal凭证冷却解除(t *testing.T) {
|
||||
l.clearCred(9) // 幂等
|
||||
}
|
||||
|
||||
func Test429估计器水位收敛(t *testing.T) {
|
||||
func TestRateLimit429Estimator_OnsetConverges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
@@ -677,7 +677,7 @@ func TestEffectiveSoftLimit(t *testing.T) {
|
||||
assert.Equal(t, 4, effectiveSoftLimit(0, 2), "下限保护 max(4, ...)")
|
||||
}
|
||||
|
||||
func Test手动恢复复位熔断并开启爬坡(t *testing.T) {
|
||||
func TestManualRecovery_ResetsBreakerAndStartsRamp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestState(t, nil)
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRunHandlerSafely_Panic转为错误 robfig/cron v3 的 cron.New() 不带 Recover 链,
|
||||
// TestRunHandlerSafely_PanicBecomesError robfig/cron v3 的 cron.New() 不带 Recover 链,
|
||||
// startJob 是裸的 `go j.Run()`——任务 panic 会崩掉整个进程。此处必须兜住并转成
|
||||
// 普通 error,才能既保住进程、又让失败落进 sys_cron_jobs 供管理后台查看。
|
||||
func TestRunHandlerSafely_Panic转为错误(t *testing.T) {
|
||||
func TestRunHandlerSafely_PanicBecomesError(t *testing.T) {
|
||||
job := &CronJob{
|
||||
Name: "panicking_job",
|
||||
Handler: func(_ context.Context) error {
|
||||
@@ -27,7 +27,7 @@ func TestRunHandlerSafely_Panic转为错误(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "boom", "错误信息应保留 panic 内容供排查")
|
||||
}
|
||||
|
||||
func TestRunHandlerSafely_正常返回值透传(t *testing.T) {
|
||||
func TestRunHandlerSafely_PassesThroughReturnValue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
okJob := &CronJob{Name: "ok", Handler: func(_ context.Context) error { return nil }}
|
||||
@@ -39,9 +39,9 @@ func TestRunHandlerSafely_正常返回值透传(t *testing.T) {
|
||||
assert.ErrorIs(t, runHandlerSafely(ctx, failJob), assert.AnError, "普通错误必须原样返回")
|
||||
}
|
||||
|
||||
// TestReschedule_未启动时只更新记录 StartBackground 之前没有 cron 条目,
|
||||
// TestReschedule_OnlyUpdatesRecordWhenNotStarted StartBackground 之前没有 cron 条目,
|
||||
// 此时重排只改 job.Schedule,供随后的 StartBackground 按新表达式注册。
|
||||
func TestReschedule_未启动时只更新记录(t *testing.T) {
|
||||
func TestReschedule_OnlyUpdatesRecordWhenNotStarted(t *testing.T) {
|
||||
cs := NewCronScheduler()
|
||||
cs.Register("job_a", "任务A", "*/5 * * * *", func(_ context.Context) error { return nil })
|
||||
|
||||
@@ -52,14 +52,14 @@ func TestReschedule_未启动时只更新记录(t *testing.T) {
|
||||
assert.Equal(t, "@every 30m", jobs[0].Schedule)
|
||||
}
|
||||
|
||||
func TestReschedule_未注册任务报错(t *testing.T) {
|
||||
func TestReschedule_UnregisteredTaskErrors(t *testing.T) {
|
||||
cs := NewCronScheduler()
|
||||
assert.Error(t, cs.Reschedule(context.Background(), "nonexistent", "@every 5m"))
|
||||
}
|
||||
|
||||
// TestReschedule_非法表达式保持原调度 一次错误配置不能把任务从调度器里彻底摘掉,
|
||||
// TestReschedule_InvalidExprKeepsExistingSchedule 一次错误配置不能把任务从调度器里彻底摘掉,
|
||||
// 因此实现是「先加新条目、成功后再摘旧条目」。
|
||||
func TestReschedule_非法表达式保持原调度(t *testing.T) {
|
||||
func TestReschedule_InvalidExprKeepsExistingSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cs := NewCronScheduler()
|
||||
cs.Register("job_b", "任务B", "@every 5m", func(_ context.Context) error { return nil })
|
||||
@@ -74,9 +74,9 @@ func TestReschedule_非法表达式保持原调度(t *testing.T) {
|
||||
assert.Len(t, cs.cron.Entries(), 1)
|
||||
}
|
||||
|
||||
// TestReschedule_有效表达式替换条目 同时验证 @every 语法确实被 cron.New() 的
|
||||
// TestReschedule_ValidExprReplacesEntry 同时验证 @every 语法确实被 cron.New() 的
|
||||
// 默认解析器接受——探测间隔配置正是靠它支持任意分钟数(*/N 在 N>=60 时会静默失真)。
|
||||
func TestReschedule_有效表达式替换条目(t *testing.T) {
|
||||
func TestReschedule_ValidExprReplacesEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cs := NewCronScheduler()
|
||||
cs.Register("job_c", "任务C", "@every 5m", func(_ context.Context) error { return nil })
|
||||
@@ -93,9 +93,9 @@ func TestReschedule_有效表达式替换条目(t *testing.T) {
|
||||
assert.NotEqual(t, oldID, cs.entryIDs["job_c"], "条目 ID 应已更新")
|
||||
}
|
||||
|
||||
// TestOnSettingsChanged_回调panic不影响其余回调 回调在订阅 goroutine 内串行执行,
|
||||
// TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers 回调在订阅 goroutine 内串行执行,
|
||||
// 一个回调 panic 不能带崩订阅循环或阻断后续回调。
|
||||
func TestOnSettingsChanged_回调panic不影响其余回调(t *testing.T) {
|
||||
func TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers(t *testing.T) {
|
||||
settingsHookMu.Lock()
|
||||
saved := settingsHooks
|
||||
settingsHooks = nil
|
||||
|
||||
@@ -44,7 +44,7 @@ func createTarball(t *testing.T, files map[string]string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
func TestExtractBinary_正常提取(t *testing.T) {
|
||||
func TestExtractBinary_Success(t *testing.T) {
|
||||
tarball := createTarball(t, map[string]string{
|
||||
"team-api-1.0.0-linux-amd64/team-api": "new-binary-content",
|
||||
"team-api-1.0.0-linux-amd64/README.md": "docs",
|
||||
@@ -79,7 +79,7 @@ func TestExtractBinary_正常提取(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBinary_归档中无二进制(t *testing.T) {
|
||||
func TestExtractBinary_NoBinaryInArchive(t *testing.T) {
|
||||
tarball := createTarball(t, map[string]string{
|
||||
"team-api-1.0.0-linux-amd64/README.md": "docs",
|
||||
})
|
||||
@@ -90,7 +90,7 @@ func TestExtractBinary_归档中无二进制(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceBinary_正常替换(t *testing.T) {
|
||||
func TestReplaceBinary_Success(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "team-api")
|
||||
newBin := filepath.Join(dir, "team-api.new")
|
||||
@@ -125,7 +125,7 @@ func TestReplaceBinary_正常替换(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceBinary_残留old文件被清理(t *testing.T) {
|
||||
func TestReplaceBinary_StaleOldFileCleaned(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "team-api")
|
||||
newBin := filepath.Join(dir, "team-api.new")
|
||||
@@ -146,7 +146,7 @@ func TestReplaceBinary_残留old文件被清理(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceBinary_失败时自救恢复(t *testing.T) {
|
||||
func TestReplaceBinary_RollbackOnFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
current := filepath.Join(dir, "team-api")
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestReplaceBinary_失败时自救恢复(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_内容与权限一致(t *testing.T) {
|
||||
func TestCopyFile_PreservesContentAndPermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
@@ -193,7 +193,7 @@ func TestCopyFile_内容与权限一致(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_源文件不存在(t *testing.T) {
|
||||
func TestCopyFile_SourceMissing(t *testing.T) {
|
||||
if err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil {
|
||||
t.Fatal("源文件不存在时应返回错误")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ func newTestManager() *UpdateManager {
|
||||
return &UpdateManager{}
|
||||
}
|
||||
|
||||
func TestUpdateManager_默认状态(t *testing.T) {
|
||||
func TestUpdateManager_DefaultState(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
status := m.GetStatus()
|
||||
@@ -28,7 +28,7 @@ func TestUpdateManager_默认状态(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManager_进度状态机(t *testing.T) {
|
||||
func TestUpdateManager_ProgressStateMachine(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
m.setProgress(PhaseDownloading, "正在下载...", 50)
|
||||
@@ -47,7 +47,7 @@ func TestUpdateManager_进度状态机(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManager_失败收尾(t *testing.T) {
|
||||
func TestUpdateManager_FailureFinalizes(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
m.setProgress(PhaseDownloading, "正在下载...", 50)
|
||||
@@ -71,7 +71,7 @@ func TestUpdateManager_失败收尾(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateManager_更新锁互斥(t *testing.T) {
|
||||
func TestUpdateManager_UpdateLockMutualExclusion(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
if !m.updating.CompareAndSwap(false, true) {
|
||||
@@ -90,7 +90,7 @@ func TestUpdateManager_更新锁互斥(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_终态进度过期自动失效(t *testing.T) {
|
||||
func TestGetStatus_TerminalProgressExpiresAutomatically(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
// 上次升级留下的过期 complete 快照:不应再下发
|
||||
@@ -119,7 +119,7 @@ func TestGetStatus_终态进度过期自动失效(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_终态进度窗口内保留(t *testing.T) {
|
||||
func TestGetStatus_TerminalProgressKeptWithinWindow(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
// 刚完成的 complete:管理员仍在轮询确认窗口内,应正常返回
|
||||
@@ -135,7 +135,7 @@ func TestGetStatus_终态进度窗口内保留(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatus_非终态进度不过期(t *testing.T) {
|
||||
func TestGetStatus_NonTerminalProgressDoesNotExpire(t *testing.T) {
|
||||
m := newTestManager()
|
||||
|
||||
// 进行中的进度(无 FinishedAt)不受过期机制影响
|
||||
|
||||
@@ -33,7 +33,7 @@ func serveChecksums(t *testing.T, status int, body string) *httptest.Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestVerifyChecksum_成功(t *testing.T) {
|
||||
func TestVerifyChecksum_Success(t *testing.T) {
|
||||
content := "fake-binary-content"
|
||||
filePath := writeTempFile(t, "team-api-1.0.0-linux-amd64.tar.gz", content)
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestVerifyChecksum_成功(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChecksum_哈希不一致须报错(t *testing.T) {
|
||||
func TestVerifyChecksum_HashMismatchErrors(t *testing.T) {
|
||||
filePath := writeTempFile(t, "asset.tar.gz", "actual-content")
|
||||
|
||||
wrongHash := strings.Repeat("0", 64)
|
||||
@@ -62,7 +62,7 @@ func TestVerifyChecksum_哈希不一致须报错(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) {
|
||||
func TestVerifyChecksum_MissingChecksumURLRejected(t *testing.T) {
|
||||
filePath := writeTempFile(t, "asset.tar.gz", "content")
|
||||
|
||||
err := VerifyChecksum(context.Background(), filePath, "", "asset.tar.gz")
|
||||
@@ -74,7 +74,7 @@ func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) {
|
||||
func TestVerifyChecksum_ChecksumFetchFailureRejected(t *testing.T) {
|
||||
filePath := writeTempFile(t, "asset.tar.gz", "content")
|
||||
|
||||
// 服务返回 500,模拟校验文件不可用
|
||||
@@ -86,7 +86,7 @@ func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchExpectedHash_解析格式(t *testing.T) {
|
||||
func TestFetchExpectedHash_ParsesFormat(t *testing.T) {
|
||||
content := "aaa file-a.tar.gz\nbbb *file-b.tar.gz\n"
|
||||
srv := serveChecksums(t, http.StatusOK, content)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func breakerPol() BreakerPolicy { return DefaultRoutingPolicy().Breaker }
|
||||
|
||||
func TestEffectiveBreakerState_惰性转移(t *testing.T) {
|
||||
func TestEffectiveBreakerState_LazyTransition(t *testing.T) {
|
||||
p := breakerPol()
|
||||
s := BreakerSnapshot{State: BreakerOpen, OpenedAtMs: 0, CooldownMs: int64(p.CooldownSeconds) * 1000}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestEffectiveBreakerState_惰性转移(t *testing.T) {
|
||||
assert.Equal(t, BreakerClosed, EffectiveBreakerState(BreakerSnapshot{State: BreakerClosed}, 0))
|
||||
}
|
||||
|
||||
func TestBreakerOnFailure_窗口阈值熔断(t *testing.T) {
|
||||
func TestBreakerOnFailure_WindowThresholdOpens(t *testing.T) {
|
||||
p := breakerPol() // failThreshold=8
|
||||
|
||||
s := BreakerSnapshot{State: BreakerClosed, FailWindowCount: 7}
|
||||
@@ -31,14 +31,14 @@ func TestBreakerOnFailure_窗口阈值熔断(t *testing.T) {
|
||||
assert.Equal(t, int64(30_000), s.CooldownMs)
|
||||
}
|
||||
|
||||
func TestBreakerOnFailure_致命一次直达(t *testing.T) {
|
||||
func TestBreakerOnFailure_FatalOpensImmediately(t *testing.T) {
|
||||
p := breakerPol()
|
||||
s := BreakerSnapshot{State: BreakerClosed, FailWindowCount: 0}
|
||||
s = BreakerOnFailure(s, true, 1000, p)
|
||||
assert.Equal(t, BreakerOpen, s.State, "CHANNEL_FATAL 一次直达熔断")
|
||||
}
|
||||
|
||||
func TestBreakerOnFailure_探测失败冷却翻倍(t *testing.T) {
|
||||
func TestBreakerOnFailure_ProbeFailureDoublesCooldown(t *testing.T) {
|
||||
p := breakerPol() // cooldown 30s, max 300s
|
||||
|
||||
// OPEN 冷却期满(有效态 HALF_OPEN)时失败 = 探测失败 → 回 OPEN,冷却翻倍
|
||||
@@ -60,7 +60,7 @@ func TestBreakerOnFailure_探测失败冷却翻倍(t *testing.T) {
|
||||
assert.Equal(t, s2, got)
|
||||
}
|
||||
|
||||
func TestBreakerOnSuccess_探测成功复位(t *testing.T) {
|
||||
func TestBreakerOnSuccess_ProbeSuccessResets(t *testing.T) {
|
||||
// HALF_OPEN(有效态)成功 → CLOSED 并复位
|
||||
s := BreakerSnapshot{State: BreakerOpen, OpenedAtMs: 0, CooldownMs: 30_000, FailWindowCount: 9}
|
||||
s = BreakerOnSuccess(s, 30_000)
|
||||
@@ -79,7 +79,7 @@ func TestBreakerOnSuccess_探测成功复位(t *testing.T) {
|
||||
assert.Zero(t, s.FailWindowCount, "成功清失败计数")
|
||||
}
|
||||
|
||||
func TestBreaker_全转移路径(t *testing.T) {
|
||||
func TestBreaker_AllTransitionPaths(t *testing.T) {
|
||||
p := breakerPol()
|
||||
|
||||
// CLOSED → OPEN → HALF_OPEN(探测失败) → OPEN → HALF_OPEN(探测成功) → CLOSED
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/qianfree/team-api/relaykit/types"
|
||||
)
|
||||
|
||||
func TestClassify_状态码全表(t *testing.T) {
|
||||
func TestClassify_StatusCodeTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
code int
|
||||
want ErrorClass
|
||||
@@ -41,7 +41,7 @@ func TestClassify_状态码全表(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_NewAPIError错误码(t *testing.T) {
|
||||
func TestClassify_NewAPIErrorCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code types.ErrorCode
|
||||
@@ -66,32 +66,32 @@ func TestClassify_NewAPIError错误码(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_SkipRetry语义(t *testing.T) {
|
||||
func TestClassify_SkipRetrySemantics(t *testing.T) {
|
||||
// SkipRetry 标记优先于一切:即使状态码本可重试也归客户端类(不重试)
|
||||
e := types.NewErrorWithStatusCode(errors.New("x"), types.ErrorCodeBadResponse, 502, types.ErrOptionWithSkipRetry())
|
||||
assert.Equal(t, ErrClassClient, Classify(502, e, DeliveryResponseReceived))
|
||||
}
|
||||
|
||||
func TestClassify_NewAPIError状态码兜底(t *testing.T) {
|
||||
func TestClassify_NewAPIErrorStatusCodeFallback(t *testing.T) {
|
||||
// 错误码不能定类时用其携带的状态码
|
||||
e := types.NewErrorWithStatusCode(errors.New("x"), types.ErrorCodeBadResponse, 429)
|
||||
assert.Equal(t, ErrClassRateLimit, Classify(0, e, DeliveryResponseReceived))
|
||||
}
|
||||
|
||||
func TestClassify_上下文错误(t *testing.T) {
|
||||
func TestClassify_ContextErrors(t *testing.T) {
|
||||
assert.Equal(t, ErrClassTimeout, Classify(0, context.DeadlineExceeded, DeliveryMaybeSent))
|
||||
assert.Equal(t, ErrClassClient, Classify(0, context.Canceled, DeliveryMaybeSent), "客户端取消不重试不计健康")
|
||||
wrapped := fmt.Errorf("do request: %w", context.DeadlineExceeded)
|
||||
assert.Equal(t, ErrClassTimeout, Classify(0, wrapped, DeliveryNotSent))
|
||||
}
|
||||
|
||||
func TestClassify_纯网络错误(t *testing.T) {
|
||||
func TestClassify_PureNetworkError(t *testing.T) {
|
||||
err := errors.New("connection refused")
|
||||
assert.Equal(t, ErrClassTransient, Classify(0, err, DeliveryNotSent))
|
||||
assert.Equal(t, ErrClassTransient, Classify(0, err, DeliveryMaybeSent), "MaybeSent 仍归 TRANSIENT,禁止原地由 FSM 硬规则处理")
|
||||
}
|
||||
|
||||
func TestClassify_无错误(t *testing.T) {
|
||||
func TestClassify_NoError(t *testing.T) {
|
||||
assert.Equal(t, ErrClassNone, Classify(0, nil, DeliveryNotSent))
|
||||
assert.Equal(t, ErrClassNone, Classify(200, nil, DeliveryResponseReceived))
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func testProfile() RequestProfile {
|
||||
// 场景测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCoordinator_首次选择与绑定命中(t *testing.T) {
|
||||
func TestCoordinator_FirstSelectionAndBindingHit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
@@ -143,7 +143,7 @@ func TestCoordinator_首次选择与绑定命中(t *testing.T) {
|
||||
assert.Equal(t, d1.Channel.ID, d2.Channel.ID)
|
||||
}
|
||||
|
||||
func TestCoordinator_守卫_健康跌破重绑(t *testing.T) {
|
||||
func TestCoordinator_Guard_RebindsOnHealthDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestCoordinator_守卫_健康跌破重绑(t *testing.T) {
|
||||
assert.Equal(t, 1, state.setBinds, "重绑写入新绑定")
|
||||
}
|
||||
|
||||
func TestCoordinator_守卫_饱和渠道重绑(t *testing.T) {
|
||||
func TestCoordinator_Guard_RebindsOnSaturatedChannel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -178,9 +178,9 @@ func TestCoordinator_守卫_饱和渠道重绑(t *testing.T) {
|
||||
assert.NotEqual(t, ReasonBind, d.Reason)
|
||||
}
|
||||
|
||||
// TestCoordinator_守卫_glm缺陷回归 溢出到 secondary 建立的绑定必须保持稳定:
|
||||
// TestCoordinator_Guard_GLMDefectRegression 溢出到 secondary 建立的绑定必须保持稳定:
|
||||
// 守卫判据与 tier/cost 解耦,不得因 tierFactor 低而每请求重绑(规避 keepThreshold 缺陷)。
|
||||
func TestCoordinator_守卫_glm缺陷回归(t *testing.T) {
|
||||
func TestCoordinator_Guard_GLMDefectRegression(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
@@ -201,7 +201,7 @@ func TestCoordinator_守卫_glm缺陷回归(t *testing.T) {
|
||||
assert.Zero(t, state.setBinds, "不得发生任何重绑")
|
||||
}
|
||||
|
||||
func TestCoordinator_502原地重试与预算耗尽(t *testing.T) {
|
||||
func TestCoordinator_502InPlaceRetryUntilBudgetExhausted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
@@ -245,7 +245,7 @@ func TestCoordinator_502原地重试与预算耗尽(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinator_401凭证轮换链路(t *testing.T) {
|
||||
func TestCoordinator_401CredentialRotationPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -284,7 +284,7 @@ func TestCoordinator_401凭证轮换链路(t *testing.T) {
|
||||
assert.Equal(t, int64(21), d.KeyID)
|
||||
}
|
||||
|
||||
func TestCoordinator_单Key渠道401直接failover(t *testing.T) {
|
||||
func TestCoordinator_SingleKeyChannel401FailsOverDirectly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestCoordinator_单Key渠道401直接failover(t *testing.T) {
|
||||
assert.True(t, state.cooled[11])
|
||||
}
|
||||
|
||||
func TestCoordinator_全Key冷却渠道跳过(t *testing.T) {
|
||||
func TestCoordinator_SkipsChannelWithAllKeysCooled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
state.cooled[11] = true
|
||||
@@ -320,10 +320,10 @@ func TestCoordinator_全Key冷却渠道跳过(t *testing.T) {
|
||||
assert.Equal(t, 1, d.Excluded.Request)
|
||||
}
|
||||
|
||||
// TestCoordinator_无可用渠道诊断带冷却KeyID 凭证冷却只存在于 Redis 且只靠 TTL 自愈,
|
||||
// TestCoordinator_NoChannelDiagIncludesCooledKeyIDs 凭证冷却只存在于 Redis 且只靠 TTL 自愈,
|
||||
// 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID,
|
||||
// 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。
|
||||
func TestCoordinator_无可用渠道诊断带冷却KeyID(t *testing.T) {
|
||||
func TestCoordinator_NoChannelDiagIncludesCooledKeyIDs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
state.cooled[11] = true
|
||||
@@ -344,7 +344,7 @@ func TestCoordinator_无可用渠道诊断带冷却KeyID(t *testing.T) {
|
||||
assert.Contains(t, diag.Summary(), "凭证全部冷却×2(key=11,21)")
|
||||
}
|
||||
|
||||
func TestCoordinator_租约拒绝不扣预算(t *testing.T) {
|
||||
func TestCoordinator_LeaseDenialDoesNotConsumeBudget(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
state.leaseDeny[1] = true
|
||||
@@ -364,7 +364,7 @@ func TestCoordinator_租约拒绝不扣预算(t *testing.T) {
|
||||
assert.Zero(t, s.attempt.FailoverUsed, "租约失败不扣 failover 预算")
|
||||
}
|
||||
|
||||
func TestCoordinator_熔断排除与探测放行(t *testing.T) {
|
||||
func TestCoordinator_BreakerExclusionAndProbeAdmission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
open := healthyChannel(1, TierPrimary, 100)
|
||||
@@ -393,7 +393,7 @@ func TestCoordinator_熔断排除与探测放行(t *testing.T) {
|
||||
assert.Equal(t, ReasonProbe, d2.Reason)
|
||||
}
|
||||
|
||||
func TestCoordinator_模型级熔断排除(t *testing.T) {
|
||||
func TestCoordinator_ModelBreakerExclusion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -407,9 +407,9 @@ func TestCoordinator_模型级熔断排除(t *testing.T) {
|
||||
assert.Equal(t, int64(2), d.Channel.ID)
|
||||
}
|
||||
|
||||
// TestCoordinator_模型级半开探测 模型级熔断冷却期满(HALF_OPEN)走模型级探测令牌:
|
||||
// TestCoordinator_ModelHalfOpenProbe 模型级熔断冷却期满(HALF_OPEN)走模型级探测令牌:
|
||||
// 渠道级熔断 CLOSED 时不再被渠道级令牌误拒(修复模型级熔断无法靠流量恢复的死锁)。
|
||||
func TestCoordinator_模型级半开探测(t *testing.T) {
|
||||
func TestCoordinator_ModelHalfOpenProbe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
modelHalf := healthyChannel(1, TierPrimary, 100)
|
||||
@@ -436,9 +436,9 @@ func TestCoordinator_模型级半开探测(t *testing.T) {
|
||||
assert.Equal(t, 1, d2.Excluded.Breaker, "模型级探测拒绝计入 Breaker 排除")
|
||||
}
|
||||
|
||||
// TestCoordinator_双级别半开组合 渠道级与模型级同时 HALF_OPEN:两枚令牌都取,
|
||||
// TestCoordinator_DualLevelHalfOpenCombination 渠道级与模型级同时 HALF_OPEN:两枚令牌都取,
|
||||
// 任一被拒即排除;模型级令牌先取(被拒不消耗渠道级令牌)。
|
||||
func TestCoordinator_双级别半开组合(t *testing.T) {
|
||||
func TestCoordinator_DualLevelHalfOpenCombination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
both := healthyChannel(1, TierPrimary, 100)
|
||||
@@ -467,7 +467,7 @@ func TestCoordinator_双级别半开组合(t *testing.T) {
|
||||
assert.Equal(t, ReasonProbe, d2.Reason)
|
||||
}
|
||||
|
||||
func TestCoordinator_tier扩组兜底(t *testing.T) {
|
||||
func TestCoordinator_TierExpansionFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -488,7 +488,7 @@ func TestCoordinator_tier扩组兜底(t *testing.T) {
|
||||
assert.Equal(t, int64(2), d.Channel.ID)
|
||||
}
|
||||
|
||||
func TestCoordinator_溢出reason(t *testing.T) {
|
||||
func TestCoordinator_OverflowReason(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -504,7 +504,7 @@ func TestCoordinator_溢出reason(t *testing.T) {
|
||||
assert.Equal(t, ReasonOverflow, d.Reason, "primary 在场但饱和 → 溢出")
|
||||
}
|
||||
|
||||
func TestCoordinator_ReplayUnsafe_MaybeSent必Abort(t *testing.T) {
|
||||
func TestCoordinator_ReplayUnsafe_MaybeSentMustAbort(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
|
||||
@@ -519,7 +519,7 @@ func TestCoordinator_ReplayUnsafe_MaybeSent必Abort(t *testing.T) {
|
||||
assert.Nil(t, s.Next(ctx))
|
||||
}
|
||||
|
||||
func TestCoordinator_504不原地重试(t *testing.T) {
|
||||
func TestCoordinator_504NoInPlaceRetry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
@@ -538,7 +538,7 @@ func TestCoordinator_504不原地重试(t *testing.T) {
|
||||
assert.NotEqual(t, first, d.Channel.ID)
|
||||
}
|
||||
|
||||
func TestCoordinator_429RetryAfter原地等待(t *testing.T) {
|
||||
func TestCoordinator_429RetryAfterWaitsInPlace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
|
||||
@@ -551,7 +551,7 @@ func TestCoordinator_429RetryAfter原地等待(t *testing.T) {
|
||||
assert.Equal(t, 1500*time.Millisecond, backoff, "遵从 Retry-After(entropy=0 无 jitter)")
|
||||
}
|
||||
|
||||
func TestCoordinator_客户端错误Abort且不上报(t *testing.T) {
|
||||
func TestCoordinator_ClientErrorAbortsWithoutReporting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
|
||||
@@ -564,7 +564,7 @@ func TestCoordinator_客户端错误Abort且不上报(t *testing.T) {
|
||||
assert.Empty(t, state.outcomes, "客户端错误不计渠道健康")
|
||||
}
|
||||
|
||||
func TestCoordinator_总时限耗尽Abort(t *testing.T) {
|
||||
func TestCoordinator_TotalDeadlineExhaustedAborts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, clock := newTestCoordinator(state,
|
||||
@@ -580,7 +580,7 @@ func TestCoordinator_总时限耗尽Abort(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, dec)
|
||||
}
|
||||
|
||||
func TestCoordinator_全渠道耗尽返回nil(t *testing.T) {
|
||||
func TestCoordinator_ReturnsNilWhenAllChannelsExhausted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
@@ -605,7 +605,7 @@ func TestCoordinator_全渠道耗尽返回nil(t *testing.T) {
|
||||
s.Finish(ctx, false, 0)
|
||||
}
|
||||
|
||||
func TestCoordinator_jitter叠加(t *testing.T) {
|
||||
func TestCoordinator_JitterApplied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
clock := &fakeClock{t: time.UnixMilli(0)}
|
||||
@@ -622,7 +622,7 @@ func TestCoordinator_jitter叠加(t *testing.T) {
|
||||
assert.Equal(t, 150*time.Millisecond, backoff, "100ms + 50% jitter")
|
||||
}
|
||||
|
||||
func TestCoordinator_RefreshLease透传(t *testing.T) {
|
||||
func TestCoordinator_RefreshLeasePassThrough(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
|
||||
@@ -634,7 +634,7 @@ func TestCoordinator_RefreshLease透传(t *testing.T) {
|
||||
assert.Equal(t, 1, state.refreshed[d.Channel.ID])
|
||||
}
|
||||
|
||||
func TestCoordinator_Finish幂等且失败不续绑(t *testing.T) {
|
||||
func TestCoordinator_FinishIdempotentAndNoTouchOnFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
|
||||
@@ -651,7 +651,7 @@ func TestCoordinator_Finish幂等且失败不续绑(t *testing.T) {
|
||||
assert.Nil(t, s.Next(ctx), "Finish 后不再返回决策")
|
||||
}
|
||||
|
||||
func TestCoordinator_策略热更新(t *testing.T) {
|
||||
func TestCoordinator_PolicyHotReload(t *testing.T) {
|
||||
co, _ := newTestCoordinator(newFakeState(), healthyChannel(1, TierPrimary, 10))
|
||||
p2 := DefaultRoutingPolicy()
|
||||
p2.Version = 2
|
||||
@@ -661,9 +661,9 @@ func TestCoordinator_策略热更新(t *testing.T) {
|
||||
assert.Equal(t, 2, co.Policy().Version, "nil 策略不生效")
|
||||
}
|
||||
|
||||
// TestCoordinator_防抖_headroom抖动 绑定守卫防抖:被绑渠道余量在守卫线上方抖动时,
|
||||
// TestCoordinator_Debounce_HeadroomJitter 绑定守卫防抖:被绑渠道余量在守卫线上方抖动时,
|
||||
// 绑定必须保持稳定(重绑次数为 0)——纯 HRW 会横跳,守卫是防抖层。
|
||||
func TestCoordinator_防抖_headroom抖动(t *testing.T) {
|
||||
func TestCoordinator_Debounce_HeadroomJitter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
|
||||
@@ -699,8 +699,8 @@ func TestCoordinator_防抖_headroom抖动(t *testing.T) {
|
||||
assert.Equal(t, rebinds, state.setBinds, "抖动期间重绑次数必须为 0")
|
||||
}
|
||||
|
||||
// TestCoordinator_租户级策略覆盖 profile.Policy 非空时本会话使用租户策略而非全局策略。
|
||||
func TestCoordinator_租户级策略覆盖(t *testing.T) {
|
||||
// TestCoordinator_TenantPolicyOverride profile.Policy 非空时本会话使用租户策略而非全局策略。
|
||||
func TestCoordinator_TenantPolicyOverride(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state,
|
||||
|
||||
@@ -17,7 +17,7 @@ func scoredSet(weights map[int64]float64) []ScoredChannel {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPickHRW_粘性稳定(t *testing.T) {
|
||||
func TestPickHRW_StickyStability(t *testing.T) {
|
||||
cands := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
|
||||
first := PickHRW(cands, "session-abc")
|
||||
require.NotNil(t, first)
|
||||
@@ -27,7 +27,7 @@ func TestPickHRW_粘性稳定(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickHRW_候选顺序无关(t *testing.T) {
|
||||
func TestPickHRW_CandidateOrderIndependent(t *testing.T) {
|
||||
a := []ScoredChannel{
|
||||
{Channel: Channel{ID: 1}, Weight: 5},
|
||||
{Channel: Channel{ID: 2}, Weight: 3},
|
||||
@@ -41,7 +41,7 @@ func TestPickHRW_候选顺序无关(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickHRW_权重比例分布(t *testing.T) {
|
||||
func TestPickHRW_WeightProportionalDistribution(t *testing.T) {
|
||||
// 权重 5:3:2 → 期望占比 50%/30%/20%,10 万采样误差 < 2 个百分点
|
||||
cands := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
|
||||
const n = 100_000
|
||||
@@ -57,7 +57,7 @@ func TestPickHRW_权重比例分布(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickHRW_权重非正不参与(t *testing.T) {
|
||||
func TestPickHRW_NonPositiveWeightExcluded(t *testing.T) {
|
||||
cands := scoredSet(map[int64]float64{1: 0, 2: -1, 3: 2})
|
||||
for i := range 1000 {
|
||||
p := PickHRW(cands, fmt.Sprintf("s-%d", i))
|
||||
@@ -68,7 +68,7 @@ func TestPickHRW_权重非正不参与(t *testing.T) {
|
||||
assert.Nil(t, PickHRW(nil, "s"))
|
||||
}
|
||||
|
||||
func TestPickHRW_单调性(t *testing.T) {
|
||||
func TestPickHRW_Monotonicity(t *testing.T) {
|
||||
// 增加一个渠道只迁移有限比例会话:不含新渠道时选中 A 的会话,
|
||||
// 加入新渠道后要么仍选 A,要么选新渠道,绝不迁到其它旧渠道。
|
||||
oldSet := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
|
||||
@@ -87,7 +87,7 @@ func TestPickHRW_单调性(t *testing.T) {
|
||||
assert.InDelta(t, 3.0/13.0, float64(moved)/n, 0.02)
|
||||
}
|
||||
|
||||
func TestUniformHash_值域(t *testing.T) {
|
||||
func TestUniformHash_Range(t *testing.T) {
|
||||
for i := range 10_000 {
|
||||
u := uniformHash(fmt.Sprintf("k-%d", i), int64(i))
|
||||
require.Greater(t, u, 0.0)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func retryPol() RetryPolicy { return DefaultRoutingPolicy().Retry }
|
||||
|
||||
func TestDecide_硬规则优先(t *testing.T) {
|
||||
func TestDecide_HardRulesTakePrecedence(t *testing.T) {
|
||||
p := retryPol()
|
||||
|
||||
// 已向客户端写出响应:任何类别都终止
|
||||
@@ -33,7 +33,7 @@ func TestDecide_硬规则优先(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_客户端错误不重试(t *testing.T) {
|
||||
func TestDecide_ClientErrorNoRetry(t *testing.T) {
|
||||
p := retryPol()
|
||||
d, _ := Decide(ErrClassClient, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{}, p)
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
@@ -41,7 +41,7 @@ func TestDecide_客户端错误不重试(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_瞬时错误原地重试与退避(t *testing.T) {
|
||||
func TestDecide_TransientInPlaceRetryWithBackoff(t *testing.T) {
|
||||
p := retryPol() // inPlaceBudget=2, base=100ms, max=1000ms
|
||||
|
||||
d, backoff := Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 0}, p)
|
||||
@@ -61,14 +61,14 @@ func TestDecide_瞬时错误原地重试与退避(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_MaybeSent禁止原地(t *testing.T) {
|
||||
func TestDecide_MaybeSentForbidsInPlace(t *testing.T) {
|
||||
p := retryPol()
|
||||
// TRANSIENT + MaybeSent:可能已送达,原地重发有重复风险 → 直接 failover
|
||||
d, _ := Decide(ErrClassTransient, DeliveryMaybeSent, ReplayCostly, 0, AttemptState{}, p)
|
||||
assert.Equal(t, DecisionFailover, d)
|
||||
}
|
||||
|
||||
func TestDecide_超时不原地重试(t *testing.T) {
|
||||
func TestDecide_TimeoutNoInPlaceRetry(t *testing.T) {
|
||||
p := retryPol()
|
||||
// TIMEOUT(含 504)不原地重试
|
||||
d, _ := Decide(ErrClassTimeout, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
||||
@@ -82,7 +82,7 @@ func TestDecide_超时不原地重试(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_限流RetryAfter(t *testing.T) {
|
||||
func TestDecide_RateLimitRetryAfter(t *testing.T) {
|
||||
p := retryPol() // rateLimitWaitMax=2000ms
|
||||
|
||||
// Retry-After 短 → 原地等待,退避 = Retry-After
|
||||
@@ -99,7 +99,7 @@ func TestDecide_限流RetryAfter(t *testing.T) {
|
||||
assert.Equal(t, DecisionFailover, d)
|
||||
}
|
||||
|
||||
func TestDecide_凭证轮换(t *testing.T) {
|
||||
func TestDecide_CredentialRotation(t *testing.T) {
|
||||
p := retryPol() // credRotateBudget=1
|
||||
|
||||
// 有备用 Key 且预算未耗尽 → 轮换
|
||||
@@ -120,13 +120,13 @@ func TestDecide_凭证轮换(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_渠道致命零原地(t *testing.T) {
|
||||
func TestDecide_ChannelFatalZeroInPlace(t *testing.T) {
|
||||
p := retryPol()
|
||||
d, _ := Decide(ErrClassChannelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
||||
assert.Equal(t, DecisionFailover, d)
|
||||
}
|
||||
|
||||
func TestDecide_模型致命直接failover(t *testing.T) {
|
||||
func TestDecide_ModelFatalFailsOverDirectly(t *testing.T) {
|
||||
p := retryPol()
|
||||
// 模型致命与渠道致命同语义:零原地重试、立即 failover
|
||||
d, _ := Decide(ErrClassModelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
|
||||
@@ -137,7 +137,7 @@ func TestDecide_模型致命直接failover(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d)
|
||||
}
|
||||
|
||||
func TestDecide_ReplayUnsafe预算收紧(t *testing.T) {
|
||||
func TestDecide_ReplayUnsafeTightensBudget(t *testing.T) {
|
||||
p := retryPol() // unsafe 预算默认 0/0
|
||||
|
||||
// 已收到错误响应(非 NotSent)→ Abort
|
||||
@@ -156,9 +156,9 @@ func TestDecide_ReplayUnsafe预算收紧(t *testing.T) {
|
||||
assert.Equal(t, DecisionAbort, d, "非 NotSent 即使有预算也不允许 failover")
|
||||
}
|
||||
|
||||
// TestDecide_属性_任意错误序列不突破预算 属性测试:模拟协调器的预算记账,
|
||||
// TestDecide_Property_AnyErrorSequenceStaysWithinBudget 属性测试:模拟协调器的预算记账,
|
||||
// 用随机错误序列驱动 FSM,断言任何序列都不会突破预算且必然终止。
|
||||
func TestDecide_属性_任意错误序列不突破预算(t *testing.T) {
|
||||
func TestDecide_Property_AnyErrorSequenceStaysWithinBudget(t *testing.T) {
|
||||
p := retryPol()
|
||||
classes := []ErrorClass{ErrClassClient, ErrClassTransient, ErrClassRateLimit, ErrClassCredential, ErrClassChannelFatal, ErrClassModelFatal, ErrClassTimeout}
|
||||
deliveries := []DeliveryState{DeliveryNotSent, DeliveryMaybeSent, DeliveryResponseReceived}
|
||||
@@ -203,7 +203,7 @@ func TestDecide_属性_任意错误序列不突破预算(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffMs_封顶(t *testing.T) {
|
||||
func TestBackoffMs_Capped(t *testing.T) {
|
||||
p := retryPol() // base=100, max=1000
|
||||
assert.Equal(t, int64(100), backoffMs(0, p))
|
||||
assert.Equal(t, int64(200), backoffMs(1, p))
|
||||
@@ -213,11 +213,11 @@ func TestBackoffMs_封顶(t *testing.T) {
|
||||
assert.Equal(t, int64(1000), backoffMs(60, p), "大次数不溢出")
|
||||
}
|
||||
|
||||
func TestRoutingPolicy_默认值合法(t *testing.T) {
|
||||
func TestRoutingPolicy_DefaultsAreValid(t *testing.T) {
|
||||
assert.NoError(t, DefaultRoutingPolicy().Validate())
|
||||
}
|
||||
|
||||
func TestRoutingPolicy_校验拒绝非法配置(t *testing.T) {
|
||||
func TestRoutingPolicy_ValidateRejectsInvalidConfig(t *testing.T) {
|
||||
mutate := func(f func(*RoutingPolicy)) *RoutingPolicy {
|
||||
p := DefaultRoutingPolicy()
|
||||
f(p)
|
||||
|
||||
@@ -16,7 +16,7 @@ func healthyChannel(id int64, tier Tier, weight float64) Channel {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_健康满分基准(t *testing.T) {
|
||||
func TestEffectiveWeight_FullHealthBaseline(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
w, bd := EffectiveWeight(healthyChannel(1, TierPrimary, 10), pol)
|
||||
assert.InDelta(t, 10.0, w, 1e-9)
|
||||
@@ -27,7 +27,7 @@ func TestEffectiveWeight_健康满分基准(t *testing.T) {
|
||||
assert.Equal(t, 1.0, bd.Ramp)
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_层级偏置(t *testing.T) {
|
||||
func TestEffectiveWeight_TierBias(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
wp, _ := EffectiveWeight(healthyChannel(1, TierPrimary, 10), pol)
|
||||
ws, _ := EffectiveWeight(healthyChannel(2, TierSecondary, 10), pol)
|
||||
@@ -40,7 +40,7 @@ func TestEffectiveWeight_层级偏置(t *testing.T) {
|
||||
assert.Zero(t, wu)
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_健康因子(t *testing.T) {
|
||||
func TestEffectiveWeight_HealthFactor(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy() // alpha=2
|
||||
|
||||
c := healthyChannel(1, TierPrimary, 10)
|
||||
@@ -73,7 +73,7 @@ func TestEffectiveWeight_健康因子(t *testing.T) {
|
||||
assert.Equal(t, 1.0, bd.Health)
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_负载余量(t *testing.T) {
|
||||
func TestEffectiveWeight_HeadroomFactor(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy() // gamma=2
|
||||
|
||||
c := healthyChannel(1, TierPrimary, 10)
|
||||
@@ -99,7 +99,7 @@ func TestEffectiveWeight_负载余量(t *testing.T) {
|
||||
assert.Equal(t, 1.0, bd.Headroom)
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_成本因子(t *testing.T) {
|
||||
func TestEffectiveWeight_CostFactor(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy() // beta=0.5, clamp [0.5, 2.0]
|
||||
|
||||
c := healthyChannel(1, TierPrimary, 10)
|
||||
@@ -120,7 +120,7 @@ func TestEffectiveWeight_成本因子(t *testing.T) {
|
||||
assert.Equal(t, 1.0, bd.Cost)
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_爬坡因子(t *testing.T) {
|
||||
func TestEffectiveWeight_RampFactor(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy() // window=120s, floor=0.05
|
||||
|
||||
c := healthyChannel(1, TierPrimary, 10)
|
||||
@@ -142,7 +142,7 @@ func TestEffectiveWeight_爬坡因子(t *testing.T) {
|
||||
assert.Equal(t, 1.0, bd.Ramp, "非爬坡期恒为 1")
|
||||
}
|
||||
|
||||
func TestEffectiveWeight_基础权重非正(t *testing.T) {
|
||||
func TestEffectiveWeight_NonPositiveBaseWeight(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
c := healthyChannel(1, TierPrimary, 0)
|
||||
w, _ := EffectiveWeight(c, pol)
|
||||
|
||||
@@ -15,7 +15,7 @@ func defaultSessionPolicy() SessionPolicy {
|
||||
return DefaultRoutingPolicy().Session
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_解析链优先级(t *testing.T) {
|
||||
func TestResolveSessionKey_ResolutionChainPriority(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
|
||||
tests := []struct {
|
||||
@@ -38,7 +38,7 @@ func TestResolveSessionKey_解析链优先级(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_Anthropic会话段提取(t *testing.T) {
|
||||
func TestResolveSessionKey_AnthropicSessionSegmentExtraction(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
|
||||
// 含 session UUID:提取段后哈希;两个不同 user 前缀但相同 session → 相同键
|
||||
@@ -56,7 +56,7 @@ func TestResolveSessionKey_Anthropic会话段提取(t *testing.T) {
|
||||
assert.NotEqual(t, a.Key, c.Key)
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_策略开关(t *testing.T) {
|
||||
func TestResolveSessionKey_PolicyToggles(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
pol.ParseAnthropicMetadata = false
|
||||
got := ResolveSessionKey(profileWith(SessionSignals{AnthropicUserID: "user_x_session_11111111-2222-3333-4444-555555555555"}), pol)
|
||||
@@ -68,7 +68,7 @@ func TestResolveSessionKey_策略开关(t *testing.T) {
|
||||
assert.Equal(t, SourceIdentity, got.Source, "关闭 openai 解析后应回退身份级")
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_非法信号跳过(t *testing.T) {
|
||||
func TestResolveSessionKey_SkipsInvalidSignal(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
|
||||
tests := []struct {
|
||||
@@ -87,7 +87,7 @@ func TestResolveSessionKey_非法信号跳过(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_命名空间隔离(t *testing.T) {
|
||||
func TestResolveSessionKey_NamespaceIsolation(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
sig := SessionSignals{HeaderSessionID: "same-session"}
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestResolveSessionKey_命名空间隔离(t *testing.T) {
|
||||
assert.NotEqual(t, ResolveSessionKey(p1, pol).Key, ResolveSessionKey(p3, pol).Key, "不同模型的绑定应互相独立")
|
||||
}
|
||||
|
||||
func TestResolveSessionKey_确定性(t *testing.T) {
|
||||
func TestResolveSessionKey_Deterministic(t *testing.T) {
|
||||
pol := defaultSessionPolicy()
|
||||
p := profileWith(SessionSignals{HeaderSessionID: "stable"})
|
||||
first := ResolveSessionKey(p, pol)
|
||||
|
||||
Reference in new issue
Block a user