zhx dc8435f869 增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- 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>
2026-07-17 00:22:12 +08:00

83 lines
3.4 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 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)
}
}