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

- 引入因果坐标内核、热流体恢复和递进长时回归\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

@@ -3077,10 +3077,25 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 20, y: 20 },
});
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
const manualDraft = page.locator(".flow-canvas .manual-connection-draft");
await expect(manualDraft).toBeVisible();
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(manualDraft).toHaveCount(0);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(compatiblePhysicalPort).toHaveAttribute(
@@ -3094,8 +3109,16 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
);
await firstPhysicalOutput.click();
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 70, y: 90 },
});
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(manualDraft).toHaveCount(0);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
@@ -3107,6 +3130,15 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const routedEdge = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:project:demo-system",
);
const project = raw ? JSON.parse(raw) : null;
return project?.edges?.[0] ?? null;
});
expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0);
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
@@ -0,0 +1,719 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp, wideProject } from "./fixtures";
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
async function locatorCenter(locator: Locator) {
const bounds = await locator.boundingBox();
expect(bounds).not.toBeNull();
return {
x: bounds!.x + bounds!.width / 2,
y: bounds!.y + bounds!.height / 2,
};
}
async function distanceBetween(first: Locator, second: Locator) {
const [firstCenter, secondCenter] = await Promise.all([
locatorCenter(first),
locatorCenter(second),
]);
return Math.hypot(
secondCenter.x - firstCenter.x,
secondCenter.y - firstCenter.y,
);
}
async function dragGenericToCanvas(
page: Page,
targetPosition: { x: number; y: number },
) {
const currentCount = await page
.locator('.flow-canvas .react-flow__node[data-id^="generic_sensor_"]')
.count();
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"), { targetPosition });
const node = page.locator(
`.flow-canvas .react-flow__node[data-id="generic_sensor_${currentCount + 1}"]`,
);
await expect(node).toBeVisible();
return node;
}
async function moveNodePortNearTarget(
page: Page,
movingNode: Locator,
movingPort: Locator,
targetPort: Locator,
remainingGap = 8,
) {
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
locatorCenter(movingNode.locator(".sim-node")),
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const deltaX = targetPortCenter.x - movingPortCenter.x;
const deltaY = targetPortCenter.y - movingPortCenter.y;
const distance = Math.hypot(deltaX, deltaY);
const ratio = (distance - remainingGap) / distance;
let pointerX = dragStart.x + deltaX * ratio;
let pointerY = dragStart.y + deltaY * ratio;
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(pointerX, pointerY, { steps: 12 });
for (let attempt = 0; attempt < 2; attempt += 1) {
const [movingCenter, targetCenter] = await Promise.all([
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const correctionX = targetCenter.x - movingCenter.x;
const correctionY = targetCenter.y - movingCenter.y;
const correctionDistance = Math.hypot(correctionX, correctionY);
if (correctionDistance <= remainingGap + 1) {
break;
}
const correctionRatio =
(correctionDistance - remainingGap) / correctionDistance;
pointerX += correctionX * correctionRatio;
pointerY += correctionY * correctionRatio;
await page.mouse.move(pointerX, pointerY, { steps: 4 });
}
return { pointerX, pointerY };
}
async function movePointerUntilPortsMeet(
page: Page,
pointer: { x: number; y: number },
movingPort: Locator,
targetPort: Locator,
) {
const nextPointer = { ...pointer };
for (let attempt = 0; attempt < 3; attempt += 1) {
const [movingCenter, targetCenter] = await Promise.all([
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const correction = {
x: targetCenter.x - movingCenter.x,
y: targetCenter.y - movingCenter.y,
};
if (Math.hypot(correction.x, correction.y) < 2) {
break;
}
nextPointer.x += correction.x;
nextPointer.y += correction.y;
await page.mouse.move(nextPointer.x, nextPointer.y, { steps: 6 });
}
return nextPointer;
}
function projectNode(id: string, x: number, y: number) {
const template = structuredClone(wideProject.nodes[0]);
return {
...template,
id,
position: { x, y },
data: {
...template.data,
label: id,
},
};
}
test("多选块拖动会即时断开边界接触,并由块内空闲端口整体吸附", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
const secondMovingNode = await dragGenericToCanvas(page, { x: 410, y: 430 });
const snapTargetNode = await dragGenericToCanvas(page, { x: 720, y: 430 });
const fixedPort = fixedNode.locator('.port-handle[data-port-name="port_b"]');
const boundaryPort = firstMovingNode.locator(
'.port-handle[data-port-name="port_a"]',
);
const blockFreePort = secondMovingNode.locator(
'.port-handle[data-port-name="port_b"]',
);
const targetFreePort = snapTargetNode.locator(
'.port-handle[data-port-name="port_a"]',
);
await moveNodePortNearTarget(
page,
firstMovingNode,
boundaryPort,
fixedPort,
);
await page.mouse.up();
await expect.poll(() => distanceBetween(boundaryPort, fixedPort)).toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await firstMovingNode.locator(".sim-node").click();
await secondMovingNode.locator(".sim-node").click({ modifiers: ["Control"] });
await expect(firstMovingNode).toHaveClass(/selected/);
await expect(secondMovingNode).toHaveClass(/selected/);
const [firstBefore, secondBefore] = await Promise.all([
firstMovingNode.boundingBox(),
secondMovingNode.boundingBox(),
]);
expect(firstBefore).not.toBeNull();
expect(secondBefore).not.toBeNull();
await moveNodePortNearTarget(
page,
firstMovingNode,
blockFreePort,
targetFreePort,
);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(blockFreePort).toHaveAttribute("data-connection-state", "origin");
await expect(targetFreePort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect.poll(() => distanceBetween(blockFreePort, targetFreePort)).toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await expect(boundaryPort).toHaveAttribute("data-connected", "false");
await expect(fixedPort).toHaveAttribute("data-connected", "false");
await expect(blockFreePort).toHaveAttribute("data-connected", "true");
await expect(targetFreePort).toHaveAttribute("data-connected", "true");
const [firstAfter, secondAfter] = await Promise.all([
firstMovingNode.boundingBox(),
secondMovingNode.boundingBox(),
]);
expect(firstAfter).not.toBeNull();
expect(secondAfter).not.toBeNull();
expect(firstAfter!.x - firstBefore!.x).toBeCloseTo(
secondAfter!.x - secondBefore!.x,
1,
);
expect(firstAfter!.y - firstBefore!.y).toBeCloseTo(
secondAfter!.y - secondBefore!.y,
1,
);
});
test("复制块保留内部连接,同时未连接端口仍参与待放置吸附", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
const target = await dragGenericToCanvas(page, { x: 720, y: 370 });
await first.locator('.port-handle[data-port-name="port_b"]').click();
await second.locator('.port-handle[data-port-name="port_a"]').click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await first.locator(".sim-node").click();
await second.locator(".sim-node").click({ modifiers: ["Control"] });
await expect(first).toHaveClass(/selected/);
await expect(second).toHaveClass(/selected/);
await page.keyboard.press("Control+c");
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
let pointer = {
x: paneBounds!.x + paneBounds!.width * 0.5,
y: paneBounds!.y + paneBounds!.height * 0.72,
};
await page.mouse.move(pointer.x, pointer.y);
await page.keyboard.press("Control+v");
const firstPreview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_4"]',
);
const secondPreview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_5"]',
);
await expect(firstPreview).toHaveClass(/pending-paste-node/);
await expect(secondPreview).toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .pending-paste-edge")).toHaveCount(1);
await expect(
firstPreview.locator('.port-handle[data-port-name="port_b"]'),
).toHaveAttribute("data-connection-state", "connected");
await expect(
secondPreview.locator('.port-handle[data-port-name="port_a"]'),
).toHaveAttribute("data-connection-state", "connected");
const copiedFreePort = secondPreview.locator(
'.port-handle[data-port-name="port_b"]',
);
const targetPort = target.locator('.port-handle[data-port-name="port_a"]');
pointer = await movePointerUntilPortsMeet(
page,
pointer,
copiedFreePort,
targetPort,
);
await expect.poll(() => distanceBetween(copiedFreePort, targetPort)).toBeLessThan(2);
await expect(copiedFreePort).toHaveAttribute("data-connection-state", "origin");
await expect(targetPort).toHaveAttribute("data-connection-state", "compatible");
await page.mouse.click(pointer.x, pointer.y);
await expect(firstPreview).not.toHaveClass(/pending-paste-node/);
await expect(secondPreview).not.toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(3);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(2);
await expect(copiedFreePort).toHaveAttribute("data-connected", "true");
await expect(targetPort).toHaveAttribute("data-connected", "true");
});
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("left", 0, 260),
projectNode("right", 700, 260),
projectNode("top", 350, 0),
projectNode("bottom", 350, 520),
],
edges: [
{
id: "edge-horizontal",
source: "left",
target: "right",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 650, y: 360 },
],
},
},
{
id: "edge-vertical",
source: "top",
target: "bottom",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 450, y: 150 },
{ x: 450, y: 550 },
],
},
},
],
};
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 horizontalEdge = page.locator(
'.flow-canvas .react-flow__edge[data-id="edge-horizontal"]',
);
const verticalEdge = page.locator(
'.flow-canvas .react-flow__edge[data-id="edge-vertical"]',
);
await expect(horizontalEdge).toBeVisible();
await expect(verticalEdge).toBeVisible();
const horizontalPath = horizontalEdge.locator(".react-flow__edge-path");
await expect(horizontalPath).toHaveAttribute("d", / Q /);
await expect(verticalEdge.locator(".react-flow__edge-path")).not.toHaveAttribute(
"d",
/ Q /,
);
await horizontalEdge
.locator(".react-flow__edge-interaction")
.dispatchEvent("click");
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
const horizontalSegment = horizontalEdge
.locator(".manual-edge-segment-handle.horizontal")
.first();
const verticalSegment = horizontalEdge
.locator(".manual-edge-segment-handle.vertical")
.first();
await expect(horizontalSegment).toHaveCount(1);
await expect(verticalSegment).toHaveCount(1);
await expect(horizontalSegment).toHaveCSS("pointer-events", "stroke");
await expect(verticalSegment).toHaveCSS("pointer-events", "stroke");
const horizontalDragPoint = await horizontalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取水平连接线段坐标");
}
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 pathBeforeHorizontalDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y);
await page.mouse.down();
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y + 36, {
steps: 6,
});
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
pathBeforeHorizontalDrag ?? "",
);
await expect(horizontalPath).toHaveAttribute("d", / Q /);
const verticalDragPoint = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取竖直连接线段坐标");
}
const point = new DOMPoint(
(line.x1.baseVal.value + line.x2.baseVal.value) / 2,
(line.y1.baseVal.value + line.y2.baseVal.value) / 2,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
},
);
const pathBeforeVerticalDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(verticalDragPoint.x, verticalDragPoint.y);
await page.mouse.down();
await page.mouse.move(verticalDragPoint.x + 28, verticalDragPoint.y, {
steps: 6,
});
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
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);
await page.keyboard.press("Control+z");
await expect(horizontalPath).toHaveAttribute(
"d",
pathBeforeCornerDrag ?? "",
);
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(
"d",
boundaryPathBefore ?? "",
);
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(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
expect(linePoints.length).toBeGreaterThan(2);
linePoints.slice(1).forEach((point, index) => {
const previous = linePoints[index];
expect(
Math.abs(point.x - previous.x) < 0.01 ||
Math.abs(point.y - previous.y) < 0.01,
).toBe(true);
});
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedRoute = await page.evaluate((projectKey) => {
const raw = window.localStorage.getItem(projectKey);
const saved = raw ? JSON.parse(raw) : null;
return saved?.edges?.find(
(edge: { id?: string }) => edge.id === "edge-horizontal",
)?.data?.routePoints ?? null;
}, PROJECT_KEY);
expect(savedRoute).not.toEqual(project.edges[0].data.routePoints);
expect(savedRoute?.length).toBeGreaterThan(1);
});
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),
);
}, 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 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.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);
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,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("shared-source", 0, 260),
projectNode("right-target", 700, 260),
projectNode("bottom-target", 350, 520),
],
edges: [
{
id: "shared-horizontal",
source: "shared-source",
target: "right-target",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 650, y: 360 },
],
},
},
{
id: "shared-vertical",
source: "shared-source",
target: "bottom-target",
sourceHandle: "port_a",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 250, y: 180 },
{ x: 450, y: 180 },
{ x: 450, y: 540 },
{ x: 350, y: 540 },
],
},
},
],
};
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 horizontalPath = page.locator(
'.flow-canvas .react-flow__edge[data-id="shared-horizontal"] .react-flow__edge-path',
);
const verticalPath = page.locator(
'.flow-canvas .react-flow__edge[data-id="shared-vertical"] .react-flow__edge-path',
);
await expect(horizontalPath).toBeVisible();
await expect(horizontalPath).toHaveAttribute("d", / Q /);
await expect(verticalPath).not.toHaveAttribute("d", / Q /);
});
+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,
}) => {
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp, resultSnapshot } from "./fixtures";
@@ -39,6 +39,86 @@ async function openResults(page: Page) {
await expect(page.locator(".results-chart-workspace")).toBeVisible();
}
async function readChartDomain(chart: Locator) {
return chart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
yMin: Number(element.getAttribute("data-view-y-min")),
yMax: Number(element.getAttribute("data-view-y-max")),
}));
}
async function dragChartSelection(
page: Page,
chart: Locator,
start: { x: number; y: number },
end: { x: number; y: number },
) {
const bounds = await chart.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
bounds!.x + bounds!.width * start.x,
bounds!.y + bounds!.height * start.y,
);
await page.mouse.down();
await page.mouse.move(
bounds!.x + bounds!.width * end.x,
bounds!.y + bounds!.height * end.y,
{ steps: 5 },
);
await page.mouse.up();
}
async function wheelAtLocator(
page: Page,
target: Locator,
deltaY = -240,
position = { x: 0.5, y: 0.5 },
) {
const bounds = await target.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
bounds!.x + bounds!.width * position.x,
bounds!.y + bounds!.height * position.y,
);
await page.mouse.wheel(0, deltaY);
}
async function dragMiddleAtLocator(
page: Page,
target: Locator,
delta = { x: 0.08, y: 0.08 },
) {
const bounds = await target.boundingBox();
expect(bounds).not.toBeNull();
const startX = bounds!.x + bounds!.width * 0.5;
const startY = bounds!.y + bounds!.height * 0.5;
await page.mouse.move(startX, startY);
await page.mouse.down({ button: "middle" });
await page.mouse.move(
startX + bounds!.width * delta.x,
startY + bounds!.height * delta.y,
{ steps: 5 },
);
await page.mouse.up({ button: "middle" });
}
async function readFlowViewport(page: Page) {
return page
.locator(".results-system-canvas .react-flow__viewport")
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
?.map(Number) ?? [];
return {
x: values[4] ?? 0,
y: values[5] ?? 0,
zoom: values[0] ?? 1,
};
});
}
test.beforeEach(async ({ page }) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
@@ -155,6 +235,553 @@ test("a pending window recovered from stale variable keys accepts a new first cu
);
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
await page.locator(".results-variable-list button").first().dragTo(pendingWindow);
await page
.locator(".results-variable-list button")
.first()
.dragTo(pendingWindow);
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
});
test("single curve supports box zoom, original-size restore, and persisted interaction mode", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
let chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
let chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toBeVisible();
const initial = await readChartDomain(chart);
const zoomButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
});
await zoomButton.click();
await expect(
chartWindow.getByRole("button", {
name: "关闭 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "true");
await dragChartSelection(
page,
chart,
{ x: 0.32, y: 0.3 },
{ x: 0.76, y: 0.72 },
);
await expect(chart).toHaveAttribute("data-zoomed", "true");
await expect(chart.locator(".result-chart-zoom-selection")).toHaveCount(0);
await expect(chart.locator('[data-chart-clipped-series="true"]')).toHaveCount(
1,
);
const zoomed = await readChartDomain(chart);
expect(zoomed.xMin).toBeGreaterThan(initial.xMin);
expect(zoomed.xMax).toBeLessThan(initial.xMax);
expect(zoomed.yMin).toBeGreaterThan(initial.yMin);
expect(zoomed.yMax).toBeLessThan(initial.yMax);
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-layout:${snapshotId}`,
);
const windows = raw ? JSON.parse(raw) : [];
return windows[0]?.viewport?.x?.start ?? 0;
}, chartWindowSnapshot.id),
)
.toBeGreaterThan(0);
await page.getByRole("tab", { name: "建模", exact: true }).click();
await openResults(page);
chartWindow = page.locator('.result-chart-window[data-chart-kind="single"]');
chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toHaveAttribute("data-zoomed", "true");
const restored = await readChartDomain(chart);
expect(restored.xMin).toBeCloseTo(zoomed.xMin, 8);
expect(restored.xMax).toBeCloseTo(zoomed.xMax, 8);
await expect(
chartWindow.getByRole("button", {
name: "关闭 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "true");
await chartWindow
.getByRole("button", { name: "打开 数值 曲线游标", exact: true })
.click();
await expect(
chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "false");
await chartWindow
.getByRole("button", {
name: "恢复 数值 原始尺寸",
exact: true,
})
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
const undone = await readChartDomain(chart);
expect(undone.xMin).toBeCloseTo(initial.xMin, 8);
expect(undone.xMax).toBeCloseTo(initial.xMax, 8);
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
chart,
{ x: 0.3, y: 0.28 },
{ x: 0.7, y: 0.68 },
);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
});
test("combined curves share X zoom while stacked Y zoom stays in the selected band", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
const valueVariable = page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first();
const pressureVariable = page
.locator(".results-variable-list button")
.filter({ hasText: "压力" })
.first();
await page
.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true })
.click();
const multiWindow = page.locator(
'.result-chart-window[data-chart-kind="multi"]',
);
await valueVariable.dragTo(multiWindow);
const multiChart = multiWindow.locator('svg[data-result-chart="true"]');
await multiWindow
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
multiChart,
{ x: 0.31, y: 0.34 },
{ x: 0.73, y: 0.72 },
);
await expect(multiChart).toHaveAttribute("data-zoomed", "true");
await expect(
multiChart.locator('[data-chart-clipped-series="true"] path'),
).toHaveCount(1);
const multiCursor = multiWindow.locator("button.cursor");
await multiCursor.click();
await expect(multiWindow.locator(".result-chart-cursor-panel")).toBeVisible();
await multiWindow
.getByRole("button", { name: "管理多曲线窗口中的曲线", exact: true })
.click();
const visibilityToggle = multiWindow.locator(
'.result-chart-multi-menu-row input[type="checkbox"]',
);
await visibilityToggle.click();
await expect(multiCursor).toBeDisabled();
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await visibilityToggle.click();
await expect(multiCursor).toBeEnabled();
await expect(multiCursor).toHaveAttribute("aria-pressed", "false");
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await page
.getByRole("button", { name: "新建异单位上下对比窗口", exact: true })
.click();
const mixedWindow = page.locator(
'.result-chart-window[data-chart-kind="mixed"]',
);
await valueVariable.dragTo(mixedWindow);
await pressureVariable.dragTo(mixedWindow);
const mixedChart = mixedWindow.locator('svg[data-result-chart="true"]');
const beforeXRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
await mixedWindow
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
mixedChart,
{ x: 0.31, y: 0.13 },
{ x: 0.72, y: 0.42 },
);
await expect(mixedChart).toHaveAttribute("data-zoomed", "true");
const afterRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(afterRanges.xMin).toBeGreaterThan(beforeXRanges.xMin);
expect(afterRanges.xMax).toBeLessThan(beforeXRanges.xMax);
expect(afterRanges.y["generic_sensor_1.value"]).not.toEqual(
beforeXRanges.y["generic_sensor_1.value"],
);
expect(afterRanges.y["generic_sensor_1.pressure"]).toEqual(
beforeXRanges.y["generic_sensor_1.pressure"],
);
const secondYAxis = mixedChart.locator('[data-chart-zoom-axis="y"]').nth(1);
await expect(secondYAxis).toBeVisible();
await wheelAtLocator(page, secondYAxis);
await expect
.poll(async () =>
mixedChart.evaluate((element) => {
const ranges = JSON.parse(
element.getAttribute("data-view-y-ranges") ?? "{}",
);
return JSON.stringify(ranges["generic_sensor_1.pressure"]);
}),
)
.not.toBe(JSON.stringify(afterRanges.y["generic_sensor_1.pressure"]));
const axisWheelRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(axisWheelRanges.xMin).toBeCloseTo(afterRanges.xMin, 8);
expect(axisWheelRanges.xMax).toBeCloseTo(afterRanges.xMax, 8);
expect(axisWheelRanges.y["generic_sensor_1.value"]).toEqual(
afterRanges.y["generic_sensor_1.value"],
);
expect(axisWheelRanges.y["generic_sensor_1.pressure"]).not.toEqual(
afterRanges.y["generic_sensor_1.pressure"],
);
await dragMiddleAtLocator(page, secondYAxis, { x: 0, y: 0.08 });
const bandPannedRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(bandPannedRanges.xMin).toBeCloseTo(axisWheelRanges.xMin, 8);
expect(bandPannedRanges.xMax).toBeCloseTo(axisWheelRanges.xMax, 8);
expect(bandPannedRanges.y["generic_sensor_1.value"]).toEqual(
axisWheelRanges.y["generic_sensor_1.value"],
);
const beforePressure = axisWheelRanges.y["generic_sensor_1.pressure"];
const afterPressure = bandPannedRanges.y["generic_sensor_1.pressure"];
expect(afterPressure[1] - afterPressure[0]).toBeCloseTo(
beforePressure[1] - beforePressure[0],
8,
);
expect(afterPressure[0]).toBeGreaterThan(beforePressure[0]);
await expect(
mixedChart.locator('[data-chart-clipped-series="true"]'),
).toHaveCount(2);
});
test("result system viewport survives modeling and results tab switches", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
const pane = page.locator(".results-system-canvas .react-flow__pane");
const paneBounds = await pane.boundingBox();
expect(paneBounds).not.toBeNull();
await page.mouse.move(
paneBounds!.x + paneBounds!.width * 0.55,
paneBounds!.y + paneBounds!.height * 0.5,
);
const fitted = await readFlowViewport(page);
await page.mouse.wheel(0, -260);
await expect
.poll(async () => (await readFlowViewport(page)).zoom)
.not.toBeCloseTo(fitted.zoom, 3);
const adjusted = await readFlowViewport(page);
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-system-viewport:${snapshotId}`,
);
return raw ? JSON.parse(raw).zoom : 0;
}, chartWindowSnapshot.id),
)
.toBeCloseTo(adjusted.zoom, 3);
await page.getByRole("tab", { name: "建模", exact: true }).click();
await openResults(page);
const restored = await readFlowViewport(page);
expect(restored.x).toBeCloseTo(adjusted.x, 1);
expect(restored.y).toBeCloseTo(adjusted.y, 1);
expect(restored.zoom).toBeCloseTo(adjusted.zoom, 3);
await page
.getByRole("button", { name: "适应系统图窗口", exact: true })
.click();
await expect
.poll(async () => (await readFlowViewport(page)).zoom)
.not.toBeCloseTo(restored.zoom, 3);
});
test("zoom wheel follows the pointer and axis hit areas while cursor keeps the viewport", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
const chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
const chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toBeVisible();
const cursorButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线游标",
exact: true,
});
await cursorButton.click();
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
const zoomButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
});
await zoomButton.click();
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
const initial = await readChartDomain(chart);
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
await expect(xAxis).toBeVisible();
await expect(yAxis).toBeVisible();
const xAxisBounds = await xAxis.boundingBox();
const yAxisBounds = await yAxis.boundingBox();
expect(xAxisBounds).not.toBeNull();
expect(yAxisBounds).not.toBeNull();
const plotPointer = { x: 0.25, y: 0.35 };
const initialXAnchor =
initial.xMin + (initial.xMax - initial.xMin) * plotPointer.x;
const initialYAnchor =
initial.yMax - (initial.yMax - initial.yMin) * plotPointer.y;
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * plotPointer.x,
yAxisBounds!.y + yAxisBounds!.height * plotPointer.y,
);
await page.mouse.wheel(0, -240);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.xMax - current.xMin;
})
.toBeLessThan(initial.xMax - initial.xMin);
const plotZoomed = await readChartDomain(chart);
expect(plotZoomed.yMax - plotZoomed.yMin).toBeLessThan(
initial.yMax - initial.yMin,
);
const zoomedXAnchor =
plotZoomed.xMin + (plotZoomed.xMax - plotZoomed.xMin) * plotPointer.x;
const zoomedYAnchor =
plotZoomed.yMax - (plotZoomed.yMax - plotZoomed.yMin) * plotPointer.y;
expect(
Math.abs(zoomedXAnchor - initialXAnchor) / (initial.xMax - initial.xMin),
).toBeLessThan(0.002);
expect(
Math.abs(zoomedYAnchor - initialYAnchor) / (initial.yMax - initial.yMin),
).toBeLessThan(0.002);
await wheelAtLocator(page, xAxis);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.xMax - current.xMin;
})
.toBeLessThan(plotZoomed.xMax - plotZoomed.xMin);
const xOnlyZoomed = await readChartDomain(chart);
expect(xOnlyZoomed.yMin).toBeCloseTo(plotZoomed.yMin, 8);
expect(xOnlyZoomed.yMax).toBeCloseTo(plotZoomed.yMax, 8);
await wheelAtLocator(page, yAxis);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.yMax - current.yMin;
})
.toBeLessThan(xOnlyZoomed.yMax - xOnlyZoomed.yMin);
const yOnlyZoomed = await readChartDomain(chart);
expect(yOnlyZoomed.xMin).toBeCloseTo(xOnlyZoomed.xMin, 8);
expect(yOnlyZoomed.xMax).toBeCloseTo(xOnlyZoomed.xMax, 8);
const beforePlotPan = yOnlyZoomed;
await dragMiddleAtLocator(page, chart, { x: 0.05, y: 0.04 });
const plotPanned = await readChartDomain(chart);
expect(plotPanned.xMax - plotPanned.xMin).toBeCloseTo(
beforePlotPan.xMax - beforePlotPan.xMin,
8,
);
expect(plotPanned.yMax - plotPanned.yMin).toBeCloseTo(
beforePlotPan.yMax - beforePlotPan.yMin,
8,
);
expect(plotPanned.xMin).toBeLessThan(beforePlotPan.xMin);
expect(plotPanned.yMin).toBeGreaterThan(beforePlotPan.yMin);
const beforeXAxisPan = plotPanned;
await dragMiddleAtLocator(page, xAxis, { x: 0.06, y: 0 });
const xAxisPanned = await readChartDomain(chart);
expect(xAxisPanned.xMax - xAxisPanned.xMin).toBeCloseTo(
beforeXAxisPan.xMax - beforeXAxisPan.xMin,
8,
);
expect(xAxisPanned.xMin).toBeLessThan(beforeXAxisPan.xMin);
expect(xAxisPanned.yMin).toBeCloseTo(beforeXAxisPan.yMin, 8);
expect(xAxisPanned.yMax).toBeCloseTo(beforeXAxisPan.yMax, 8);
const beforeYAxisPan = xAxisPanned;
await dragMiddleAtLocator(page, yAxis, { x: 0, y: 0.06 });
const yAxisPanned = await readChartDomain(chart);
expect(yAxisPanned.yMax - yAxisPanned.yMin).toBeCloseTo(
beforeYAxisPan.yMax - beforeYAxisPan.yMin,
8,
);
expect(yAxisPanned.yMin).toBeGreaterThan(beforeYAxisPan.yMin);
expect(yAxisPanned.xMin).toBeCloseTo(beforeYAxisPan.xMin, 8);
expect(yAxisPanned.xMax).toBeCloseTo(beforeYAxisPan.xMax, 8);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * 0.5,
yAxisBounds!.y + yAxisBounds!.height * 0.5,
);
await page.mouse.wheel(0, -180);
await expect(chart).toHaveAttribute("data-zoomed", "true");
const beforeCursor = await readChartDomain(chart);
await cursorButton.click();
await expect(
chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "false");
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
const withCursor = await readChartDomain(chart);
expect(withCursor).toEqual(beforeCursor);
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * 0.5,
yAxisBounds!.y + yAxisBounds!.height * 0.5,
);
await page.mouse.wheel(0, 900);
await expect
.poll(async () => (await readChartDomain(chart)).xMin)
.toBeLessThan(initial.xMin);
const expandedDomain = await readChartDomain(chart);
expect(expandedDomain.xMax).toBeGreaterThan(initial.xMax);
expect(expandedDomain.yMin).toBeLessThan(initial.yMin);
expect(expandedDomain.yMax).toBeGreaterThan(initial.yMax);
const outline = await chart.evaluate((element) => {
element.focus();
return getComputedStyle(element).outlineStyle;
});
expect(outline).toBe("none");
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-layout:${snapshotId}`,
);
const windows = raw ? JSON.parse(raw) : [];
const single = windows.find(
(item: { kind?: string }) => item.kind === "single",
);
return single?.viewport?.x?.start ?? 0;
}, chartWindowSnapshot.id),
)
.toBeLessThan(0);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
const restoredDomain = await readChartDomain(chart);
expect(restoredDomain).toEqual(initial);
});
test("cursor is unavailable when a zoomed viewport contains no curve", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
const chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
const chart = chartWindow.locator('svg[data-result-chart="true"]');
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
const xBounds = await xAxis.boundingBox();
const yBounds = await yAxis.boundingBox();
expect(xBounds).not.toBeNull();
expect(yBounds).not.toBeNull();
await page.mouse.move(
xBounds!.x + xBounds!.width * 0.08,
yBounds!.y + yBounds!.height * 0.08,
);
await page.mouse.down();
await page.mouse.move(
xBounds!.x + xBounds!.width * 0.32,
yBounds!.y + yBounds!.height * 0.28,
{ steps: 5 },
);
await page.mouse.up();
await expect(chart).toHaveAttribute("data-zoomed", "true");
const cursorButton = chartWindow.locator("button.cursor");
await expect(cursorButton).toBeDisabled();
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(cursorButton).toBeEnabled();
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
});