import { expect, test } 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`; test("新增列:补充信息触发追问并重新确认后生成公式工作簿", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); 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(); const uploaded = page.waitForResponse( (response) => response.url().endsWith("/api/datasets") && response.request().method() === "POST", ); await page.getByLabel("上传财务表格").setInputFiles({ name: `formula-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 }); let designCalls = 0; let sentMessages: { role: string; content: string }[] = []; await page.route("**/formula-design", async (route) => { sentMessages = JSON.parse(route.request().postData()!).messages; designCalls++; await route.fulfill({ json: designCalls === 1 || designCalls === 3 ? { complete: false, questions: [ designCalls === 1 ? "缺失值和零分母如何处理?" : "请说明新增边界条件的计算口径。", ], column: null, } : { complete: true, questions: [], column: { name: "收入成本差", meaning: "实际收入减成本", scope: "所选分组与期间", missing: "结果不可用(#N/A);零分母不可用", expression: designCalls === 5 ? { operator: "multiply", left: { operator: "subtract", left: { metric: "revenue" }, right: { metric: "cost" }, }, right: { number: 100 }, } : { operator: "subtract", left: { metric: "revenue" }, right: { metric: "cost" }, }, }, }, }); }); await page.getByRole("button", { name: "生成公式工作簿" }).click(); const dialog = page.getByRole("dialog", { name: "生成公式工作簿" }); await dialog.getByRole("checkbox").check(); await dialog .getByLabel("列计算描述") .fill("新增收入成本差;请询问缺失值和零分母处理方式"); await dialog.getByRole("button", { name: "发送描述" }).click(); await expect(dialog).toContainText("缺失值和零分母如何处理?"); await dialog.getByLabel("列计算描述").fill("都显示不可用"); await dialog.getByRole("button", { name: "发送描述" }).click(); await expect(dialog.getByText("收入成本差", { exact: true })).toBeVisible(); await expect(dialog).toContainText("计算表达式:(实际收入 − 成本)"); await expect(dialog).toContainText("本轮描述 2/6"); await dialog.getByLabel("列计算描述").fill("增加一项特殊边界条件"); await dialog.getByRole("button", { name: "发送描述" }).click(); await expect(dialog).toContainText("请说明新增边界条件的计算口径。"); await expect( dialog.getByRole("button", { name: "确认并生成公式工作簿" }), ).toHaveCount(0); await dialog.getByLabel("列计算描述").fill("边界条件按收入减成本计算"); await dialog.getByRole("button", { name: "发送描述" }).click(); await expect( dialog.getByRole("button", { name: "确认并生成公式工作簿" }), ).toBeVisible(); await expect(dialog).toContainText("本轮描述 4/6"); expect(sentMessages.map((message) => message.role)).toEqual([ "user", "assistant", "user", "assistant", "user", "assistant", "user", ]); expect(sentMessages.at(-1)?.content).toContain("边界条件按收入减成本"); await dialog.getByRole("button", { name: "保存到定义库" }).click(); await expect(dialog.getByLabel("已保存定义")).toHaveValue(/.+/); await dialog.getByRole("button", { name: "确认并生成公式工作簿" }).click(); await expect(dialog.getByRole("status")).toContainText("生成完成", { timeout: 30000, }); const download = page.waitForEvent("download"); await dialog.getByRole("button", { name: "下载公式工作簿" }).click(); expect((await download).suggestedFilename()).toBe( "traceiq-formula-analysis.xlsx", ); await dialog.getByLabel("关闭", { exact: true }).click(); await page.getByRole("button", { name: "生成公式工作簿" }).click(); const reopened = page.getByRole("dialog", { name: "生成公式工作簿" }); await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" }); await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)"); // F04 回归:载入定义后,原定义作为可见上下文进入对话 await expect(reopened).toContainText("已载入已保存定义「收入成本差」"); await expect(reopened).toContainText("我:已载入已保存定义"); await expect(reopened).toContainText("助手:已载入该定义作为当前基准"); // F04 回归:选择空选项时同步清理当前定义与上下文,选择状态与预览一致 await reopened.getByLabel("已保存定义").selectOption({ label: "选择定义" }); await expect(reopened.getByText("计算表达式:(实际收入 − 成本)")).toHaveCount( 0, ); await expect(reopened.getByText("已载入已保存定义")).toHaveCount(0); await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" }); await reopened.getByRole("checkbox").check(); await reopened.getByLabel("列计算描述").fill("把刚才公式改成乘以 100"); await reopened.getByRole("button", { name: "发送描述" }).click(); await expect(reopened).toContainText( "计算表达式:((实际收入 − 成本) × 100)", ); // 模型请求必须包含载入的原定义上下文和新的修改描述 expect(sentMessages.at(0)?.content).toContain( "已载入已保存定义「收入成本差」", ); expect(sentMessages.at(0)?.content).toContain('"operator":"subtract"'); expect(sentMessages.at(-1)?.content).toBe("把刚才公式改成乘以 100"); await reopened.getByRole("button", { name: "确认并生成公式工作簿" }).click(); await expect(reopened.getByRole("status")).toContainText("生成完成", { timeout: 30000, }); expect(designCalls).toBe(5); });