问题:批量叫号仅按数量输入,员工无法直接叫到 0002x 号段。 实现:允许输入完整目标号码,按待叫队列换算连续数量,保持 FIFO,并拦截非法目标和超上限操作。 验证:前端 68 项测试、类型检查和生产构建通过。
363 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|