RS6 修复移动响应式布局体系
这个提交包含在:
@@ -263,25 +263,46 @@ async function collectMobileMetrics(page: Page) {
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
const actionLabels = Array.from(document.querySelectorAll("[data-testid^='mobile-action-'][data-testid$='-label']")).map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const key = element.getAttribute("data-testid")?.replace(/^mobile-action-/, "").replace(/-label$/, "") ?? "";
|
||||
const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => {
|
||||
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
|
||||
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
|
||||
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon']`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const labelRect = label?.getBoundingClientRect();
|
||||
const iconRect = icon?.getBoundingClientRect();
|
||||
const style = label ? window.getComputedStyle(label) : null;
|
||||
const overlaps = Boolean(
|
||||
labelRect &&
|
||||
iconRect &&
|
||||
!(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom),
|
||||
);
|
||||
return {
|
||||
text: element.textContent?.trim() ?? "",
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
right: rect.right,
|
||||
lineHeight: Number.parseFloat(style.lineHeight) || 24,
|
||||
whiteSpace: style.whiteSpace,
|
||||
iconLeft: iconRect?.left ?? null,
|
||||
key,
|
||||
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
text: label?.textContent?.trim() ?? "",
|
||||
labelWidth: labelRect?.width ?? 0,
|
||||
labelHeight: labelRect?.height ?? 0,
|
||||
labelLineHeight: style ? Number.parseFloat(style.lineHeight) || 18 : 18,
|
||||
labelWhiteSpace: style?.whiteSpace ?? "",
|
||||
iconWidth: iconRect?.width ?? 0,
|
||||
iconHeight: iconRect?.height ?? 0,
|
||||
gapToIcon: iconRect ? iconRect.left - rect.right : null,
|
||||
overlaps,
|
||||
labelWithin:
|
||||
Boolean(rect && labelRect) &&
|
||||
labelRect!.left >= rect!.left - 1 &&
|
||||
labelRect!.right <= rect!.right + 1 &&
|
||||
labelRect!.top >= rect!.top - 1 &&
|
||||
labelRect!.bottom <= rect!.bottom + 1,
|
||||
iconWithin:
|
||||
Boolean(rect && iconRect) &&
|
||||
iconRect!.left >= rect!.left - 1 &&
|
||||
iconRect!.right <= rect!.right + 1 &&
|
||||
iconRect!.top >= rect!.top - 1 &&
|
||||
iconRect!.bottom <= rect!.bottom + 1,
|
||||
};
|
||||
});
|
||||
}).filter((item) => item.visible);
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
@@ -297,7 +318,7 @@ async function collectMobileMetrics(page: Page) {
|
||||
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
|
||||
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
|
||||
stickyButtonMinWidth: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.width)) : 0,
|
||||
actionLabels,
|
||||
actionItems,
|
||||
text: document.body?.innerText ?? "",
|
||||
};
|
||||
});
|
||||
@@ -334,15 +355,17 @@ test.describe("R5 真机反馈移动交互", () => {
|
||||
expect(homeMetrics.bottomNavBottomGap).not.toBeNull();
|
||||
expect(homeMetrics.bottomNavBottomGap!).toBeLessThanOrEqual(2);
|
||||
expect(homeMetrics.scrollWidth).toBeLessThanOrEqual(homeMetrics.width + 2);
|
||||
expect(homeMetrics.actionLabels.length).toBeGreaterThanOrEqual(4);
|
||||
for (const label of homeMetrics.actionLabels) {
|
||||
expect(label.whiteSpace, `${device.label} ${label.text} action label no wrap`).toBe("nowrap");
|
||||
expect(label.height, `${device.label} ${label.text} action label single line`).toBeLessThanOrEqual(label.lineHeight + 2);
|
||||
expect(label.width, `${device.label} ${label.text} action label readable width`).toBeGreaterThanOrEqual(40);
|
||||
expect(label.iconWidth, `${device.label} ${label.text} action icon width`).toBeGreaterThanOrEqual(28);
|
||||
expect(label.iconHeight, `${device.label} ${label.text} action icon height`).toBeGreaterThanOrEqual(28);
|
||||
expect(label.gapToIcon, `${device.label} ${label.text} action label/icon spacing`).not.toBeNull();
|
||||
expect(label.gapToIcon!, `${device.label} ${label.text} action label/icon spacing`).toBeGreaterThanOrEqual(6);
|
||||
expect(homeMetrics.actionItems.length).toBeGreaterThanOrEqual(4);
|
||||
for (const action of homeMetrics.actionItems) {
|
||||
expect(action.height, `${device.label} ${action.text} action touch height`).toBeGreaterThanOrEqual(44);
|
||||
expect(action.labelWhiteSpace, `${device.label} ${action.text} action label no wrap`).toBe("nowrap");
|
||||
expect(action.labelHeight, `${device.label} ${action.text} action label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
|
||||
expect(action.labelWidth, `${device.label} ${action.text} action label readable width`).toBeGreaterThanOrEqual(20);
|
||||
expect(action.iconWidth, `${device.label} ${action.text} action icon width`).toBeGreaterThanOrEqual(28);
|
||||
expect(action.iconHeight, `${device.label} ${action.text} action icon height`).toBeGreaterThanOrEqual(28);
|
||||
expect(action.overlaps, `${device.label} ${action.text} action label/icon overlap`).toBe(false);
|
||||
expect(action.labelWithin, `${device.label} ${action.text} action label within tile`).toBe(true);
|
||||
expect(action.iconWithin, `${device.label} ${action.text} action icon within tile`).toBe(true);
|
||||
}
|
||||
for (const item of homeMetrics.navItems) {
|
||||
expect(item.visible, `${device.label} ${item.key} nav item visible`).toBe(true);
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const ROOT_DIR = path.resolve(process.cwd(), "..");
|
||||
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r6-mobile-responsive-system-hotfix");
|
||||
|
||||
const IOS_WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const ANDROID_WECOM_UA =
|
||||
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const MOBILE_BROWSER_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
||||
|
||||
const VIEWPORT_CASES = [
|
||||
{ key: "w320-ios-wecom", label: "320 iOS 企业微信", viewport: { width: 320, height: 568 }, userAgent: IOS_WECOM_UA },
|
||||
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
|
||||
{ key: "w375-browser", label: "375 普通移动浏览器", viewport: { width: 375, height: 667 }, userAgent: MOBILE_BROWSER_UA },
|
||||
{ key: "w390-ios-wecom", label: "390 iOS 企业微信", viewport: { width: 390, height: 844 }, userAgent: IOS_WECOM_UA },
|
||||
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
|
||||
{ key: "w414-ios-wecom", label: "414 iOS 企业微信", viewport: { width: 414, height: 896 }, userAgent: IOS_WECOM_UA },
|
||||
{ key: "w430-browser", label: "430 普通移动浏览器", viewport: { width: 430, height: 932 }, userAgent: MOBILE_BROWSER_UA },
|
||||
] as const;
|
||||
|
||||
const employee = {
|
||||
id: "r6-responsive-employee",
|
||||
employeeNo: "SMOKE002",
|
||||
name: "OA试运行测试01",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-r6",
|
||||
directManagerId: "manager-r6",
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["manager", "employee"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 60,
|
||||
},
|
||||
wecomBinding: {
|
||||
externalUserId: "oa_smoke_01",
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-r6-mobile-responsive", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function screenshotPath(fileName: string) {
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
|
||||
}
|
||||
|
||||
function reimbursementListItem(status: "draft" | "reviewing" = "draft") {
|
||||
return {
|
||||
id: "r6-responsive-reimbursement",
|
||||
formNumber: "BX20260511001",
|
||||
status,
|
||||
reimbursementDate: "2026-05-11",
|
||||
totalAmount: "168.30",
|
||||
description: "R6 移动响应式体系验证",
|
||||
payment: {},
|
||||
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
project: null,
|
||||
pendingWorkflow: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
updatedAt: "2026-05-11T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function mockApis(page: Page) {
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathName = url.pathname;
|
||||
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: "mock-r6-responsive-token", expiresIn: 900 })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "ww-r6-responsive",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/mobile",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/me") {
|
||||
await fulfillJson(route, employee);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/employees/${employee.id}/profile`) {
|
||||
await fulfillJson(route, {
|
||||
employee: {
|
||||
...employee,
|
||||
title: "测试员工",
|
||||
avatarUrl: null,
|
||||
department: { id: "dept-r6", name: "试运行部" },
|
||||
directManager: { id: "manager-r6", name: "OA试运行测试02" },
|
||||
},
|
||||
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
|
||||
assignedAssets: [],
|
||||
organization: { reportingChain: [], directReports: [] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/attendance/me/today") {
|
||||
await fulfillJson(route, { workDate: "2026-05-11", needClockIn: false, exemptionReason: null, item: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/notifications" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
unreadOnly: false,
|
||||
unreadCount: 1,
|
||||
items: [
|
||||
{
|
||||
id: "notification-r6",
|
||||
type: "workflow.task_assigned",
|
||||
title: "报销待审批",
|
||||
body: "R6 移动响应式验证待处理",
|
||||
targetType: "workflow_task",
|
||||
targetId: "task-r6",
|
||||
readAt: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
id: "task-r6",
|
||||
instanceId: "instance-r6",
|
||||
stepId: "step-r6",
|
||||
stepName: "主管审批",
|
||||
assigneeId: employee.id,
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comments: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
id: "instance-r6",
|
||||
entityType: "reimbursement",
|
||||
entityId: "r6-responsive-reimbursement",
|
||||
currentStatus: "reviewing",
|
||||
startedAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "BX20260511001",
|
||||
title: "报销待审批",
|
||||
applicantName: employee.name,
|
||||
amount: "168.30",
|
||||
duration: null,
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/tasks/task-r6" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
task: {
|
||||
id: "task-r6",
|
||||
instanceId: "instance-r6",
|
||||
stepId: "step-r6",
|
||||
stepName: "主管审批",
|
||||
assigneeId: employee.id,
|
||||
assigneeRole: null,
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
comment: null,
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
workflowInstance: {
|
||||
id: "instance-r6",
|
||||
entityType: "reimbursement",
|
||||
currentStatus: "reviewing",
|
||||
currentStepId: "step-r6",
|
||||
contextData: { standardViolations: [] },
|
||||
startedAt: "2026-05-11T08:00:00.000Z",
|
||||
completedAt: null,
|
||||
},
|
||||
reimbursement: {
|
||||
id: "r6-responsive-reimbursement",
|
||||
formNumber: "BX20260511001",
|
||||
status: "reviewing",
|
||||
reimbursementDate: "2026-05-11",
|
||||
totalAmount: "168.30",
|
||||
description: "R6 移动响应式体系验证",
|
||||
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
},
|
||||
leaveRequest: null,
|
||||
purchaseRequest: null,
|
||||
travelRequest: null,
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
history: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursements" && request.method() === "GET") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [reimbursementListItem()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/reimbursements/r6-responsive-reimbursement") {
|
||||
await fulfillJson(route, {
|
||||
...reimbursementListItem(),
|
||||
submitterId: employee.id,
|
||||
ownerId: employee.id,
|
||||
departmentId: "dept-r6",
|
||||
department: { id: "dept-r6", name: "试运行部" },
|
||||
submittedAt: null,
|
||||
projectId: null,
|
||||
linkedTravelRequestId: null,
|
||||
linkedTravelRequest: null,
|
||||
project: null,
|
||||
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
|
||||
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
|
||||
invoices: [],
|
||||
attachments: [],
|
||||
budgetHint: null,
|
||||
pendingWorkflow: null,
|
||||
workflowProgress: null,
|
||||
approvalHistory: [],
|
||||
riskFlags: [],
|
||||
items: [
|
||||
{
|
||||
id: "item-r6",
|
||||
expenseTypeId: "expense-type-r6",
|
||||
expenseDate: "2026-05-11",
|
||||
amount: "168.30",
|
||||
description: "响应式验证",
|
||||
expenseType: { id: "expense-type-r6", code: "office", name: "办公费", category: "office", enabled: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/expense-types") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
items: [{ id: "expense-type-r6", code: "office", name: "办公费", category: "office", enabled: true }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-types") {
|
||||
await fulfillJson(route, {
|
||||
items: [
|
||||
{
|
||||
id: "leave-type-r6",
|
||||
code: "annual",
|
||||
name: "年假",
|
||||
description: null,
|
||||
enabled: true,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
sortOrder: 1,
|
||||
policy: { id: "leave-policy-r6", annualQuotaDays: 5, enabled: true, description: null },
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-balances") {
|
||||
await fulfillJson(route, {
|
||||
employeeId: employee.id,
|
||||
items: [
|
||||
{
|
||||
leaveType: {
|
||||
id: "leave-type-r6",
|
||||
code: "annual",
|
||||
name: "年假",
|
||||
description: null,
|
||||
enabled: true,
|
||||
requiresAttachment: false,
|
||||
attachmentMimeTypes: [],
|
||||
sortOrder: 1,
|
||||
policy: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
policy: { id: "leave-policy-r6", annualQuotaDays: 5, enabled: true, description: null },
|
||||
granted: 5,
|
||||
adjusted: 0,
|
||||
reserved: 0,
|
||||
used: 1,
|
||||
available: 4,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/leave-requests") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
scope: "own",
|
||||
items: [
|
||||
{
|
||||
id: "leave-r6",
|
||||
ownerId: employee.id,
|
||||
departmentId: "dept-r6",
|
||||
status: "draft",
|
||||
startDate: "2026-05-12",
|
||||
endDate: "2026-05-12",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
requestedDays: 1,
|
||||
reason: "响应式验证",
|
||||
attachment: null,
|
||||
leaveType: { id: "leave-type-r6", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
|
||||
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
createdAt: "2026-05-11T08:00:00.000Z",
|
||||
updatedAt: "2026-05-11T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
|
||||
if (await accountLogin.isVisible().catch(() => false)) {
|
||||
await accountLogin.click();
|
||||
}
|
||||
await page.getByTestId("login-identifier").fill(employee.email);
|
||||
await page.getByTestId("login-password").fill("Password1234");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function collectMetrics(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const bottomNav = document.querySelector("[data-testid='mobile-bottom-nav']");
|
||||
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
|
||||
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const centerX = rect ? rect.left + rect.width / 2 : 0;
|
||||
const centerY = rect ? rect.top + rect.height / 2 : 0;
|
||||
const hit = rect ? document.elementFromPoint(centerX, centerY) : null;
|
||||
return {
|
||||
key,
|
||||
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
hitWithin: Boolean(element && hit instanceof Element && hit.closest(`[data-testid='mobile-nav-${key}']`) === element),
|
||||
};
|
||||
});
|
||||
const actionItems = ["approvals", "capture", "reimbursements", "me", "leave"].map((key) => {
|
||||
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
|
||||
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
|
||||
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon']`);
|
||||
const rect = element?.getBoundingClientRect();
|
||||
const labelRect = label?.getBoundingClientRect();
|
||||
const iconRect = icon?.getBoundingClientRect();
|
||||
const labelStyle = label ? window.getComputedStyle(label) : null;
|
||||
const overlaps = Boolean(
|
||||
labelRect &&
|
||||
iconRect &&
|
||||
!(labelRect.right <= iconRect.left || labelRect.left >= iconRect.right || labelRect.bottom <= iconRect.top || labelRect.top >= iconRect.bottom),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
text: label?.textContent?.trim() ?? "",
|
||||
labelHeight: labelRect?.height ?? 0,
|
||||
labelLineHeight: labelStyle ? Number.parseFloat(labelStyle.lineHeight) || 18 : 18,
|
||||
labelWhiteSpace: labelStyle?.whiteSpace ?? "",
|
||||
iconWidth: iconRect?.width ?? 0,
|
||||
iconHeight: iconRect?.height ?? 0,
|
||||
overlaps,
|
||||
labelWithin:
|
||||
Boolean(rect && labelRect) &&
|
||||
labelRect!.left >= rect!.left - 1 &&
|
||||
labelRect!.right <= rect!.right + 1 &&
|
||||
labelRect!.top >= rect!.top - 1 &&
|
||||
labelRect!.bottom <= rect!.bottom + 1,
|
||||
iconWithin:
|
||||
Boolean(rect && iconRect) &&
|
||||
iconRect!.left >= rect!.left - 1 &&
|
||||
iconRect!.right <= rect!.right + 1 &&
|
||||
iconRect!.top >= rect!.top - 1 &&
|
||||
iconRect!.bottom <= rect!.bottom + 1,
|
||||
};
|
||||
}).filter((item) => item.visible);
|
||||
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
|
||||
const stickyRect = stickyActions?.getBoundingClientRect();
|
||||
const stickyButtons = [
|
||||
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
|
||||
document.querySelector("[data-testid='reimbursement-submit']"),
|
||||
].filter(Boolean).map((element) => element!.getBoundingClientRect());
|
||||
return {
|
||||
viewportWidth,
|
||||
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
||||
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
|
||||
bottomNavVisible: Boolean(bottomNav?.getBoundingClientRect().height),
|
||||
navItems,
|
||||
actionItems,
|
||||
stickyActionsVisible: Boolean(stickyRect && stickyRect.height > 0 && stickyRect.bottom <= window.innerHeight + 2),
|
||||
stickyButtonsSameRow:
|
||||
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
|
||||
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function expectMobilePageStable(page: Page, label: string, screenshotName: string) {
|
||||
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
|
||||
const metrics = await collectMetrics(page);
|
||||
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
|
||||
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
|
||||
if (metrics.bottomNavVisible) {
|
||||
for (const item of metrics.navItems) {
|
||||
expect(item.visible, `${label} ${item.key} nav visible`).toBe(true);
|
||||
expect(item.width, `${label} ${item.key} nav width`).toBeGreaterThanOrEqual(44);
|
||||
expect(item.height, `${label} ${item.key} nav height`).toBeGreaterThanOrEqual(44);
|
||||
expect(item.hitWithin, `${label} ${item.key} nav hit`).toBe(true);
|
||||
}
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
test.describe("R6 移动响应式体系", () => {
|
||||
for (const device of VIEWPORT_CASES) {
|
||||
test.describe(device.label, () => {
|
||||
test.use({
|
||||
viewport: device.viewport,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: device.userAgent,
|
||||
locale: "zh-CN",
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
|
||||
test("关键移动页面跨视口自适应且首页宫格不挤压", async ({ page }) => {
|
||||
await mockApis(page);
|
||||
await login(page);
|
||||
|
||||
await page.goto("/mobile");
|
||||
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
|
||||
const homeMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-home`);
|
||||
expect(homeMetrics.actionItems.length, `${device.label} home action count`).toBeGreaterThanOrEqual(4);
|
||||
for (const action of homeMetrics.actionItems) {
|
||||
expect(action.width, `${device.label} ${action.text} action width`).toBeGreaterThanOrEqual(44);
|
||||
expect(action.height, `${device.label} ${action.text} action height`).toBeGreaterThanOrEqual(44);
|
||||
expect(action.labelWhiteSpace, `${device.label} ${action.text} label nowrap`).toBe("nowrap");
|
||||
expect(action.labelHeight, `${device.label} ${action.text} label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
|
||||
expect(action.iconWidth, `${device.label} ${action.text} icon width`).toBeGreaterThanOrEqual(28);
|
||||
expect(action.iconHeight, `${device.label} ${action.text} icon height`).toBeGreaterThanOrEqual(28);
|
||||
expect(action.overlaps, `${device.label} ${action.text} icon/label overlap`).toBe(false);
|
||||
expect(action.labelWithin, `${device.label} ${action.text} label within tile`).toBe(true);
|
||||
expect(action.iconWithin, `${device.label} ${action.text} icon within tile`).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByTestId("mobile-nav-approvals").click();
|
||||
await expect(page).toHaveURL(/\/mobile\/approvals$/);
|
||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-approvals`);
|
||||
|
||||
await page.goto("/mobile/reimbursements");
|
||||
await expect(page.getByTestId("mobile-reimbursements-list")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursements`);
|
||||
|
||||
await page.goto("/mobile/me");
|
||||
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-me`);
|
||||
|
||||
await page.goto("/mobile/notifications");
|
||||
await expect(page.getByTestId("mobile-notifications-page")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-notifications`);
|
||||
|
||||
await page.goto("/mobile/capture");
|
||||
await expect(page.getByTestId("mobile-capture-file")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-capture`);
|
||||
|
||||
await page.goto("/mobile/leave");
|
||||
await expect(page.getByTestId("mobile-leave-form")).toBeVisible();
|
||||
await expectMobilePageStable(page, device.label, `${device.key}-mobile-leave`);
|
||||
|
||||
await page.goto("/mobile/reimbursements/r6-responsive-reimbursement");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
|
||||
const detailMetrics = await expectMobilePageStable(page, device.label, `${device.key}-mobile-reimbursement-detail`);
|
||||
expect(detailMetrics.stickyActionsVisible, `${device.label} sticky actions visible`).toBe(true);
|
||||
expect(detailMetrics.stickyButtonsSameRow, `${device.label} sticky buttons same row`).toBe(true);
|
||||
expect(detailMetrics.stickyButtonMinHeight, `${device.label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户