import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; const staffPageTestState = vi.hoisted(() => ({ projectStatus: "RUNNING" })); vi.mock("../components/AppShell", () => ({ AppShell: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
{children}
), })); vi.mock("../hooks/usePollingResource", () => ({ usePollingResource: (_load: unknown, options: { intervalMs: number }) => options.intervalMs === 60_000 ? { data: { projects: [ { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, { id: "project-2", name: "西门观光车", status: "RUNNING", batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, ] }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(), } : { data: { project: { id: "project-1", name: "东门观光车", status: staffPageTestState.projectStatus, batch_size: 2, call_mode: "BOTH", default_call_ticket_count: 2, max_call_ticket_count: 20, default_call_people_count: 5, max_call_people_count: 30, min_party_size: 1, max_party_size: 8 }, revision: 8, waiting: Array.from({ length: 25 }, (_, index) => ({ id: `waiting-${index + 1}`, ticket_number: String(index + 13).padStart(5, "0"), phone: "13900139000", last_name: "李", honorific: "女士", status: "WAITING", party_size: index === 0 ? 4 : 2, created_at: "2026-07-10T09:02:00Z", })), current_batch: { id: "batch-1", batch_number: 4, status: "ACTIVE", called_at: "2026-07-10T09:01:00Z", tickets: [{ id: "called-1", ticket_number: "00012", phone: "13800138000", last_name: "张", honorific: "先生", status: "CALLED", party_size: 2, }], ticket_count: 1, people_count: 2, }, metrics: { waiting_count: 25, waiting_ticket_count: 25, waiting_people_count: 52, estimated_wait: { available: true, estimate_minutes: 15, min_minutes: 15, max_minutes: 15 }, last_ticket_number: "00037", next_ticket_number: "00038", }, last_success_at: new Date().toISOString(), }, loading: false, refreshing: false, error: null, offline: false, lastClientSuccessAt: new Date().toISOString(), refresh: vi.fn(), }, })); vi.mock("../auth/AuthContext", () => ({ useAuth: () => ({ user: { id: "staff-1", username: "staff01", display_name: "小李", role: "STAFF" } }), })); import { StaffPage } from "./StaffPage"; import { api } from "../api"; function renderScene(path: string) { return render(); } describe("StaffPage scene-focused H5", () => { beforeEach(() => { staffPageTestState.projectStatus = "RUNNING"; sessionStorage.setItem("scenic-current-project", "project-1"); }); it("allows calling but blocks ticket creation while the project is paused", () => { staffPageTestState.projectStatus = "PAUSED"; renderScene("/staff"); expect(screen.getByRole("button", { name: "快速叫下一个号" })).toBeEnabled(); expect(screen.queryByText("当前项目未开放叫号")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("link", { name: "取号" })); expect(screen.getByRole("button", { name: "创建排队号码" })).toBeDisabled(); expect(screen.getByText("当前不可取号")).toBeInTheDocument(); }); it("keeps the call scene focused on the active batch and the primary call action", () => { renderScene("/staff"); expect(screen.getByTestId("app-shell")).toHaveAttribute("data-variant", "mobile"); expect(screen.getByRole("link", { name: "叫号" })).toHaveClass("active"); expect(screen.getByRole("link", { name: "我的" })).toBeVisible(); expect(screen.getByRole("region", { name: "叫号" })).toBeVisible(); expect(screen.queryByText("13800138000")).not.toBeInTheDocument(); expect(screen.queryByText("张先生")).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "核验" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "到场" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "过号" })).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "按号数/人数叫号" })).toBeVisible(); expect(screen.getByRole("region", { name: "按号数/人数叫号" }).parentElement).toHaveClass("staff-call-sticky"); expect(screen.queryByText("按号码叫号", { selector: ".staff-call-mode > strong" })).not.toBeInTheDocument(); expect(screen.queryByText("按人数叫号", { selector: ".staff-call-mode > strong" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "快速叫下一个号" })).toBeEnabled(); expect(screen.getByRole("button", { name: "批量叫号" })).toBeEnabled(); expect(screen.getByRole("button", { name: "批量叫号" })).toHaveClass("button--secondary"); expect(screen.getByRole("button", { name: "批量叫人" })).toBeEnabled(); expect(screen.getByRole("button", { name: "批量叫人" })).toHaveClass("button--secondary"); expect(screen.queryByRole("button", { name: "按号码叫号" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "按人数叫号" })).not.toBeInTheDocument(); expect(screen.getByRole("region", { name: "叫号" })).toHaveTextContent("000134 人"); expect(screen.queryByText(/本次共叫/)).not.toBeInTheDocument(); expect(screen.queryByLabelText("等待队列表,可横向滚动")).not.toBeInTheDocument(); expect(screen.queryByRole("group", { name: "数据视图" })).not.toBeInTheDocument(); expect(screen.queryByText("演示")).not.toBeInTheDocument(); expect(screen.queryByText("实时")).not.toBeInTheDocument(); const callRegion = screen.getByRole("region", { name: "叫号" }); expect(callRegion).toHaveTextContent("下个号人数4 人"); expect(callRegion).toHaveTextContent("剩余未叫号25 个"); expect(callRegion).toHaveTextContent("剩余未叫人数52 人"); expect(callRegion).not.toHaveTextContent("最末号数"); expect(callRegion).not.toHaveTextContent("最末号预计时长"); }); it("reveals waiting tickets ten at a time and can collapse", () => { renderScene("/staff"); const waitingList = screen.getByRole("list", { name: "待叫号码" }); expect(within(waitingList).getAllByRole("listitem")).toHaveLength(10); expect(screen.queryByText("00023")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "查看更多" })); expect(within(waitingList).getAllByRole("listitem")).toHaveLength(20); expect(screen.getByText("00032")).toBeVisible(); expect(screen.queryByText("00033")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "查看更多" })).toBeVisible(); expect(screen.getByRole("button", { name: "收起" })).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "查看更多" })); expect(within(waitingList).getAllByRole("listitem")).toHaveLength(25); expect(screen.getByText("00037")).toBeVisible(); expect(screen.queryByRole("button", { name: "查看更多" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "收起" })); expect(within(waitingList).getAllByRole("listitem")).toHaveLength(10); expect(screen.getByRole("button", { name: "查看更多" })).toBeVisible(); expect(screen.queryByText("00023")).not.toBeInTheDocument(); }); it("keeps business success primary when a device acknowledgement fails", async () => { const callNext = vi.spyOn(api, "callNext").mockResolvedValueOnce({ batch: { id: "batch-2", batch_number: 5, status: "CALLED", called_at: "2026-07-10T09:03:00Z", ticket_count: 1, people_count: 4, tickets: [{ id: "called-2", ticket_number: "00013", status: "CALLED", party_size: 4 }], }, revision: 9, device_results: [{ channel: "SIMULATOR", status: "FAILURE", message: "模拟设备执行失败;业务叫号已成功" }], }); try { renderScene("/staff"); fireEvent.click(screen.getByRole("button", { name: "快速叫下一个号" })); await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("叫号已生效:00013")); expect(screen.getByRole("status")).toHaveTextContent("业务叫号已生效;1 个设备通道执行失败"); expect(screen.queryByText("叫号未生效")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "删除叫号通知" })); expect(screen.queryByText("叫号已生效:00013")).not.toBeInTheDocument(); } finally { callNext.mockRestore(); } }); it("automatically removes a call notice after five seconds", async () => { vi.useFakeTimers(); const callNext = vi.spyOn(api, "callNext").mockResolvedValueOnce({ batch: { id: "batch-2", batch_number: 5, status: "CALLED", called_at: "2026-07-10T09:03:00Z", ticket_count: 1, people_count: 4, tickets: [{ id: "called-2", ticket_number: "00013", status: "CALLED", party_size: 4 }], }, revision: 9, device_results: [{ channel: "SIMULATOR", status: "SUCCESS" }], }); try { renderScene("/staff"); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "快速叫下一个号" })); await Promise.resolve(); }); expect(screen.getByText("叫号已生效:00013")).toBeVisible(); act(() => vi.advanceTimersByTime(4_999)); expect(screen.getByText("叫号已生效:00013")).toBeVisible(); act(() => vi.advanceTimersByTime(1)); expect(screen.queryByText("叫号已生效:00013")).not.toBeInTheDocument(); } finally { callNext.mockRestore(); vi.useRealTimers(); } }); it("rejects an over-limit people target instead of silently capping it", async () => { const callNext = vi.spyOn(api, "callNext"); try { renderScene("/staff"); fireEvent.change(screen.getByRole("spinbutton", { name: "按人数叫号数量" }), { target: { value: "31" } }); fireEvent.click(screen.getByRole("button", { name: "批量叫人" })); expect(callNext).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent("人数请输入 1 到 30 的整数"); } finally { callNext.mockRestore(); } }); it("can call through a full target number beyond the 0001x range", async () => { const callNext = vi.spyOn(api, "callNext").mockResolvedValueOnce({ batch: { id: "batch-2", batch_number: 5, status: "CALLED", called_at: "2026-07-10T09:03:00Z", ticket_count: 13, people_count: 28, tickets: [{ id: "called-25", ticket_number: "00025", status: "CALLED", party_size: 2 }], }, revision: 9, device_results: [{ channel: "SIMULATOR", status: "SUCCESS" }], }); try { renderScene("/staff"); const targetInput = screen.getByRole("textbox", { name: "叫到号码" }); expect(targetInput).toHaveValue("00014"); fireEvent.change(targetInput, { target: { value: "00025" } }); fireEvent.click(screen.getByRole("button", { name: "批量叫号" })); await waitFor(() => expect(callNext).toHaveBeenCalledWith("project-1", 8, "TICKET", 13, expect.any(String))); } finally { callNext.mockRestore(); } }); it("rejects a target outside the waiting queue or beyond the configured batch limit", () => { const callNext = vi.spyOn(api, "callNext"); try { renderScene("/staff"); const targetInput = screen.getByRole("textbox", { name: "叫到号码" }); fireEvent.change(targetInput, { target: { value: "00040" } }); fireEvent.click(screen.getByRole("button", { name: "批量叫号" })); expect(callNext).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent("请输入当前待叫队列中的完整目标号码"); fireEvent.change(targetInput, { target: { value: "00033" } }); fireEvent.click(screen.getByRole("button", { name: "批量叫号" })); expect(callNext).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent("从 00013 叫到 00033 共 21 个号码,超过单次上限 20 个"); } finally { callNext.mockRestore(); } }); it("updates number indicators after creation without showing a success card", async () => { vi.spyOn(api, "createTicket").mockResolvedValueOnce({ ticket: { id: "ticket-30", ticket_number: "00030", status: "WAITING", party_size: 3 }, revision: 9, }); renderScene("/staff/tickets"); expect(screen.getByRole("link", { name: "取号" })).toHaveClass("active"); expect(screen.getByRole("region", { name: "取号" })).toBeVisible(); expect(screen.getByLabelText("手机号")).toBeVisible(); expect(screen.getByRole("button", { name: "创建排队号码" })).toBeVisible(); expect(screen.getByRole("region", { name: "取号指标" })).toHaveTextContent("上个取号00037"); expect(screen.getByRole("region", { name: "取号指标" })).toHaveTextContent("下个取号00038"); expect(screen.queryByLabelText("等待队列表,可横向滚动")).not.toBeInTheDocument(); expect(screen.queryByText("叫下一批(1 个号码)")).not.toBeInTheDocument(); fireEvent.change(screen.getByLabelText("手机号"), { target: { value: "13800138000" } }); const partySizeInput = screen.getByRole("spinbutton", { name: "同行人数" }); expect(partySizeInput).toHaveValue(1); fireEvent.change(partySizeInput, { target: { value: "" } }); expect(partySizeInput).toHaveValue(null); fireEvent.change(partySizeInput, { target: { value: "3" } }); expect(partySizeInput).toHaveValue(3); fireEvent.click(screen.getByRole("button", { name: "创建排队号码" })); await waitFor(() => expect(screen.getByRole("region", { name: "取号指标" })).toHaveTextContent("上个取号00030")); expect(screen.getByRole("region", { name: "取号指标" })).toHaveTextContent("3 人"); expect(screen.getByRole("region", { name: "取号指标" })).toHaveTextContent("下个取号00031"); expect(screen.queryByText("取号成功:00030")).not.toBeInTheDocument(); expect(screen.queryByText("打开游客状态页")).not.toBeInTheDocument(); }); it("only switches project after explicit confirmation", async () => { renderScene("/staff/me"); expect(screen.getByRole("link", { name: "我的" })).toHaveClass("active"); expect(screen.getByRole("region", { name: "我的" })).toBeVisible(); expect(screen.getByText("小李")).toBeVisible(); expect(screen.getByText("staff01")).toBeVisible(); const switchButton = screen.getByRole("button", { name: "切换项目" }); expect(screen.queryByLabelText("选择项目")).not.toBeInTheDocument(); fireEvent.click(switchButton); const dialog = screen.getByRole("dialog", { name: "切换项目" }); const projectOptions = screen.getByRole("group", { name: "选择项目" }); const currentProject = screen.getByRole("radio", { name: "东门观光车" }); const nextProject = screen.getByRole("radio", { name: "西门观光车" }); expect(dialog).toBeVisible(); expect(projectOptions).toBeVisible(); expect(currentProject).toBeChecked(); expect(screen.getByRole("button", { name: "确认切换" })).toBeDisabled(); fireEvent.click(nextProject); expect(sessionStorage.getItem("scenic-current-project")).toBe("project-1"); expect(screen.getByRole("button", { name: "确认切换" })).toBeEnabled(); fireEvent.click(screen.getByRole("button", { name: "确认切换" })); await waitFor(() => expect(sessionStorage.getItem("scenic-current-project")).toBe("project-2")); expect(screen.queryByRole("dialog", { name: "切换项目" })).not.toBeInTheDocument(); }); it("closes the project switcher with Escape and returns focus", async () => { renderScene("/staff/me"); const switchButton = screen.getByRole("button", { name: "切换项目" }); fireEvent.click(switchButton); expect(screen.getByRole("dialog", { name: "切换项目" })).toBeVisible(); fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("dialog", { name: "切换项目" })).not.toBeInTheDocument()); expect(switchButton).toHaveFocus(); }); });