- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key) - 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝 - Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛 - AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional) - admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103) - 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.4 KiB
Go
83 lines
3.4 KiB
Go
package luckygift
|
||
|
||
import "testing"
|
||
|
||
// 分桶必须纯确定:room saga 在 Check 固化 strategy_version 且恢复不重新 Check,
|
||
// 同一用户在 Check 与 Draw 的两次解析必须得到同一组。
|
||
func TestExperimentBucketPPMIsDeterministic(t *testing.T) {
|
||
for _, userKey := range []string{"1", "42", "9007199254740993", "ext:abc-123"} {
|
||
first := ExperimentBucketPPM("salt-a", userKey)
|
||
for i := 0; i < 10; i++ {
|
||
if again := ExperimentBucketPPM("salt-a", userKey); again != first {
|
||
t.Fatalf("bucket for %q drifted: %d != %d", userKey, again, first)
|
||
}
|
||
}
|
||
if first < 0 || first >= ExperimentTrafficPPMScale {
|
||
t.Fatalf("bucket for %q out of range: %d", userKey, first)
|
||
}
|
||
}
|
||
if ExperimentBucketPPM("salt-a", "1") == ExperimentBucketPPM("salt-b", "1") &&
|
||
ExperimentBucketPPM("salt-a", "2") == ExperimentBucketPPM("salt-b", "2") &&
|
||
ExperimentBucketPPM("salt-a", "3") == ExperimentBucketPPM("salt-b", "3") {
|
||
t.Fatal("different salts should reshuffle buckets")
|
||
}
|
||
}
|
||
|
||
// 放量必须单调:调大流量时既有 treatment 用户全部保留,不允许来回倒组打断用户体验状态。
|
||
func TestResolveExperimentArmMonotonicRamp(t *testing.T) {
|
||
low := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 50_000}
|
||
high := low
|
||
high.TreatmentTrafficPPM = 300_000
|
||
treatmentAtLow := 0
|
||
for userID := int64(1); userID <= 2_000; userID++ {
|
||
key := ExperimentUserKey(userID)
|
||
lowArm := ResolveExperimentArm(low, key, "")
|
||
highArm := ResolveExperimentArm(high, key, "")
|
||
if lowArm == ExperimentArmTreatment {
|
||
treatmentAtLow++
|
||
if highArm != ExperimentArmTreatment {
|
||
t.Fatalf("user %d left treatment when traffic increased", userID)
|
||
}
|
||
}
|
||
}
|
||
if treatmentAtLow == 0 {
|
||
t.Fatal("5% ramp assigned nobody to treatment in a 2000-user sample")
|
||
}
|
||
}
|
||
|
||
// 分桶应大致均匀:50% 流量在 10000 用户样本上偏差不应超过 ±5 个百分点。
|
||
func TestExperimentBucketDistribution(t *testing.T) {
|
||
experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "distribution", TreatmentTrafficPPM: 500_000}
|
||
treatment := 0
|
||
const sample = 10_000
|
||
for userID := int64(1); userID <= sample; userID++ {
|
||
if ResolveExperimentArm(experiment, ExperimentUserKey(userID), "") == ExperimentArmTreatment {
|
||
treatment++
|
||
}
|
||
}
|
||
if treatment < sample*45/100 || treatment > sample*55/100 {
|
||
t.Fatalf("treatment share %d/%d deviates from 50%% beyond tolerance", treatment, sample)
|
||
}
|
||
}
|
||
|
||
func TestResolveExperimentArmPrecedence(t *testing.T) {
|
||
experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 0}
|
||
if arm := ResolveExperimentArm(experiment, "7", ExperimentArmTreatment); arm != ExperimentArmTreatment {
|
||
t.Fatalf("override treatment ignored, got %s", arm)
|
||
}
|
||
full := experiment
|
||
full.TreatmentTrafficPPM = ExperimentTrafficPPMScale
|
||
if arm := ResolveExperimentArm(full, "7", ExperimentArmControl); arm != ExperimentArmControl {
|
||
t.Fatalf("override control ignored at 100%% traffic, got %s", arm)
|
||
}
|
||
if arm := ResolveExperimentArm(full, "7", ""); arm != ExperimentArmTreatment {
|
||
t.Fatalf("100%% traffic should send everyone to treatment, got %s", arm)
|
||
}
|
||
// paused 是紧急停闸:包括白名单在内的全部流量回落 control。
|
||
paused := full
|
||
paused.Status = ExperimentStatusPaused
|
||
if arm := ResolveExperimentArm(paused, "7", ExperimentArmTreatment); arm != ExperimentArmControl {
|
||
t.Fatalf("paused experiment must send everyone to control, got %s", arm)
|
||
}
|
||
}
|