import { expect, test, type Page } from "@playwright/test"; import { EMPLOYEE, MANAGER, login, type E2EUser } from "./helpers/auth"; const LEAVE_CALENDAR_HR: E2EUser = { identifier: "hr@hyinda.com", password: "HrPassword1234", name: "孙人事", }; function envelope(data: T) { return { requestId: "mock-request", data, }; } function startOfUtcDay(value: Date) { return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); } function addUtcDays(value: Date, days: number) { const next = startOfUtcDay(value); next.setUTCDate(next.getUTCDate() + days); return next; } function toDateKey(value: Date) { return startOfUtcDay(value).toISOString().slice(0, 10); } function buildCalendarItems(dateFrom: string, dateTo: string) { const start = startOfUtcDay(new Date(`${dateFrom}T00:00:00.000Z`)); const end = startOfUtcDay(new Date(`${dateTo}T00:00:00.000Z`)); const maxOffset = Math.max(0, Math.round((end.getTime() - start.getTime()) / 86400000)); const second = addUtcDays(start, Math.min(1, maxOffset)); const third = addUtcDays(start, Math.min(2, maxOffset)); return [ { id: "leave-calendar-1", ownerId: "employee-manager-1", departmentId: "department-1", status: "approved", startDate: toDateKey(start), endDate: toDateKey(start), startSession: "am", endSession: "pm", requestedDays: 1, reason: "家庭事务", attachment: null, leaveType: { id: "leave-type-annual", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [], }, owner: { id: "employee-manager-1", employeeNo: "E1001", name: "王小明", }, createdAt: "2026-03-25T08:00:00.000Z", updatedAt: "2026-03-25T08:00:00.000Z", }, { id: "leave-calendar-2", ownerId: "employee-manager-2", departmentId: "department-1", status: "reviewing", startDate: toDateKey(second), endDate: toDateKey(third.getTime() > end.getTime() ? second : third), startSession: "am", endSession: "pm", requestedDays: third.getTime() > end.getTime() ? 1 : 2, reason: "出差调休", attachment: null, leaveType: { id: "leave-type-sick", code: "sick", name: "病假", requiresAttachment: false, attachmentMimeTypes: [], }, owner: { id: "employee-manager-2", employeeNo: "E1002", name: "李四", }, createdAt: "2026-03-25T08:00:00.000Z", updatedAt: "2026-03-25T08:00:00.000Z", }, { id: "leave-calendar-3", ownerId: "employee-manager-3", departmentId: "department-1", status: "approved", startDate: toDateKey(end), endDate: toDateKey(end), startSession: "pm", endSession: "pm", requestedDays: 0.5, reason: "下午就医", attachment: null, leaveType: { id: "leave-type-personal", code: "personal", name: "事假", requiresAttachment: false, attachmentMimeTypes: [], }, owner: { id: "employee-manager-3", employeeNo: "E1003", name: "赵六", }, createdAt: "2026-03-25T08:00:00.000Z", updatedAt: "2026-03-25T08:00:00.000Z", }, { id: "leave-calendar-4", ownerId: "employee-manager-4", departmentId: "department-1", status: "approved", startDate: toDateKey(second), endDate: toDateKey(second), startSession: "am", endSession: "am", requestedDays: 0.5, reason: "半天外出", attachment: null, leaveType: { id: "leave-type-annual", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [], }, owner: { id: "employee-manager-4", employeeNo: "E1004", name: "孙七", }, createdAt: "2026-03-25T08:00:00.000Z", updatedAt: "2026-03-25T08:00:00.000Z", }, ]; } async function mockLeaveRequests(page: Page) { const observedCalendarQueries: Array<{ dateFrom: string; dateTo: string; pageSize: string; departmentId: string | null; }> = []; await page.route("**/api/leave-requests?**", async (route) => { const url = new URL(route.request().url()); const scope = url.searchParams.get("scope") ?? "own"; const pageSize = url.searchParams.get("pageSize") ?? "10"; const dateFrom = url.searchParams.get("dateFrom"); const dateTo = url.searchParams.get("dateTo"); const departmentId = url.searchParams.get("departmentId"); if (scope === "all" && dateFrom && dateTo) { observedCalendarQueries.push({ dateFrom, dateTo, pageSize, departmentId }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( envelope({ page: 1, pageSize: Number(pageSize), total: 5, scope: "all", items: buildCalendarItems(dateFrom, dateTo), }), ), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( envelope({ page: 1, pageSize: Number(pageSize), total: 0, scope, items: [], }), ), }); }); return observedCalendarQueries; } test("E2E 请假团队日历:HR 可见全员周/月视图并显示截断提示", async ({ page }) => { const observedCalendarQueries = await mockLeaveRequests(page); await login(page, LEAVE_CALENDAR_HR); await page.goto("/leave-requests"); await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); await expect(page.getByTestId("leave-view-calendar")).toBeVisible(); const weekResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-view-calendar").click(); await weekResponse; await expect(page.getByTestId("leave-calendar-mode-week")).toHaveAttribute("data-state", "active"); await expect(page.getByTestId("leave-team-calendar-grid")).toBeVisible(); await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "王小明" }).first()).toBeVisible(); await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "李四" }).first()).toBeVisible(); await expect(page.getByTestId("leave-team-calendar-truncation")).toContainText("仅显示前 4 条,共 5 条"); await expect(page.getByTestId("leave-team-calendar-cell")).toHaveCount(7); const monthResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-calendar-mode-month").click(); await monthResponse; await expect(page.getByTestId("leave-calendar-mode-month")).toHaveAttribute("data-state", "active"); await expect.poll(() => page.getByTestId("leave-team-calendar-cell").count()).toBeGreaterThan(7); await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(2); expect(observedCalendarQueries.every((query) => Boolean(query.dateFrom && query.dateTo))).toBe(true); expect(observedCalendarQueries.every((query) => query.pageSize === "100")).toBe(true); expect(observedCalendarQueries.every((query) => query.departmentId == null)).toBe(true); }); test("E2E 请假团队日历:经理只拉本部门数据且导航会触发新查询", async ({ page }) => { const observedCalendarQueries = await mockLeaveRequests(page); await login(page, MANAGER); await page.goto("/leave-requests"); await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); await expect(page.getByTestId("leave-view-calendar")).toBeVisible(); const firstResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-view-calendar").click(); await firstResponse; await expect(page.getByTestId("leave-team-calendar-grid")).toBeVisible(); await expect(page.getByTestId("leave-team-calendar-entry").filter({ hasText: "王小明" }).first()).toBeVisible(); await expect.poll(() => observedCalendarQueries.length).toBeGreaterThanOrEqual(1); expect(observedCalendarQueries.every((query) => Boolean(query.departmentId))).toBe(true); const firstQuery = observedCalendarQueries.at(-1); if (!firstQuery) { throw new Error("经理团队日历未记录到首个查询"); } const previousResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-calendar-prev").click(); await previousResponse; const previousQuery = observedCalendarQueries.at(-1); if (!previousQuery) { throw new Error("经理团队日历未记录到上一窗口查询"); } expect(previousQuery.dateFrom).not.toBe(firstQuery.dateFrom); const todayResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-calendar-today").click(); await todayResponse; const todayQuery = observedCalendarQueries.at(-1); if (!todayQuery) { throw new Error("经理团队日历未记录到今天查询"); } expect(todayQuery.dateFrom).toBe(firstQuery.dateFrom); const monthResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-calendar-mode-month").click(); await monthResponse; await expect.poll(() => page.getByTestId("leave-team-calendar-cell").count()).toBeGreaterThan(7); const nextMonthResponse = page.waitForResponse((response) => { const url = new URL(response.url()); return response.ok() && url.pathname === "/api/leave-requests" && url.searchParams.get("scope") === "all"; }); await page.getByTestId("leave-calendar-next").click(); await nextMonthResponse; const nextMonthQuery = observedCalendarQueries.at(-1); if (!nextMonthQuery) { throw new Error("经理团队日历未记录到下一窗口查询"); } expect(nextMonthQuery.dateFrom).not.toBe(todayQuery.dateFrom); }); test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ page }) => { await mockLeaveRequests(page); await login(page, EMPLOYEE); await page.goto("/leave-requests"); await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); await expect(page.getByRole("heading", { name: "日常请假入口" })).toBeVisible(); await expect(page.getByRole("heading", { name: "OA 应急请假草稿" })).toHaveCount(0); await expect(page.getByRole("button", { name: "创建应急草稿" })).toHaveCount(0); await expect(page.getByTestId("leave-view-calendar")).toHaveCount(0); await expect(page.getByTestId("leave-page-view-tabs")).toHaveCount(0); });