import { expect, test, type Locator, type Page } from "@playwright/test"; import { prepareApp } from "./fixtures"; test.beforeEach(async ({ page }) => { await prepareApp(page); }); function parameterRow(table: Locator, name: string) { return table.getByRole("rowheader", { name, exact: true }).locator(".."); } async function addPneumaticTestComponent(page: Page) { const canvas = page.locator(".flow-canvas .react-flow__pane"); await page .getByRole("button", { name: "AMESim 气动测试元件", exact: true }) .dragTo(canvas, { targetPosition: { x: 620, y: 330 } }); const node = page.locator( '.flow-canvas .react-flow__node[data-id="amesim_pneumatic_test_1"]', ); await expect(node).toBeVisible(); await node.click(); } test("组件参数和仿真设置使用共享的三列表格", async ({ page }) => { await page.goto("/"); await addPneumaticTestComponent(page); const componentTable = page.getByRole("table", { name: "组件参数" }); const simulationTable = page.getByRole("table", { name: "仿真设置" }); await expect(componentTable).toBeVisible(); await expect(simulationTable).toBeVisible(); await expect(componentTable.getByRole("columnheader")).toHaveText([ "参数", "值", "单位", ]); await expect(componentTable.getByRole("rowheader")).toHaveText([ "组件名称", "节点 ID", "介质物性索引", "参考压力", "参考温度", ]); await expect(simulationTable.getByRole("rowheader")).toHaveText([ "起始时间", "结束时间", "采样步长", "最大积分步长", "求解器", ]); await expect(parameterRow(simulationTable, "起始时间").locator("td").last()).toHaveText( "s", ); await expect(parameterRow(simulationTable, "求解器").locator("td").last()).toHaveText( "—", ); const componentNameWidth = await componentTable .locator("thead .parameter-table-name-cell") .evaluate((element) => element.getBoundingClientRect().width); const simulationNameWidth = await simulationTable .locator("thead .parameter-table-name-cell") .evaluate((element) => element.getBoundingClientRect().width); expect(Math.abs(componentNameWidth - simulationNameWidth)).toBeLessThanOrEqual(1); const panelTitleHeights = await page .locator(".properties .panel-title") .evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height), ); expect(panelTitleHeights).toHaveLength(2); panelTitleHeights.forEach((height) => expect(height).toBeLessThanOrEqual(32)); const compactTableMetrics = await page .locator(".properties .parameter-table") .evaluateAll((tables) => tables.map((table) => { const content = table.closest(".properties-subpane-content"); const headerCell = table.querySelector("thead th"); const bodyRows = Array.from( table.querySelectorAll("tbody .parameter-table-row"), ); const control = table.querySelector("input, select"); if (!content || !headerCell || !control) { throw new Error("参数表缺少布局测量所需的元素"); } const tableBounds = table.getBoundingClientRect(); const contentBounds = content.getBoundingClientRect(); const tableStyle = getComputedStyle(table); const contentLeft = contentBounds.left + content.clientLeft; const contentRight = contentLeft + content.clientWidth; const controlBounds = control.getBoundingClientRect(); const controlCell = control.closest("td"); if (!controlCell) { throw new Error("参数控件不在数据单元格中"); } const controlCellBounds = controlCell.getBoundingClientRect(); return { leftInset: tableBounds.left - contentLeft, rightInset: contentRight - tableBounds.right, borderRadius: tableStyle.borderRadius, borderTopWidth: tableStyle.borderTopWidth, headerHeight: headerCell.getBoundingClientRect().height, headerPosition: getComputedStyle(headerCell).position, maximumRowHeight: Math.max( ...bodyRows.map((row) => row.getBoundingClientRect().height), ), controlHeight: controlBounds.height, controlContained: controlBounds.left >= controlCellBounds.left - 0.5 && controlBounds.right <= controlCellBounds.right + 0.5 && controlBounds.top >= controlCellBounds.top - 0.5 && controlBounds.bottom <= controlCellBounds.bottom + 0.5, }; }), ); expect(compactTableMetrics).toHaveLength(2); compactTableMetrics.forEach((metrics) => { expect(Math.abs(metrics.leftInset)).toBeLessThanOrEqual(1); expect(Math.abs(metrics.rightInset)).toBeLessThanOrEqual(1); expect(metrics.borderRadius).toBe("0px"); expect(metrics.borderTopWidth).toBe("0px"); expect(metrics.headerHeight).toBeLessThanOrEqual(28); expect(metrics.headerPosition).toBe("sticky"); expect(metrics.maximumRowHeight).toBeLessThanOrEqual(32); expect(metrics.controlHeight).toBeGreaterThanOrEqual(22); expect(metrics.controlHeight).toBeLessThanOrEqual(28); expect(metrics.controlContained).toBe(true); }); await expect( componentTable.getByRole("rowheader", { name: "组件名称", exact: true, }), ).toHaveCSS("text-align", "left"); await expect(page.getByLabel("参考压力", { exact: true })).toHaveCSS( "text-align", "right", ); const valueColumnAlignment = await page .locator(".properties .parameter-table tbody .parameter-table-value-cell") .evaluateAll((cells) => cells.every((cell) => { const controls = Array.from( cell.querySelectorAll("input, select"), ); return ( getComputedStyle(cell).textAlign === "right" && controls.every( (control) => getComputedStyle(control).textAlign === "right", ) ); }), ); expect(valueColumnAlignment).toBe(true); await expect( componentTable.locator(".parameter-table-read-only-value").first(), ).toHaveCSS("justify-content", "flex-end"); await expect(page.getByLabel("介质物性索引", { exact: true })).toHaveCSS( "text-align-last", "right", ); const solver = page.getByLabel("求解器", { exact: true }); await expect(solver).toHaveCSS("text-align", "right"); await expect(solver).toHaveCSS("text-align-last", "right"); await solver.selectOption("Radau"); await expect(solver).toHaveValue("Radau"); }); test("最窄参数面板滚动时表头固定且值列保持可用宽度", async ({ page }) => { await page.goto("/"); await addPneumaticTestComponent(page); const widthSeparator = page.getByRole("separator", { name: "调整参数面板宽度", exact: true, }); await widthSeparator.focus(); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight"); await expect(widthSeparator).toHaveAttribute("aria-valuenow", "280"); const heightSeparator = page.getByRole("separator", { name: "调整组件参数和仿真设置区域高度", exact: true, }); await heightSeparator.focus(); for (let step = 0; step < 11; step += 1) { await page.keyboard.press("ArrowUp"); } await expect(heightSeparator).toHaveAttribute("aria-valuenow", "120"); const componentContent = page .getByRole("region", { name: "组件参数子栏", exact: true }) .locator(".properties-subpane-content"); await expect .poll(() => componentContent.evaluate( (element) => element.scrollHeight - element.clientHeight, ), ) .toBeGreaterThan(0); await componentContent.evaluate((element) => { element.scrollTop = 48; }); await expect .poll(() => componentContent.evaluate((element) => element.scrollTop)) .toBeGreaterThan(0); const narrowLayout = await componentContent.evaluate((content) => { const header = content.querySelector( "thead .parameter-table-name-cell", ); const valueHeader = content.querySelector( "thead .parameter-table-value-cell", ); const wrap = content.querySelector(".parameter-table-wrap"); if (!header || !valueHeader || !wrap) { throw new Error("无法测量最窄参数表布局"); } return { stickyTopDelta: header.getBoundingClientRect().top - content.getBoundingClientRect().top, valueWidth: valueHeader.getBoundingClientRect().width, horizontalOverflow: wrap.scrollWidth - wrap.clientWidth, }; }); expect(Math.abs(narrowLayout.stickyTopDelta)).toBeLessThanOrEqual(1); expect(narrowLayout.valueWidth).toBeGreaterThanOrEqual(95); expect(narrowLayout.horizontalOverflow).toBeLessThanOrEqual(1); }); test("参数说明延迟显示,单位切换不改变 XML 基础值", async ({ page }) => { await page.goto("/"); await addPneumaticTestComponent(page); const componentTable = page.getByRole("table", { name: "组件参数" }); const pressureRow = parameterRow(componentTable, "参考压力"); const preview = page.locator("body > #parameter-help-preview"); const pressureRowBox = await pressureRow.boundingBox(); expect(pressureRowBox).not.toBeNull(); const pointer = { x: pressureRowBox!.x + pressureRowBox!.width / 2, y: pressureRowBox!.y + pressureRowBox!.height / 2, }; await page.mouse.move(pointer.x, pointer.y); await page.waitForTimeout(600); await expect(preview).toBeVisible(); await expect(preview.locator(".parameter-help-preview-title")).toHaveText("参考压力"); await expect(preview.locator(".parameter-help-preview-description")).toContainText( "单位换算和 XML 基础单位输出", ); await expect(preview.locator(".parameter-help-preview-meta")).toContainText( "默认值:100000", ); await expect(preview.locator(".parameter-help-preview-meta")).toContainText( "当前单位:Pa", ); const previewBox = await preview.boundingBox(); expect(previewBox).not.toBeNull(); const horizontalGap = pointer.x < previewBox!.x ? previewBox!.x - pointer.x : pointer.x > previewBox!.x + previewBox!.width ? pointer.x - (previewBox!.x + previewBox!.width) : 0; const verticalGap = pointer.y < previewBox!.y ? previewBox!.y - pointer.y : pointer.y > previewBox!.y + previewBox!.height ? pointer.y - (previewBox!.y + previewBox!.height) : 0; expect(Math.hypot(horizontalGap, verticalGap)).toBeLessThanOrEqual(24); await page.mouse.move(pointer.x + 24, pointer.y); await page.waitForTimeout(100); const stationaryPreviewBox = await preview.boundingBox(); expect(stationaryPreviewBox).not.toBeNull(); expect(Math.abs(stationaryPreviewBox!.x - previewBox!.x)).toBeLessThanOrEqual(1); expect(Math.abs(stationaryPreviewBox!.y - previewBox!.y)).toBeLessThanOrEqual(1); await page.locator(".panel-title").filter({ hasText: "组件参数" }).hover(); await expect(preview).toHaveCount(0); await page.getByLabel("参考压力单位").selectOption("kPa"); await expect(page.getByLabel("参考压力", { exact: true })).toHaveValue("100"); await pressureRow.hover(); await page.waitForTimeout(600); await expect(preview.locator(".parameter-help-preview-meta")).toContainText( "默认值:100", ); await expect(preview.locator(".parameter-help-preview-meta")).toContainText( "当前单位:kPa", ); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel.getByLabel("生成的系统 XML")).toContainText( ' { await page.goto("/"); await addPneumaticTestComponent(page); const pressureInput = page.getByLabel("参考压力", { exact: true }); const pressureUnit = page.getByLabel("参考压力单位", { exact: true }); await pressureInput.fill("1e-5"); await pressureInput.press("Enter"); await pressureUnit.selectOption("kPa"); await expect(pressureInput).toHaveValue("1e-8"); await pressureUnit.selectOption("Pa"); await expect(pressureInput).toHaveValue("1e-5"); await pressureUnit.selectOption("kPa"); await pressureInput.fill("1.234567890123E+3"); await pressureInput.press("Enter"); await pressureUnit.selectOption("Pa"); await expect(pressureInput).toHaveValue("1.234567890123E+6"); await pressureUnit.selectOption("kPa"); await expect(pressureInput).toHaveValue("1.234567890123E+3"); await pressureInput.fill("1.20E+3"); await pressureInput.press("Enter"); await pressureUnit.selectOption("Pa"); await expect(pressureInput).toHaveValue("1.20E+6"); await pressureUnit.selectOption("kPa"); await expect(pressureInput).toHaveValue("1.20E+3"); const temperatureInput = page.getByLabel("参考温度", { exact: true }); const temperatureUnit = page.getByLabel("参考温度单位", { exact: true }); await temperatureUnit.selectOption("degC"); await temperatureInput.fill("1e-10"); await temperatureInput.press("Enter"); await temperatureUnit.selectOption("K"); await expect(temperatureInput).toHaveValue("2.731500000001e2"); await temperatureUnit.selectOption("degC"); await expect(temperatureInput).toHaveValue("1e-10"); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await pressureInput.fill("2"); await pressureInput.press("Enter"); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await page .locator('.flow-canvas .react-flow__node[data-id="amesim_pneumatic_test_1"]') .click(); await expect(pressureUnit).toHaveValue("kPa"); await expect(pressureInput).toHaveValue("1.20E+3"); await expect(temperatureUnit).toHaveValue("degC"); await expect(temperatureInput).toHaveValue("1e-10"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); await expect(page.getByLabel("生成的系统 XML")).toContainText( ' { await page.goto("/"); const simulationTable = page.getByRole("table", { name: "仿真设置" }); const nameHeader = simulationTable.locator("thead .parameter-table-name-cell"); const separator = simulationTable.getByRole("separator", { name: "调整参数名列宽", exact: true, }); const initialWidth = await nameHeader.evaluate( (element) => element.getBoundingClientRect().width, ); const initialValue = Number(await separator.getAttribute("aria-valuenow")); const separatorBox = await separator.boundingBox(); expect(separatorBox).not.toBeNull(); await page.mouse.move( separatorBox!.x + separatorBox!.width / 2, separatorBox!.y + separatorBox!.height / 2, ); await page.mouse.down(); await page.mouse.move( separatorBox!.x + separatorBox!.width / 2 + 16, separatorBox!.y + separatorBox!.height / 2, { steps: 3 }, ); await page.mouse.up(); await expect(separator).toHaveAttribute( "aria-valuenow", String(initialValue + 16), ); const resizedWidth = await nameHeader.evaluate( (element) => element.getBoundingClientRect().width, ); expect(resizedWidth).toBeGreaterThan(initialWidth + 14); await page.reload(); const reloadedTable = page.getByRole("table", { name: "仿真设置" }); await expect( reloadedTable.getByRole("separator", { name: "调整参数名列宽", exact: true, }), ).toHaveAttribute("aria-valuenow", String(initialValue + 16)); const wrapOverflow = await reloadedTable.evaluate((table) => { const wrap = table.closest(".parameter-table-wrap"); return wrap ? wrap.scrollWidth - wrap.clientWidth : Number.POSITIVE_INFINITY; }); expect(wrapOverflow).toBeLessThanOrEqual(1); const reloadedSeparator = reloadedTable.getByRole("separator", { name: "调整参数名列宽", exact: true, }); await reloadedSeparator.dblclick(); await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "112"); await reloadedSeparator.focus(); await page.keyboard.press("ArrowRight"); await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120"); }); test("组件参数和仿真设置是可独立滚动并可调高度的子栏", async ({ page }) => { await page.goto("/"); await addPneumaticTestComponent(page); const componentPane = page.getByRole("region", { name: "组件参数子栏", exact: true, }); const simulationPane = page.getByRole("region", { name: "仿真设置子栏", exact: true, }); const separator = page.getByRole("separator", { name: "调整组件参数和仿真设置区域高度", exact: true, }); await expect(componentPane).toBeVisible(); await expect(simulationPane).toBeVisible(); await expect(separator).toBeVisible(); const initialValue = Number(await separator.getAttribute("aria-valuenow")); const initialMaximum = Number(await separator.getAttribute("aria-valuemax")); expect(initialMaximum).toBeGreaterThanOrEqual(initialValue); const initialComponentBox = await componentPane.boundingBox(); const initialSimulationBox = await simulationPane.boundingBox(); const separatorBox = await separator.boundingBox(); expect(initialComponentBox).not.toBeNull(); expect(initialSimulationBox).not.toBeNull(); expect(separatorBox).not.toBeNull(); expect(initialSimulationBox!.height).toBeGreaterThanOrEqual(120); await page.mouse.move( separatorBox!.x + separatorBox!.width / 2, separatorBox!.y + separatorBox!.height / 2, ); await page.mouse.down(); await page.mouse.move( separatorBox!.x + separatorBox!.width / 2, separatorBox!.y + separatorBox!.height / 2 + 40, { steps: 4 }, ); await page.mouse.up(); await expect(separator).toHaveAttribute( "aria-valuenow", String(initialValue + 40), ); const resizedComponentBox = await componentPane.boundingBox(); const resizedSimulationBox = await simulationPane.boundingBox(); expect(resizedComponentBox!.height).toBeGreaterThan( initialComponentBox!.height + 38, ); expect(resizedSimulationBox!.height).toBeLessThan( initialSimulationBox!.height - 38, ); const overflowModes = await page .locator(".properties-subpane-content") .evaluateAll((elements) => elements.map((element) => getComputedStyle(element).overflowY), ); expect(overflowModes).toEqual(["auto", "auto"]); await page.reload(); const reloadedSeparator = page.getByRole("separator", { name: "调整组件参数和仿真设置区域高度", exact: true, }); await expect(reloadedSeparator).toHaveAttribute( "aria-valuenow", String(initialValue + 40), ); await reloadedSeparator.focus(); await page.keyboard.press("ArrowUp"); await expect(reloadedSeparator).toHaveAttribute( "aria-valuenow", String(initialValue + 20), ); await reloadedSeparator.dblclick(); await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "340"); }); test("组件参数公式保留原式,并在说明中显示计算值且 XML 只写数值", async ({ page, }) => { await page.goto("/"); await addPneumaticTestComponent(page); const expression = "3.14*10^2/4"; const componentTable = page.getByRole("table", { name: "组件参数" }); const pressureRow = parameterRow(componentTable, "参考压力"); const pressureInput = page.getByLabel("参考压力", { exact: true }); await page.getByLabel("参考压力单位").selectOption("kPa"); await pressureInput.fill(expression); await pressureInput.press("Enter"); await expect(pressureInput).toHaveValue(expression); await expect(pressureRow).not.toContainText("78.5"); await pressureRow.hover(); await page.waitForTimeout(600); const preview = page.locator("body > #parameter-help-preview"); await expect(preview).toBeVisible(); await expect(preview.getByTestId("parameter-calculated-value")).toContainText( "78.5", ); await expect(preview.getByTestId("parameter-calculated-value")).toContainText( "kPa", ); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); const xmlBlock = consolePanel.getByLabel("生成的系统 XML"); await expect(xmlBlock).toContainText( ' { await page.goto("/"); await addPneumaticTestComponent(page); const invalidExpression = "3.14*/4"; const componentTable = page.getByRole("table", { name: "组件参数" }); const pressureInput = page.getByLabel("参考压力", { exact: true }); await pressureInput.fill(invalidExpression); await pressureInput.press("Enter"); await expect(pressureInput).toHaveValue(invalidExpression); await expect(pressureInput).toHaveAttribute("aria-invalid", "true"); await expect( componentTable.locator(".parameter-table-message-row .field-error"), ).toBeVisible(); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true, }); await expect(consolePanel).toContainText("XML 生成失败"); await expect(consolePanel).toContainText("参考压力"); await expect(consolePanel.getByLabel("生成的系统 XML")).toHaveCount(0); }); test("仿真数值设置同样保留公式,并在执行协议中使用计算值", async ({ page, }) => { await page.goto("/"); const expression = "1+2*3"; const simulationTable = page.getByRole("table", { name: "仿真设置" }); const stopRow = parameterRow(simulationTable, "结束时间"); const stopInput = page.getByLabel("结束时间", { exact: true }); await stopInput.fill(expression); await stopInput.press("Enter"); await expect(stopInput).toHaveValue(expression); await expect(stopRow).not.toContainText("7"); await stopRow.hover(); await page.waitForTimeout(600); await expect( page.getByTestId("parameter-calculated-value"), ).toContainText("7 s"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const xmlBlock = page.getByLabel("生成的系统 XML"); await expect(xmlBlock).toContainText('tStop="7"'); await expect(xmlBlock).not.toContainText(expression); }); test("仿真数值设置中的科学计数法在保存加载后保持", async ({ page }) => { await page.goto("/"); const stepInput = page.getByLabel("采样步长", { exact: true }); const maxStepInput = page.getByLabel("最大积分步长", { exact: true }); await stepInput.fill("1e-5"); await stepInput.press("Enter"); await maxStepInput.fill("1.20E-4"); await maxStepInput.press("Enter"); await page.getByRole("button", { name: "保存工程", exact: true }).click(); await stepInput.fill("0.1"); await stepInput.press("Enter"); await maxStepInput.fill("0.01"); await maxStepInput.press("Enter"); await page.getByRole("button", { name: "加载工程", exact: true }).click(); await expect(stepInput).toHaveValue("1e-5"); await expect(maxStepInput).toHaveValue("1.20E-4"); await page.getByRole("button", { name: "生成系统 XML", exact: true }).click(); const xmlBlock = page.getByLabel("生成的系统 XML"); await expect(xmlBlock).toContainText('step="0.00001"'); await expect(xmlBlock).toContainText('maxStep="0.00012"'); });