236 lines
10 KiB
TypeScript
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);
|
|
});
|