272 lines
14 KiB
TypeScript
272 lines
14 KiB
TypeScript
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/);
|
||
});
|