hyapp-admin-platform/ops-center/src/components/LuckyGiftConfigDialog.test.jsx

391 lines
16 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 { afterEach, expect, test, vi } from "vitest";
import { LuckyGiftConfigDialog } from "./LuckyGiftConfigDialog.jsx";
afterEach(() => {
vi.unstubAllGlobals();
});
test("keeps every dynamic_v3 control reachable while locking rule-time pool injection", () => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByRole("navigation", { name: "全局参数分区" })).toBeInTheDocument();
["规则", "资金", "水位", "大奖", "限额"].forEach((label) => {
expect(within(dialog).getByRole("button", { name: label })).toBeInTheDocument();
});
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).getByLabelText(/初始奖池金币/)).toHaveValue(0);
expect(within(dialog).getByLabelText(/初始奖池金币/)).toBeDisabled();
[
"低水位金币",
"高水位金币",
"充值加权窗口 (ms)",
"独立大奖倍率",
"全局大奖 RTP 上限 (%)",
"用户日累计消费触发门槛(金币)",
"单次返奖上限",
"主播每日上限",
].forEach((label) =>
expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(),
);
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument();
expect(within(dialog).getByRole("columnheader", { name: "普通中奖机会 %" })).toBeInTheDocument();
expect(within(dialog).getByRole("heading", { name: "独立大奖路径" })).toBeInTheDocument();
expect(within(dialog).getByText("4. 未成功才走普通开奖")).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly");
expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段派生充值范围")).toHaveTextContent("7 日充值 < 0 或 30 日充值 < 1");
expect(
within(dialog).getByText("任一未达到即为新手7 日与 30 日均达到门槛(含等于)后进入普通。"),
).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("tab", { name: /普通/ }));
expect(within(dialog).getByLabelText("7 日最低充值(金币)")).toHaveValue(0);
expect(within(dialog).getByLabelText("30 日最低充值(金币)")).toHaveValue(1);
});
test("section navigation handles reduced motion and falls back after switching from dynamic_v3 to fixed_v2", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({
addEventListener: vi.fn(),
matches: true,
media: "(prefers-reduced-motion: reduce)",
removeEventListener: vi.fn(),
}),
);
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const sectionList = dialog.querySelector(".ops-config-section-list");
const identitySection = dialog.querySelector('[data-config-section="identity"]');
const waterSection = dialog.querySelector('[data-config-section="water"]');
const scrollTo = vi.fn();
Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 480 });
Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_400 });
Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: scrollTo });
Object.defineProperty(sectionList, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 200 }),
});
Object.defineProperty(waterSection, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 520 }),
});
// 尺寸变化后监听器也必须从响应式外层重新绑定到桌面内层 scroller。
fireEvent(window, new Event("resize"));
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 312 });
expect(within(dialog).getByRole("button", { name: "水位" })).toHaveAttribute("aria-current", "true");
expect(within(dialog).getByRole("heading", { name: "动态水位" })).toHaveFocus();
sectionList.scrollTop = 137;
Object.defineProperty(identitySection, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 71 }),
});
fireEvent.click(within(dialog).getByRole("button", { name: "规则" }));
expect(scrollTo).toHaveBeenLastCalledWith({ behavior: "auto", top: 0 });
expect(within(dialog).getByRole("heading", { name: "规则身份" })).toHaveFocus();
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
fireEvent.mouseDown(within(dialog).getByLabelText("策略版本"));
fireEvent.click(screen.getByRole("option", { name: "fixed_v2历史兼容" }));
await waitFor(() => {
expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "规则" })).toHaveAttribute("aria-current", "true");
});
expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument();
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeInTheDocument();
});
test.each([390, 1024])(
"uses the dialog content scroller for %ipx responsive navigation and active-section sync",
(viewportWidth) => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const sectionNav = dialog.querySelector(".ops-config-section-nav");
const sectionList = dialog.querySelector(".ops-config-section-list");
const outerScroller = sectionList.closest(".ops-config-dialog-content");
const innerScrollTo = vi.fn();
const outerScrollTo = vi.fn();
// 不向组件暴露 viewport 宽度;测试通过导航的真实 flex 布局区分手机横栏和平板左栏。
sectionNav.style.flexDirection = viewportWidth === 390 ? "row" : "column";
Object.defineProperty(sectionNav, "getBoundingClientRect", {
configurable: true,
value: () => ({ height: 50 }),
});
Object.defineProperty(sectionList, "clientHeight", { configurable: true, value: 1_200 });
Object.defineProperty(sectionList, "scrollHeight", { configurable: true, value: 1_200 });
Object.defineProperty(sectionList, "scrollTo", { configurable: true, value: innerScrollTo });
Object.defineProperty(outerScroller, "scrollTop", { configurable: true, value: 1_837, writable: true });
Object.defineProperty(outerScroller, "scrollTo", { configurable: true, value: outerScrollTo });
Object.defineProperty(outerScroller, "getBoundingClientRect", {
configurable: true,
value: () => ({ top: 120 }),
});
const sectionTops = {
funds: -800,
identity: -1_000,
jackpot: 180,
limits: 800,
water: -579,
};
for (const [sectionKey, top] of Object.entries(sectionTops)) {
Object.defineProperty(
dialog.querySelector(`[data-config-section="${sectionKey}"]`),
"getBoundingClientRect",
{
configurable: true,
value: () => ({ top }),
},
);
}
fireEvent.scroll(outerScroller);
const expectedActiveSection = viewportWidth === 390 ? "大奖" : "水位";
expect(within(dialog).getByRole("button", { name: expectedActiveSection })).toHaveAttribute(
"aria-current",
"true",
);
fireEvent.click(within(dialog).getByRole("button", { name: "水位" }));
expect(outerScrollTo).toHaveBeenCalledWith({
behavior: "smooth",
// 横向 sticky nav 占 50px目标标题落在其底边下方 8px左栏布局不需要额外偏移。
top: viewportWidth === 390 ? 1_080 : 1_130,
});
expect(innerScrollTo).not.toHaveBeenCalled();
},
);
test("stage tabs expose complete aria relationships and support roving keyboard navigation", () => {
renderDialog(dynamicConfig());
const dialog = screen.getByRole("dialog");
const noviceTab = within(dialog).getByRole("tab", { name: /新手/ });
const normalTab = within(dialog).getByRole("tab", { name: /普通/ });
const advancedTab = within(dialog).getByRole("tab", { name: /高阶/ });
expect(noviceTab).toHaveAttribute("aria-controls", "ops-stage-panel-novice");
expect(noviceTab).toHaveAttribute("tabindex", "0");
expect(normalTab).toHaveAttribute("tabindex", "-1");
expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "ops-stage-tab-novice");
fireEvent.keyDown(noviceTab, { key: "ArrowRight" });
expect(normalTab).toHaveFocus();
expect(normalTab).toHaveAttribute("aria-selected", "true");
expect(within(dialog).getByRole("tabpanel")).toHaveAttribute("id", "ops-stage-panel-normal");
expect(within(dialog).getByLabelText("正常阶段第 1 档倍率")).toBeInTheDocument();
fireEvent.keyDown(normalTab, { key: "End" });
expect(advancedTab).toHaveFocus();
expect(advancedTab).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(advancedTab, { key: "Home" });
expect(noviceTab).toHaveFocus();
expect(noviceTab).toHaveAttribute("aria-selected", "true");
});
test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fields on submit", () => {
const onSubmit = vi.fn();
renderDialog(fixedConfig(), { onSubmit });
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByRole("switch", { name: "发布状态" })).toBeChecked();
expect(within(dialog).queryByRole("button", { name: "水位" })).not.toBeInTheDocument();
expect(within(dialog).queryByText(/资金合计/)).not.toBeInTheDocument();
expect(within(dialog).queryByLabelText(/初始奖池金币/)).not.toBeInTheDocument();
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
effective_from_ms: 987_654_321,
enabled: true,
strategy_version: "fixed_v2",
stages: expect.arrayContaining([
expect.objectContaining({
stage: "novice",
tiers: expect.arrayContaining([
expect.objectContaining({
enabled: true,
high_water_only: false,
tier_id: "novice_none",
}),
]),
}),
]),
}),
);
});
test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => {
const onSubmit = vi.fn();
const stages = stagesFor(98).map((stage) =>
stage.stage === "normal"
? {
...stage,
tiers: [
{ ...stage.tiers[0], probability_percent: 1.99 },
stage.tiers[1],
{
enabled: true,
high_water_only: true,
multiplier: 200,
probability_percent: 0.01,
tier_id: "normal_200x",
},
],
}
: stage,
);
renderDialog(dynamicConfig({ stages }), { onSubmit });
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
jackpot_multipliers: [200, 500, 1000],
stages: expect.arrayContaining([
expect.objectContaining({
stage: "normal",
tiers: expect.arrayContaining([
expect.objectContaining({
high_water_only: true,
multiplier: 200,
probability_percent: 0.01,
tier_id: "normal_200x",
}),
]),
}),
]),
}),
);
});
test("focuses the validation summary when the fixed action bar submission is rejected", async () => {
const onSubmit = vi.fn();
renderDialog(dynamicConfig({ low_watermark_coins: 0 }), { onSubmit });
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "保存配置" }));
const alert = await within(dialog).findByRole("alert");
expect(alert).toHaveTextContent("高水位必须大于正数低水位");
expect(alert).toHaveFocus();
expect(onSubmit).not.toHaveBeenCalled();
});
function renderDialog(config, overrides = {}) {
return render(
<LuckyGiftConfigDialog
appOptions={[
["lalu", "Lalu"],
["yumi", "Yumi"],
]}
config={config}
mode="edit"
saving={false}
onClose={vi.fn()}
onSubmit={vi.fn()}
{...overrides}
/>,
);
}
function dynamicConfig(overrides = {}) {
return {
anchor_daily_payout_cap: 6_000_000,
anchor_rate_percent: 1,
app_code: "lalu",
control_band_percent: 3,
device_daily_payout_cap: 4_000_000,
effective_from_ms: 123_456_789,
enabled: true,
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_id: "lucky",
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,
rule_version: 3,
settlement_window_wager: 1_000_000,
stages: stagesFor(98),
strategy_version: "dynamic_v3",
target_rtp_percent: 98,
user_daily_payout_cap: 3_000_000,
user_hourly_payout_cap: 2_000_000,
...overrides,
};
}
function fixedConfig() {
return {
app_code: "lalu",
control_band_percent: 1,
effective_from_ms: 987_654_321,
enabled: true,
gift_price_reference: 100,
pool_id: "legacy",
pool_rate_percent: 95,
rule_version: 8,
settlement_window_wager: 1_000_000,
stages: stagesFor(95),
strategy_version: "fixed_v2",
target_rtp_percent: 95,
};
}
function stagesFor(targetRTP) {
return ["novice", "normal", "advanced"].map((stage, index) => ({
min_recharge_30d_coins: index === 0 ? 0 : 1,
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`,
},
],
}));
}