feat(ui): Copilot 多轮对话、列定义预览确认与来源标识
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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 });
|
||||
});
|
||||
@@ -128,8 +128,9 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
|
||||
);
|
||||
await columns.getByRole("button", { name: "应用", exact: true }).click();
|
||||
const customResult = await (await customQuery).json();
|
||||
expect(customResult.summary.custom_0.current).toBe("1000");
|
||||
expect(customResult.rows[0].values.custom_0.current).toBe("1000");
|
||||
// Decimal 以字符串保留存储精度(如 "1000.000000000000000000000000"),按数值断言。
|
||||
expect(Number(customResult.summary.custom_0.current)).toBe(1000);
|
||||
expect(Number(customResult.rows[0].values.custom_0.current)).toBe(1000);
|
||||
|
||||
await page.getByRole("button", { name: "生成公式工作簿" }).click();
|
||||
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
|
||||
|
||||
Reference in New Issue
Block a user