import { expect, test, type Page } from "@playwright/test"; import { expandSimulationConsole, prepareApp, wideProject } from "./fixtures"; const projectNameField = (page: Page) => page.getByRole("textbox", { name: "工程", exact: true }); async function exportProject(page: Page) { const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click(); const download = await downloadPromise; const stream = await download.createReadStream(); if (!stream) throw new Error("无法读取导出的工程 JSON"); const chunks: Buffer[] = []; for await (const chunk of stream) chunks.push(Buffer.from(chunk)); const buffer = Buffer.concat(chunks); return { filename: download.suggestedFilename(), buffer, project: JSON.parse(buffer.toString("utf8")) as typeof wideProject, }; } async function deleteFirstNode(page: Page) { await page .locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]') .click(); await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length - 1, ); } test.beforeEach(async ({ page }) => { await prepareApp(page); await page.goto("/"); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); }); test("外部重命名的工程 JSON 导入后名称一致,保存和刷新后均可加载", async ({ page, }) => { await page.locator('input[type="file"]').setInputFiles({ name: `${wideProject.name}.json`, mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)), }); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); const original = await exportProject(page); expect(original.filename).toBe(`${wideProject.name}.json`); expect(original.project.name).toBe(wideProject.name); // Only the external filename changes; the exported JSON still has its old name. const renamedProject = "气动 回路.v2"; await page.locator('input[type="file"]').setInputFiles({ name: `${renamedProject}.JSON`, mimeType: "application/json", buffer: original.buffer, }); await expect(projectNameField(page)).toHaveValue(renamedProject); const renamed = await exportProject(page); expect(renamed.filename).toBe(`${renamedProject}.json`); expect(renamed.project.name).toBe(renamedProject); expect(renamed.project.nodes).toEqual(original.project.nodes); expect(renamed.project.simulation).toEqual(original.project.simulation); await page.getByRole("button", { name: "保存工程", exact: true }).click(); const saved = await page.evaluate((name) => { const raw = localStorage.getItem( `system-simulation-flow:project:${encodeURIComponent(name)}`, ); return raw ? JSON.parse(raw) : null; }, renamedProject); expect(saved).toMatchObject({ name: renamedProject, projectSchemaVersion: 2 }); expect(saved.nodes).toHaveLength(wideProject.nodes.length); await deleteFirstNode(page); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); await expect(projectNameField(page)).toHaveValue(renamedProject); await page.reload(); await page.getByRole("button", { name: "忽略", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(0); await projectNameField(page).fill(renamedProject); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); await expect(projectNameField(page)).toHaveValue(renamedProject); }); for (const pressure of [2.5, "1+1.5"]) { test(`v2 浏览器保存、加载及草稿恢复不重复提示或换算:${pressure}`, async ({ page }) => { const project = { ...wideProject, projectSchemaVersion: 2, name: "unit-roundtrip", edges: [], nodes: [{ ...wideProject.nodes[0], id: "pressure_1", data: { ...wideProject.nodes[0].data, label: "pressure_1", componentType: "amesim_pneumatic_test", modelType: "amesim_pneumatic_test", parameters: { gi: 0, reference_pressure: pressure, reference_temperature: 20, }, parameterUnits: { reference_pressure: "bar", reference_temperature: "degC" }, } }], }; await page.locator('input[type="file"]').setInputFiles({ name: "unit-roundtrip.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)) }); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1); await expandSimulationConsole(page); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); const readSaved = () => page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:autosave") || "null")); await expect.poll(async () => (await readSaved())?.projectSchemaVersion).toBe(2); for (let iteration = 0; iteration < 3; iteration++) { await page.getByRole("button", { name: "保存工程", exact: true }).click(); const saved = await readSaved(); expect(saved.nodes[0].data.parameters.reference_pressure).toBe(pressure); expect(saved.nodes[0].data.parameters.reference_temperature).toBe(20); const exported = await exportProject(page); expect(exported.project).toEqual(saved); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(consolePanel).not.toContainText("已按旧工程规则读取参数"); } await page.reload(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.getByRole("button", { name: "恢复", exact: true }).click(); await expandSimulationConsole(page); await expect(consolePanel).not.toContainText("已按旧工程规则读取参数"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await expect(page.getByLabel("生成的系统 XML").last()).toContainText('name="reference_pressure" value="250000"'); await expect(page.getByLabel("生成的系统 XML").last()).toContainText('name="reference_temperature" value="293.15"'); }); } test("草稿提示空闲淡出、交互暂停,精简按钮直接恢复且不删除存档", async ({ page }) => { await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject); await page.clock.install(); await page.reload(); const banner = page.locator(".recovery-banner"); await expect(banner).toBeVisible(); await banner.hover(); await page.clock.runFor(6000); await expect(banner).toBeVisible(); await page.mouse.move(0, 0); await page.getByRole("button", { name: "恢复", exact: true }).focus(); await page.clock.runFor(6000); await expect(banner).toBeVisible(); await projectNameField(page).focus(); await page.clock.runFor(5100); await expect(banner).toHaveClass(/is-fading/); await page.clock.runFor(200); await expect(banner).toHaveCount(0); expect(await page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:pending-recovery")!).nodes.length)).toBe(4); await expect(page.locator(".save-indicator")).toHaveCount(0); await page.getByRole("button", { name: "恢复草稿", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(4); await expect(banner).toHaveCount(0); await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeDisabled(); }); test("草稿提示立即让位于粘贴提示,结束粘贴后不自动弹回", async ({ page }) => { await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject); await page.reload(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.locator(".recovery-banner").hover(); await page.locator('input[type="file"]').setInputFiles({ name: "other.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) }); await page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]').click(); await page.keyboard.press("Control+c"); await page.keyboard.press("Control+v"); await expect(page.locator(".pending-paste-hint")).toBeVisible(); await expect(page.locator(".recovery-banner")).toHaveCount(0); await page.keyboard.press("Escape"); await expect(page.locator(".recovery-banner")).toHaveCount(0); await page.getByRole("button", { name: "恢复草稿", exact: true }).click(); await expect(page.locator(".recovery-banner")).toHaveCount(0); await expect(projectNameField(page)).toHaveValue(wideProject.name); await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeDisabled(); }); test("未恢复旧草稿也正常自动保存,控制台每 30 秒汇总成功消息且空闲不刷屏", async ({ page }) => { await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject); await page.clock.install(); await page.reload(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.locator('input[type="file"]').setInputFiles({ name: "current-work.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) }); await expandSimulationConsole(page); await page.clock.runFor(1000); const stored = await page.evaluate(() => ({ current: JSON.parse(localStorage.getItem("system-simulation-flow:autosave")!), recovery: JSON.parse(localStorage.getItem("system-simulation-flow:pending-recovery")!), })); expect(stored.current).toMatchObject({ projectSchemaVersion: 2, name: "current-work" }); expect(stored.recovery).toEqual(wideProject); await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeEnabled(); await expect(page.locator(".save-indicator")).toHaveCount(0); expect(await projectNameField(page).evaluate(input => input.getBoundingClientRect().width)).toBeGreaterThanOrEqual(240); const notices = page.locator(".simulation-console-dock-entry-message").filter({ hasText: "工程已自动保存:" }); await expect(notices).toHaveCount(0); await page.clock.runFor(30_000); await expect(notices).toHaveCount(1); await expect(notices.last()).toContainText("current-work"); await page.clock.runFor(30_000); await expect(notices).toHaveCount(1); await projectNameField(page).fill("changed-work"); await page.clock.runFor(31_000); await expect(notices).toHaveCount(2); await expect(notices.last()).toContainText("changed-work"); await page.getByRole("button", { name: "恢复草稿", exact: true }).click(); await expect(projectNameField(page)).toHaveValue(wideProject.name); expect(await page.evaluate(() => localStorage.getItem("system-simulation-flow:pending-recovery"))).toBeNull(); }); test("旧草稿未处理时编辑并刷新,下次恢复最近的自动保存而非旧副本", async ({ page }) => { await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject); await page.reload(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.locator('input[type="file"]').setInputFiles({ name: "newer-work.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) }); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:autosave")!).name)).toBe("newer-work"); await page.reload(); await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible(); await page.getByRole("button", { name: "恢复草稿", exact: true }).click(); await expect(projectNameField(page)).toHaveValue("newer-work"); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(4); }); test("不能编码新版的工程不覆盖已有浏览器存档,也不下载旧格式", async ({ page }) => { const storageKey = `system-simulation-flow:project:${wideProject.name}`; await page.evaluate(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), { key: storageKey, project: wideProject }); const invalid = structuredClone(wideProject); Object.assign(invalid.nodes[0].data.parameters, { obsolete: 1 }); await page.locator('input[type="file"]').setInputFiles({ name: `${wideProject.name}.json`, mimeType: "application/json", buffer: Buffer.from(JSON.stringify(invalid)) }); await expandSimulationConsole(page); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("保存失败"); expect(await page.evaluate(key => JSON.parse(localStorage.getItem(key)!), storageKey)).toEqual(wideProject); const downloads: string[] = []; page.on("download", download => downloads.push(download.suggestedFilename())); await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click(); await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("未生成旧格式文件"); expect(downloads).toEqual([]); }); test("加载始终读取最后一次浏览器存档并采用存档名称,不按输入名称选历史工程", async ({ page, }) => { const storedName = "已重命名 工程.v3"; await page.evaluate( ({ name, project }) => { localStorage.setItem( `system-simulation-flow:project:${encodeURIComponent(name)}`, JSON.stringify({ ...project, name, nodes: [] , edges: [] }), ); localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)); }, { name: storedName, project: wideProject }, ); await projectNameField(page).fill(storedName); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); await expect(projectNameField(page)).toHaveValue(wideProject.name); await deleteFirstNode(page); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount( wideProject.nodes.length, ); await expect(projectNameField(page)).toHaveValue(wideProject.name); }); for (const { internalName, expectedName } of [ { internalName: " 内部 工程.v1 ", expectedName: "内部 工程.v1" }, { internalName: " ", expectedName: "untitled" }, ]) { test(`外部文件名没有基名时,工程名回退为 ${expectedName}`, async ({ page }) => { await page.locator('input[type="file"]').setInputFiles({ name: " .JSON", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ ...wideProject, name: internalName })), }); await expect(projectNameField(page)).toHaveValue(expectedName); const exported = await exportProject(page); expect(exported.filename).toBe(`${expectedName}.json`); expect(exported.project.name).toBe(expectedName); }); }