gcli2api / internal /pool /transition_test.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
8.08 kB
package pool
import (
"testing"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)
// 状态机迁移正交性测试:聚焦 transition.go 收敛出的「单一权威状态机」语义。
// entry 的可选择性由四个正交维度决定(disabled / 冷却域 until+coolKind+softStreak+
// modelCooldowns / 熔断器 fails+retryCount+breakerUntil / sessionDeadFails),本文件
// 锁定迁移原语对这四个维度的边界,特别是旧实现的缺陷点:
// - Disable 旧实现只置 disabled+reason,不碰 until/modelCooldowns → 「disabled 但
// cooling」杂交态(疑点 4)。新语义:disableLocked 置 disabled 并清冷却域。
// - reviveCoolingLocked(签到解冻)只清冷却域、不动熔断器(C5 语义)。
//
// 与 pool_test.go / modelcooldown_test.go / sessiondead_test.go 的差异:那些测试
// 锁定各维度的行为,本文件锁定「跨维度」的迁移边界(冷却↔禁用↔熔断互不越界)。
// 直接读取 entry 的冷却域原始字段(Status 不暴露 coolKind 数值与 modelCooldowns)。
func coolingDomain(t *testing.T, p *Pool, uid string) (until time.Time, coolKind CoolKind, reason string, softStreak int, modelCooldowns int) {
t.Helper()
p.mu.RLock()
defer p.mu.RUnlock()
e, ok := p.byUID[uid]
if !ok {
t.Fatalf("uid %s missing", uid)
}
return e.until, e.coolKind, e.reason, e.softStreak, len(e.modelCooldowns)
}
// TestTransitionDisableClearsCoolingDomain 疑点 4 修正:先冷却(软冷却 + 6004 模型级
// 冷却)再 Disable → 冷却域全清(until/coolKind/reason/softStreak/modelCooldowns),
// 只留 disabled 终态。旧实现只置 disabled+reason,会出现「disabled=true 但 cooling=true」
// 与残留 modelCooldowns 的杂交态。
func TestTransitionDisableClearsCoolingDomain(t *testing.T) {
p := New("")
p.Add(&auth.Auth{UID: "u1"})
p.Cooldown("u1", CoolSoft, 600*time.Second, "429") // until + coolKind=soft + softStreak=1
p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004") // modelCooldowns=1(softStreak 增到 2)
_, _, reason, _, mc := coolingDomain(t, p, "u1")
if reason == "" || mc != 1 {
t.Fatalf("precondition: 应先处于软冷却+模型级冷却态 (reason=%q modelCooldowns=%d)", reason, mc)
}
p.Disable("u1", "account banned")
st, _ := p.Status("u1")
until, kind, reason, streak, mc := coolingDomain(t, p, "u1")
if !st.Disabled {
t.Fatal("Disable 后应 disabled")
}
if !until.IsZero() {
t.Errorf("Disable 后 until=%v 应为零值(冷却域清零)", until)
}
if kind != 0 {
t.Errorf("Disable 后 coolKind=%v 应为零值", kind)
}
if reason != "account banned" {
t.Errorf("Disable 后 reason=%q 应为禁用原因", reason)
}
if streak != 0 {
t.Errorf("Disable 后 softStreak=%d 应为 0(冷却域清零)", streak)
}
if mc != 0 {
t.Errorf("Disable 后 modelCooldowns=%d 应为 0(模型豁免随冷却域清零)", mc)
}
if st.Cooling {
t.Errorf("Disable 后不应呈现 cooling(禁用是更强终态,绝不杂交): %+v", st)
}
}
// TestTransitionDisablePreservesBreaker 疑点 4 修正的另一半:Disable 只清冷却域、
// 不动熔断器。熔断是「连续 5xx 失败」信号(与授权/session 正交),禁用后再复活时
// 熔断观测仍有效,不应被禁用覆盖。
func TestTransitionDisablePreservesBreaker(t *testing.T) {
p := New("")
p.Add(&auth.Auth{UID: "u1"})
p.SetBreaker(1, time.Hour, time.Hour)
p.NoteError("u1") // 触发熔断(fails=0, retryCount=1, breakerUntil=1h)
bt, ok := p.breakerUntil("u1")
if !ok || bt.IsZero() {
t.Fatal("precondition: breaker should be open")
}
p.Disable("u1", "session dead")
if bt, ok := p.breakerUntil("u1"); !ok || bt.IsZero() {
t.Fatal("Disable 后 breakerUntil 应保留(熔断与禁用正交)")
}
if fails := p.breakerFails("u1"); fails != 0 {
t.Errorf("Disable 后 fails=%d(已置 0,不应被 Disable 刻意改动)", fails)
}
// disabled 优先:即使熔断仍在,账号也不可选。
if got := p.Pick(); got != nil {
t.Fatalf("disabled 账号不可选(含熔断期),got %+v", got)
}
}
// TestTransitionSessionDeadDisableClearsCooling session 死亡走 NoteSessionDead 连续
// 计数,达阈值后经 disableLocked:冷却域一并清零,不得留下「disabled 但仍 cooling」
// 的杂交态(此前 handler 走 Disable、阈值路径却残留冷却,同一信号不同处置)。
func TestTransitionSessionDeadDisableClearsCooling(t *testing.T) {
p := New("")
p.Add(&auth.Auth{UID: "u1"})
p.Cooldown("u1", CoolSoft, time.Hour, "429 rate limit") // 软冷却中
until, _, _, _, _ := coolingDomain(t, p, "u1")
if until.IsZero() {
t.Fatal("precondition: 应处于软冷却")
}
if p.NoteSessionDead("u1") || p.NoteSessionDead("u1") || !p.NoteSessionDead("u1") {
t.Fatal("第 3 次 NoteSessionDead 应达阈值禁用")
}
st, _ := p.Status("u1")
if !st.Disabled {
t.Fatal("应 disabled")
}
if st.Cooling || !st.Until.IsZero() || st.SoftStreak != 0 {
t.Errorf("session dead 禁用应清冷却域(无杂交态): Cooling=%v Until=%v SoftStreak=%d",
st.Cooling, st.Until, st.SoftStreak)
}
if st.DisabledReason != sessionDeadReason {
t.Errorf("disabled_reason=%q want %q", st.DisabledReason, sessionDeadReason)
}
}
// TestTransitionReviveKeepsSoftCoolingKeepsBreaker reviveCoolingLocked(签到/余额
// 刷新解冻)语义:只解冻余额耗尽冷却(CoolHard),软限流冷却与 6004 模型级台账
// **保留**(限流恢复证据是重置墙钟到期,不是余额恢复)+ 更新 credits,不动熔断。
// 既有单维度测试已各自锁定 reason/softStreak/modelCooldowns,本用例一次性断言完整
// 字段集,锁定迁移原语对冷却域/熔断域的处置永远一致。
func TestTransitionReviveKeepsSoftCoolingKeepsBreaker(t *testing.T) {
p := New("")
p.Add(&auth.Auth{UID: "u1"})
// 冷却域:软冷却 + 6004 模型级冷却(softStreak 累计)。
p.Cooldown("u1", CoolSoft, 600*time.Second, "429")
p.CooldownSoftForModel("u1", time.Minute, time.Now().Add(5*time.Minute), "glm-5.3", "6004")
// 熔断域:独立信号,签到不解冻。
p.SetBreaker(1, time.Hour, time.Hour)
p.NoteError("u1")
p.ReenableIfCredits("u1", 700, 0)
st, _ := p.Status("u1")
if st.Credits != 700 {
t.Errorf("revive 后 credits=%d want 700", st.Credits)
}
until, kind, reason, streak, mc := coolingDomain(t, p, "u1")
if until.IsZero() || kind == 0 || reason == "" {
t.Errorf("revive 不得解除软限流冷却:until=%v kind=%v reason=%q", until, kind, reason)
}
if mc == 0 {
t.Error("revive 不得清 6004 模型级台账(余额恢复不构成限流解除证据)")
}
_ = streak // 本场景 streak 恒 0(Cooldown 固定时长入口不累计、带 resetAt 的模型级写入也不累计)
if bt, ok := p.breakerUntil("u1"); !ok || bt.IsZero() {
t.Fatal("revive 不得清熔断(chat 通道健康未证明)")
}
// 熔断域保留 → 账号不进 normal 候选(仅全冷却兜底仍可能选中,与熔断兜底语义一致)。
if p.internalHealthy("u1") {
t.Fatal("revive 后熔断期内不应 healthy(熔断域未被签到覆盖)")
}
}
// TestTransitionReviveUnfreezesHardCooling 余额耗尽冷却(CoolHard)才是
// ReenableIfCredits 的解冻对象:余额恢复(remain>0)正是它的权威恢复证据。
func TestTransitionReviveUnfreezesHardCooling(t *testing.T) {
p := New("")
p.Add(&auth.Auth{UID: "u1"})
p.CooldownUntilTomorrow4AM("u1", "余额不足")
p.ReenableIfCredits("u1", 700, 0)
until, kind, reason, _, _ := coolingDomain(t, p, "u1")
if !until.IsZero() || kind != 0 || reason != "" {
t.Errorf("余额恢复应解冻 CoolHard:until=%v kind=%v reason=%q", until, kind, reason)
}
}