Files
huya-go/internal/sniper/optimization_test.go
T

532 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sniper
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"huya-go/internal/huya"
)
// TestClassifyExchangeThrottleTexts 覆盖 2026-10-16 三台机器日志里真实出现过的限流文案。
//
// 这些文案过去全部落到 failed:既看不出被限流,也享受不到限流该有的退避语义
// (pc-1 的 1421 次请求里 917 次「活动太火爆」+ 244 次「领取频率限制」)。
func TestClassifyExchangeThrottleTexts(t *testing.T) {
throttleTexts := []string{
"活动太火爆了,请稍后重试",
"领取频率限制,请稍后重试!!",
"请求太频繁,请稍后重试",
"操作频繁,请稍后再试",
"当前网络繁忙,请稍后重试",
"您点击太快,请稍等再试",
}
for _, text := range throttleTexts {
resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: text}
if got := ClassifyExchange(resp, nil); got != CategoryTooFrequent {
t.Fatalf("ClassifyExchange(%q)=%s, want %s", text, got, CategoryTooFrequent)
}
}
}
// TestClassifyExchangeOrderPrefersSpecificReason 验证具体原因优先于限流:
// 「库存不足,请稍后重试」必须判成售罄而不是限流。
func TestClassifyExchangeOrderPrefersSpecificReason(t *testing.T) {
cases := []struct {
text string
want string
}{
{text: "库存不足,请稍后重试", want: CategorySoldOut},
{text: "该奖品已抢完", want: CategorySoldOut},
{text: "兑换货币不足,请参与活动获得兑换货币", want: CategoryNotEnough},
{text: "奖品暂未开放兑换", want: CategoryNotOpen},
{text: "账号未绑定!", want: CategorySessionLost},
{text: "登录态已失效,请重新登录", want: CategorySessionLost},
{text: "奖品已下架", want: CategoryOffShelf},
}
for _, tt := range cases {
resp := &huya.ScoreExchangePrizeResp{Status: 500, Msg: tt.text}
if got := ClassifyExchange(resp, nil); got != tt.want {
t.Fatalf("ClassifyExchange(%q)=%s, want %s", tt.text, got, tt.want)
}
}
}
// TestClassifyExchangeProtocolError 验证协议层错误与普通失败分开归类。
func TestClassifyExchangeProtocolError(t *testing.T) {
protocolErr := fmt.Errorf("%w: webActUI.scoreExchangePrize 响应缺少 tRsp/tResp(iRet=-1 keys={tErr})", huya.ErrProtocolResponse)
if got := ClassifyExchange(nil, protocolErr); got != CategoryProtocol {
t.Fatalf("协议错误应归为 %s,实际 %s", CategoryProtocol, got)
}
if got := ClassifyExchange(nil, errors.New("timeout")); got != CategoryError {
t.Fatalf("普通网络错误应归为 %s,实际 %s", CategoryError, got)
}
}
// TestCategoryLabelAndLogLevel 验证新增分类的标签与日志等级映射。
func TestCategoryLabelAndLogLevel(t *testing.T) {
if got := CategoryLabel(CategoryProtocol); got != "协议异常" {
t.Fatalf("CategoryLabel(protocol)=%q", got)
}
if got := LogLevelForCategory(CategoryProtocol); got != "error" {
t.Fatalf("协议异常应为 error 级,实际 %s", got)
}
if got := LogLevelForCategory(CategorySessionLost); got != "warn" {
t.Fatalf("会话失效应为 warn 级,实际 %s", got)
}
}
// TestRetryEscalationMath 验证退避阶梯的两个纯函数。
func TestRetryEscalationMath(t *testing.T) {
wantStreak := map[int]int{1: 1, 2: 1, 3: 2, 4: 4, 5: 4, 9: 4}
for streak, want := range wantStreak {
if got := streakMultiplier(streak); got != want {
t.Fatalf("streakMultiplier(%d)=%d, want %d", streak, got, want)
}
}
wantStage := map[time.Duration]int{
500 * time.Millisecond: 1,
3 * time.Second: 2,
6 * time.Second: 3,
}
for elapsed, want := range wantStage {
if got := stageMultiplier(elapsed, CategoryFailed); got != want {
t.Fatalf("stageMultiplier(%v)=%d, want %d", elapsed, got, want)
}
}
// 正常等待类(售罄/未开放)放慢得更保守,避免错过库存回流。
if got := stageMultiplier(6*time.Second, CategorySoldOut); got != 2 {
t.Fatalf("售罄阶段倍率=%d, want 2", got)
}
}
// TestThrottleGovernorEscalatesAndRecovers 验证全局降速器:连续无进展才降速、有进展立刻回退。
func TestThrottleGovernorEscalatesAndRecovers(t *testing.T) {
g := newThrottleGovernor()
g.minRequests = 2
g.stallWindow = time.Millisecond
g.lastProgress = time.Now().Add(-time.Second)
g.lastEscalate = time.Now().Add(-time.Second)
g.observe(CategoryTooFrequent)
if got := g.current(); got != 1 {
t.Fatalf("未达阈值不该降速,factor=%v", got)
}
g.observe(CategoryTooFrequent)
if got := g.current(); got <= 1 {
t.Fatalf("连续无进展应开始降速,factor=%v", got)
}
if got := g.peak(); got <= 1 {
t.Fatalf("peak 应记录最大倍率,实际 %v", got)
}
before := g.current()
g.observe(CategorySuccess)
if got := g.current(); got >= before {
t.Fatalf("有进展后应回退,factor 从 %v 到 %v", before, got)
}
if got := g.current(); got < 1 {
t.Fatalf("倍率不应小于 1,实际 %v", got)
}
}
// TestRunStateFirstWaveStagger 验证首发错峰:第 0 个账号立即发,其余按步长错开,每个账号只生效一次。
func TestRunStateFirstWaveStagger(t *testing.T) {
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}, {UID: 3, Cookie: "yyuid=3"}}
state := newRunState(accounts, 1, 1, Config{
OpenAt: time.Now(),
FirstWaveSpread: DefaultFirstWaveSpread,
FirstWaveMaxStep: DefaultFirstWaveMaxStep,
})
want := []time.Duration{0, DefaultFirstWaveMaxStep, 2 * DefaultFirstWaveMaxStep}
for index, account := range accounts {
if got := state.takeFirstWaveDelay(account.UID); got != want[index] {
t.Fatalf("账号 %d 首发等待=%v, want %v", account.UID, got, want[index])
}
}
if got := state.takeFirstWaveDelay(1); got != 0 {
t.Fatalf("同一账号不应重复错峰,实际 %v", got)
}
}
// TestRunTargetsWindowModeKeepsTryingUntilDeadline 是 P1 的核心回归:
// 账号很少时,旧实现会「20 轮 × 150ms」在开抢前把预算烧光(pc-3 instance-4 0 成功),
// 窗口模式必须一直打到窗口结束。
func TestRunTargetsWindowModeKeepsTryingUntilDeadline(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
},
}
runner := Runner{Client: client}
started := time.Now()
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
OpenAt: started,
windowOverride: 120 * time.Millisecond,
Loops: 0, // 窗口模式:不限制轮次
Delay: 5 * time.Millisecond,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
if elapsed := time.Since(started); elapsed < 100*time.Millisecond {
t.Fatalf("窗口模式应跑到窗口结束,实际只跑了 %v", elapsed)
}
_, exchangeCount, _ := client.counts()
if exchangeCount < 5 {
t.Fatalf("窗口内应持续尝试,实际只有 %d 次", exchangeCount)
}
}
// TestRunTargetsPrefireOnceBeforeOpen 验证开抢前只预打一轮,然后静默等到窗口起点。
func TestRunTargetsPrefireOnceBeforeOpen(t *testing.T) {
var mu sync.Mutex
var calls []time.Time
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
mu.Lock()
calls = append(calls, time.Now())
mu.Unlock()
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, nil
},
}
runner := Runner{Client: client}
// 触发时刻远早于窗口起点:应当先预打一轮,再静默等到窗口起点。
openAt := time.Now().Add(500 * time.Millisecond)
started := time.Now()
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
OpenAt: openAt,
windowOverride: 60 * time.Millisecond,
Loops: 0,
Delay: 5 * time.Millisecond,
StartAhead: 20 * time.Millisecond,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
mu.Lock()
captured := append([]time.Time(nil), calls...)
mu.Unlock()
if len(captured) < 2 {
t.Fatalf("应有预打 + 窗口内重试,实际 %d 次", len(captured))
}
windowStart := openAt.Add(-20 * time.Millisecond)
if !captured[0].Before(windowStart.Add(-50 * time.Millisecond)) {
t.Fatalf("第一次请求应明显早于窗口起点(预打),实际 %v(窗口起点 %v)", captured[0], windowStart)
}
for _, at := range captured[1:] {
if at.Before(windowStart.Add(-30 * time.Millisecond)) {
t.Fatalf("预打之后应等到窗口起点,实际在 %v(窗口起点 %v)", at, windowStart)
}
}
if elapsed := time.Since(started); elapsed < 500*time.Millisecond {
t.Fatalf("应跑到窗口结束(%v),实际 %v", openAt.Add(60*time.Millisecond), elapsed)
}
}
// TestRunTargetsSkipsPrefireNearWindowStart 验证触发时刻与窗口起点重合时不会把首发齐射打两遍。
func TestRunTargetsSkipsPrefireNearWindowStart(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
openAt := time.Now().Add(60 * time.Millisecond)
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
OpenAt: openAt,
windowOverride: 40 * time.Millisecond,
Loops: 1, // 只跑一轮,用于观察首发是否被重复
StartAhead: 60 * time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
if _, exchangeCount, _ := client.counts(); exchangeCount != 1 {
t.Fatalf("窗口起点附近不应额外预打,实际请求 %d 次", exchangeCount)
}
}
// TestRunTargetsWindowAlreadyExpiredSkips 验证窗口已过时不再发请求。
func TestRunTargetsWindowAlreadyExpiredSkips(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
started := time.Now()
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
OpenAt: time.Now().Add(-2 * time.Second),
windowOverride: time.Second,
Loops: 0,
}, nil)
if err != nil {
t.Fatal(err)
}
if _, exchangeCount, _ := client.counts(); exchangeCount != 0 {
t.Fatalf("窗口已过不该发请求,实际 %d 次", exchangeCount)
}
if elapsed := time.Since(started); elapsed > 200*time.Millisecond {
t.Fatalf("窗口已过应立即返回,实际 %v", elapsed)
}
}
// TestRunTargetsSessionLostStopsAfterLimit 验证会话失效连续多次后停止该账号。
func TestRunTargetsSessionLostStopsAfterLimit(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "账号未绑定!"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 10,
Delay: 2 * time.Millisecond,
ErrorBackoff: time.Millisecond,
SessionLostLimit: 3,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 3 {
t.Fatalf("连续失效达到阈值应停止该账号,实际请求 %d 次", exchangeCount)
}
}
// TestRunTargetsSummaryIncludesRequestAndAccountStats 验证接口统计与逐账号结局。
func TestRunTargetsSummaryIncludesRequestAndAccountStats(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return nil, errors.New("网络抖动")
},
}
var summary RunSummary
runner := Runner{Client: client, OnSummary: func(item RunSummary) { summary = item }}
err := runner.RunTargets(context.Background(), []Account{{UID: 7, Cookie: "yyuid=7"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 2,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
StopAfterSuccess: true,
}, func(Result) {})
if err != nil {
t.Fatal(err)
}
var exchangeStat *RequestStat
for index := range summary.RequestStats {
if summary.RequestStats[index].Kind == reqExchange.String() {
exchangeStat = &summary.RequestStats[index]
}
}
if exchangeStat == nil {
t.Fatalf("缺少兑换接口统计: %+v", summary.RequestStats)
}
if exchangeStat.Count < 1 || exchangeStat.Failed != exchangeStat.Count || exchangeStat.LastErr == "" {
t.Fatalf("兑换接口统计不正确: %+v", *exchangeStat)
}
if exchangeStat.Slowest <= 0 {
t.Fatalf("应记录最慢耗时: %+v", *exchangeStat)
}
if len(summary.AccountOutcomes) != 1 {
t.Fatalf("应有 1 条账号结局,实际 %+v", summary.AccountOutcomes)
}
outcome := summary.AccountOutcomes[0]
if outcome.UID != 7 || outcome.Category != CategoryError || outcome.Attempts < 1 {
t.Fatalf("账号结局不正确: %+v", outcome)
}
}
// TestPlanTriggerDynamicAdvance 验证定时触发计划:动态提前量、预热/复校/窗口时刻。
func TestPlanTriggerDynamicAdvance(t *testing.T) {
target := time.Date(2026, 10, 16, 17, 0, 0, 0, time.Local)
calibration := huya.TimeCalibration{
Offset: 900 * time.Millisecond, // 本机比服务端慢 0.9s(pc-3 实测)
ErrorBound: 30 * time.Millisecond,
RTT: 60 * time.Millisecond,
}
plan := PlanTrigger(target, calibration, 0, 30*time.Second)
if want := 60 * time.Millisecond; plan.Advance != want {
t.Fatalf("动态提前量=%v, want %v", plan.Advance, want)
}
wantOpen := target.Add(-900 * time.Millisecond)
if !plan.OpenAt.Equal(wantOpen) {
t.Fatalf("开抢时刻=%v, want %v", plan.OpenAt, wantOpen)
}
if !plan.TriggerAt.Equal(wantOpen.Add(-60 * time.Millisecond)) {
t.Fatalf("触发时刻=%v", plan.TriggerAt)
}
if !plan.WarmupAt.Equal(wantOpen.Add(-WarmupLead)) {
t.Fatalf("预热时刻=%v", plan.WarmupAt)
}
if !plan.ResyncAt.Equal(wantOpen.Add(-ResyncLead)) {
t.Fatalf("复校时刻=%v", plan.ResyncAt)
}
if !plan.WindowEnd.Equal(wantOpen.Add(30 * time.Second)) {
t.Fatalf("窗口结束=%v", plan.WindowEnd)
}
if got := PlanTrigger(target, calibration, 2500*time.Millisecond, time.Second).Advance; got != 2500*time.Millisecond {
t.Fatalf("显式提前量应覆盖动态值,实际 %v", got)
}
if got := PlanTrigger(target, huya.TimeCalibration{}, 0, time.Second).Advance; got != MinTriggerAdvance {
t.Fatalf("提前量下限=%v, want %v", got, MinTriggerAdvance)
}
if got := WindowDuration(0); got != DefaultWindowSeconds*time.Second {
t.Fatalf("默认窗口=%v", got)
}
}
// TestRunTargetsPerAccountLoopsAreIndependent 是「每账号独立循环」的核心回归:
// 一个账号卡在慢请求上时,其他账号必须继续按自己的节奏推进。
//
// 旧实现按「一轮打完等所有人」(wg.Wait)同步,账号 1 的慢请求会把账号 2 的第二轮
// 死死卡在起跑线上;账号少的实例尤其吃亏(pc-3 instance-4 因此 0 成功)。
func TestRunTargetsPerAccountLoopsAreIndependent(t *testing.T) {
blocked := make(chan struct{})
entered := make(chan struct{})
var enterOnce sync.Once
var slowExchanges, fastExchanges int64
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
if uid == 1 {
atomic.AddInt64(&slowExchanges, 1)
enterOnce.Do(func() { close(entered) })
<-blocked
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
}
atomic.AddInt64(&fastExchanges, 1)
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
},
}
runner := Runner{Client: client}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
done := make(chan error, 1)
go func() {
done <- runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{
SID: 2203,
Loops: 30,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
Concurrency: 4,
}, nil)
}()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("账号 1 的慢请求未开始")
}
// 账号 1 仍阻塞在第一个请求里:账号 2 应当已经独立跑完多轮。
deadline := time.Now().Add(2 * time.Second)
for atomic.LoadInt64(&fastExchanges) < 3 && time.Now().Before(deadline) {
time.Sleep(2 * time.Millisecond)
}
got := atomic.LoadInt64(&fastExchanges)
close(blocked)
if err := <-done; err != nil {
t.Fatal(err)
}
if got < 3 {
t.Fatalf("账号 1 阻塞期间账号 2 只完成了 %d 次兑换,独立循环应继续推进(want >= 3)", got)
}
}
// TestRunTargetsSharedListTTLSingleFlight 验证共享列表快照是「TTL + 单飞」:
// 多个账号多轮里只查一次列表,既不被单次抖动拖垮,也不把名单打爆。
//
// 只做单飞(无 TTL)时每轮都会重新查询,2 账号 × 3 轮最坏要 6 次。
func TestRunTargetsSharedListTTLSingleFlight(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}},
},
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "活动太火爆了,请稍后重试"}, nil
},
}
runner := Runner{Client: client}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
err := runner.RunTargets(context.Background(), accounts, []Target{{Name: "小黄鸭"}}, Config{
SID: 2203,
Loops: 3,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
TooFrequentBackoff: time.Millisecond,
Concurrency: 4,
ListBeforeExchange: true,
SharePrizeList: true,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, _, _ := client.counts()
if listCount != 1 {
t.Fatalf("共享列表应只查一次(TTL 内复用),实际 %d 次", listCount)
}
}
// TestRunStateClipToDeadline 验证等待时间被裁剪到窗口结束,不会睡过 deadline。
func TestRunStateClipToDeadline(t *testing.T) {
state := &runState{deadline: time.Now().Add(50 * time.Millisecond)}
if got := state.clipToDeadline(10 * time.Millisecond); got != 10*time.Millisecond {
t.Fatalf("窗口内等待不应被改动,实际 %v", got)
}
if got := state.clipToDeadline(time.Second); got <= 0 || got > 60*time.Millisecond {
t.Fatalf("超出窗口的等待应被裁剪,实际 %v", got)
}
if got := (&runState{deadline: time.Now().Add(-time.Millisecond)}).clipToDeadline(time.Second); got != 0 {
t.Fatalf("窗口已过应不再等待,实际 %v", got)
}
if got := (&runState{}).clipToDeadline(time.Second); got != time.Second {
t.Fatalf("非窗口模式不应裁剪,实际 %v", got)
}
}
// TestRunStateAccountFinishedAndRoundTracking 覆盖独立循环新增的两个判定:
// accountFinished(账号自己打完就退出,同时尊重全局目标终止)与最大轮次统计。
func TestRunStateAccountFinishedAndRoundTracking(t *testing.T) {
state := newRunState([]Account{{UID: 1}}, 2, 1, Config{})
if state.accountFinished(1, 2) {
t.Fatal("目标都没完成时不应判定账号结束")
}
state.accounts[1][0].Done = true
if state.accountFinished(1, 2) {
t.Fatal("还有一个目标未完成时不应判定账号结束")
}
state.accounts[1][1].Done = true
if !state.accountFinished(1, 2) {
t.Fatal("所有目标完成后应判定账号结束")
}
// 全局目标终止(售罄 / 下架)后,即使本账号状态未落,也算完成。
global := newRunState([]Account{{UID: 2}}, 1, 1, Config{})
global.targetDone[0] = true
if !global.accountFinished(2, 1) {
t.Fatal("全局目标已终止时账号应判定结束")
}
rounds := newRunState([]Account{{UID: 3}}, 1, 1, Config{})
rounds.noteRound(3)
rounds.noteRound(2)
rounds.noteRound(7)
if got := rounds.maxRound(); got != 7 {
t.Fatalf("最大轮次=%d, want 7", got)
}
}