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( , ); } 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`, }, ], })); }