267 lines
9.8 KiB
TypeScript
267 lines
9.8 KiB
TypeScript
import { expect, test } 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 = (customerCode: string) =>
|
||
`期间,科室,学科,医生等级,医生,产品一级分类,实际收入,成本,毛利贡献,客户编码\n2025-08,测试儿保,普儿,高级,医生A,医疗服务,10000,4000,6000,${customerCode}\n2026-08,测试儿保,普儿,高级,医生A,医疗服务,5000,4000,3000,${customerCode}\n`;
|
||
|
||
// 模型请求由 route mock,登录、导入、保存定义与查询走真实后端。
|
||
async function forceAiEnabled(page: Page) {
|
||
await page.route("**/api/session", async (route) => {
|
||
const response = await route.fetch();
|
||
const json = await response.json();
|
||
const headers = Object.fromEntries(
|
||
Object.entries(response.headers()).filter(
|
||
([name]) => name.toLowerCase() !== "content-length",
|
||
),
|
||
);
|
||
await route.fulfill({
|
||
status: response.status(),
|
||
headers,
|
||
json: { ...json, ai_enabled: true },
|
||
});
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function uploadCsv(page: Page) {
|
||
const uploaded = page.waitForResponse(
|
||
(response) =>
|
||
response.url().endsWith("/api/datasets") &&
|
||
response.request().method() === "POST",
|
||
);
|
||
await page.getByLabel("上传财务表格").setInputFiles({
|
||
name: `assistant-e2e-${Date.now()}.csv`,
|
||
mimeType: "text/csv",
|
||
buffer: Buffer.from(csv(`E2E-${Date.now()}`)),
|
||
});
|
||
await uploaded;
|
||
await page
|
||
.getByRole("dialog", { name: "确认导入字段" })
|
||
.getByRole("button", { name: "确认并导入" })
|
||
.click();
|
||
await expect(
|
||
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||
).toBeVisible({ timeout: 60000 });
|
||
}
|
||
|
||
async function openCopilot(page: Page) {
|
||
await page.getByRole("button", { name: "Copilot", exact: true }).click();
|
||
const copilot = page.locator(".copilot");
|
||
await copilot
|
||
.getByRole("checkbox", { name: "使用已配置的 AI 模型理解问题" })
|
||
.check();
|
||
await copilot.getByRole("checkbox", { name: /同意将本次对话/ }).check();
|
||
return copilot;
|
||
}
|
||
|
||
test("动态回答:展示模型标记与实际模型名,普通问答不改变页面", async ({
|
||
page,
|
||
}) => {
|
||
await page.setViewportSize({ width: 1512, height: 982 });
|
||
await forceAiEnabled(page);
|
||
const requests: Array<Record<string, unknown>> = [];
|
||
await page.route("**/api/datasets/*/assistant", async (route) => {
|
||
const payload = route.request().postDataJSON();
|
||
requests.push(payload);
|
||
await route.fulfill({
|
||
json: {
|
||
answer: "动态分析标记 A:本期收入 5,000,成本 4,000。",
|
||
intent: {
|
||
action: "answer",
|
||
metric: payload.context.metric,
|
||
group: payload.context.group,
|
||
filters: payload.context.filters,
|
||
},
|
||
context: payload.context,
|
||
version: "assistant-e2e",
|
||
provider: "local",
|
||
evidence: [],
|
||
kind: "answer",
|
||
source: "model",
|
||
model: "deepseek-chat-e2e",
|
||
},
|
||
});
|
||
});
|
||
await login(page);
|
||
await uploadCsv(page);
|
||
const copilot = await openCopilot(page);
|
||
await copilot.getByLabel("描述要分析的问题").fill("本期收入有哪些变化?");
|
||
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||
|
||
await expect(
|
||
copilot.getByText("动态分析标记 A", { exact: false }),
|
||
).toBeVisible();
|
||
await expect(copilot).toContainText("来源:模型回答 · deepseek-chat-e2e");
|
||
await expect(
|
||
page.getByRole("heading", { name: "集团经营总览", exact: true }),
|
||
).toBeVisible();
|
||
await expect(page.getByRole("heading", { name: /异常调查/ })).toHaveCount(0);
|
||
expect(requests).toHaveLength(1);
|
||
expect(requests[0]).toMatchObject({
|
||
question: "本期收入有哪些变化?",
|
||
use_model: true,
|
||
consent: true,
|
||
mode: "auto",
|
||
history: [],
|
||
});
|
||
expect(requests[0].context).toMatchObject({ filters: {} });
|
||
});
|
||
|
||
test("对话新增列:追问、预览、确认后真实保存并出现列头", async ({ page }) => {
|
||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||
await forceAiEnabled(page);
|
||
const requests: Array<Record<string, unknown>> = [];
|
||
await page.route("**/api/datasets/*/assistant", async (route) => {
|
||
const payload = route.request().postDataJSON();
|
||
requests.push(payload);
|
||
const first = requests.length === 1;
|
||
await route.fulfill({
|
||
json: {
|
||
answer: first ? "请补充列的定义。" : "已整理列定义。",
|
||
intent: {
|
||
action: "design_column",
|
||
metric: payload.context.metric,
|
||
group: payload.context.group,
|
||
filters: payload.context.filters,
|
||
},
|
||
context: payload.context,
|
||
version: "assistant-e2e",
|
||
provider: "local",
|
||
evidence: [],
|
||
kind: "column_design",
|
||
source: "model",
|
||
model: "deepseek-chat-e2e",
|
||
design: first
|
||
? {
|
||
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 login(page);
|
||
await uploadCsv(page);
|
||
const copilot = await openCopilot(page);
|
||
await copilot.getByLabel("描述要分析的问题").fill("我想加一个新的分析列。");
|
||
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||
|
||
const preview = copilot.getByRole("region", { name: "列定义预览" });
|
||
await expect(preview).toContainText("缺失值和零分母如何处理?");
|
||
await expect(
|
||
preview.getByRole("button", { name: "确认并添加分析列" }),
|
||
).toHaveCount(0);
|
||
await expect(
|
||
copilot.getByRole("button", { name: "退出列设计", exact: true }).first(),
|
||
).toBeVisible();
|
||
|
||
await copilot
|
||
.getByLabel("描述要分析的问题")
|
||
.fill(
|
||
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
|
||
);
|
||
await copilot.getByRole("button", { name: "发送", exact: true }).click();
|
||
|
||
await expect(preview).toContainText("收入成本差");
|
||
await expect(preview).toContainText("实际收入减成本");
|
||
await expect(preview).toContainText("计算表达式");
|
||
await expect(preview).toContainText("(实际收入 − 成本)");
|
||
await expect(preview).toContainText("适用范围");
|
||
await expect(preview).toContainText("缺失/零分母");
|
||
expect(requests).toHaveLength(2);
|
||
expect(requests[1].mode).toBe("column_design");
|
||
expect(requests[1].question).toBe(
|
||
"列名收入成本差,用实际收入减成本,应用当前筛选的本期和基期,缺失显示不可用。",
|
||
);
|
||
expect(requests[1].history).toEqual([
|
||
{ role: "user", content: "我想加一个新的分析列。" },
|
||
{
|
||
role: "assistant",
|
||
content: expect.stringContaining("缺失值和零分母如何处理?"),
|
||
},
|
||
]);
|
||
|
||
// 保存定义与查询不 mock:走真实后端并检查返回值和表格列头。
|
||
const savedResponse = page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes("/formula-definitions") &&
|
||
response.request().method() === "POST",
|
||
);
|
||
const customQuery = page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes("/query") &&
|
||
response.request().method() === "POST" &&
|
||
((response.request().postDataJSON()?.custom_columns?.length as number) ||
|
||
0) > 0,
|
||
);
|
||
await preview
|
||
.getByRole("button", { name: "确认并添加分析列", exact: true })
|
||
.click();
|
||
const saved = await savedResponse;
|
||
expect(saved.ok()).toBeTruthy();
|
||
expect((await saved.json()).definition.name).toBe("收入成本差");
|
||
const query = await customQuery;
|
||
const customResult = await query.json();
|
||
// Decimal 以字符串保留精度(如 "1000.000000000000000000000000"),按数值断言。
|
||
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
|
||
expect(Number(customResult.summary.custom_0.baseline)).toBe(6000);
|
||
expect(Number(customResult.summary.custom_0.delta)).toBe(-5000);
|
||
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
|
||
|
||
await expect(
|
||
copilot.getByRole("status").filter({ hasText: "已添加分析列" }),
|
||
).toBeVisible();
|
||
const header = page.locator(".ag-header-cell-text", {
|
||
hasText: "收入成本差",
|
||
});
|
||
// AG Grid 可能水平虚拟化列头;每次轮询滚到最右再检查是否已渲染。
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
for (const selector of [
|
||
".ag-body-horizontal-scroll-viewport",
|
||
".ag-center-cols-viewport",
|
||
]) {
|
||
const scroller = page.locator(selector);
|
||
if (await scroller.count()) {
|
||
await scroller.evaluate((element) => {
|
||
const viewport = element as HTMLElement;
|
||
viewport.scrollLeft = viewport.scrollWidth;
|
||
});
|
||
}
|
||
}
|
||
return header.count();
|
||
},
|
||
{ timeout: 20000 },
|
||
)
|
||
.toBeGreaterThan(0);
|
||
await expect(header).toBeVisible({ timeout: 20000 });
|
||
});
|