204 lines
11 KiB
TypeScript
204 lines
11 KiB
TypeScript
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
import { buildAlignmentIndex, findAlignment, type AlignmentAnchor } from "../../src/alignmentGuides";
|
|
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
|
|
|
|
const KEY = "system-simulation-flow:project:demo-system";
|
|
const anchor = (nodeId: string, kind: AlignmentAnchor["kind"], x: number, y: number): AlignmentAnchor =>
|
|
({ id: `${nodeId}:${kind}`, nodeId, kind, x, y });
|
|
|
|
test("中心、定位点和端口按同类参考点对齐,整块采用同一个平移量", () => {
|
|
for (const kind of ["center", "origin", "port"] as const) {
|
|
const index = buildAlignmentIndex([anchor("ref-x", kind, 100, 200), anchor("ref-y", kind, 300, 40)]);
|
|
const result = findAlignment([anchor("a", kind, 95, 10), anchor("b", kind, 145, 37)], index, 1)!;
|
|
expect(result.positionDelta).toEqual({ x: 5, y: 3 });
|
|
expect(result.guides.map((g) => g.kind)).toEqual([kind, kind]);
|
|
expect(result.guides.every((g) => g.from[g.axis] === g.to[g.axis])).toBe(true);
|
|
}
|
|
expect(findAlignment([anchor("a", "origin", 95, 10)],
|
|
buildAlignmentIndex([anchor("ref", "center", 100, 200)]), 1)).toBeNull();
|
|
});
|
|
|
|
test("对齐容差保持约 8 屏幕像素,远处元件与离开范围的元件不吸附", () => {
|
|
const index = buildAlignmentIndex([anchor("ref", "center", 100, 100)]);
|
|
expect(findAlignment([anchor("a", "center", 96.5, 0)], index, 2)?.positionDelta.x).toBe(3.5);
|
|
expect(findAlignment([anchor("a", "center", 95, 0)], index, 2)).toBeNull();
|
|
expect(findAlignment([anchor("a", "center", 96, 5000)], index, 1)).toBeNull();
|
|
expect(findAlignment([anchor("a", "center", 70, 0)], index, 0.1)).toBeNull();
|
|
});
|
|
|
|
test("大量元件共用同一坐标时,索引仍选取另一方向最近的参考点", () => {
|
|
const index = buildAlignmentIndex(Array.from({ length: 20000 }, (_, i) => anchor(`ref-${i}`, "port", 100, i * 20)));
|
|
const result = findAlignment([anchor("a", "port", 104, 200005)], index, 1)!;
|
|
expect(result.positionDelta).toEqual({ x: -4, y: -5 });
|
|
expect(result.guides[0].to).toEqual({ x: 100, y: 200000 });
|
|
});
|
|
|
|
async function loadProject(page: Page, ports: boolean, rotatedTarget = false, block = false) {
|
|
await prepareApp(page);
|
|
if (!ports) {
|
|
const catalog = structuredClone(componentCatalog);
|
|
catalog.libraries.flatMap((library) => library.components)
|
|
.find((component) => component.type === "generic_sensor")!.ports = [];
|
|
await page.route("**/api/components/catalog", (route) => route.fulfill({
|
|
contentType: "application/json", body: JSON.stringify(catalog),
|
|
}));
|
|
}
|
|
const nodes = [
|
|
{ id: "moving", x: 0, y: 0, rotation: 0 },
|
|
{ id: "reference", x: 400, y: block ? 400 : 210, rotation: rotatedTarget ? 90 : 0 },
|
|
...(block ? [{ id: "partner", x: 0, y: 130, rotation: 0 }] : []),
|
|
].map(({ id, x, y, rotation }) => ({
|
|
...structuredClone(wideProject.nodes[0]), id, position: { x, y },
|
|
data: { ...structuredClone(wideProject.nodes[0].data), label: id, rotation,
|
|
ports: ports ? wideProject.nodes[0].data.ports : [] },
|
|
}));
|
|
await page.addInitScript(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), {
|
|
key: KEY, project: { ...structuredClone(wideProject), nodes, edges: block ? [{
|
|
...wideProject.edges[0], source: "moving", target: "partner",
|
|
data: { isContactEdge: false, routePoints: [{ x: 200, y: 50 }, { x: 200, y: 170 }] },
|
|
}] : [] },
|
|
});
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas", block ? 3 : 2);
|
|
return {
|
|
moving: page.locator('.react-flow__node[data-id="moving"]'),
|
|
reference: page.locator('.react-flow__node[data-id="reference"]'),
|
|
guides: page.locator(".alignment-guides"),
|
|
};
|
|
}
|
|
|
|
async function bounds(locator: Locator) {
|
|
const box = await locator.boundingBox();
|
|
expect(box).not.toBeNull();
|
|
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
|
|
}
|
|
|
|
async function beginDrag(page: Page, node: Locator) {
|
|
const before = await bounds(node);
|
|
await page.mouse.move(before.cx, before.cy);
|
|
await page.mouse.down();
|
|
// 先跨过 React Flow 的拖拽启动阈值,再按实际抓取偏移定位。
|
|
await page.mouse.move(before.cx, before.cy + 5);
|
|
const after = await bounds(node);
|
|
return { x: before.cx - after.cx, y: before.cy + 5 - after.cy };
|
|
}
|
|
|
|
for (const kind of ["center", "origin", "port"] as const) {
|
|
test(`拖动时显示${kind}辅助线并精确吸附,松手后隐藏,支持撤销`, async ({ page }) => {
|
|
const { moving, reference, guides } = await loadProject(page, kind === "port", kind !== "center");
|
|
const before = await bounds(moving);
|
|
const target = await bounds(reference);
|
|
const portSelector = '.port-handle[data-port-name="port_b"]';
|
|
const sourceAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
|
|
: kind === "center" ? before.cy : before.y;
|
|
const targetAnchor = kind === "port" ? (await bounds(reference.locator(portSelector))).cy
|
|
: kind === "center" ? target.cy : target.y;
|
|
const grab = await beginDrag(page, moving);
|
|
await page.mouse.move(before.cx + grab.x, before.cy + targetAnchor - sourceAnchor + 4 + grab.y, { steps: 8 });
|
|
await expect(guides.locator(`[data-alignment-kind="${kind}"]`)).toBeVisible();
|
|
await expect(guides.locator("text")).toHaveCount(0);
|
|
await expect(guides).toHaveCSS("opacity", "0.5");
|
|
await expect(guides).toHaveCSS("pointer-events", "none");
|
|
const snapped = await bounds(moving);
|
|
const snappedAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
|
|
: kind === "center" ? snapped.cy : snapped.y;
|
|
expect(Math.abs(snappedAnchor - targetAnchor)).toBeLessThan(0.7);
|
|
const line = guides.locator('[data-alignment-axis="y"] line');
|
|
expect(await line.getAttribute("y1")).toBe(await line.getAttribute("y2"));
|
|
await expect(line).toHaveCSS("stroke", "rgb(128, 197, 239)");
|
|
await expect(line).toHaveCSS("stroke-width", "4px");
|
|
await expect(line).toHaveCSS("stroke-dasharray", "none");
|
|
await expect(line).toHaveCSS("vector-effect", "non-scaling-stroke");
|
|
await expect(guides.locator("circle").first()).toHaveAttribute("r", "5");
|
|
await expect(guides.locator("circle").first()).toHaveCSS("fill", "rgb(128, 197, 239)");
|
|
await expect(guides.locator("circle").first()).toHaveCSS("stroke", "none");
|
|
if (kind === "center") await page.screenshot({ path: "test-results/alignment-preview.png" });
|
|
await page.mouse.up();
|
|
await expect(guides).toHaveCount(0);
|
|
expect(Math.abs((await bounds(moving)).y - snapped.y)).toBeLessThan(0.7);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
|
await page.keyboard.press("Control+z");
|
|
expect(Math.abs((await bounds(moving)).y - before.y)).toBeLessThan(0.7);
|
|
});
|
|
}
|
|
|
|
test("离开范围、按 Alt 或关闭自动对齐时自由拖动且不残留辅助线", async ({ page }) => {
|
|
const { moving, reference, guides } = await loadProject(page, false);
|
|
const start = await bounds(moving);
|
|
const target = await bounds(reference);
|
|
const grab = await beginDrag(page, moving);
|
|
await page.mouse.move(start.cx + grab.x, target.cy + 4 + grab.y, { steps: 8 });
|
|
await expect(guides).toBeVisible();
|
|
await page.keyboard.down("Alt");
|
|
await page.mouse.move(start.cx + grab.x, target.cy + 5 + grab.y);
|
|
await expect(guides).toHaveCount(0);
|
|
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(3);
|
|
await page.keyboard.up("Alt");
|
|
await page.mouse.move(start.cx + grab.x, target.cy + 3 + grab.y);
|
|
await expect(guides).toBeVisible();
|
|
await page.mouse.move(start.cx + grab.x, target.cy + 35 + grab.y);
|
|
await expect(guides).toHaveCount(0);
|
|
await page.mouse.up();
|
|
await page.getByRole("button", { name: "关闭自动对齐(拖动时 Alt 暂停)", exact: true }).click();
|
|
const current = await bounds(moving);
|
|
const nextGrab = await beginDrag(page, moving);
|
|
await page.mouse.move(current.cx + nextGrab.x, target.cy + 4 + nextGrab.y, { steps: 5 });
|
|
await expect(guides).toHaveCount(0);
|
|
await page.mouse.up();
|
|
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(2);
|
|
});
|
|
|
|
test("多选块对齐保持相对位置,内部折线路径整体平移,保存重载仍一致", async ({ page }) => {
|
|
const { moving, reference, guides } = await loadProject(page, true, false, true);
|
|
const partner = page.locator('.react-flow__node[data-id="partner"]');
|
|
const before = await bounds(moving);
|
|
const partnerBefore = await bounds(partner);
|
|
const target = await bounds(reference);
|
|
await moving.locator(".sim-node").click();
|
|
await partner.locator(".sim-node").click({ modifiers: ["Control"] });
|
|
const grab = await beginDrag(page, moving);
|
|
await page.mouse.move(target.cx + 4 + grab.x, before.cy + grab.y, { steps: 8 });
|
|
await expect(guides.locator('[data-alignment-axis="x"]')).toBeVisible();
|
|
const moved = await bounds(moving);
|
|
const partnerMoved = await bounds(partner);
|
|
expect(Math.abs(moved.cx - target.cx)).toBeLessThan(0.7);
|
|
expect(Math.abs(partnerMoved.x - moved.x - (partnerBefore.x - before.x))).toBeLessThan(0.7);
|
|
expect(Math.abs(partnerMoved.y - moved.y - (partnerBefore.y - before.y))).toBeLessThan(0.7);
|
|
await page.mouse.up();
|
|
await expect(guides).toHaveCount(0);
|
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
|
const project = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)!), KEY);
|
|
const first = project.nodes.find((node: { id: string }) => node.id === "moving");
|
|
const second = project.nodes.find((node: { id: string }) => node.id === "partner");
|
|
expect(first.position.x).toBeCloseTo(400, 1);
|
|
expect(second.position.x).toBeCloseTo(first.position.x, 3);
|
|
expect(second.position.y - first.position.y).toBeCloseTo(130, 3);
|
|
expect(project.edges[0].data.routePoints).toEqual([
|
|
{ x: 200 + first.position.x, y: 50 + first.position.y },
|
|
{ x: 200 + first.position.x, y: 170 + first.position.y },
|
|
]);
|
|
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas", 3);
|
|
expect(Math.abs((await bounds(moving)).cx - (await bounds(reference)).cx)).toBeLessThan(0.7);
|
|
});
|
|
|
|
test("接触端口吸附优先,显示端口辅助线,只有落下后才建立连接", async ({ page }) => {
|
|
const { moving, reference, guides } = await loadProject(page, true);
|
|
const start = await bounds(moving);
|
|
const source = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
|
|
const target = await bounds(reference.locator('.port-handle[data-port-name="port_a"]'));
|
|
const grab = await beginDrag(page, moving);
|
|
await page.mouse.move(start.cx + target.cx - source.cx - 5 + grab.x,
|
|
start.cy + target.cy - source.cy + 4 + grab.y, { steps: 12 });
|
|
await expect(guides.locator('[data-alignment-kind="port"]')).toHaveCount(2);
|
|
await expect(guides.locator("text")).toHaveCount(0);
|
|
await expect(guides).toHaveCSS("opacity", "0.5");
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
|
await page.mouse.up();
|
|
await expect(guides).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
|
const connectedSource = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
|
|
expect(Math.hypot(connectedSource.cx - target.cx, connectedSource.cy - target.cy)).toBeLessThan(0.7);
|
|
});
|