import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; import fs from "node:fs"; const password = process.env.E2E_PASSWORD || fs.readFileSync("var/local-access.txt", "utf8").match(/密码:(.+)/)![1]; const csv = "期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,C1\n2025-08,测试儿保,普儿,高级,医生B,医疗服务,10000,4000,6000,C2\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,C1\n2026-08,测试儿保,普儿,高级,医生B,医疗服务,5000,4000,3000,C2\n"; async function login(page: Page) { await page.goto("/"); await page.getByLabel("用户名", { exact: true }).fill("admin"); await page.getByLabel("密码", { exact: true }).fill(password); await page.getByRole("button", { name: "登录", exact: true }).click(); await expect(page.getByRole("button", { name: "导入新表格" })).toBeVisible(); } test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导出", async ({ page, }) => { await page.setViewportSize({ width: 1512, height: 982 }); const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); await login(page); const uploaded = page.waitForResponse( (r) => r.url().endsWith("/api/datasets") && r.request().method() === "POST", ); await page.getByLabel("上传财务表格").setInputFiles({ name: "e2e-finance.csv", mimeType: "text/csv", buffer: Buffer.from(csv.replaceAll("C2", `C2-${Date.now()}`)), }); await uploaded; const dialog = page.getByRole("dialog"); await expect( dialog.getByRole("heading", { name: "确认导入字段" }), ).toBeVisible(); await dialog.getByRole("button", { name: "确认并导入" }).click(); await expect( page.getByRole("heading", { name: "集团经营总览", exact: true }), ).toBeVisible({ timeout: 60000 }); await expect(page.locator(".kpi").first()).toContainText("10,000"); await page.getByRole("button", { name: "分析设置", exact: true }).click(); await page.getByLabel("最小样本数").fill("1"); await page.getByRole("button", { name: "保存设置", exact: true }).click(); await expect(page.locator(".kpi").first()).toContainText("10,000"); await page.screenshot({ path: "test-results/desktop-overview.png", fullPage: true, }); await page .getByRole("button", { name: "异常追踪", exact: false }) .first() .click(); await expect( page.getByRole("heading", { name: "1 个分组需要关注" }), ).toBeVisible(); await page.getByLabel("下钻测试儿保", { exact: true }).click(); await expect( page.getByRole("heading", { name: "测试儿保 · 异常调查" }), ).toBeVisible(); await page.getByLabel("下钻普儿", { exact: true }).click(); await expect( page.getByRole("heading", { name: "普儿 · 异常调查" }), ).toBeVisible(); await page.getByLabel("下钻高级", { exact: true }).click(); await expect( page.getByRole("heading", { name: "高级 · 异常调查" }), ).toBeVisible(); await page.getByLabel("调查状态", { exact: true }).selectOption("reviewing"); await page.getByLabel("调查备注", { exact: true }).fill("核对退款与产品口径"); await page.getByRole("button", { name: "保存调查记录", exact: true }).click(); await expect( page.getByRole("button", { name: "已保存", exact: true }), ).toBeVisible(); await page .getByRole("navigation", { name: "分析路径" }) .getByRole("button", { name: "普儿", exact: true }) .click(); await page.getByLabel("下钻高级", { exact: true }).click(); await expect(page.getByLabel("调查备注", { exact: true })).toHaveValue( "核对退款与产品口径", ); await expect(page.getByLabel("调查状态", { exact: true })).toHaveValue( "reviewing", ); await page.getByLabel("查看医生A证据", { exact: true }).click(); const evidenceDialog = page.getByRole("dialog"); await expect(evidenceDialog).toContainText("1 条"); await expect(evidenceDialog).toContainText("2026-08"); await expect(evidenceDialog).toContainText("CSV"); await evidenceDialog .getByRole("button", { name: "本期", exact: true }) .click(); await expect(evidenceDialog).toContainText("2026-08"); await expect(evidenceDialog).not.toContainText("加载中"); await evidenceDialog .getByRole("button", { name: "基期", exact: true }) .click(); await expect(evidenceDialog).toContainText("2025-08"); await evidenceDialog.getByLabel("关闭", { exact: true }).click(); const evidenceOffsets: number[] = []; await page.route("**/api/datasets/*/evidence", async (route) => { const request = route.request().postDataJSON() as { offset?: number }; const offset = request.offset || 0; evidenceOffsets.push(offset); const count = offset === 0 ? 50 : 1; await route.fulfill({ json: { total: 51, sheet: "CSV", rows: Array.from({ length: count }, (_, index) => ({ source_row: offset + index + 1, period: offset === 0 ? "2026-08" : "2026-09", dimensions: {}, revenue: "1", cost: "1", gross: "0", })), }, }); }); await page.getByLabel("查看医生A证据", { exact: true }).click(); await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条"); await evidenceDialog .getByRole("button", { name: "下一页", exact: true }) .click(); await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条"); await expect(evidenceDialog).toContainText("2026-09"); expect(evidenceOffsets).toEqual([0, 50]); await evidenceDialog .getByRole("button", { name: "基期", exact: true }) .click(); await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条"); await expect(evidenceDialog).not.toContainText("2026-09"); await evidenceDialog.getByLabel("关闭", { exact: true }).click(); await page .getByRole("navigation", { name: "分析路径" }) .getByRole("button", { name: "集团总览", exact: true }) .click(); await expect( page.getByRole("heading", { name: "集团经营总览", exact: true }), ).toBeVisible(); await page.getByRole("button", { name: "环比", exact: true }).click(); await expect( page.getByRole("status").filter({ hasText: "缺少期间" }), ).toContainText("2026-07"); await page.getByRole("button", { name: "同比", exact: true }).click(); await expect(page.locator(".kpi").first()).toContainText("10,000"); await page.getByRole("button", { name: "分析设置", exact: true }).click(); await page.getByLabel("异常阈值").fill("0.8"); await page.getByRole("button", { name: "取消", exact: true }).click(); await page.getByRole("button", { name: "分析设置", exact: true }).click(); await expect(page.getByLabel("异常阈值")).toHaveValue("0.15"); await page.getByRole("button", { name: "取消", exact: true }).click(); await page.getByRole("button", { name: "Copilot", exact: true }).click(); await page.getByLabel("描述要分析的问题").fill("查看异常"); await page.locator(".copilot").getByRole("button", { name: "发送" }).click(); await expect(page.locator(".message")).toContainText("按当前规则识别出"); await expect( page.getByRole("heading", { name: "异常追踪", exact: true }), ).toBeVisible(); await page.locator(".copilot").getByLabel("关闭", { exact: true }).click(); const downloaded = page.waitForEvent("download"); await page.getByLabel("导出当前范围").click(); const file = await downloaded; expect(file.suggestedFilename()).toBe("traceiq-evidence.csv"); await page.getByRole("button", { name: "经营总览", exact: true }).click(); await expect(page.locator("canvas").first()).toBeVisible(); const pixels = await page .locator("canvas") .first() .evaluate((canvas: HTMLCanvasElement) => { const ctx = canvas.getContext("2d")!; const p = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let n = 0; for (let i = 3; i < p.length; i += 4) if (p[i] > 0) n++; return n; }); expect(pixels).toBeGreaterThan(1000); let designCalls = 0; await page.route("**/formula-design", async (route) => { designCalls++; await route.fulfill({ json: designCalls === 1 ? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null, } : { complete: true, questions: [], column: { name: "收入成本差", meaning: "实际收入减成本", scope: "所选分组与期间", missing: "结果不可用(#N/A);零分母不可用", expression: { operator: "subtract", left: { metric: "revenue" }, right: { metric: "cost" }, }, }, }, }); }); await page.getByRole("button", { name: "生成公式工作簿" }).click(); const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" }); await formulaDialog.getByRole("checkbox").check(); await formulaDialog .getByLabel("列计算描述") .fill("新增收入成本差,缺失或零分母显示不可用"); await formulaDialog.getByRole("button", { name: "发送描述" }).click(); await expect(formulaDialog).toContainText("缺失值和零分母如何处理?"); await formulaDialog .getByLabel("列计算描述") .fill("缺失项和零分母都按不可用处理"); await formulaDialog.getByRole("button", { name: "发送描述" }).click(); await formulaDialog .getByRole("button", { name: "确认并生成公式工作簿" }) .click(); await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000, }); const formulaDownload = page.waitForEvent("download"); await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click(); expect((await formulaDownload).suggestedFilename()).toBe( "traceiq-formula-analysis.xlsx", ); await formulaDialog.getByLabel("关闭").click(); }); test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await login(page); await expect(page.locator(".kpi").first()).toBeVisible(); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); await page.screenshot({ path: "test-results/mobile-overview.png", fullPage: true, }); await page.getByLabel("展开导航").click(); await page.getByRole("button", { name: "分析设置", exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.screenshot({ path: "test-results/mobile-settings.png", fullPage: true, }); await page.getByRole("button", { name: "取消", exact: true }).click(); await page.getByRole("button", { name: "经营总览", exact: true }).click(); await page.getByRole("button", { name: "Copilot", exact: true }).click(); await expect(page.getByLabel("描述要分析的问题")).toBeVisible(); expect( await page .locator(".copilot") .evaluate((el) => el.getBoundingClientRect().right <= innerWidth), ).toBe(true); await page.locator(".copilot").getByLabel("关闭", { exact: true }).click(); }); test("原附件:全量导入结果在桌面及手机正确显示", async ({ page }) => { test.skip(!process.env.REFERENCE_DATASET_NAME, "仅本机显式启用原附件验收"); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.setViewportSize({ width: 1512, height: 982 }); await login(page); await page .getByLabel("选择数据集") .selectOption({ label: process.env.REFERENCE_DATASET_NAME! }); await expect(page.locator(".kpi").nth(0)).toContainText("152,419,115.35"); await expect(page.locator(".kpi").nth(1)).toContainText("17,868,580.85"); await expect(page.locator(".kpi").nth(3)).toContainText("不可用"); await expect(page.locator(".kpi").nth(4)).toContainText("38,361"); await page.screenshot({ path: "test-results/reference-desktop.png", fullPage: true, }); await page.getByRole("button", { name: "环比", exact: true }).click(); await expect( page.getByRole("status").filter({ hasText: "缺少期间" }), ).toContainText("2026-07"); await page.getByRole("button", { name: "同比", exact: true }).click(); await expect(page.locator(".kpi").first()).toContainText("-32.4%"); await page.setViewportSize({ width: 390, height: 844 }); await expect .poll(() => page.evaluate( () => document.documentElement.scrollWidth <= innerWidth && [...document.querySelectorAll(".chart canvas")].every( (canvas) => canvas.getBoundingClientRect().right <= innerWidth, ), ), ) .toBe(true); await page.screenshot({ path: "test-results/reference-mobile.png", fullPage: true, }); expect(errors).toEqual([]); });