File size: 8,082 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | 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)
}
}
|