C内核流量计算方法优化,前端文件名称读取优化

This commit is contained in:
lujingze committed 2026-09-11 08:48:56 +00:00
1 parent 5d5a2e1843
commit 808c484f5b
94 files changed
+20163 -4418

No files matched your search

+56 -4
View File
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { expandSimulationConsole } from "./fixtures";
@@ -2439,8 +2440,8 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
anchors: [
{ portName: "port_1", x: 32, y: 11.2 },
{ portName: "port_2", x: 19.2, y: 24 },
{ portName: "port_3", x: 44.8, y: 24 },
{ portName: "port_4", x: 32, y: 36.8 },
{ portName: "port_3", x: 32, y: 36.8 },
{ portName: "port_4", x: 44.8, y: 24 },
],
center: { x: 32, y: 24 },
finePath: "M32 11.2V36.8M32 24H44.8M19.2 24H22.1",
@@ -2448,8 +2449,8 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
positions: {
port_1: "top",
port_2: "left",
port_3: "right",
port_4: "bottom",
port_3: "bottom",
port_4: "right",
},
thickPath: "M22.1 24H32",
},
@@ -2505,6 +2506,57 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
}
});
test("P4NODE2 的真实 Handle 在 180° 时匹配 AME 四口方位,镜像后参考粗线仍指向 port_2", async ({ page }) => {
const source = JSON.parse(readFileSync(new URL(
"../../../tests/model_audit/p4node2-ame-geometry.json", import.meta.url,
), "utf8")) as {
components: Array<{ geometry: number; ports: Record<string, { x: number; y: number }> }>;
};
// Independent AME COMP_PORT/PORT_POS coordinates, rather than frontend anchors.
const amePorts = source.components[0].ports;
for (const component of source.components) {
expect(component.geometry).toBe(1);
expect(component.ports).toEqual(amePorts);
}
const ameCenter = Object.values(amePorts).reduce(
(sum, port) => ({ x: sum.x + port.x / 4, y: sum.y + port.y / 4 }),
{ x: 0, y: 0 },
);
await page.goto("/");
const node = await dragSymbolToCanvas(page, "amesim_p4node2", { x: 460, y: 320 });
const rotate = page.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true });
await rotate.click();
await rotate.click();
const svg = node.locator('[data-component-symbol="amesim_p4node2"] svg');
const getDirection = async (portName: string) => {
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
const handle = await locatorCenter(node.locator(`.port-handle[data-port-name="${portName}"]`));
const length = Math.hypot(handle.x - center.x, handle.y - center.y);
expect(length).toBeGreaterThan(1);
return { x: (handle.x - center.x) / length, y: (handle.y - center.y) / length };
};
for (const [portName, coordinate] of Object.entries(amePorts)) {
const length = Math.hypot(coordinate.x - ameCenter.x, coordinate.y - ameCenter.y);
await expect.poll(async () => {
const actual = await getDirection(portName);
return Math.hypot(actual.x - (coordinate.x - ameCenter.x) / length,
actual.y - (coordinate.y - ameCenter.y) / length);
}).toBeLessThan(0.02);
}
const expectReferenceStroke = async () => {
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
const stroke = await locatorCenter(svg.locator('[data-junction-part="collector-branch"]'));
const reference = await getDirection("port_2");
const length = Math.hypot(stroke.x - center.x, stroke.y - center.y);
expect((stroke.x - center.x) * reference.x + (stroke.y - center.y) * reference.y).toBeGreaterThan(0);
expect(Math.abs((stroke.x - center.x) * reference.y - (stroke.y - center.y) * reference.x) / length).toBeLessThan(0.02);
};
await expectReferenceStroke();
await page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }).click();
await expect.poll(async () => (await getDirection("port_2")).x).toBeLessThan(-0.99);
await expectReferenceStroke();
});
test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", async ({
page,
}) => {
+123 -209
View File
@@ -272,7 +272,7 @@ test("复制块保留内部连接,同时未连接端口仍参与待放置吸
await expect(targetPort).toHaveAttribute("data-connected", "true");
});
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
test("选中正交连接线不显示拐点,仍可拖动线段、撤销并保存跨线桥路由", async ({
page,
}) => {
const project = {
@@ -342,6 +342,7 @@ test("正交连接线可拖动内部线段,且无连接交叉点显示跨线
.locator(".react-flow__edge-interaction")
.dispatchEvent("click");
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
await expect(horizontalEdge.locator("circle")).toHaveCount(0);
const horizontalSegment = horizontalEdge
.locator(".manual-edge-segment-handle.horizontal")
.first();
@@ -404,125 +405,21 @@ test("正交连接线可拖动内部线段,且无连接交叉点显示跨线
pathBeforeVerticalDrag ?? "",
);
const cornerHandle = horizontalEdge.locator(
'.manual-edge-corner-handle[data-corner-index="2"]',
);
await expect(cornerHandle).toHaveCount(1);
const cornerBefore = await cornerHandle.evaluate(
(circle: SVGCircleElement) => ({
flowX: circle.cx.baseVal.value,
flowY: circle.cy.baseVal.value,
screen: (() => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取连接线拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
})(),
}),
);
const pathBeforeCornerDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(cornerBefore.screen.x, cornerBefore.screen.y);
await page.mouse.down();
await page.mouse.move(
cornerBefore.screen.x + 32,
cornerBefore.screen.y + 24,
{ steps: 6 },
);
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
pathBeforeCornerDrag ?? "",
);
const cornerAfter = await cornerHandle.evaluate(
(circle: SVGCircleElement) => ({
flowX: circle.cx.baseVal.value,
flowY: circle.cy.baseVal.value,
}),
);
expect(Math.abs(cornerAfter.flowX - cornerBefore.flowX)).toBeGreaterThan(1);
expect(Math.abs(cornerAfter.flowY - cornerBefore.flowY)).toBeGreaterThan(1);
const pathAfterSegmentDrags = await horizontalPath.getAttribute("d");
await expect(horizontalEdge.locator("circle")).toHaveCount(0);
await page.keyboard.press("Control+z");
await expect(horizontalPath).toHaveAttribute(
"d",
pathBeforeCornerDrag ?? "",
pathBeforeVerticalDrag ?? "",
);
await horizontalEdge
.locator(".react-flow__edge-interaction")
.dispatchEvent("click");
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
const boundaryCorner = horizontalEdge.locator(
'.manual-edge-corner-handle[data-corner-index="1"]',
);
await expect(boundaryCorner).toHaveCount(1);
const boundaryStart = await boundaryCorner.evaluate(
(circle: SVGCircleElement) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取端点相邻拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
},
);
const boundaryPathLocator = horizontalEdge.locator(".react-flow__edge-path");
const boundaryPathBefore = await boundaryPathLocator.getAttribute("d");
const boundaryTarget = {
x: boundaryStart.x + 38,
y: boundaryStart.y + 30,
};
await page.mouse.move(boundaryStart.x, boundaryStart.y);
await page.mouse.down();
await page.mouse.move(
(boundaryStart.x + boundaryTarget.x) / 2,
(boundaryStart.y + boundaryTarget.y) / 2,
{ steps: 4 },
);
await page.mouse.move(boundaryTarget.x, boundaryTarget.y, { steps: 4 });
await page.mouse.up();
await expect(boundaryPathLocator).not.toHaveAttribute(
await page.keyboard.press("Control+y");
await expect(horizontalPath).toHaveAttribute(
"d",
boundaryPathBefore ?? "",
pathAfterSegmentDrags ?? "",
);
await expect(boundaryPathLocator).toHaveAttribute("d", / Q /);
const cornerScreenPoints = await horizontalEdge
.locator(".manual-edge-corner-handle")
.evaluateAll((circles: SVGCircleElement[]) =>
circles.map((circle) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取调整后的拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
}),
);
const closestCornerDistance = Math.min(
...cornerScreenPoints.map((point) =>
Math.hypot(point.x - boundaryTarget.x, point.y - boundaryTarget.y),
),
);
expect(
closestCornerDistance,
JSON.stringify({ boundaryStart, boundaryTarget, cornerScreenPoints }),
).toBeLessThan(3);
const boundaryPathAfter = await boundaryPathLocator.getAttribute("d");
const linePoints = [
...(boundaryPathAfter ?? "").matchAll(
...(pathAfterSegmentDrags ?? "").matchAll(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
@@ -623,110 +520,127 @@ test("缩小画布后连接线端点仍与端口图标边缘对齐", async ({ pa
expect(alignment!.targetGapY).toBeLessThan(0.75);
});
test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("straight-source", 0, 260),
projectNode("straight-target", 700, 260),
],
edges: [
{
id: "straight-edge",
source: "straight-source",
target: "straight-target",
sourceHandle: "port_b",
targetHandle: "port_a",
data: { isContactEdge: false },
},
],
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
for (const route of [
{ label: "直线", targetY: 260, targetRotation: 0, pointCount: 2 },
{ label: "L 型连接线", targetY: 480, targetRotation: 90, pointCount: 3 },
]) {
test(`${route.label}可直接拖动线条,首次折弯后仍连续且保持正交`, async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("straight-source", 0, 260),
{
...projectNode("straight-target", 700, route.targetY),
data: {
...projectNode("straight-target", 700, route.targetY).data,
rotation: route.targetRotation,
},
},
],
edges: [
{
id: "straight-edge",
source: "straight-source",
target: "straight-target",
sourceHandle: "port_b",
targetHandle: "port_a",
data: { isContactEdge: false },
},
],
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const edge = page.locator(
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const edge = page.locator(
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
);
const edgePath = edge.locator(".react-flow__edge-path");
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
const routeHandle = edge.locator(".manual-edge-route-handle");
await expect(routeHandle).toHaveCount(1);
const dragStart = await routeHandle.evaluate((circle: SVGCircleElement) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取直线路由手柄坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
});
const pathBefore = await edgePath.getAttribute("d");
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
await expect(routeHandle).toHaveCount(0);
const verticalSegment = edge
.locator(".manual-edge-segment-handle.vertical")
.first();
await expect(verticalSegment).toHaveCount(1);
const segmentXAtHalfMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const edgePath = edge.locator(".react-flow__edge-path");
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
await expect(edge.locator("circle")).toHaveCount(0);
const routeHandle = edge.locator(
'.manual-edge-segment-handle[data-segment-index="0"]',
);
await expect(routeHandle).toHaveCount(1);
await expect(routeHandle).toHaveCSS("pointer-events", "stroke");
const dragStart = await routeHandle.evaluate((line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取首次折弯后的竖直线段坐标");
throw new Error("无法获取待拖动连接线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
const point = new DOMPoint(
line.x1.baseVal.value * 0.75 + line.x2.baseVal.value * 0.25,
line.y1.baseVal.value * 0.75 + line.y2.baseVal.value * 0.25,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
});
const pathBefore = await edgePath.getAttribute("d");
expect([...(pathBefore ?? "").matchAll(/[ML]\s/g)]).toHaveLength(route.pointCount);
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
const segmentXAtFullMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取连续拖动后的竖直线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
await page.mouse.up();
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
await expect(
edge.locator(".manual-edge-segment-handle.horizontal").first(),
).toHaveCount(1);
await expect(routeHandle).toHaveCount(0);
const verticalSegment = edge
.locator(".manual-edge-segment-handle.vertical")
.first();
await expect(verticalSegment).toHaveCount(1);
const segmentXAtHalfMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取首次折弯后的竖直线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
const routeAfter = await edgePath.getAttribute("d");
const routePoints = [
...(routeAfter ?? "").matchAll(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
routePoints.slice(1).forEach((point, index) => {
const previous = routePoints[index];
expect(
Math.abs(point.x - previous.x) < 0.01 ||
Math.abs(point.y - previous.y) < 0.01,
).toBe(true);
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
const segmentXAtFullMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取连续拖动后的竖直线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
await page.mouse.up();
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
await expect(edge.locator("circle")).toHaveCount(0);
await expect(
edge.locator(".manual-edge-segment-handle.horizontal").first(),
).toHaveCount(1);
const routeAfter = await edgePath.getAttribute("d");
const routePoints = [
...(routeAfter ?? "").matchAll(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
routePoints.slice(1).forEach((point, index) => {
const previous = routePoints[index];
expect(
Math.abs(point.x - previous.x) < 0.01 ||
Math.abs(point.y - previous.y) < 0.01,
).toBe(true);
});
});
});
}
test("同一组件不同端口发出的线路在远端交叉时仍显示跨线桥", async ({
page,
+4 -7
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
expectAllNodesInsideCanvas,
@@ -22,12 +22,9 @@ type ImportedProject = {
edges: ImportedProjectEdge[];
};
const MQL_8_PROJECT_PATHS = [
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
].map(fileURLToPath);
const MQL_8_PROJECT_PATH =
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
const MQL_8_PROJECT_PATH = fileURLToPath(
new URL("../../../tests/fixtures/legacy/test-mql-8.json", import.meta.url),
);
const mql8Project = JSON.parse(
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
) as ImportedProject;
+2 -2
View File
@@ -210,8 +210,8 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
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");
await solver.selectOption("RK45");
await expect(solver).toHaveValue("RK45");
});
test("通用离散选项按多条件控制参数行且保留隐藏值和未知旧值", async ({
+198
View File
@@ -0,0 +1,198 @@
import { expect, test, type Page } from "@playwright/test";
import { expandSimulationConsole, prepareApp, wideProject } from "./fixtures";
const componentId = "amesim_pneumatic_test_1";
const pressureInput = (page: Page) =>
page.getByLabel("参考压力", { exact: true });
const pressureUnit = (page: Page) =>
page.getByLabel("参考压力单位", { exact: true });
const selectPressureComponent = (page: Page) =>
page.locator(`.flow-canvas .react-flow__node[data-id="${componentId}"]`).click();
type ExportedProject = {
nodes: Array<{
data: {
parameters: Record<string, number | string>;
parameterUnits: Record<string, string>;
};
}>;
};
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 {
buffer,
project: JSON.parse(buffer.toString("utf8")) as ExportedProject,
};
}
async function expectPressureXml(page: Page, expectedValue: number) {
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(page.getByLabel("生成的系统 XML").last()).toContainText(
`<Parameter name="reference_pressure" value="${expectedValue}"`,
);
}
async function addPressureComponent(page: Page) {
await page
.getByRole("button", { name: "AMESim 气动测试元件", exact: true })
.dragTo(page.locator(".flow-canvas .react-flow__pane"), {
targetPosition: { x: 620, y: 330 },
});
await selectPressureComponent(page);
}
function legacyPressureProject(value: number | string, unit?: string) {
return {
...wideProject,
nodes: [{
...wideProject.nodes[0],
id: componentId,
data: {
...wideProject.nodes[0].data,
label: componentId,
componentType: "amesim_pneumatic_test",
modelType: "amesim_pneumatic_test",
parameters: {
gi: 0,
reference_pressure: value,
reference_temperature: 300,
},
parameterUnits: unit ? { reference_pressure: unit } : undefined,
},
}],
edges: [],
};
}
test.beforeEach(async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await expect(
page.getByRole("button", { name: "AMESim 气动测试元件", exact: true }),
).toBeVisible();
});
test("压力默认显示 Pa,bar 与 Pa 换算保持绝压且无大气压偏移", async ({ page }) => {
await addPressureComponent(page);
await expect(pressureUnit(page)).toHaveValue("Pa");
await expect(pressureInput(page)).toHaveValue("100000");
await expect(pressureUnit(page).locator('option[value="barA"]')).toHaveCount(0);
await pressureUnit(page).selectOption("bar");
await expect(pressureInput(page)).toHaveValue("1");
await pressureInput(page).fill("2.5");
await pressureInput(page).press("Enter");
await expectPressureXml(page, 250000);
for (const { unit, value } of [
{ unit: "Pa", value: "250000" },
{ unit: "kPa", value: "250" },
{ unit: "MPa", value: "0.25" },
{ unit: "bar", value: "2.5" },
]) {
await pressureUnit(page).selectOption(unit);
await expect(pressureInput(page)).toHaveValue(value);
await expectPressureXml(page, 250000);
}
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
});
test("bar 压力公式在保存、加载和 JSON 导入导出后保留原式与单位", async ({ page }) => {
await addPressureComponent(page);
await pressureUnit(page).selectOption("bar");
const expression = "1+1.5";
await pressureInput(page).fill(expression);
await pressureInput(page).press("Enter");
await expect(pressureUnit(page)).toHaveValue("bar");
await expectPressureXml(page, 250000);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
await pressureInput(page).fill("7");
await pressureInput(page).press("Enter");
await pressureUnit(page).selectOption("kPa");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await selectPressureComponent(page);
await expect(pressureInput(page)).toHaveValue(expression);
await expect(pressureUnit(page)).toHaveValue("bar");
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(expression);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
await pressureInput(page).fill("9");
await pressureInput(page).press("Enter");
await page.locator('input[type="file"]').setInputFiles({
name: "pressure-expression.json",
mimeType: "application/json",
buffer: exported.buffer,
});
// Wait for the imported project before selecting its component.
await expect(page.getByRole("textbox", { name: "工程", exact: true })).toHaveValue(
"pressure-expression",
);
await selectPressureComponent(page);
await expect(pressureInput(page)).toHaveValue(expression);
await expect(pressureUnit(page)).toHaveValue("bar");
await expectPressureXml(page, 250000);
});
for (const { storedUnit, displayedUnit, displayedValue } of [
{ storedUnit: "Pa", displayedUnit: "Pa", displayedValue: "250000" },
{ storedUnit: undefined, displayedUnit: "Pa", displayedValue: "250000" },
]) {
test(`旧 JSON 数值参数单位 ${storedUnit ?? "未记录"} 加载为 ${displayedUnit},SI 数值不变`, async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles({
name: "legacy-pressure.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(legacyPressureProject(250000, storedUnit))),
});
await selectPressureComponent(page);
await expect(pressureUnit(page)).toHaveValue(displayedUnit);
await expect(pressureInput(page)).toHaveValue(displayedValue);
await expectPressureXml(page, 250000);
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe(displayedUnit);
});
}
test("旧 JSON 公式没有记录单位时沿用原 Pa 含义", async ({ page }) => {
const expression = "100000+150000";
await page.locator('input[type="file"]').setInputFiles({
name: "legacy-pressure-expression.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(legacyPressureProject(expression))),
});
await selectPressureComponent(page);
await expect(pressureUnit(page)).toHaveValue("Pa");
await expect(pressureInput(page)).toHaveValue(expression);
await expectPressureXml(page, 250000);
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(expression);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("Pa");
});
test("弹性接触的剪切模量 G 保持 Pa 默认单位", async ({ page }) => {
await page
.getByRole("button", { name: "LSTP00A 弹性接触", exact: true })
.dragTo(page.locator(".flow-canvas .react-flow__pane"), {
targetPosition: { x: 620, y: 330 },
});
await page.locator('.flow-canvas .react-flow__node[data-id="amesim_lstp00a_1"]').click();
await page.getByLabel("刚度模式", { exact: true }).selectOption("2");
await page.getByRole("table", { name: "组件参数" }).getByRole("button", { name: /刚度/ }).click();
await expect(page.getByLabel("剪切模量单位", { exact: true })).toHaveValue("Pa");
await expect(page.getByLabel("剪切模量", { exact: true })).toHaveValue("85700000000");
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.G).toBe(8.57e10);
expect(exported.project.nodes[0].data.parameterUnits.G).toBe("Pa");
});
@@ -0,0 +1,139 @@
import { expect, test, type Page } from "@playwright/test";
import { 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 });
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);
});
test("浏览器工程内容保留旧名称时,按存储名称连续加载仍能恢复工程", async ({
page,
}) => {
const storedName = "已重命名 工程.v3";
await page.evaluate(
({ name, project }) => {
localStorage.setItem(
`system-simulation-flow:project:${encodeURIComponent(name)}`,
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(storedName);
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(storedName);
});
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);
});
}
@@ -211,6 +211,114 @@ test.beforeEach(async ({ page }) => {
}, chartWindowSnapshot);
});
for (const quantity of ["pressure", "absolute_pressure"]) {
for (const kind of ["single", "multi", "mixed"] as const) {
test(`${quantity} 的 ${kind} 图默认 Pa,绝压换算及单位持久化不改变原始数据`, async ({ page }) => {
await page.addInitScript(({ snapshot, pressureQuantity }) => {
const pressure = snapshot.result.variables.find(
(variable) => variable.key === "generic_sensor_1.pressure",
);
if (pressure) pressure.quantity = pressureQuantity;
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, { snapshot: chartWindowSnapshot, pressureQuantity: quantity });
await page.goto("/");
await openResults(page);
const pressureVariable = page
.locator(".results-variable-list button")
.filter({ hasText: "压力" })
.first();
await expect(pressureVariable.locator("small")).toHaveText("Pa");
const chartWindow = page.locator(
`.result-chart-window[data-chart-kind="${kind}"]`,
);
if (kind === "single") {
await pressureVariable.click();
} else {
await page.getByRole("button", {
name: kind === "multi" ? "新建同单位多曲线对比窗口" : "新建异单位上下对比窗口",
exact: true,
}).click();
if (kind === "mixed") {
await page.locator(".results-variable-list button")
.filter({ hasText: "数值" }).first().dragTo(chartWindow);
}
await pressureVariable.dragTo(chartWindow);
}
const chart = chartWindow.locator('svg[data-result-chart="true"]');
const unitSelect = chartWindow.getByRole("combobox", {
name: kind === "mixed"
? "generic_sensor_1.pressure纵轴单位"
: kind === "multi" ? "多曲线纵轴单位" : "压力纵轴单位",
exact: true,
});
const showUnits = async () => {
if (kind === "mixed") {
await chartWindow.getByRole("button", {
name: "管理多曲线窗口中的曲线", exact: true,
}).click();
}
};
const readPressureRange = () => chart.evaluate((element) => {
const ranges = element.getAttribute("data-view-y-ranges");
return ranges
? JSON.parse(ranges)["generic_sensor_1.pressure"] as number[]
: [
Number(element.getAttribute("data-view-y-min")),
Number(element.getAttribute("data-view-y-max")),
];
});
await showUnits();
await expect(unitSelect).toHaveValue("Pa");
await expect(unitSelect.locator('option[value="barA"]')).toHaveCount(0);
await expect(chart.locator(".result-chart-unit")).toContainText(["Pa"]);
const pascalRange = await readPressureRange();
expect(pascalRange[0]).toBeCloseTo(98400, 6);
expect(pascalRange[1]).toBeCloseTo(121600, 6);
await unitSelect.selectOption("bar");
await expect(unitSelect).toHaveValue("bar");
const barRange = await readPressureRange();
expect(barRange[0]).toBeCloseTo(0.984, 6);
expect(barRange[1]).toBeCloseTo(1.216, 6);
if (kind === "multi") {
await expect(chartWindow.locator(".result-chart-window-title")).toContainText("bar");
await chartWindow.getByRole("button", {
name: "管理多曲线窗口中的曲线", exact: true,
}).click();
await expect(chartWindow.locator(".result-chart-multi-menu-label small")).toContainText("bar");
}
const storedPressure = await page.evaluate(() => {
const snapshot = JSON.parse(sessionStorage.getItem(
"system-simulation-flow:latest-result",
) ?? "null");
return {
values: snapshot.result.series["generic_sensor_1.pressure"],
final: snapshot.result.final["generic_sensor_1.pressure"],
unit: snapshot.result.variables.find(
(variable: { key: string }) => variable.key === "generic_sensor_1.pressure",
).unit,
};
});
expect(storedPressure).toEqual({
values: [100000, 110000, 120000], final: 120000, unit: "Pa",
});
await page.reload();
await openResults(page);
await showUnits();
await expect(unitSelect).toHaveValue("bar");
expect(await readPressureRange()).toEqual(barRange);
await unitSelect.selectOption("Pa");
expect(await readPressureRange()).toEqual(pascalRange);
});
}
}
test("机械状态事件力点不参与连续曲线纵轴,但原始数据与非力状态保持不变", async ({
page,
}) => {
@@ -43,7 +43,7 @@ type BrowserStreamEvidence = {
};
const PROJECT_PATH = fileURLToPath(
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
new URL("../../../tests/fixtures/legacy/test-mql-8.json", import.meta.url),
);
const EVIDENCE_PATH = fileURLToPath(
new URL(
@@ -514,7 +514,7 @@ test("test-mql-8 在真实浏览器流式链路完成 0.2 s BDF 仿真", async (
servingMode: "isolated-vite-dev-proxy",
},
source: {
path: "tests/data/test-mql-8.json",
path: "tests/fixtures/legacy/test-mql-8.json",
bytes: statSync(PROJECT_PATH).size,
sha256: createHash("sha256").update(projectBytes).digest("hex"),
},
+235
View File
@@ -0,0 +1,235 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { componentCatalog, prepareApp, wideProject } from "./fixtures";
const VERTICAL_MIRROR = "竖直镜像 (Shift+V)";
const horizontalMirror = (page: Page) => page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true });
const verticalMirror = (page: Page) => page.getByRole("button", { name: VERTICAL_MIRROR, exact: true });
const nodeById = (page: Page, id: string) => page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`);
const extraPort = { ...wideProject.nodes[0].data.ports[0], name: "port_c" };
function mirrorProject(rotation = 0, mirrored = false) {
return {
...structuredClone(wideProject),
name: "vertical-mirror",
nodes: wideProject.nodes.slice(0, 2).map((node, index) => ({
...structuredClone(node),
position: { x: index * 420, y: index * 240 },
data: {
...structuredClone(node.data),
ports: [...node.data.ports, extraPort],
rotation: index === 0 ? rotation : 90,
mirrored: index === 0 ? mirrored : true,
},
})),
edges: [{
id: "mirror-edge",
source: "generic_sensor_1",
target: "generic_sensor_2",
sourceHandle: "port_a",
targetHandle: "port_b",
data: { isContactEdge: false },
}],
};
}
test.beforeEach(async ({ page }) => {
await prepareApp(page);
// Two left ports make the component asymmetric about its horizontal centerline.
const catalog = {
...componentCatalog,
libraries: componentCatalog.libraries.map((library) => ({
...library,
components: library.components.map((component) => component.type === "generic_sensor"
? { ...component, ports: [...component.ports, extraPort] }
: component),
})),
};
await page.route("**/api/components/catalog", (route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(catalog),
}));
});
async function importMirrorProject(page: Page, project: ReturnType<typeof mirrorProject>) {
await page.goto("/");
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({
name: "vertical-mirror.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(project)),
});
await expect(nodeById(page, "generic_sensor_1")).toBeVisible();
}
async function saveProject(page: Page) {
await page.getByRole("button", { name: "保存工程", exact: true }).click();
return page.evaluate(() => JSON.parse(window.localStorage.getItem(
"system-simulation-flow:project:vertical-mirror",
)!)) as Promise<ReturnType<typeof mirrorProject>>;
}
async function nodeGeometry(node: Locator) {
return node.evaluate((element) => {
const body = element.querySelector<HTMLElement>(".sim-node")!;
const frame = element.querySelector<HTMLElement>(".sim-node-frame")!;
const matrix = new DOMMatrixReadOnly(getComputedStyle(frame).transform);
const width = Number(body.dataset.nodeLayoutWidth);
const height = Number(body.dataset.nodeLayoutHeight);
return {
width,
height,
matrix: { a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d },
ports: Array.from(element.querySelectorAll<HTMLElement>(".port-handle")).map((port) => ({
name: port.dataset.handleid,
x: parseFloat(port.style.left) - width / 2,
y: parseFloat(port.style.top) - height / 2,
})),
};
});
}
async function expectEdgeAttached(page: Page) {
await expect.poll(() => page.evaluate(() => {
const path = document.querySelector<SVGPathElement>(
'.flow-canvas .react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path',
);
const handle = document.querySelector<HTMLElement>(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-handleid="port_a"]',
);
const matrix = path?.getScreenCTM();
if (!path || !handle || !matrix) return Infinity;
const point = path.getPointAtLength(0).matrixTransform(matrix);
const bounds = handle.getBoundingClientRect();
// React Flow attaches the path to the outer edge of a Handle, not its center.
const x = handle.classList.contains("react-flow__handle-left")
? bounds.left
: handle.classList.contains("react-flow__handle-right")
? bounds.right
: bounds.left + bounds.width / 2;
const y = handle.classList.contains("react-flow__handle-top")
? bounds.top
: handle.classList.contains("react-flow__handle-bottom")
? bounds.bottom
: bounds.top + bounds.height / 2;
return Math.hypot(point.x - x, point.y - y);
})).toBeLessThan(1);
}
for (const rotation of [0, 90, 180, 270]) {
for (const mirrored of [false, true]) {
test(`竖直镜像始终上下翻转画布组件与端口:旋转 ${rotation}°,镜像 ${mirrored}`, async ({ page }) => {
const project = mirrorProject(rotation, mirrored);
await importMirrorProject(page, project);
await expect(verticalMirror(page)).toBeDisabled();
const first = nodeById(page, "generic_sensor_1");
const second = nodeById(page, "generic_sensor_2");
await first.click();
await expect(verticalMirror(page)).toBeEnabled();
const [horizontalBox, verticalBox] = await Promise.all([
horizontalMirror(page).boundingBox(), verticalMirror(page).boundingBox(),
]);
expect(verticalBox!.x).toBeGreaterThan(horizontalBox!.x);
expect(verticalBox!.x - horizontalBox!.x - horizontalBox!.width).toBeLessThan(16);
const before = await nodeGeometry(first);
const otherBefore = await nodeGeometry(second);
const path = page.locator('.react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path');
const pathBefore = await path.getAttribute("d");
await verticalMirror(page).click();
const after = await nodeGeometry(first);
expect(after.width).toBe(before.width);
expect(after.height).toBe(before.height);
expect(after.matrix.a).toBeCloseTo(before.matrix.a, 8);
expect(after.matrix.b).toBeCloseTo(-before.matrix.b, 8);
expect(after.matrix.c).toBeCloseTo(before.matrix.c, 8);
expect(after.matrix.d).toBeCloseTo(-before.matrix.d, 8);
after.ports.forEach((port, index) => {
expect(port.x).toBeCloseTo(before.ports[index].x, 6);
expect(port.y).toBeCloseTo(-before.ports[index].y, 6);
});
expect(await nodeGeometry(second)).toEqual(otherBefore);
await expect(path).not.toHaveAttribute("d", pathBefore!);
await expectEdgeAttached(page);
const saved = await saveProject(page);
expect(saved.nodes[0].data.rotation).toBe((540 - rotation) % 360);
expect(saved.nodes[0].data.mirrored).toBe(!mirrored);
expect(saved.nodes[0].position).toEqual(project.nodes[0].position);
expect(saved.nodes[0].data.parameters).toEqual(project.nodes[0].data.parameters);
expect(saved.edges[0]).toMatchObject(project.edges[0]);
await verticalMirror(page).click();
expect(await nodeGeometry(first)).toEqual(before);
await expectEdgeAttached(page);
});
}
}
test("竖直镜像支持混合方向多选、快捷键、撤销重做和工程加载", async ({ page }) => {
const project = mirrorProject();
await importMirrorProject(page, project);
await nodeById(page, "generic_sensor_1").click();
await page.keyboard.press("Control+a");
await expect(page.locator(".flow-canvas .react-flow__node.selected")).toHaveCount(2);
await page.keyboard.press("Shift+v");
const mirrored = await saveProject(page);
expect(mirrored.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[180, true], [90, false],
]);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
let saved = await saveProject(page);
expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[0, false], [90, true],
]);
await page.getByRole("button", { name: "重做 (Ctrl+Y)", exact: true }).click();
saved = await saveProject(page);
expect(saved.nodes).toEqual(mirrored.nodes);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
expect((await saveProject(page)).nodes).toEqual(mirrored.nodes);
await nodeById(page, "generic_sensor_1").click();
await page.keyboard.press("Control+a");
await verticalMirror(page).click();
saved = await saveProject(page);
expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[0, false], [90, true],
]);
expect(saved.nodes.map((node) => node.position)).toEqual(project.nodes.map((node) => node.position));
await expectEdgeAttached(page);
});
test("竖直镜像使接触端口分离时断开连接,撤销恢复端口接触", async ({ page }) => {
const project = mirrorProject();
project.nodes[0].position = { x: 300, y: 200 };
project.nodes[1].data.rotation = 0;
project.nodes[1].data.mirrored = false;
await importMirrorProject(page, project);
const sourceGeometry = await nodeGeometry(nodeById(page, "generic_sensor_1"));
const targetGeometry = await nodeGeometry(nodeById(page, "generic_sensor_2"));
const sourcePort = sourceGeometry.ports.find((port) => port.name === "port_a")!;
const targetPort = targetGeometry.ports.find((port) => port.name === "port_b")!;
project.nodes[1].position = {
x: project.nodes[0].position.x + sourceGeometry.width / 2 + sourcePort.x
- targetGeometry.width / 2 - targetPort.x,
y: project.nodes[0].position.y + sourceGeometry.height / 2 + sourcePort.y
- targetGeometry.height / 2 - targetPort.y,
};
project.edges[0].data.isContactEdge = true;
await page.locator('input[type="file"]').setInputFiles({
name: "vertical-mirror.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(project)),
});
const source = nodeById(page, "generic_sensor_1");
const handle = source.locator('.port-handle[data-handleid="port_a"]');
await expect(handle).toHaveAttribute("data-connected", "true");
const before = await saveProject(page);
expect(before.edges).toHaveLength(1);
expect(before.edges[0].data.isContactEdge).toBe(true);
await source.click();
await verticalMirror(page).click();
await expect(handle).toHaveAttribute("data-connected", "false");
expect((await saveProject(page)).edges).toHaveLength(0);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
await expect(handle).toHaveAttribute("data-connected", "true");
const restored = await saveProject(page);
expect(restored.nodes).toEqual(before.nodes);
expect(restored.edges).toEqual(before.edges);
});