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( permissions.includes(code)} /> , ); } 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(); });