Files
huya-go/internal/sniper/sniper_test.go
T
yml2213 6e5beac151 refactor(sniper): 对齐 dygou_go 调度架构,增加双时间日志并解耦预热
- 抢购调度重构:参考 dygou_go 将 sniper 拆分为独立子模块(runner/run_control/retry/exchange/classify/config/summary/category/helpers);移除全局自适应降速与轮次上限,采用账号独立循环、统一账号级退避、单请求并发闸门与首发错峰
- 预热与建连优化:默认关闭预热(避免单账号超时连坐整机开抢),开启时转为后台非阻塞协程;WebSocket 握手超时从 10s 降至 1500ms
- 双时间日志:logger 增加 SetServerOffset,校准后日志前缀自动渲染「本地时间(H虎牙估算时间)」,实现跨机器绝对毫秒对齐
- 默认配置调优:版本提升至 v0.5.0,默认线程调为 2(缓解多实例并发限流),前端自动平滑迁移
- 验证方式:go test ./... -count=1 全通,npm run build 构建正常,单测覆盖双时间与预热配置
2026-10-17 18:29:17 +08:00

668 lines
21 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"
"sync"
"sync/atomic"
"testing"
"time"
"huya-go/internal/huya"
)
type fakeActivityClient struct {
mu sync.Mutex
listCount int
exchangeCount int
prewarmCount int
scoreCount int
recordCount int
listResp *huya.GetActPrizeListResp
exchangeFn func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error)
}
func (f *fakeActivityClient) GetActPrizeList(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetActPrizeListResp, error) {
f.mu.Lock()
f.listCount++
f.mu.Unlock()
if f.listResp != nil {
return f.listResp, nil
}
return &huya.GetActPrizeListResp{}, nil
}
func (f *fakeActivityClient) QueryUserScore(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserScoreResp, error) {
f.mu.Lock()
f.scoreCount++
f.mu.Unlock()
return &huya.GetUserScoreResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) GetUserPrizeRecords(ctx context.Context, uid int64, cookie string, sid int32) (*huya.GetUserPrizeRecordsResp, error) {
f.mu.Lock()
f.recordCount++
f.mu.Unlock()
return &huya.GetUserPrizeRecordsResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) ScoreExchangePrize(ctx context.Context, uid int64, cookie string, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
f.mu.Lock()
f.exchangeCount++
f.mu.Unlock()
if f.exchangeFn != nil {
return f.exchangeFn(uid, sid, pid)
}
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
}
func (f *fakeActivityClient) PrewarmActivity(ctx context.Context, uid int64, cookie string, sid int32) error {
f.mu.Lock()
f.prewarmCount++
f.mu.Unlock()
return nil
}
func (f *fakeActivityClient) counts() (int, int, int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.listCount, f.exchangeCount, f.prewarmCount
}
func TestClassifyExchange(t *testing.T) {
tests := []struct {
name string
resp *huya.ScoreExchangePrizeResp
err error
want string
}{
{name: "success", resp: &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, want: CategorySuccess},
{name: "frequent", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, want: CategoryTooFrequent},
{name: "not open", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品暂未开放兑换"}, want: CategoryNotOpen},
{name: "not enough", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "兑换货币不足"}, want: CategoryNotEnough},
{name: "already", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "已兑换该奖品"}, want: CategoryAlreadyDone},
{name: "off shelf", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "奖品已下架"}, want: CategoryOffShelf},
{name: "sold out", resp: &huya.ScoreExchangePrizeResp{Status: 500, Msg: "库存不足"}, want: CategorySoldOut},
{name: "error", err: errors.New("timeout"), want: CategoryError},
{
name: "success with order id",
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "sOrderId_2203_12889_1_1783328404_957"},
want: CategorySuccess,
},
{
name: "not enough in precondition tip",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
Msg: "兑换失败",
ActPreCondition: huya.ActPreCondition{Tip: "积分不足,无法兑换"},
},
want: CategoryNotEnough,
},
{
name: "not enough in condition params",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
Msg: "兑换失败",
ActPreCondition: huya.ActPreCondition{ConditionParams: map[string]string{"tip": "余额不足"}},
},
want: CategoryNotEnough,
},
{
name: "condition gate without text",
resp: &huya.ScoreExchangePrizeResp{
Status: 500,
ActPreCondition: huya.ActPreCondition{ConditionType: 2},
},
want: CategoryCondition,
},
{name: "empty response", resp: &huya.ScoreExchangePrizeResp{Status: 500}, want: CategoryFailed},
{name: "nil response", resp: nil, want: CategoryFailed},
{
name: "text wins over order id",
resp: &huya.ScoreExchangePrizeResp{Status: 200, OrderID: "order", Msg: "库存不足"},
want: CategorySoldOut,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyExchange(tt.resp, tt.err); got != tt.want {
t.Fatalf("ClassifyExchange()=%s, want %s", got, tt.want)
}
})
}
}
func TestRunTargetsDirectExchangeSkipsListAndStopsPerAccount(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 5,
Delay: time.Millisecond,
Concurrency: 2,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, exchangeCount, _ := client.counts()
if listCount != 0 {
t.Fatalf("listCount=%d, want 0", listCount)
}
if exchangeCount != len(accounts) {
t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts))
}
}
func TestRunTargetsTerminalNotEnoughStopsAccountTarget(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,
maxAttempts: 4,
Delay: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1", exchangeCount)
}
}
func TestRunTargetsTooFrequentBackoffDelaysActualAttempts(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,
maxAttempts: 2,
Delay: time.Millisecond,
TooFrequentBackoff: 50 * time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 2 {
t.Fatalf("exchangeCount=%d, want 2(maxAttempts 应统计真实尝试)", exchangeCount)
}
if elapsed := time.Since(started); elapsed < 45*time.Millisecond {
t.Fatalf("退避没有生效,耗时=%v", elapsed)
}
}
func TestRunTargetsSharedListForNameTargets(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Prizes: []huya.PrizeInfo{{ID: 12889, SID: 2203, Name: "套装-潮萌小黄鸭", NewScore: 988, LeftNum: 10}},
},
}
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,
maxAttempts: 1,
Concurrency: 2,
StopAccountAfterSuccess: true,
ListBeforeExchange: true,
SharePrizeList: true,
}, nil)
if err != nil {
t.Fatal(err)
}
listCount, exchangeCount, _ := client.counts()
if listCount != 1 {
t.Fatalf("listCount=%d, want 1", listCount)
}
if exchangeCount != len(accounts) {
t.Fatalf("exchangeCount=%d, want %d", exchangeCount, len(accounts))
}
}
func TestRunTargetsAutoSelectPrefersPrizeWithLeft(t *testing.T) {
var gotPID int32
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Prizes: []huya.PrizeInfo{
{ID: 1, SID: 2203, Name: "无剩余", NewScore: 10, LeftNum: 0},
{ID: 2, SID: 2203, Name: "有剩余", NewScore: 20, LeftNum: 5},
},
},
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
gotPID = pid
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{
SID: 2203,
maxAttempts: 1,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
if gotPID != 2 {
t.Fatalf("gotPID=%d, want 2", gotPID)
}
}
// TestRunTargetsCategoryFilter 验证按类目限定后只会在该类的商品里成交。
func TestRunTargetsCategoryFilter(t *testing.T) {
var gotPID int32
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Tags: []huya.PrizeTag{
{ID: 101, Name: "精英专享", Weight: 2},
{ID: 99, Name: "限量返场", Weight: 1},
},
Prizes: []huya.PrizeInfo{
{ID: 13415, Name: "套装-雪山精英", NewScore: 600, LeftNum: 100, Tags: "101"},
{ID: 13433, Name: "怪诞乔克服装礼包", NewScore: 8000, LeftNum: 50, Tags: "99"},
},
},
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
gotPID = pid
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Auto: true}}, Config{
SID: 2277,
Category: "限量返场",
maxAttempts: 1,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
if gotPID != 13433 {
t.Fatalf("gotPID=%d, want 13433(应只在限量返场里选品)", gotPID)
}
}
// TestRunTargetsCategoryFilterMisses 验证类目里没有目标时给出带类目的未命中提示。
func TestRunTargetsCategoryFilterMisses(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Msg: "请求成功",
Tags: []huya.PrizeTag{{ID: 99, Name: "限量返场", Weight: 1}},
Prizes: []huya.PrizeInfo{{ID: 13415, Name: "套装-雪山精英", NewScore: 600, Tags: "101"}},
},
}
results := make([]Result, 0, 4)
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{Name: "雪山"}}, Config{
SID: 2277,
Category: "限量返场",
maxAttempts: 1,
ListBeforeExchange: true,
}, func(result Result) {
results = append(results, result)
})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Category != CategoryNotFound {
t.Fatalf("results=%+v, want 单条 not_found", results)
}
if results[0].Message != "类目[限量返场]内未查询到所需物品,继续监控" {
t.Fatalf("Message=%q", results[0].Message)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 0 {
t.Fatalf("exchangeCount=%d, want 0", exchangeCount)
}
}
func TestRunTargetsRefreshAfterSuccessEmitsRefreshResults(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
results := make(chan Result, 8)
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 1,
StopAccountAfterSuccess: true,
RefreshAfterSuccess: true,
}, func(result Result) {
results <- result
})
if err != nil {
t.Fatal(err)
}
seenScore := false
seenRecords := false
deadline := time.After(time.Second)
for !seenScore || !seenRecords {
select {
case result := <-results:
seenScore = seenScore || result.Action == "refresh_score"
seenRecords = seenRecords || result.Action == "refresh_records"
case <-deadline:
t.Fatalf("未收到刷新结果: score=%v records=%v", seenScore, seenRecords)
}
}
}
func TestRunTargetsConditionGateKeepsRetrying(t *testing.T) {
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
return &huya.ScoreExchangePrizeResp{
Status: 500,
ActPreCondition: huya.ActPreCondition{ConditionType: 3},
}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 3,
Delay: 10 * time.Millisecond,
ErrorBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 3 {
t.Fatalf("exchangeCount=%d, want 3", exchangeCount)
}
}
func TestNormalizeTargetsDropsDuplicatePrizeTargets(t *testing.T) {
targets := normalizeTargets([]Target{
{PID: 13409, Name: "幸运币礼包(小)"},
{PID: 13409},
{Name: "幸运币礼包(小)"},
{PID: 12889, Name: "套装-潮萌小黄鸭"},
})
if len(targets) != 2 {
t.Fatalf("targets=%d, want 2: %+v", len(targets), targets)
}
if targets[0].PID != 13409 || targets[1].PID != 12889 {
t.Fatalf("unexpected targets: %+v", targets)
}
}
func TestRunTargetsExchangesPrizeOnceWhenTargetsDuplicated(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{PID: 13409, Name: "幸运币礼包(小)"},
{PID: 13409},
}, Config{
SID: 2277,
maxAttempts: 3,
Delay: time.Millisecond,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount)
}
}
func TestRunTargetsWaitsForRefreshBeforeReturn(t *testing.T) {
client := &fakeActivityClient{}
runner := Runner{Client: client}
var mu sync.Mutex
seen := map[string]bool{}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 13409}}, Config{
SID: 2277,
maxAttempts: 1,
StopAccountAfterSuccess: true,
RefreshAfterSuccess: true,
}, func(result Result) {
mu.Lock()
seen[result.Action] = true
mu.Unlock()
})
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !seen["refresh_score"] || !seen["refresh_records"] {
t.Fatalf("RunTargets 返回时异步刷新还没结束: %v", seen)
}
}
func TestRunTargetsDoesNotExchangeSamePrizeTwiceViaNameMatch(t *testing.T) {
client := &fakeActivityClient{
listResp: &huya.GetActPrizeListResp{
Status: 200,
Prizes: []huya.PrizeInfo{{ID: 13409, Name: "幸运币礼包(小)", NewScore: 20}},
},
}
runner := Runner{Client: client}
// 两个不同名称的 target 都会匹配到同一个奖品。
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{
{Name: "幸运币"},
{Name: "礼包"},
}, Config{
SID: 2277,
maxAttempts: 2,
Delay: time.Millisecond,
DirectExchange: false,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(同一奖品不应重复兑换)", exchangeCount)
}
}
// TestRunTargetsSoldOutKeepsProbingUntilSuccess 验证「已抢完」不再默认终止:
// 库存可能因为别人取消 / 锁单过期回流,窗口内要继续探测到成功。
func TestRunTargetsSoldOutKeepsProbingUntilSuccess(t *testing.T) {
var mu sync.Mutex
calls := 0
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
mu.Lock()
calls++
current := calls
mu.Unlock()
if current <= 2 {
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "该奖品已抢完"}, nil
}
return &huya.ScoreExchangePrizeResp{Status: 200, Msg: "请求成功"}, nil
},
}
runner := Runner{Client: client}
err := runner.RunTargets(context.Background(), []Account{{UID: 1, Cookie: "yyuid=1"}}, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 6,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
StopAccountAfterSuccess: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 3 {
t.Fatalf("exchangeCount=%d, want 3(售罄后应继续探测直到成功)", exchangeCount)
}
}
// TestRunTargetsStopOnSoldOutTerminatesTarget 验证显式开启 StopOnSoldOut 时售罄即停。
func TestRunTargetsStopOnSoldOutTerminatesTarget(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,
maxAttempts: 5,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
StopOnSoldOut: true,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(StopOnSoldOut 时售罄即停)", exchangeCount)
}
}
// TestRunTargetsOffShelfTerminatesTarget 验证下架/不存在仍然直接终止该 target。
func TestRunTargetsOffShelfTerminatesTarget(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,
maxAttempts: 4,
Delay: time.Millisecond,
ErrorBackoff: time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
_, exchangeCount, _ := client.counts()
if exchangeCount != 1 {
t.Fatalf("exchangeCount=%d, want 1(下架应终止该 target)", exchangeCount)
}
}
// TestRunTargetsConcurrencyLimitsInFlightRequests 验证并发额度作用在单次请求上:
// 在途请求数不会超过 Concurrency,但多个账号确实能同时在途。
func TestRunTargetsConcurrencyLimitsInFlightRequests(t *testing.T) {
var inFlight, peak int32
client := &fakeActivityClient{
exchangeFn: func(uid int64, sid, pid int32) (*huya.ScoreExchangePrizeResp, error) {
current := atomic.AddInt32(&inFlight, 1)
for {
observed := atomic.LoadInt32(&peak)
if current <= observed || atomic.CompareAndSwapInt32(&peak, observed, current) {
break
}
}
time.Sleep(10 * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
return &huya.ScoreExchangePrizeResp{Status: 500, Msg: "操作频繁,请稍后再试"}, nil
},
}
runner := Runner{Client: client}
accounts := make([]Account, 0, 6)
for uid := int64(1); uid <= 6; uid++ {
accounts = append(accounts, Account{UID: uid, Cookie: "yyuid=1"})
}
err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 1,
Delay: time.Millisecond,
Concurrency: 2,
TooFrequentBackoff: 10 * time.Millisecond,
}, nil)
if err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&peak); got != 2 {
t.Fatalf("peak in-flight=%d, want 2(并发额度应精确限制在途请求数)", got)
}
}
// TestRunTargetsEmitsSummary 验证任务结束时汇总请求次数与结局分布。
func TestRunTargetsEmitsSummary(t *testing.T) {
client := &fakeActivityClient{}
var summary RunSummary
runner := Runner{
Client: client,
OnSummary: func(item RunSummary) { summary = item },
}
accounts := []Account{{UID: 1, Cookie: "yyuid=1"}, {UID: 2, Cookie: "yyuid=2"}}
// 异步刷新只在有 emit 回调时才跑(没地方上报就不发请求),这里给个空回调。
err := runner.RunTargets(context.Background(), accounts, []Target{{PID: 12889}}, Config{
SID: 2203,
maxAttempts: 3,
Delay: time.Millisecond,
Concurrency: 2,
StopAccountAfterSuccess: true,
RefreshAfterSuccess: true,
}, func(Result) {})
if err != nil {
t.Fatal(err)
}
if summary.Accounts != len(accounts) || summary.Targets != 1 {
t.Fatalf("summary=%+v", summary)
}
if summary.ExchangeReqs != len(accounts) {
t.Fatalf("ExchangeReqs=%d, want %d", summary.ExchangeReqs, len(accounts))
}
if summary.ScoreReqs != len(accounts) || summary.RecordReqs != len(accounts) {
t.Fatalf("刷新请求未计入汇总: score=%d records=%d", summary.ScoreReqs, summary.RecordReqs)
}
if summary.SuccessUIDs != len(accounts) || summary.Outcomes[CategorySuccess] != len(accounts) {
t.Fatalf("成功统计不正确: %+v", summary)
}
if summary.Duration <= 0 {
t.Fatalf("Duration=%v, want > 0", summary.Duration)
}
}
// TestJitterDurationAddsUpToQuarter 验证抖动只往上加、最多 25%,且不退化为 0。
func TestJitterDurationAddsUpToQuarter(t *testing.T) {
base := 400 * time.Millisecond
for i := 0; i < 200; i++ {
got := jitterDuration(base)
if got < base || got > base+base/4 {
t.Fatalf("jitterDuration(%v)=%v, want [%v,%v]", base, got, base, base+base/4)
}
}
if got := jitterDuration(0); got != 0 {
t.Fatalf("jitterDuration(0)=%v, want 0", got)
}
if got := jitterDuration(time.Nanosecond); got != time.Nanosecond {
t.Fatalf("jitterDuration(1ns)=%v, want 1ns", got)
}
}