abd8c068b6
- weighted_picker: Pick 排除 is_fallback 保底奖,避免真实奖被"谢谢参与"挤占 - vpn_duration handler: 无活跃订阅且奖品配置 subscribe_id 时,按该套餐在抽奖 事务内新建订阅并发放时长;未配置则沿用旧的安全跳过 - 补充单测:picker 排除保底奖、vpn_duration 自动新建订阅、draw 端到端链路 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
2.7 KiB
Go
105 lines
2.7 KiB
Go
package lottery
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestWeightedPicker_EmptyPool(t *testing.T) {
|
|
p := NewWeightedPicker(42)
|
|
if _, err := p.Pick(nil); err != ErrEmptyPool {
|
|
t.Fatalf("expected ErrEmptyPool for nil pool, got %v", err)
|
|
}
|
|
if _, err := p.Pick([]Prize{{Weight: 0}, {Weight: 0}}); err != ErrEmptyPool {
|
|
t.Fatalf("expected ErrEmptyPool when all weights are 0, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWeightedPicker_SkipsZeroWeight(t *testing.T) {
|
|
p := NewWeightedPicker(1)
|
|
pool := []Prize{
|
|
{Id: 1, Weight: 0}, // 不参与
|
|
{Id: 2, Weight: 100}, // 独占权重
|
|
{Id: 3, Weight: 0}, // 不参与
|
|
}
|
|
for i := 0; i < 200; i++ {
|
|
idx, err := p.Pick(pool)
|
|
if err != nil {
|
|
t.Fatalf("Pick err: %v", err)
|
|
}
|
|
if idx != 1 {
|
|
t.Fatalf("expected idx 1 (only positive weight), got %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
|
|
p := NewWeightedPicker(2026)
|
|
pool := []Prize{
|
|
{Id: 10, Weight: 10}, // 10/60 ≈ 16.67%
|
|
{Id: 20, Weight: 20}, // 20/60 ≈ 33.33%
|
|
{Id: 30, Weight: 30}, // 30/60 = 50%
|
|
}
|
|
const trials = 60000
|
|
counts := make(map[int]int)
|
|
for i := 0; i < trials; i++ {
|
|
idx, err := p.Pick(pool)
|
|
if err != nil {
|
|
t.Fatalf("Pick err: %v", err)
|
|
}
|
|
counts[idx]++
|
|
}
|
|
// 允许 ±2% 偏差
|
|
expect := []float64{10.0 / 60, 20.0 / 60, 30.0 / 60}
|
|
for i, e := range expect {
|
|
got := float64(counts[i]) / float64(trials)
|
|
if got < e-0.02 || got > e+0.02 {
|
|
t.Fatalf("prize %d: expected %.4f (±0.02), got %.4f", i, e, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWeightedPicker_ExcludesFallback(t *testing.T) {
|
|
p := NewWeightedPicker(3)
|
|
pool := []Prize{
|
|
{Id: 1, Weight: 100, IsFallback: true}, // 保底奖:即便权重很高也绝不被随机抽中
|
|
{Id: 2, Weight: 5, Type: PrizeTypeVPNDuration}, // 唯一可抽真实奖品
|
|
}
|
|
for i := 0; i < 300; i++ {
|
|
idx, err := p.Pick(pool)
|
|
if err != nil {
|
|
t.Fatalf("Pick err: %v", err)
|
|
}
|
|
if idx != 1 {
|
|
t.Fatalf("fallback prize must never be picked; expected idx 1, got %d", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWeightedPicker_AllFallbackYieldsEmptyPool(t *testing.T) {
|
|
p := NewWeightedPicker(9)
|
|
pool := []Prize{
|
|
{Id: 1, Weight: 10, IsFallback: true},
|
|
{Id: 2, Weight: 20, IsFallback: true},
|
|
}
|
|
if _, err := p.Pick(pool); err != ErrEmptyPool {
|
|
t.Fatalf("expected ErrEmptyPool when only fallback prizes exist, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
|
|
pool := []Prize{
|
|
{Id: 1, Weight: 1},
|
|
{Id: 2, Weight: 1},
|
|
{Id: 3, Weight: 1},
|
|
}
|
|
a := NewWeightedPicker(7)
|
|
b := NewWeightedPicker(7)
|
|
for i := 0; i < 20; i++ {
|
|
ai, _ := a.Pick(pool)
|
|
bi, _ := b.Pick(pool)
|
|
if ai != bi {
|
|
t.Fatalf("iter %d: seed 7 diverged: a=%d b=%d", i, ai, bi)
|
|
}
|
|
}
|
|
}
|