import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; import { fetchLuckyGiftDraws, fetchOpsDashboard, saveLuckyGiftConfig } from "./api.js"; import { initialOpsCenterView, OpsCenterApp } from "./OpsCenterApp.jsx"; vi.mock("./api.js", () => ({ DEFAULT_POOL_ID: "default", fetchLuckyGiftDraws: vi.fn(), fetchOpsDashboard: vi.fn(), saveLuckyGiftConfig: vi.fn(), })); beforeEach(() => { vi.clearAllMocks(); window.history.replaceState({}, "", "/"); saveLuckyGiftConfig.mockResolvedValue({ app_code: "lalu", pool_id: "lucky" }); 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({ 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(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, materialized: true, pool_id: "lucky", reserve_floor: 609000, updated_at_ms: 1767000000000, }, { app_code: "yumi", available_balance: 361500, balance: 382500, materialized: false, pool_id: "default", reserve_floor: 21000, }, ], 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, }, }); }); function renderApp() { return render( , ); } 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.getByText("已入账")).toBeTruthy(); 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.getByText("lucky")).toBeTruthy(); }); 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.findByText("lucky")).toBeTruthy(); expect(screen.getByText("super_lucky")).toBeTruthy(); expect(screen.getByText("v3")).toBeTruthy(); expect(screen.getByText("v2")).toBeTruthy(); expect(screen.getByText("已启用")).toBeTruthy(); expect(screen.getByText("已停用")).toBeTruthy(); expect(screen.getByText("默认草稿")).toBeTruthy(); }); 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("1000000"); // 第一条 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.findByText("lucky"); fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); 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("1000000"); expect(within(dialog).getByLabelText(/大奖倍率/).value).toBe("200, 500, 1000"); expect(within(dialog).getAllByLabelText("概率 %")[0].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: 1, 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: /幸运礼物配置/ })); await screen.findByText("lucky"); fireEvent.click(screen.getAllByRole("button", { name: "新增版本" })[0]); 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("初始奖池必须大于 0"); expect(saveLuckyGiftConfig).not.toHaveBeenCalled(); // 奖档编辑同样必须清掉上一次提交错误,避免用户修正后仍看到已经过期的阻断原因。 fireEvent.change(within(dialog).getAllByLabelText("倍率")[0], { target: { value: "0.1" } }); expect(within(dialog).queryByRole("alert")).toBeNull(); }); function mockDynamicConfig() { return { anchor_daily_payout_cap: 6_000_000, anchor_rate_percent: 1, 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: 1_000_000, 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: 98, 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(98), strategy_version: "dynamic_v3", target_rtp_percent: 98, 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(); });