完善通用求解器回归与前端交互

- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
lujingze committed 2026-08-18 06:42:07 +00:00
1 parent 143e8dd309
commit b435daecf2
65 files changed
+172271 -701

No files matched your search

+268
View File
@@ -1,4 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
expectAllNodesInsideCanvas,
prepareApp,
@@ -6,6 +8,35 @@ import {
wideProject,
} from "./fixtures";
type ImportedProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
data?: { isContactEdge?: boolean };
};
type ImportedProject = {
nodes: Array<{ id: string }>;
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 mql8Project = JSON.parse(
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
) as ImportedProject;
const mql8ResultSnapshot = {
...resultSnapshot,
id: "e2e-mql-8-result-system-bridges",
project: mql8Project as unknown as typeof resultSnapshot.project,
};
async function readViewport(page: Page, canvasSelector: string) {
return page
.locator(`${canvasSelector} .react-flow__viewport`)
@@ -26,6 +57,110 @@ async function readModelingViewport(page: Page) {
return readViewport(page, ".flow-canvas");
}
async function readVisibleEdgeEndpointAlignment(
page: Page,
edges: ImportedProjectEdge[],
) {
return page.evaluate((projectEdges) => {
const nodeElements = new Map(
Array.from(
document.querySelectorAll<HTMLElement>(
".flow-canvas .react-flow__node[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const edgeElements = new Map(
Array.from(
document.querySelectorAll<SVGGElement>(
".flow-canvas .react-flow__edge[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const measurements: Array<{
edgeId: string;
sourceDistance: number;
targetDistance: number;
}> = [];
const missingHandles: string[] = [];
for (const edge of projectEdges) {
if (edge.data?.isContactEdge === true) {
continue;
}
const path = edgeElements
.get(edge.id)
?.querySelector<SVGPathElement>(".react-flow__edge-path");
if (!path) {
// A geometrically touching edge is intentionally rendered without a path.
continue;
}
const sourceHandle = Array.from(
nodeElements
.get(edge.source)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.sourceHandle);
const targetHandle = Array.from(
nodeElements
.get(edge.target)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.targetHandle);
const screenMatrix = path.getScreenCTM();
if (!sourceHandle || !targetHandle || !screenMatrix) {
missingHandles.push(edge.id);
continue;
}
const pathLength = path.getTotalLength();
const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix);
const targetPoint = path
.getPointAtLength(pathLength)
.matrixTransform(screenMatrix);
const sourceBounds = sourceHandle.getBoundingClientRect();
const targetBounds = targetHandle.getBoundingClientRect();
const sourceCenter = {
x: sourceBounds.left + sourceBounds.width / 2,
y: sourceBounds.top + sourceBounds.height / 2,
};
const targetCenter = {
x: targetBounds.left + targetBounds.width / 2,
y: targetBounds.top + targetBounds.height / 2,
};
measurements.push({
edgeId: edge.id,
sourceDistance: Math.hypot(
sourcePoint.x - sourceCenter.x,
sourcePoint.y - sourceCenter.y,
),
targetDistance: Math.hypot(
targetPoint.x - targetCenter.x,
targetPoint.y - targetCenter.y,
),
});
}
const worst = measurements
.map((measurement) => ({
...measurement,
maxDistance: Math.max(
measurement.sourceDistance,
measurement.targetDistance,
),
}))
.sort((first, second) => second.maxDistance - first.maxDistance)
.slice(0, 8);
return {
checkedEdgeCount: measurements.length,
maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY,
missingHandles,
worst,
};
}, edges);
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
@@ -225,6 +360,50 @@ test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
await expectAllNodesInsideCanvas(page, ".flow-canvas");
});
test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
await expectAllNodesInsideCanvas(
page,
".flow-canvas",
mql8Project.nodes.length,
);
await expect
.poll(
async () =>
(
await readVisibleEdgeEndpointAlignment(page, mql8Project.edges)
).checkedEdgeCount,
{ timeout: 15_000 },
)
.toBeGreaterThan(20);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
const alignment = await readVisibleEdgeEndpointAlignment(
page,
mql8Project.edges,
);
expect(alignment.missingHandles).toEqual([]);
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
expect(
alignment.maxDistance,
`偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`,
// React Flow anchors to the outer edge of the 6 px port glyph rather
// than its visual center; allow that radius plus subpixel rounding.
).toBeLessThanOrEqual(4);
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
@@ -434,6 +613,95 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}
});
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
page,
}) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, mql8ResultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(
page.locator(".results-system-canvas .react-flow__node"),
).toHaveCount(mql8Project.nodes.length);
await expect(
page.locator(
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path',
),
).toHaveAttribute("d", / Q /);
});
test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
const modelingPaneBox = await modelingPane.boundingBox();
expect(modelingPaneBox).not.toBeNull();
const fittedViewport = await readModelingViewport(page);
const pointer = {
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const viewport = await readModelingViewport(page);
return Math.hypot(
viewport.x - fittedViewport.x,
viewport.y - fittedViewport.y,
);
})
.toBeGreaterThan(100);
const beforeZoom = await readModelingViewport(page);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const adjustedViewport = await readModelingViewport(page);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
wideProject.nodes.length,
);
await page.waitForTimeout(250);
const restoredViewport = await readModelingViewport(page);
expect(
Math.hypot(
restoredViewport.x - adjustedViewport.x,
restoredViewport.y - adjustedViewport.y,
),
).toBeLessThan(2);
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
});
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
page,
}) => {