- 并发额度下沉到单次请求:runState.sem 只覆盖一次在途请求(列表/兑换/刷新),账号在退避等待期间释放额度;旧实现把额度给整个账号循环,退避中的账号一直霸占额度,账号多时互相饿死 - 售罄不再默认终止:normalizeConfig 去掉 StopOnSoldOut 默认 true,售罄按退避继续探测到轮次结束(别人取消或锁单过期会让库存回流);CategoryOffShelf 拆成独立分支,下架/不存在仍终止该奖品;删除已无意义的 ContinueOnSoldOut - 退避统一加 0-25% 抖动(jitterDuration,只往上抖,配置值仍是下限),避免多账号在同一时刻整批重试形成周期尖峰 - 新增 Runner.OnSummary/RunSummary:轮次、账号、目标、耗时、列表/兑换/积分/记录请求次数、成功账号数与结局分布(按次数排序),在 refresh.Wait() 之后回调;CLI 与 GUI 各输出一行汇总加逐类结局 - 取消清理:等待额度的账号返回 action=exchange_canceled 且调用方直接返回,停止监控时不再为每个排队账号刷错误结果 - http 传输补连接池参数(MaxConnsPerHost=64、MaxIdleConns=128、MaxIdleConnsPerHost=64、IdleConnTimeout=30s),多账号不再反复重建连接 - CLI/GUI 的 waitUntil 从 200ms 轮询改为一次性定时器,消除 0-200ms 随机延迟(正好错过开抢窗口) - GUI 抽出 runnerConfig(),用 ListBeforeExchange=!DirectExchange 表达界面语义,修复「直接兑换」开关失效(旧实现被引擎强制回落直兑,价格/库存过滤不生效) - 测试:sniper 新增售罄继续探测、StopOnSoldOut 即停、下架终止、在途请求数等于并发数、汇总含刷新请求、抖动区间等用例;GUI 新增 runnerConfig 用例;README 补「抢购调度与退避(2026-10-16 调整)」
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
package gui
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"huya-go/internal/logger"
|
|
)
|
|
|
|
func TestSplitCookieLinesNormalizesLineBreaks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "crlf_and_cr",
|
|
input: "ck-a\r\nck-b\rck-c",
|
|
want: []string{"ck-a", "ck-b", "ck-c"},
|
|
},
|
|
{
|
|
name: "escaped_line_breaks",
|
|
input: `ck-a\r\nck-b\rck-c\nck-d`,
|
|
want: []string{"ck-a", "ck-b", "ck-c", "ck-d"},
|
|
},
|
|
{
|
|
name: "slash_r_line_breaks",
|
|
input: "ck-a/r/nck-b/rck-c/nck-d",
|
|
want: []string{"ck-a", "ck-b", "ck-c", "ck-d"},
|
|
},
|
|
{
|
|
name: "slash_n_only_is_preserved",
|
|
input: "ck-a/nck-b",
|
|
want: []string{"ck-a/nck-b"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
lines := splitCookieLines(tt.input)
|
|
if len(lines) != len(tt.want) {
|
|
t.Fatalf("len(splitCookieLines()) = %d, want %d", len(lines), len(tt.want))
|
|
}
|
|
for i, line := range lines {
|
|
if line.text != tt.want[i] {
|
|
t.Fatalf("line[%d] = %q, want %q", i, line.text, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseStartTimeAcceptsClockOnly(t *testing.T) {
|
|
t.Run("future_today", func(t *testing.T) {
|
|
now := time.Date(2026, 7, 6, 10, 20, 30, 0, time.Local)
|
|
got := nextClockTime(now, 12, 5, 6)
|
|
want := time.Date(2026, 7, 6, 12, 5, 6, 0, time.Local)
|
|
if !got.Equal(want) {
|
|
t.Fatalf("nextClockTime() = %s, want %s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("past_tomorrow", func(t *testing.T) {
|
|
now := time.Date(2026, 7, 6, 10, 20, 30, 0, time.Local)
|
|
got := nextClockTime(now, 9, 5, 6)
|
|
want := time.Date(2026, 7, 7, 9, 5, 6, 0, time.Local)
|
|
if !got.Equal(want) {
|
|
t.Fatalf("nextClockTime() = %s, want %s", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("parse_hms", func(t *testing.T) {
|
|
got, err := parseStartTime("23:59:58")
|
|
if err != nil {
|
|
t.Fatalf("parseStartTime() error = %v", err)
|
|
}
|
|
if got.Hour() != 23 || got.Minute() != 59 || got.Second() != 58 {
|
|
t.Fatalf("parseStartTime() = %s, want 23:59:58", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetLogLevelNormalizesAndFilters(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store := logger.NewStore(20)
|
|
closeLog, err := logger.Init(store, dir, "测试实例", "test", dir)
|
|
if err != nil {
|
|
t.Fatalf("logger.Init() error = %v", err)
|
|
}
|
|
defer closeLog()
|
|
defer logger.SetMinLevel(logger.LevelInfo)
|
|
|
|
app := &App{logStore: store}
|
|
if got := app.SetLogLevel("这不是合法等级"); got != logger.LevelInfo {
|
|
t.Fatalf("非法等级应回退为 info, got %q", got)
|
|
}
|
|
if got := app.SetLogLevel("debug"); got != logger.LevelDebug {
|
|
t.Fatalf("SetLogLevel(debug) = %q", got)
|
|
}
|
|
logger.LogEvent("网络", logger.LevelDebug, "debug 应被保留")
|
|
|
|
app.SetLogLevel("error")
|
|
logger.LogEvent("网络", logger.LevelDebug, "debug 应被过滤")
|
|
for _, entry := range store.Since(0) {
|
|
if entry.Message == "debug 应被过滤" {
|
|
t.Fatal("低于最低等级的日志不应进入内存缓冲")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTargetsAppliesPriceAndInventoryFilters(t *testing.T) {
|
|
targets := parseTargets(Config{
|
|
PID: 12889,
|
|
Name: "小黄鸭",
|
|
TargetText: "13001\nauto",
|
|
AutoSelect: true,
|
|
MaxPrice: 1000,
|
|
MinLeft: 2,
|
|
})
|
|
if len(targets) != 4 {
|
|
t.Fatalf("targets=%d, want 4", len(targets))
|
|
}
|
|
for _, target := range targets {
|
|
if target.MaxPrice != 1000 {
|
|
t.Fatalf("target %+v maxPrice=%d, want 1000", target, target.MaxPrice)
|
|
}
|
|
if target.MinLeft != 2 {
|
|
t.Fatalf("target %+v minLeft=%d, want 2", target, target.MinLeft)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTargetsWithoutFiltersKeepsZeroValues(t *testing.T) {
|
|
targets := parseTargets(Config{PID: 12889})
|
|
if len(targets) != 1 {
|
|
t.Fatalf("targets=%d, want 1", len(targets))
|
|
}
|
|
if targets[0].MaxPrice != 0 || targets[0].MinLeft != 0 {
|
|
t.Fatalf("unexpected filters: %+v", targets[0])
|
|
}
|
|
}
|
|
|
|
// TestRunnerConfigMapsDirectExchangeToExchangeMode 验证界面「直接兑换」开关能真正
|
|
// 切换到「先查列表」模式:引擎在 ListBeforeExchange=false 时会强制回落到直兑,
|
|
// 所以关闭直接兑换必须同时置 ListBeforeExchange,否则开关形同失效。
|
|
func TestRunnerConfigMapsDirectExchangeToExchangeMode(t *testing.T) {
|
|
direct := runnerConfig(Config{
|
|
SID: 2203,
|
|
Loops: 3,
|
|
DelayMS: 120,
|
|
Concurrency: 5,
|
|
DirectExchange: true,
|
|
})
|
|
if !direct.DirectExchange || direct.ListBeforeExchange {
|
|
t.Fatalf("直接兑换模式映射错误: %+v", direct)
|
|
}
|
|
if direct.SID != 2203 || direct.Loops != 3 || direct.Delay != 120*time.Millisecond || direct.Concurrency != 5 {
|
|
t.Fatalf("基础字段未透传: %+v", direct)
|
|
}
|
|
|
|
list := runnerConfig(Config{SID: 2203, DirectExchange: false})
|
|
if list.DirectExchange || !list.ListBeforeExchange {
|
|
t.Fatalf("关闭直接兑换后应改为先查列表: %+v", list)
|
|
}
|
|
}
|