hyapp-admin-platform/ops-center/src/OpsCenterApp.test.jsx
2026-07-17 16:55:15 +08:00

712 lines
31 KiB
JavaScript
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.

import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest";
import { PERMISSIONS } from "@/app/permissions";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import {
adjustLuckyGiftPool,
createLuckyGiftExperiment,
endLuckyGiftExperiment,
fetchLuckyGiftDraws,
fetchLuckyGiftConfigVersions,
fetchLuckyGiftExperiments,
fetchOpsDashboard,
rollbackLuckyGiftConfig,
saveLuckyGiftConfig,
setLuckyGiftExperimentOverrides,
updateLuckyGiftExperiment,
} from "./api.js";
import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx";
vi.mock("./api.js", () => ({
DEFAULT_POOL_ID: "default",
adjustLuckyGiftPool: vi.fn(),
createLuckyGiftExperiment: vi.fn(),
endLuckyGiftExperiment: vi.fn(),
fetchLuckyGiftDraws: vi.fn(),
fetchLuckyGiftConfigVersions: vi.fn(),
fetchLuckyGiftExperiments: vi.fn(),
fetchOpsDashboard: vi.fn(),
luckyGiftPoolKey: (item = {}) =>
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
rollbackLuckyGiftConfig: vi.fn(),
saveLuckyGiftConfig: vi.fn(),
setLuckyGiftExperimentOverrides: vi.fn(),
updateLuckyGiftExperiment: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState({}, "", "/");
saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" });
createLuckyGiftExperiment.mockResolvedValue({
experiment: { experiment_id: "exp-new", status: "running", treatment_rule_version: 4 },
treatment_config: { app_code: "lalu", pool_id: "lucky", rule_version: 4 },
});
fetchLuckyGiftExperiments.mockResolvedValue([mockRunningExperiment()]);
updateLuckyGiftExperiment.mockResolvedValue({ ...mockRunningExperiment(), treatment_traffic_percent: 20 });
setLuckyGiftExperimentOverrides.mockResolvedValue({ ...mockRunningExperiment(), overrides: [] });
endLuckyGiftExperiment.mockResolvedValue({
experiment: {
...mockRunningExperiment(),
ended_at_ms: 1767000600000,
final_rule_version: 4,
status: "completed",
winner_arm: "treatment",
},
final_config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 4 },
});
adjustLuckyGiftPool.mockResolvedValue({
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
idempotent_replay: false,
});
fetchLuckyGiftConfigVersions.mockResolvedValue([]);
rollbackLuckyGiftConfig.mockResolvedValue({
config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 22 },
sourceRuleVersion: 20,
});
fetchLuckyGiftDraws.mockResolvedValue({
items: [
{
app_code: "lalu",
created_at_ms: 1767000000000,
draw_id: "draw-1",
effective_reward_coins: 100,
external_user_id: "ext-1",
gift_id: "gift-9",
multiplier_ppm: 2_000_000,
pool_id: "super_lucky",
reward_status: "granted",
rule_version: 2,
},
],
page: 1,
pageSize: 20,
total: 1,
});
fetchOpsDashboard.mockResolvedValue({
experiments: [mockRunningExperiment()],
giftHostDiamondRatiosByApp: {
lalu: { lucky: 10, super_lucky: 1 },
yumi: { lucky: 10, super_lucky: 1 },
},
apps: [
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
],
appSummaries: [
{
actual_rtp_ppm: 900000,
app_code: "lalu",
failed_draws: 0,
granted_draws: 3,
pending_draws: 0,
total_draws: 3,
total_reward_coins: 900,
total_spent_coins: 1000,
unique_rooms: 2,
unique_users: 2,
},
],
luckyGifts: [
{
...mockDynamicConfig(),
app_code: "lalu",
enabled: true,
is_default: false,
pool_id: "lucky",
rule_version: 3,
},
{
...mockFixedConfig(95),
app_code: "lalu",
enabled: false,
is_default: false,
pool_id: "lucky",
rule_version: 2,
},
{
...mockFixedConfig(92),
app_code: "lalu",
enabled: false,
is_default: false,
pool_id: "super_lucky",
rule_version: 2,
},
{
...mockDynamicConfig(),
app_code: "yumi",
enabled: false,
is_default: true,
pool_id: "default",
rule_version: 0,
},
],
poolBalances: [
{
app_code: "lalu",
available_balance: 37950017,
balance: 38559017,
manual_credit_total: 1_000_000,
manual_debit_total: 200_000,
materialized: true,
pool_id: "lucky",
reserve_floor: 609000,
strategy_version: "dynamic_v3",
updated_at_ms: 1767000000000,
},
{
app_code: "lalu",
available_balance: 0,
balance: 900,
manual_credit_total: 100,
manual_debit_total: 20,
materialized: true,
pool_id: "lucky",
reserve_floor: 900,
strategy_version: "fixed_v2",
updated_at_ms: 1767000000000,
},
{
app_code: "lalu",
available_balance: 9_000_000,
balance: 10_000_000,
materialized: true,
pool_id: "super_lucky",
reserve_floor: 1_000_000,
strategy_version: "dynamic_v3",
},
{
app_code: "lalu",
available_balance: 1_500_000,
balance: 2_000_000,
materialized: true,
pool_id: "super_lucky",
reserve_floor: 500_000,
strategy_version: "fixed_v2",
},
{
app_code: "yumi",
available_balance: 361500,
balance: 382500,
materialized: false,
pool_id: "default",
reserve_floor: 21000,
strategy_version: "dynamic_v3",
},
],
summary: {
activeApps: 2,
configuredPools: 2,
enabledPools: 1,
failedDraws: 0,
grantedDraws: 3,
pendingDraws: 0,
poolAvailableTotal: 38311517,
poolBalanceTotal: 38941517,
poolReserveTotal: 630000,
totalDraws: 3,
totalRewardCoins: 900,
totalSpentCoins: 1000,
},
});
});
// 实验权限常量与 permissions.ts 并行开发,测试使用后端约定的权限码字面量(两者最终一致)。
const EXPERIMENT_PERMISSION = "lucky-gift:experiment";
const allPermissions = [
PERMISSIONS.luckyGiftPoolCredit,
PERMISSIONS.luckyGiftPoolDebit,
PERMISSIONS.luckyGiftUpdate,
EXPERIMENT_PERMISSION,
];
// 活跃实验挂在 lalu/super_lucky该奖池行必须显示实验徽章并禁用常规发布
// lalu/lucky已启用且无实验则暴露“创建 AB 实验”入口。
function mockRunningExperiment() {
return {
app_code: "lalu",
arm_stats: [
{
actual_rtp_percent: 95.2,
arm: "control",
rule_version: 2,
strategy_version: "dynamic_v3",
total_draws: 100,
total_reward_coins: 952,
total_spent_coins: 1000,
unique_users: 40,
},
{
actual_rtp_percent: 97.9,
arm: "treatment",
rule_version: 3,
strategy_version: "fixed_v2",
total_draws: 20,
total_reward_coins: 196,
total_spent_coins: 200,
unique_users: 6,
},
],
control_rule_version: 2,
experiment_id: "exp-1",
name: "rtp-up",
overrides: [{ arm: "treatment", user_key: "1001" }],
pool_id: "super_lucky",
started_at_ms: 1767000000000,
status: "running",
treatment_rule_version: 3,
treatment_traffic_percent: 5,
};
}
function renderApp(permissions = allPermissions) {
return render(
<ToastProvider>
<OpsCenterApp can={(code) => permissions.includes(code)} />
</ToastProvider>,
);
}
test("renders overview with aggregated kpis and pool balances", async () => {
renderApp();
expect(await screen.findByRole("heading", { name: "运营配置中心" })).toBeTruthy();
expect(screen.getByRole("navigation", { name: "运营中心导航" })).toBeTruthy();
["数据总览", "幸运礼物配置", "抽奖记录", "应用列表"].forEach((label) => {
expect(screen.getByRole("button", { name: new RegExp(label) })).toBeTruthy();
});
expect(await screen.findByText("运营应用")).toBeTruthy();
// 抽奖次数 KPI 必须是跨应用聚合值而不是第一个应用的汇总KPI 卡和汇总表列头各出现一次)。
expect(screen.getAllByText("抽奖次数").length).toBeGreaterThan(0);
expect(screen.getAllByText("38,941,517").length).toBeGreaterThan(0);
expect(screen.getByText("奖池实时水位")).toBeTruthy();
expect(screen.getAllByText("已入账").length).toBeGreaterThan(1);
expect(screen.getByText("默认水位")).toBeTruthy();
expect(fetchOpsDashboard).toHaveBeenCalledTimes(1);
});
test("opens the requested config view from the legacy admin document redirect", async () => {
expect(initialOpsCenterView("?view=configs")).toBe("configs");
expect(initialOpsCenterView("?view=unknown")).toBe("overview");
window.history.replaceState({}, "", "/ops-center/?view=configs");
renderApp();
expect(await screen.findByRole("button", { name: "添加配置" })).toBeTruthy();
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
});
test("lists every pool config including published non-default pools", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// 核心回归lalu 的 lucky / super_lucky 已发布奖池必须出现在配置列表里。
expect((await screen.findAllByText("lucky")).length).toBeGreaterThan(1);
expect(screen.getByText("super_lucky")).toBeTruthy();
expect(screen.getByText("v3")).toBeTruthy();
expect(screen.getAllByText("v2").length).toBeGreaterThan(0);
expect(screen.getByText("已启用")).toBeTruthy();
expect(screen.getAllByText("已停用").length).toBeGreaterThan(1);
expect(screen.getByText("默认草稿")).toBeTruthy();
});
test("shows strategy-specific balances and adjustment actions directly in the config list", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
expect(await screen.findByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeEnabled();
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / fixed_v2" })).toBeTruthy();
expect(screen.getByRole("button", { name: "扣减水位 lalu / lucky / fixed_v2" })).toBeDisabled();
expect(screen.getByText("38,559,017")).toBeTruthy();
expect(screen.getByText("900")).toBeTruthy();
});
test("shows credit and debit buttons only when the standalone session grants each permission", async () => {
renderApp([PERMISSIONS.luckyGiftPoolCredit]);
await screen.findByText("运营应用");
expect(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
expect(screen.queryByRole("button", { name: "添加配置" })).toBeNull();
expect(screen.queryByRole("button", { name: /新增版本 lalu/ })).toBeNull();
});
test("submits a pool credit, reports before and after balances, closes, and refreshes", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: "添加水位 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
expect(within(dialog).getByText("dynamic_v3")).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "运营补水" } });
fireEvent.click(within(dialog).getByRole("button", { name: "确认添加" }));
await waitFor(() => {
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
expect.objectContaining({
adjustmentId: expect.any(String),
amountCoins: 1_000_000,
appCode: "lalu",
direction: "in",
poolId: "lucky",
reason: "运营补水",
strategyVersion: "dynamic_v3",
}),
);
});
expect(await screen.findByText(/38,559,017 → 39,559,017/)).toBeTruthy();
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
});
test("keeps the form and adjustment id stable when a debit request is retried", async () => {
adjustLuckyGiftPool.mockRejectedValueOnce(new Error("可用水位已变化")).mockResolvedValueOnce({
adjustment: { balance_after: 38_558_917, balance_before: 38_559_017 },
idempotent_replay: false,
});
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: "扣减水位 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog", { name: "扣减奖池水位" });
fireEvent.change(within(dialog).getByLabelText(/调整金币/), { target: { value: "100" } });
fireEvent.change(within(dialog).getByLabelText(/调整原因/), { target: { value: "冲正" } });
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
expect(await screen.findByText("可用水位已变化")).toBeTruthy();
expect(within(dialog).getByLabelText(/调整金币/)).toHaveValue("100");
expect(within(dialog).getByLabelText(/调整原因/)).toHaveValue("冲正");
const firstAdjustmentID = adjustLuckyGiftPool.mock.calls[0][0].adjustmentId;
fireEvent.click(within(dialog).getByRole("button", { name: "确认扣减" }));
await waitFor(() => expect(adjustLuckyGiftPool).toHaveBeenCalledTimes(2));
expect(adjustLuckyGiftPool.mock.calls[1][0].adjustmentId).toBe(firstAdjustmentID);
});
test("creates a disabled dynamic draft without copying another app monetary limits", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "添加配置" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("添加幸运礼物配置")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
// 第一条 lalu 已发布配置的单次上限为 100 万;新建跨 App 配置必须归零并保持停用。
expect(within(dialog).getByLabelText(/单次返奖上限/).value).toBe("0");
expect(within(dialog).getByText("发布后保持停用")).toBeTruthy();
});
test("opens config dialog from a published pool row and saves a new version", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
await screen.findAllByText("lucky");
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("新增幸运礼物版本")).toBeTruthy();
expect(within(dialog).getByLabelText("策略版本").textContent).toContain("dynamic_v3");
expect(within(dialog).getByLabelText(/初始奖池金币/).value).toBe("0");
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000");
expect(within(dialog).getByLabelText("新手阶段第 1 档概率 %").readOnly).toBe(true);
fireEvent.change(within(dialog).getByLabelText(/目标 RTP \(%\)/), { target: { value: "88" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
await waitFor(() => {
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
expect.objectContaining({
app_code: "lalu",
anchor_rate_percent: 10,
initial_pool_coins: 0,
jackpot_multipliers: [200, 500, 1000],
pool_id: "lucky",
profit_rate_percent: 1,
stages: expect.arrayContaining([expect.objectContaining({ stage: "novice" })]),
strategy_version: "dynamic_v3",
target_rtp_percent: 88,
}),
);
});
});
test("blocks an invalid enabled dynamic rule before calling the save API", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// dashboard 已完成加载,切换本地视图和打开弹窗都是同步状态;避免在全量并发测试中额外轮询文本。
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/低水位金币/), { target: { value: "0" } });
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("高水位必须大于正数低水位");
expect(saveLuckyGiftConfig).not.toHaveBeenCalled();
// 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。
fireEvent.change(within(dialog).getByLabelText("新手阶段第 1 档倍率"), { target: { value: "0.1" } });
expect(within(dialog).queryByRole("alert")).toBeNull();
});
function mockDynamicConfig() {
return {
anchor_daily_payout_cap: 6_000_000,
anchor_rate_percent: 10,
control_band_percent: 3,
device_daily_payout_cap: 4_000_000,
effective_from_ms: 0,
gift_price_reference: 100,
high_water_nonzero_factor_percent: 130,
high_watermark_coins: 20_000_000,
initial_pool_coins: 0,
jackpot_global_rtp_max_percent: 98,
jackpot_multipliers: [200, 500, 1000],
jackpot_spend_threshold_coins: 50_000,
jackpot_user_72h_rtp_max_percent: 96,
jackpot_user_day_rtp_max_percent: 96,
loss_streak_guarantee: 5,
low_water_nonzero_factor_percent: 70,
low_watermark_coins: 10_000_000,
max_jackpot_hits_per_user_day: 5,
max_single_payout: 1_000_000,
normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000,
pool_rate_percent: 89,
profit_rate_percent: 1,
recharge_boost_factor_percent: 110,
recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 5_000_000,
settlement_window_wager: 1_000_000,
stages: mockStages(89),
strategy_version: "dynamic_v3",
target_rtp_percent: 89,
user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000,
};
}
function mockFixedConfig(targetRTP) {
return {
control_band_percent: 1,
gift_price_reference: 100,
normal_max_equivalent_draws: 20_000,
novice_max_equivalent_draws: 2_000,
pool_rate_percent: targetRTP + 1,
settlement_window_wager: 1_000_000,
stages: mockStages(targetRTP),
strategy_version: "fixed_v2",
target_rtp_percent: targetRTP,
};
}
function mockStages(targetRTP) {
return ["novice", "normal", "advanced"].map((stage, index) => ({
min_recharge_30d_coins: index === 0 ? 0 : index,
min_recharge_7d_coins: index === 2 ? 1 : 0,
stage,
tiers: [
{
enabled: true,
high_water_only: false,
multiplier: 0,
probability_percent: 100 - targetRTP,
tier_id: `${stage}_none`,
},
{
enabled: true,
high_water_only: false,
multiplier: 1,
probability_percent: targetRTP,
tier_id: `${stage}_1x`,
},
],
}));
}
test("loads draw records for the first app with server pagination", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /抽奖记录/ }));
await waitFor(() => {
expect(fetchLuckyGiftDraws).toHaveBeenCalledWith(
expect.objectContaining({ appCode: "lalu", page: 1, poolId: "", status: "" }),
);
});
expect(await screen.findByText("ext-1")).toBeTruthy();
expect(screen.getByText("已发放")).toBeTruthy();
expect(screen.getByText("2x")).toBeTruthy();
});
test("shows app registry and fixed integrations in apps view", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /应用列表/ }));
expect(await screen.findByText("Lalu")).toBeTruthy();
expect(screen.getByText("主数据")).toBeTruthy();
expect(screen.getByText("外部接入")).toBeTruthy();
});
test("flags pools with a running experiment and blocks their regular publishing", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// running 实验在“实验”列展示放量徽章;该奖池的常规发布被禁用并说明原因。
expect(await screen.findByText("实验中 5%")).toBeTruthy();
const publishButton = screen.getByRole("button", { name: "新增版本 lalu / super_lucky / fixed_v2" });
expect(publishButton).toBeDisabled();
expect(publishButton).toHaveAttribute("title", "实验进行中,结束实验后才能发布");
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
// 无实验的行不受影响;只有已启用的已发布配置才有“创建 AB 实验”入口(停用行没有可对照版本)。
expect(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" })).toBeEnabled();
expect(screen.getByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / fixed_v2" })).toBeNull();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / super_lucky / fixed_v2" })).toBeNull();
});
test("hides experiment management entries without the experiment permission", async () => {
renderApp([PERMISSIONS.luckyGiftUpdate]);
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
// 无实验权限仍能看到徽章和实验详情(后端 GET 只要求 lucky-gift:view但没有创建入口。
expect(await screen.findByText("实验中 5%")).toBeTruthy();
expect(screen.getByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" })).toBeNull();
});
test("creates an ab experiment from an enabled config row with a locked enabled switch", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "创建 AB 实验 lalu / lucky / dynamic_v3" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("创建 AB 实验")).toBeTruthy();
// 实验组配置必须直接可运行:启用开关锁定为开,奖池 ID 锁定在当前行。
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeDisabled();
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).getByLabelText(/^奖池/)).toBeDisabled();
// 名称必填:空名称提交被表单校验拦截(原生 required + 纯函数校验双重兜底),不发请求。
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
expect(createLuckyGiftExperiment).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText(/实验名称/), { target: { value: "rtp-98" } });
fireEvent.click(within(dialog).getByRole("button", { name: "创建实验" }));
await waitFor(() => {
expect(createLuckyGiftExperiment).toHaveBeenCalledWith(
"lalu",
expect.objectContaining({
config: expect.objectContaining({ app_code: "lalu", enabled: true, pool_id: "lucky" }),
name: "rtp-98",
overrides: [],
treatment_traffic_percent: 5,
}),
);
});
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(fetchOpsDashboard).toHaveBeenCalledTimes(2);
});
test("manages a running experiment: stats, traffic, overrides and ending with a winner", async () => {
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
// 打开弹窗即拉 with_stats=1 的统计并展示两组卡片。
await waitFor(() =>
expect(fetchLuckyGiftExperiments).toHaveBeenCalledWith("lalu", { poolId: "super_lucky", withStats: true }),
);
expect(await within(dialog).findByText("97.90%")).toBeTruthy();
expect(within(dialog).getByText("95.20%")).toBeTruthy();
// “实验组”同时出现在分组卡片、白名单分组标签和新增分组下拉中,用 getAllByText 断言存在。
expect(within(dialog).getAllByText("对照组").length).toBeGreaterThan(0);
expect(within(dialog).getAllByText("实验组").length).toBeGreaterThan(0);
// 两组卡片按策略精确绑定独立账本;从对照组入口补水必须提交 dynamic_v3不能误调实验组的 V2 水位。
expect(within(dialog).getByText("10,000,000")).toBeTruthy();
expect(within(dialog).getByText("2,000,000")).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "添加水位 lalu / super_lucky / dynamic_v3" }));
const poolDialog = await screen.findByRole("dialog", { name: "添加奖池水位" });
fireEvent.change(within(poolDialog).getByLabelText(/调整金币/), { target: { value: "1000000" } });
fireEvent.change(within(poolDialog).getByLabelText(/调整原因/), { target: { value: "实验对照组补水" } });
fireEvent.click(within(poolDialog).getByRole("button", { name: "确认添加" }));
await waitFor(() =>
expect(adjustLuckyGiftPool).toHaveBeenCalledWith(
expect.objectContaining({
appCode: "lalu",
direction: "in",
poolId: "super_lucky",
strategyVersion: "dynamic_v3",
}),
),
);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "添加奖池水位" })).toBeNull());
fireEvent.change(within(dialog).getByLabelText(/实验组流量/), { target: { value: "20" } });
fireEvent.click(within(dialog).getByRole("button", { name: "应用" }));
await waitFor(() =>
expect(updateLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", { treatment_traffic_percent: 20 }),
);
expect(await screen.findByText("已调整实验组流量为 20%")).toBeTruthy();
await waitFor(() => expect(within(dialog).getByRole("button", { name: "移除白名单 1001" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "移除白名单 1001" }));
await waitFor(() =>
expect(setLuckyGiftExperimentOverrides).toHaveBeenCalledWith("lalu", "exp-1", {
removes: ["1001"],
upserts: [],
}),
);
// 结束实验必须二次确认:确认层说明获胜配置将重新发布并全量生效。
await waitFor(() => expect(within(dialog).getByRole("button", { name: "选择实验组为最终版本" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "选择实验组为最终版本" }));
expect(endLuckyGiftExperiment).not.toHaveBeenCalled();
fireEvent.click(await within(dialog).findByRole("button", { name: "确认结束并发布" }));
await waitFor(() => expect(endLuckyGiftExperiment).toHaveBeenCalledWith("lalu", "exp-1", "treatment"));
expect(await screen.findByText(/实验已结束:实验组配置已重新发布为 v4 并全量生效/)).toBeTruthy();
});
test("pauses a running experiment and keeps the dialog usable when the action fails", async () => {
updateLuckyGiftExperiment.mockRejectedValueOnce(new Error("实验状态已变化"));
renderApp();
await screen.findByText("运营应用");
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
fireEvent.click(await screen.findByRole("button", { name: "实验详情 lalu / super_lucky / fixed_v2" }));
const dialog = await screen.findByRole("dialog", { name: /实验管理/ });
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
// 失败保留弹窗与输入,运营可以直接重试。
expect(await screen.findByText("实验状态已变化")).toBeTruthy();
expect(screen.getByRole("dialog", { name: /实验管理/ })).toBeTruthy();
await waitFor(() => expect(within(dialog).getByRole("button", { name: "暂停实验" })).toBeEnabled());
fireEvent.click(within(dialog).getByRole("button", { name: "暂停实验" }));
await waitFor(() =>
expect(updateLuckyGiftExperiment).toHaveBeenLastCalledWith("lalu", "exp-1", { status: "paused" }),
);
expect(await screen.findByText("实验已暂停,全部流量(含白名单)回到对照组")).toBeTruthy();
});