Files
SystemSimulationApp/frontend/tests/e2e/connection-draft.spec.ts

272 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Locator, type Page } from "@playwright/test";
import { Position } from "@xyflow/react";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
import { confirmOrthogonalCorner, extendOrthogonalRoute } from "../../src/edgeRouting";
const PROJECT_KEY = "system-simulation-flow:autosave";
test("每次左键只确认一段并转向,空白预览不插入自动中点", () => {
const source = { x: 0, y: 0 };
const pointer = { x: 120, y: 65 };
const fixed = confirmOrthogonalCorner([source], pointer, Position.Right);
expect(fixed).toEqual([source, { x: 120, y: 0 }]);
expect(extendOrthogonalRoute([source], pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(extendOrthogonalRoute(fixed, pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(confirmOrthogonalCorner(fixed, { x: 200, y: 95 }, Position.Right)).toEqual([
source, { x: 120, y: 0 }, { x: 120, y: 95 },
]);
expect(confirmOrthogonalCorner(fixed, { x: 300, y: 0 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner(fixed, { x: NaN, y: 65 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner([source], pointer, Position.Bottom)).toEqual([
source, { x: 0, y: 65 },
]);
});
test("各方向端口收尾只调整未确认末段,并保持正交", () => {
for (const sourcePosition of Object.values(Position)) {
for (const targetPosition of Object.values(Position)) {
const fixed = confirmOrthogonalCorner([{ x: 0, y: 0 }], { x: 100, y: 80 }, sourcePosition);
const target = { x: 270, y: 230 };
const route = extendOrthogonalRoute(fixed, target, sourcePosition, targetPosition);
expect(route.slice(0, fixed.length)).toEqual(fixed);
expect(route.at(-1)).toEqual(target);
for (let i = 1; i < route.length; i++) {
expect(route[i].x === route[i - 1].x || route[i].y === route[i - 1].y).toBe(true);
expect(route[i]).not.toEqual(route[i - 1]);
}
const previous = route.at(-2)!;
if (targetPosition === Position.Left || targetPosition === Position.Right) {
expect(previous.y).toBe(target.y);
} else {
expect(previous.x).toBe(target.x);
}
}
}
});
async function loadRoutingProject(page: Page, rotation = 0, reverseSignal = false) {
await prepareApp(page);
if (reverseSignal) {
const catalog = structuredClone(componentCatalog);
const definition = catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!;
definition.ports = definition.ports.map((port) => ({
...port, kind: "signal", domain: "signal",
nominalRole: port.side === "left" ? "input" : "output",
}));
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
["origin", 0, 0], ["target", 650, 0], ["obstacle", 280, 260],
["edge-source", 0, 450], ["edge-target", 650, 450],
].map(([id, x, y]) => ({
...structuredClone(wideProject.nodes[0]), id: String(id),
position: { x: Number(x), y: Number(y) },
data: { ...structuredClone(wideProject.nodes[0].data), label: String(id),
rotation: id === "origin" ? rotation : 0 },
}));
const project = { ...structuredClone(wideProject), nodes, edges: [{
...wideProject.edges[0], id: "existing-edge",
source: "edge-source", target: "edge-target",
}] };
await page.addInitScript(({ key, value }) => {
localStorage.setItem(key, JSON.stringify(value));
}, { key: PROJECT_KEY, value: project });
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(5);
await expectAllNodesInsideCanvas(page, ".flow-canvas", 5);
const output = page.locator('.react-flow__node[data-id="origin"] .port-handle[data-port-name="port_b"]');
const input = page.locator('.react-flow__node[data-id="target"] .port-handle[data-port-name="port_a"]');
return {
origin: reverseSignal ? input : output,
target: reverseSignal ? output : input,
draft: page.locator(".manual-connection-draft .react-flow__connection-path"),
};
}
async function center(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
async function pathPoints(path: Locator) {
const values = (await path.getAttribute("d"))!.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)!.map(Number);
return Array.from({ length: values.length / 2 }, (_, index) => ({
x: values[index * 2], y: values[index * 2 + 1],
}));
}
for (const { rotation, reverseSignal } of [
{ rotation: 0, reverseSignal: false },
{ rotation: 90, reverseSignal: false },
{ rotation: 0, reverseSignal: true },
]) {
test(`逐段定转折后已确认部分保持固定(${reverseSignal ? "信号输入端反向起画" : `旋转 ${rotation}°`})`, async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page, rotation, reverseSignal);
const start = await center(origin);
const waypoint = {
x: Math.round(start.x + (reverseSignal ? -120 : 120)),
y: Math.round(start.y + 65),
};
await origin.click();
await page.mouse.move(waypoint.x, waypoint.y);
await expect(draft).toBeVisible();
const beforeClick = await pathPoints(draft);
// 初始为 L 形橡皮筋,一次点击只确认第一个转角,不固定末端。
expect(beforeClick).toHaveLength(3);
const firstFixed = beforeClick.slice(0, -1);
await page.mouse.click(waypoint.x, waypoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect(await pathPoints(draft)).toEqual(beforeClick);
for (const offset of [{x: 100, y: 30}, {x: -45, y: 110}, {x: 90, y: -55}]) {
await page.mouse.move(waypoint.x + offset.x, waypoint.y + offset.y);
await expect.poll(async () => (await pathPoints(draft)).slice(0, firstFixed.length)).toEqual(firstFixed);
}
const secondPoint = { x: waypoint.x + 90, y: waypoint.y - 55 };
const secondPreview = await pathPoints(draft);
const secondFixed = secondPreview.slice(0, -1);
await page.mouse.click(secondPoint.x, secondPoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
expect(await pathPoints(draft)).toEqual(secondPreview);
await page.mouse.move(secondPoint.x + 40, secondPoint.y + 110);
await expect.poll(async () => (await pathPoints(draft)).slice(0, secondFixed.length)).toEqual(secondFixed);
await target.hover();
const completedPreview = await pathPoints(draft);
expect(completedPreview.slice(0, secondFixed.length)).toEqual(secondFixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persisted = await page.evaluate((key) => {
const project = JSON.parse(localStorage.getItem(key)!);
return project.edges.find((edge: { id: string }) => edge.id !== "existing-edge").data.routePoints;
}, PROJECT_KEY);
expect(persisted).toEqual((reverseSignal ? completedPreview.reverse() : completedPreview).slice(1, -1));
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
const finalizedPath = page.locator('.flow-canvas .react-flow__edge:not([data-id="existing-edge"]) .react-flow__edge-path');
const maximumDeviation = await finalizedPath.evaluate((path: SVGPathElement, fixed) => {
const samples = Array.from({ length: Math.ceil(path.getTotalLength()) + 1 }, (_, index) =>
path.getPointAtLength(index),
);
return Math.max(...fixed.map((point) =>
Math.min(...samples.map((sample) => Math.hypot(sample.x - point.x, sample.y - point.y))),
));
}, secondFixed.slice(1));
expect(maximumDeviation).toBeLessThan(1);
});
}
test("右键逐段退回并恢复绘制方向,退到起点后再次右键取消", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const firstFixed = (await pathPoints(draft)).slice(0, 2);
await page.mouse.click(start.x + 200, start.y + 120);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const afterUndo = await pathPoints(draft);
expect(afterUndo.slice(0, 2)).toEqual(firstFixed);
expect(afterUndo[2].x).toBe(firstFixed[1].x); // 恢复为纵向绘制
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "0");
const atStart = await pathPoints(draft);
expect(atStart[1].y).toBe(atStart[0].y); // 恢复起始端口的横向
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "idle");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
// 取消的草稿不生成线路,下一次连接不受 React Flow 的点击状态干扰。
await origin.click();
await target.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(draft).toHaveCount(0);
});
test("绘线时缩放、滚动条和中键平移不会确认额外转角或改变已确认路径", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
const fixed = (await pathPoints(draft)).slice(0, 2);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const beforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", beforeZoom!);
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const scrollbar = page.getByRole("scrollbar", { name: "水平滚动画布" });
const beforeScroll = await viewport.getAttribute("style");
await scrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", beforeScroll!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const beforePan = await viewport.getAttribute("style");
await page.mouse.move(start.x + 180, start.y + 180);
await page.mouse.down({ button: "middle" });
await page.mouse.move(start.x + 220, start.y + 200, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect(viewport).not.toHaveAttribute("style", beforePan!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
test("连接模式点击元件和已有连线只添加折点,取消后恢复选择", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const obstacle = page.locator('.react-flow__node[data-id="obstacle"]');
const obstacleCenter = await center(obstacle.locator(".sim-node"));
const edge = page.locator('.react-flow__edge[data-id="existing-edge"]');
const edgeCenter = await edge.locator(".react-flow__edge-path").evaluate((path: SVGPathElement) => {
const p = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM()!);
return { x: p.x, y: p.y };
});
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
await origin.click();
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
await expect(obstacle).not.toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".manual-edge-segment-handle, .manual-edge-corner-handle, .manual-edge-route-handle")).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await expect(target).toHaveAttribute("data-connection-state", "compatible");
const beforeDrag = await obstacle.getAttribute("style");
await page.mouse.move(obstacleCenter.x, obstacleCenter.y);
await page.mouse.down();
await page.mouse.move(obstacleCenter.x + 35, obstacleCenter.y + 25, { steps: 3 });
await page.mouse.up();
expect(await obstacle.getAttribute("style")).toBe(beforeDrag);
await expect(page.locator(".react-flow__selection")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(draft).toHaveCount(0);
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(obstacle).toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
});