完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in new issue
Block a user