Files
SystemSimulationApp/frontend/tests/e2e/vertical-mirror.spec.ts

236 lines
10 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
import { componentCatalog, prepareApp, wideProject } from "./fixtures";
const VERTICAL_MIRROR = "竖直镜像 (Shift+V)";
const horizontalMirror = (page: Page) => page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true });
const verticalMirror = (page: Page) => page.getByRole("button", { name: VERTICAL_MIRROR, exact: true });
const nodeById = (page: Page, id: string) => page.locator(`.flow-canvas .react-flow__node[data-id="${id}"]`);
const extraPort = { ...wideProject.nodes[0].data.ports[0], name: "port_c" };
function mirrorProject(rotation = 0, mirrored = false) {
return {
...structuredClone(wideProject),
name: "vertical-mirror",
nodes: wideProject.nodes.slice(0, 2).map((node, index) => ({
...structuredClone(node),
position: { x: index * 420, y: index * 240 },
data: {
...structuredClone(node.data),
ports: [...node.data.ports, extraPort],
rotation: index === 0 ? rotation : 90,
mirrored: index === 0 ? mirrored : true,
},
})),
edges: [{
id: "mirror-edge",
source: "generic_sensor_1",
target: "generic_sensor_2",
sourceHandle: "port_a",
targetHandle: "port_b",
data: { isContactEdge: false },
}],
};
}
test.beforeEach(async ({ page }) => {
await prepareApp(page);
// Two left ports make the component asymmetric about its horizontal centerline.
const catalog = {
...componentCatalog,
libraries: componentCatalog.libraries.map((library) => ({
...library,
components: library.components.map((component) => component.type === "generic_sensor"
? { ...component, ports: [...component.ports, extraPort] }
: component),
})),
};
await page.route("**/api/components/catalog", (route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(catalog),
}));
});
async function importMirrorProject(page: Page, project: ReturnType<typeof mirrorProject>) {
await page.goto("/");
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({
name: "vertical-mirror.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(project)),
});
await expect(nodeById(page, "generic_sensor_1")).toBeVisible();
}
async function saveProject(page: Page) {
await page.getByRole("button", { name: "保存工程", exact: true }).click();
return page.evaluate(() => JSON.parse(window.localStorage.getItem(
"system-simulation-flow:autosave",
)!)) as Promise<ReturnType<typeof mirrorProject>>;
}
async function nodeGeometry(node: Locator) {
return node.evaluate((element) => {
const body = element.querySelector<HTMLElement>(".sim-node")!;
const frame = element.querySelector<HTMLElement>(".sim-node-frame")!;
const matrix = new DOMMatrixReadOnly(getComputedStyle(frame).transform);
const width = Number(body.dataset.nodeLayoutWidth);
const height = Number(body.dataset.nodeLayoutHeight);
return {
width,
height,
matrix: { a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d },
ports: Array.from(element.querySelectorAll<HTMLElement>(".port-handle")).map((port) => ({
name: port.dataset.handleid,
x: parseFloat(port.style.left) - width / 2,
y: parseFloat(port.style.top) - height / 2,
})),
};
});
}
async function expectEdgeAttached(page: Page) {
await expect.poll(() => page.evaluate(() => {
const path = document.querySelector<SVGPathElement>(
'.flow-canvas .react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path',
);
const handle = document.querySelector<HTMLElement>(
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-handleid="port_a"]',
);
const matrix = path?.getScreenCTM();
if (!path || !handle || !matrix) return Infinity;
const point = path.getPointAtLength(0).matrixTransform(matrix);
const bounds = handle.getBoundingClientRect();
// React Flow attaches the path to the outer edge of a Handle, not its center.
const x = handle.classList.contains("react-flow__handle-left")
? bounds.left
: handle.classList.contains("react-flow__handle-right")
? bounds.right
: bounds.left + bounds.width / 2;
const y = handle.classList.contains("react-flow__handle-top")
? bounds.top
: handle.classList.contains("react-flow__handle-bottom")
? bounds.bottom
: bounds.top + bounds.height / 2;
return Math.hypot(point.x - x, point.y - y);
})).toBeLessThan(1);
}
for (const rotation of [0, 90, 180, 270]) {
for (const mirrored of [false, true]) {
test(`竖直镜像始终上下翻转画布组件与端口:旋转 ${rotation}°,镜像 ${mirrored}`, async ({ page }) => {
const project = mirrorProject(rotation, mirrored);
await importMirrorProject(page, project);
await expect(verticalMirror(page)).toBeDisabled();
const first = nodeById(page, "generic_sensor_1");
const second = nodeById(page, "generic_sensor_2");
await first.click();
await expect(verticalMirror(page)).toBeEnabled();
const [horizontalBox, verticalBox] = await Promise.all([
horizontalMirror(page).boundingBox(), verticalMirror(page).boundingBox(),
]);
expect(verticalBox!.x).toBeGreaterThan(horizontalBox!.x);
expect(verticalBox!.x - horizontalBox!.x - horizontalBox!.width).toBeLessThan(16);
const before = await nodeGeometry(first);
const otherBefore = await nodeGeometry(second);
const path = page.locator('.react-flow__edge[data-id="mirror-edge"] .react-flow__edge-path');
const pathBefore = await path.getAttribute("d");
await verticalMirror(page).click();
const after = await nodeGeometry(first);
expect(after.width).toBe(before.width);
expect(after.height).toBe(before.height);
expect(after.matrix.a).toBeCloseTo(before.matrix.a, 8);
expect(after.matrix.b).toBeCloseTo(-before.matrix.b, 8);
expect(after.matrix.c).toBeCloseTo(before.matrix.c, 8);
expect(after.matrix.d).toBeCloseTo(-before.matrix.d, 8);
after.ports.forEach((port, index) => {
expect(port.x).toBeCloseTo(before.ports[index].x, 6);
expect(port.y).toBeCloseTo(-before.ports[index].y, 6);
});
expect(await nodeGeometry(second)).toEqual(otherBefore);
await expect(path).not.toHaveAttribute("d", pathBefore!);
await expectEdgeAttached(page);
const saved = await saveProject(page);
expect(saved.nodes[0].data.rotation).toBe((540 - rotation) % 360);
expect(saved.nodes[0].data.mirrored).toBe(!mirrored);
expect(saved.nodes[0].position).toEqual(project.nodes[0].position);
expect(saved.nodes[0].data.parameters).toEqual(project.nodes[0].data.parameters);
expect(saved.edges[0]).toMatchObject(project.edges[0]);
await verticalMirror(page).click();
expect(await nodeGeometry(first)).toEqual(before);
await expectEdgeAttached(page);
});
}
}
test("竖直镜像支持混合方向多选、快捷键、撤销重做和工程加载", async ({ page }) => {
const project = mirrorProject();
await importMirrorProject(page, project);
await nodeById(page, "generic_sensor_1").click();
await page.keyboard.press("Control+a");
await expect(page.locator(".flow-canvas .react-flow__node.selected")).toHaveCount(2);
await page.keyboard.press("Shift+v");
const mirrored = await saveProject(page);
expect(mirrored.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[180, true], [90, false],
]);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
let saved = await saveProject(page);
expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[0, false], [90, true],
]);
await page.getByRole("button", { name: "重做 (Ctrl+Y)", exact: true }).click();
saved = await saveProject(page);
expect(saved.nodes).toEqual(mirrored.nodes);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
expect((await saveProject(page)).nodes).toEqual(mirrored.nodes);
await nodeById(page, "generic_sensor_1").click();
await page.keyboard.press("Control+a");
await verticalMirror(page).click();
saved = await saveProject(page);
expect(saved.nodes.map((node) => [node.data.rotation, node.data.mirrored])).toEqual([
[0, false], [90, true],
]);
expect(saved.nodes.map((node) => node.position)).toEqual(project.nodes.map((node) => node.position));
await expectEdgeAttached(page);
});
test("竖直镜像使接触端口分离时断开连接,撤销恢复端口接触", async ({ page }) => {
const project = mirrorProject();
project.nodes[0].position = { x: 300, y: 200 };
project.nodes[1].data.rotation = 0;
project.nodes[1].data.mirrored = false;
await importMirrorProject(page, project);
const sourceGeometry = await nodeGeometry(nodeById(page, "generic_sensor_1"));
const targetGeometry = await nodeGeometry(nodeById(page, "generic_sensor_2"));
const sourcePort = sourceGeometry.ports.find((port) => port.name === "port_a")!;
const targetPort = targetGeometry.ports.find((port) => port.name === "port_b")!;
project.nodes[1].position = {
x: project.nodes[0].position.x + sourceGeometry.width / 2 + sourcePort.x
- targetGeometry.width / 2 - targetPort.x,
y: project.nodes[0].position.y + sourceGeometry.height / 2 + sourcePort.y
- targetGeometry.height / 2 - targetPort.y,
};
project.edges[0].data.isContactEdge = true;
await page.locator('input[type="file"]').setInputFiles({
name: "vertical-mirror.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(project)),
});
const source = nodeById(page, "generic_sensor_1");
const handle = source.locator('.port-handle[data-handleid="port_a"]');
await expect(handle).toHaveAttribute("data-connected", "true");
const before = await saveProject(page);
expect(before.edges).toHaveLength(1);
expect(before.edges[0].data.isContactEdge).toBe(true);
await source.click();
await verticalMirror(page).click();
await expect(handle).toHaveAttribute("data-connected", "false");
expect((await saveProject(page)).edges).toHaveLength(0);
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
await expect(handle).toHaveAttribute("data-connected", "true");
const restored = await saveProject(page);
expect(restored.nodes).toEqual(before.nodes);
expect(restored.edges).toEqual(before.edges);
});