- analyze 支持 custom_columns:白名单表达式树由服务端 Decimal 求值, 自定义列进入汇总、分组行与明细(缺失保持不可用,不转零) - 新增「选择网页自定义列」弹窗:从定义库(含共享)多选最多 10 列, 应用后进入主分析表格与热力图;公式工作簿可一键导出网页已选列 - 请求去重纳入自定义列;Playwright 覆盖选择后显示与数值断言
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
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([]);
|
|
});
|