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> = []; 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> = []; 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 }); });