357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import {
|
|
expectAllNodesInsideCanvas,
|
|
prepareApp,
|
|
resultSnapshot,
|
|
wideProject,
|
|
} from "./fixtures";
|
|
|
|
async function readModelingViewport(page: Page) {
|
|
return page
|
|
.locator(".flow-canvas .react-flow__viewport")
|
|
.evaluate((viewport) => {
|
|
const values =
|
|
getComputedStyle(viewport)
|
|
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
|
|
?.map(Number) ?? [];
|
|
return {
|
|
x: values[4] ?? 0,
|
|
y: values[5] ?? 0,
|
|
zoom: values[0] ?? 1,
|
|
};
|
|
});
|
|
}
|
|
|
|
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
|
await prepareApp(page);
|
|
await page.goto("/");
|
|
|
|
const viewport = page.locator(".flow-canvas .react-flow__viewport");
|
|
await expect(page.locator(".flow-canvas .react-flow__controls")).toHaveCount(0);
|
|
await expect(page.locator(".flow-canvas .react-flow__attribution")).toHaveCount(0);
|
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
|
const paneBox = await pane.boundingBox();
|
|
expect(paneBox).not.toBeNull();
|
|
await page.mouse.move(
|
|
paneBox!.x + paneBox!.width / 2,
|
|
paneBox!.y + paneBox!.height / 2,
|
|
);
|
|
await page.keyboard.down("Control");
|
|
await page.mouse.wheel(0, 240);
|
|
await page.keyboard.up("Control");
|
|
await page.waitForTimeout(250);
|
|
const viewportBeforeDrop = await viewport.getAttribute("style");
|
|
expect(viewportBeforeDrop).toBeTruthy();
|
|
|
|
await page
|
|
.getByRole("button", { name: "通用测试元件", exact: true })
|
|
.dragTo(page.locator(".flow-canvas .react-flow__pane"));
|
|
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1);
|
|
await page.waitForTimeout(250);
|
|
await expect(viewport).toHaveAttribute("style", viewportBeforeDrop!);
|
|
|
|
await page.getByRole("button", { name: "适配视图", exact: true }).click();
|
|
await expect
|
|
.poll(() => viewport.getAttribute("style"))
|
|
.not.toBe(viewportBeforeDrop);
|
|
});
|
|
|
|
test("普通滚轮纵向平移,Ctrl 滚轮缩放且滚动条和中键拖动保持同步", async ({
|
|
page,
|
|
}) => {
|
|
await prepareApp(page);
|
|
await page.goto("/");
|
|
|
|
const canvas = page.locator(".flow-canvas");
|
|
const pane = canvas.locator(".react-flow__pane");
|
|
const horizontalScrollbar = page.getByRole("scrollbar", {
|
|
name: "水平滚动画布",
|
|
});
|
|
const verticalScrollbar = page.getByRole("scrollbar", {
|
|
name: "垂直滚动画布",
|
|
});
|
|
await expect(horizontalScrollbar).toBeVisible();
|
|
await expect(verticalScrollbar).toBeVisible();
|
|
|
|
const horizontalBox = await horizontalScrollbar.boundingBox();
|
|
const verticalBox = await verticalScrollbar.boundingBox();
|
|
expect(horizontalBox).not.toBeNull();
|
|
expect(verticalBox).not.toBeNull();
|
|
expect(horizontalBox!.height).toBeGreaterThanOrEqual(8);
|
|
expect(horizontalBox!.height).toBeLessThanOrEqual(12);
|
|
expect(verticalBox!.width).toBeGreaterThanOrEqual(8);
|
|
expect(verticalBox!.width).toBeLessThanOrEqual(12);
|
|
|
|
const paneBox = await pane.boundingBox();
|
|
expect(paneBox).not.toBeNull();
|
|
const pointer = {
|
|
x: paneBox!.x + paneBox!.width * 0.55,
|
|
y: paneBox!.y + paneBox!.height * 0.45,
|
|
};
|
|
await page.mouse.move(pointer.x, pointer.y);
|
|
|
|
const beforeWheel = await readModelingViewport(page);
|
|
const beforeVerticalScroll = await verticalScrollbar.evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
await page.mouse.wheel(0, 180);
|
|
await expect
|
|
.poll(async () => (await readModelingViewport(page)).y)
|
|
.not.toBeCloseTo(beforeWheel.y, 1);
|
|
const afterWheel = await readModelingViewport(page);
|
|
expect(afterWheel.x).toBeCloseTo(beforeWheel.x, 1);
|
|
expect(afterWheel.zoom).toBeCloseTo(beforeWheel.zoom, 3);
|
|
await expect
|
|
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
|
|
.not.toBeCloseTo(beforeVerticalScroll, 1);
|
|
|
|
const beforeZoom = await readModelingViewport(page);
|
|
const horizontalBeforeZoom = await horizontalScrollbar.evaluate(
|
|
(element) => element.scrollLeft,
|
|
);
|
|
const verticalBeforeZoom = await verticalScrollbar.evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
await page.keyboard.down("Control");
|
|
await page.mouse.wheel(0, -180);
|
|
await page.keyboard.up("Control");
|
|
await expect
|
|
.poll(async () => (await readModelingViewport(page)).zoom)
|
|
.not.toBeCloseTo(beforeZoom.zoom, 2);
|
|
const horizontalAfterZoom = await horizontalScrollbar.evaluate(
|
|
(element) => element.scrollLeft,
|
|
);
|
|
const verticalAfterZoom = await verticalScrollbar.evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
expect(
|
|
Math.hypot(
|
|
horizontalAfterZoom - horizontalBeforeZoom,
|
|
verticalAfterZoom - verticalBeforeZoom,
|
|
),
|
|
).toBeGreaterThan(1);
|
|
|
|
const beforeMiddleDrag = await readModelingViewport(page);
|
|
await page.mouse.move(pointer.x, pointer.y);
|
|
await page.mouse.down({ button: "middle" });
|
|
await page.mouse.move(pointer.x + 70, pointer.y + 45, { steps: 4 });
|
|
await page.mouse.up({ button: "middle" });
|
|
await expect
|
|
.poll(async () => {
|
|
const next = await readModelingViewport(page);
|
|
return Math.hypot(next.x - beforeMiddleDrag.x, next.y - beforeMiddleDrag.y);
|
|
})
|
|
.toBeGreaterThan(20);
|
|
const afterMiddleDrag = await readModelingViewport(page);
|
|
expect(afterMiddleDrag.zoom).toBeCloseTo(beforeMiddleDrag.zoom, 3);
|
|
await expect(canvas.locator(".react-flow__selection")).toHaveCount(0);
|
|
|
|
await verticalScrollbar.focus();
|
|
const beforeKeyboardScroll = await verticalScrollbar.evaluate(
|
|
(element) => element.scrollTop,
|
|
);
|
|
await page.keyboard.press("ArrowDown");
|
|
await expect
|
|
.poll(() => verticalScrollbar.evaluate((element) => element.scrollTop))
|
|
.toBeGreaterThan(beforeKeyboardScroll);
|
|
|
|
const beforeScrollbarDrag = await readModelingViewport(page);
|
|
await horizontalScrollbar.evaluate((element) => {
|
|
element.scrollLeft += 120;
|
|
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
});
|
|
await expect
|
|
.poll(async () => (await readModelingViewport(page)).x)
|
|
.not.toBeCloseTo(beforeScrollbarDrag.x, 1);
|
|
const afterScrollbarDrag = await readModelingViewport(page);
|
|
expect(afterScrollbarDrag.y).toBeCloseTo(beforeScrollbarDrag.y, 1);
|
|
expect(afterScrollbarDrag.zoom).toBeCloseTo(beforeScrollbarDrag.zoom, 3);
|
|
|
|
const horizontalMaximum = await horizontalScrollbar.evaluate(
|
|
(element) => element.scrollWidth - element.clientWidth,
|
|
);
|
|
const verticalMaximum = await verticalScrollbar.evaluate(
|
|
(element) => element.scrollHeight - element.clientHeight,
|
|
);
|
|
await expect(horizontalScrollbar).toHaveAttribute(
|
|
"aria-valuemax",
|
|
String(Math.round(horizontalMaximum)),
|
|
);
|
|
await expect(verticalScrollbar).toHaveAttribute(
|
|
"aria-valuemax",
|
|
String(Math.round(verticalMaximum)),
|
|
);
|
|
});
|
|
|
|
test("加载本地工程后自动适配建模画布", async ({ page }) => {
|
|
await prepareApp(page);
|
|
await page.addInitScript((project) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(project),
|
|
);
|
|
}, wideProject);
|
|
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程" }).click();
|
|
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
|
const consolePanel = page.getByRole("complementary", {
|
|
name: "仿真控制台",
|
|
exact: true,
|
|
});
|
|
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
|
|
"工程已加载",
|
|
);
|
|
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(consolePanel).toContainText("工程已加载");
|
|
});
|
|
|
|
test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
|
|
await prepareApp(page);
|
|
await page.goto("/");
|
|
|
|
await page.locator('input[type="file"]').setInputFiles({
|
|
name: "wide-project.json",
|
|
mimeType: "application/json",
|
|
buffer: Buffer.from(JSON.stringify(wideProject)),
|
|
});
|
|
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
|
});
|
|
|
|
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
|
|
await prepareApp(page);
|
|
await page.addInitScript((project) => {
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:autosave",
|
|
JSON.stringify(project),
|
|
);
|
|
}, wideProject);
|
|
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "恢复" }).click();
|
|
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
|
const consolePanel = page.getByRole("complementary", {
|
|
name: "仿真控制台",
|
|
exact: true,
|
|
});
|
|
await expect(consolePanel.locator(".simulation-console-dock-summary")).toContainText(
|
|
"已恢复上次自动保存的工程",
|
|
);
|
|
await expect(consolePanel.locator(".simulation-console-dock-log")).toHaveCount(0);
|
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
|
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
|
|
});
|
|
|
|
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|
await prepareApp(page);
|
|
await page.addInitScript(({ project, snapshot }) => {
|
|
window.sessionStorage.setItem(
|
|
"system-simulation-flow:latest-result",
|
|
JSON.stringify(snapshot),
|
|
);
|
|
window.localStorage.setItem(
|
|
"system-simulation-flow:project:demo-system",
|
|
JSON.stringify(project),
|
|
);
|
|
}, { project: wideProject, snapshot: resultSnapshot });
|
|
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "加载工程" }).click();
|
|
const freeModelingHandle = page.locator(
|
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
|
|
);
|
|
await freeModelingHandle.dispatchEvent("click");
|
|
await expect(freeModelingHandle).toHaveAttribute(
|
|
"data-connection-state",
|
|
"origin",
|
|
);
|
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
|
await expect(
|
|
page.locator(".results-system-canvas .react-flow__attribution"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toHaveCount(0);
|
|
|
|
await page.getByRole("tab", { name: "建模" }).click();
|
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
|
await expect(freeModelingHandle).toHaveAttribute(
|
|
"data-connection-state",
|
|
"idle",
|
|
);
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toBeVisible();
|
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
|
await expect(
|
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
|
|
wideProject.edges.length,
|
|
);
|
|
const connectedResultHandles = page.locator(
|
|
'.results-system-canvas .port-handle[data-connected="true"]',
|
|
);
|
|
const idleResultHandles = page.locator(
|
|
'.results-system-canvas .port-handle[data-connected="false"]',
|
|
);
|
|
await expect(connectedResultHandles).toHaveCount(
|
|
wideProject.edges.length * 2,
|
|
);
|
|
await expect(idleResultHandles).toHaveCount(2);
|
|
await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0");
|
|
await expect(
|
|
idleResultHandles.locator('[data-port-glyph-shape="circle"]'),
|
|
).toHaveCount(2);
|
|
const resultViewportZoom = await page
|
|
.locator(".results-system-canvas .react-flow__viewport")
|
|
.evaluate(
|
|
(element) =>
|
|
new DOMMatrixReadOnly(getComputedStyle(element).transform).a,
|
|
);
|
|
const idleResultPortShapes = await idleResultHandles.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const bounds = element.getBoundingClientRect();
|
|
const glyphBounds = element
|
|
.querySelector<SVGSVGElement>(".port-handle-glyph")
|
|
?.getBoundingClientRect();
|
|
const circle = element.querySelector<SVGCircleElement>(
|
|
'[data-port-glyph-shape="circle"]',
|
|
);
|
|
return {
|
|
circle: circle
|
|
? {
|
|
cx: circle.cx.baseVal.value,
|
|
cy: circle.cy.baseVal.value,
|
|
r: circle.r.baseVal.value,
|
|
}
|
|
: null,
|
|
glyphHeight: glyphBounds?.height ?? null,
|
|
glyphWidth: glyphBounds?.width ?? null,
|
|
height: bounds.height,
|
|
width: bounds.width,
|
|
};
|
|
}),
|
|
);
|
|
for (const shape of idleResultPortShapes) {
|
|
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
|
|
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
|
|
expect(shape.glyphWidth).not.toBeNull();
|
|
expect(shape.glyphHeight).not.toBeNull();
|
|
expect(
|
|
Math.abs(shape.glyphWidth! - 6 * resultViewportZoom),
|
|
).toBeLessThan(0.1);
|
|
expect(
|
|
Math.abs(shape.glyphHeight! - 6 * resultViewportZoom),
|
|
).toBeLessThan(0.1);
|
|
expect(Math.abs(shape.glyphWidth! - shape.glyphHeight!)).toBeLessThan(0.01);
|
|
expect(shape.circle).toEqual({ cx: 3, cy: 3, r: 2.5 });
|
|
}
|
|
});
|