Files
XQKqueue/web/src/pages/StaffPage.test.tsx
T
brother7 815085e98b 修复:支持员工按完整目标号码批量叫号
问题:批量叫号仅按数量输入,员工无法直接叫到 0002x 号段。

实现:允许输入完整目标号码,按待叫队列换算连续数量,保持 FIFO,并拦截非法目标和超上限操作。

验证:前端 68 项测试、类型检查和生产构建通过。
2026-08-01 21:42:41 +08:00

363 lines
17 KiB
TypeScript

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 }) => (
<div data-testid="app-shell" data-variant={variant}>{children}</div>
),
}));
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(<MemoryRouter initialEntries={[path]}><StaffPage /></MemoryRouter>);
}
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();
});
});