hyapp-admin-platform/ops-center/src/OpsCenterApp.test.jsx

341 lines
13 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 { 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(
<ToastProvider>
<OpsCenterApp />
</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.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();
});