391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
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`,
|
||
},
|
||
],
|
||
}));
|
||
}
|