Files
fa-agent/tests/e2e/assistant.spec.ts
T

267 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});