C内核流量计算方法优化,前端文件名称读取优化
This commit is contained in:
1 parent
5d5a2e1843
commit
808c484f5b
94 files changed
+20163
-4418
No files matched your search
+64
-4
@@ -29,6 +29,7 @@ import {
|
||||
FileDown,
|
||||
FileUp,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
FolderOpen,
|
||||
Grip,
|
||||
ListChecks,
|
||||
@@ -5950,6 +5951,50 @@ function FlowWorkbench() {
|
||||
);
|
||||
}, [appendConsoleEntry, recordHistory, selectedNodeId, setEdges, setNodes]);
|
||||
|
||||
const mirrorSelectionVertically = useCallback(() => {
|
||||
const selectedIds = new Set(
|
||||
nodesRef.current
|
||||
.filter((node) => node.selected || node.id === selectedNodeId)
|
||||
.map((node) => node.id),
|
||||
);
|
||||
if (selectedIds.size === 0) {
|
||||
appendConsoleEntry("warning", "请先选择需要镜像的组件");
|
||||
return;
|
||||
}
|
||||
recordHistory();
|
||||
const previousNodes = nodesRef.current;
|
||||
const mirroredNodes = previousNodes.map((node) =>
|
||||
selectedIds.has(node.id)
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
// Reflect in canvas coordinates: Sy R(theta) Sx(m) = R(180-theta) Sx(!m).
|
||||
rotation: normalizeNodeRotation((540 - normalizeNodeRotation(node.data.rotation)) % 360),
|
||||
mirrored: !node.data.mirrored,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
);
|
||||
const { remainingEdges, removedEdges } = disconnectInvalidatedContactEdges(
|
||||
previousNodes,
|
||||
mirroredNodes,
|
||||
edgesRef.current,
|
||||
selectedIds,
|
||||
);
|
||||
nodesRef.current = mirroredNodes;
|
||||
edgesRef.current = remainingEdges;
|
||||
setNodes(mirroredNodes);
|
||||
if (removedEdges.length > 0) {
|
||||
setEdges(remainingEdges);
|
||||
appendConsoleEntry(
|
||||
"info",
|
||||
`镜像使接口分离,已断开 ${removedEdges.length} 个接触连接`,
|
||||
);
|
||||
}
|
||||
appendConsoleEntry("info", `已竖直镜像 ${selectedIds.size} 个组件`);
|
||||
}, [appendConsoleEntry, recordHistory, selectedNodeId, setEdges, setNodes]);
|
||||
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange<SimulationNode>[]) => {
|
||||
const removesNodes = changes.some((change) => change.type === "remove");
|
||||
@@ -7391,7 +7436,7 @@ function FlowWorkbench() {
|
||||
const loadedEdges = edgeNormalization.edges;
|
||||
setNodes(loadedNodes);
|
||||
setEdges(loadedEdges);
|
||||
setProjectName(project.name || "untitled");
|
||||
setProjectName(project.name.trim() || "untitled");
|
||||
setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation });
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
@@ -7472,7 +7517,8 @@ function FlowWorkbench() {
|
||||
return;
|
||||
}
|
||||
applyProject(
|
||||
project,
|
||||
// Older entries may still contain a name that differs from their storage key.
|
||||
{ ...project, name },
|
||||
`工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -7505,7 +7551,11 @@ function FlowWorkbench() {
|
||||
if (!project) {
|
||||
throw new Error("文件不是有效的 ReactFlow 工程 JSON");
|
||||
}
|
||||
applyProject(project, `已导入工程:${file.name}`);
|
||||
const importedName = file.name.trim().replace(/\.json$/i, "").trim();
|
||||
applyProject(
|
||||
{ ...project, name: importedName || project.name.trim() || "untitled" },
|
||||
`已导入工程:${file.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
appendConsoleEntry("error", `导入失败:${formatError(error)}`);
|
||||
}
|
||||
@@ -8058,7 +8108,7 @@ function FlowWorkbench() {
|
||||
}
|
||||
if (
|
||||
pendingPasteRef.current &&
|
||||
((event.shiftKey && !commandKey && !event.altKey && key === "h") ||
|
||||
((event.shiftKey && !commandKey && !event.altKey && ["h", "v"].includes(key)) ||
|
||||
(commandKey && ["a", "c", "v", "y", "z"].includes(key)))
|
||||
) {
|
||||
event.preventDefault();
|
||||
@@ -8067,6 +8117,9 @@ function FlowWorkbench() {
|
||||
if (event.shiftKey && !commandKey && !event.altKey && key === "h") {
|
||||
event.preventDefault();
|
||||
mirrorSelection();
|
||||
} else if (event.shiftKey && !commandKey && !event.altKey && key === "v") {
|
||||
event.preventDefault();
|
||||
mirrorSelectionVertically();
|
||||
} else if (commandKey && key === "z") {
|
||||
event.preventDefault();
|
||||
event.shiftKey ? redo() : undo();
|
||||
@@ -8093,6 +8146,7 @@ function FlowWorkbench() {
|
||||
cancelPortConnection,
|
||||
copySelection,
|
||||
mirrorSelection,
|
||||
mirrorSelectionVertically,
|
||||
pasteSelection,
|
||||
redo,
|
||||
rotatePendingPaste,
|
||||
@@ -8753,6 +8807,12 @@ function FlowWorkbench() {
|
||||
onClick={mirrorSelection}
|
||||
pressed={allSelectedNodesMirrored}
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={Boolean(pendingPaste) || selectedNodesForTools.length === 0}
|
||||
icon={FlipVertical2}
|
||||
label="竖直镜像 (Shift+V)"
|
||||
onClick={mirrorSelectionVertically}
|
||||
/>
|
||||
</div>
|
||||
<span aria-hidden="true" className="toolbar-separator" />
|
||||
<div aria-label="画布显示" className="toolbar-group" role="group">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
createOrthogonalSegmentDetour,
|
||||
edgeSegmentAxis,
|
||||
moveOrthogonalCorner,
|
||||
moveOrthogonalSegment,
|
||||
orthogonalEdgePoints,
|
||||
orthogonalPolylinePath,
|
||||
@@ -25,7 +24,7 @@ export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
||||
type RouteDrag = {
|
||||
basePoints: EdgeRoutePoint[];
|
||||
cleanup: () => void;
|
||||
kind: "corner" | "detour" | "segment";
|
||||
kind: "detour" | "segment";
|
||||
pointerId: number;
|
||||
routeIndex: number;
|
||||
startPointer: EdgeRoutePoint;
|
||||
@@ -122,19 +121,17 @@ function RoutedEdge({
|
||||
y: pointer.y - drag.startPointer.y,
|
||||
};
|
||||
const nextPoints =
|
||||
drag.kind === "corner"
|
||||
? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta)
|
||||
: drag.kind === "detour"
|
||||
? createOrthogonalSegmentDetour(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
)
|
||||
: moveOrthogonalSegment(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
);
|
||||
drag.kind === "detour"
|
||||
? createOrthogonalSegmentDetour(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
)
|
||||
: moveOrthogonalSegment(
|
||||
drag.basePoints,
|
||||
drag.routeIndex,
|
||||
delta,
|
||||
);
|
||||
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
|
||||
};
|
||||
const finishRoute = (pointerEvent: PointerEvent) => {
|
||||
@@ -166,12 +163,15 @@ function RoutedEdge({
|
||||
.slice(0, -1)
|
||||
.map((start, index) => ({ end: points[index + 1], index, start }))
|
||||
.filter(({ index }) => index > 0 && index < points.length - 2);
|
||||
const internalCorners = points
|
||||
.map((point, index) => ({ index, point }))
|
||||
.filter(({ index }) => index > 0 && index < points.length - 1);
|
||||
const fallbackIndex = longestSegmentIndex(points);
|
||||
const fallbackStart = points[fallbackIndex];
|
||||
const fallbackEnd = points[fallbackIndex + 1];
|
||||
// Straight and L-shaped routes need a detour before an internal segment exists.
|
||||
const draggableSegments =
|
||||
internalSegments.length > 0
|
||||
? internalSegments
|
||||
: fallbackEnd
|
||||
? [{ end: fallbackEnd, index: fallbackIndex, start: points[fallbackIndex] }]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -184,7 +184,7 @@ function RoutedEdge({
|
||||
style={style}
|
||||
/>
|
||||
{editable
|
||||
? internalSegments.map(({ end, index, start }) => (
|
||||
? draggableSegments.map(({ end, index, start }) => (
|
||||
<g
|
||||
className="manual-edge-segment-control"
|
||||
key={`${id}-segment-${index}`}
|
||||
@@ -195,7 +195,12 @@ function RoutedEdge({
|
||||
data-edge-id={id}
|
||||
data-segment-index={index}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "segment", index)
|
||||
beginRouteDrag(
|
||||
event,
|
||||
points,
|
||||
internalSegments.length > 0 ? "segment" : "detour",
|
||||
index,
|
||||
)
|
||||
}
|
||||
x1={start.x}
|
||||
x2={end.x}
|
||||
@@ -213,36 +218,6 @@ function RoutedEdge({
|
||||
</g>
|
||||
))
|
||||
: null}
|
||||
{editable
|
||||
? internalCorners.map(({ index, point }) => (
|
||||
<circle
|
||||
aria-label={`双向调整连接线拐点 ${index}`}
|
||||
className="manual-edge-corner-handle"
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
data-corner-index={index}
|
||||
data-edge-id={id}
|
||||
key={`${id}-corner-${index}`}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "corner", index)
|
||||
}
|
||||
r={4.5}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{editable && internalCorners.length === 0 && fallbackEnd ? (
|
||||
<circle
|
||||
aria-label="双向调整连接线路由"
|
||||
className="manual-edge-route-handle"
|
||||
cx={(fallbackStart.x + fallbackEnd.x) / 2}
|
||||
cy={(fallbackStart.y + fallbackEnd.y) / 2}
|
||||
data-edge-id={id}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "detour", fallbackIndex)
|
||||
}
|
||||
r={5}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1781,7 +1781,7 @@ export function SimulationResultsView({
|
||||
</strong>
|
||||
<span>
|
||||
{isCombined
|
||||
? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}`
|
||||
? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : selectedUnit.label || "无量纲"}`
|
||||
: componentName}
|
||||
{separatedEventSampleCount > 0
|
||||
? ` · 已隐藏 ${separatedEventSampleCount} 个孤立事件力尖峰`
|
||||
@@ -1910,7 +1910,7 @@ export function SimulationResultsView({
|
||||
<strong>
|
||||
{isMixed
|
||||
? "多单位"
|
||||
: variable.unit || "无量纲"}
|
||||
: selectedUnit.label || "无量纲"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="result-chart-multi-menu-list">
|
||||
@@ -1930,11 +1930,12 @@ export function SimulationResultsView({
|
||||
);
|
||||
const itemUnitOptions =
|
||||
resultUnitOptions(item);
|
||||
const itemUnit = resolveResultUnitOption(
|
||||
item,
|
||||
chartWindow.variableUnits[item.key] ??
|
||||
item.unit,
|
||||
);
|
||||
const itemUnit = isMixed
|
||||
? resolveResultUnitOption(
|
||||
item,
|
||||
chartWindow.variableUnits[item.key] ?? item.unit,
|
||||
)
|
||||
: selectedUnit;
|
||||
return (
|
||||
<div
|
||||
className="result-chart-multi-menu-row"
|
||||
|
||||
@@ -246,8 +246,9 @@ export const PN3NODE2_PORT_ANCHORS = {
|
||||
export const P4NODE2_PORT_ANCHORS = {
|
||||
port1: { x: 32, y: 11.2 },
|
||||
port2: { x: 19.2, y: 24 },
|
||||
port3: { x: 44.8, y: 24 },
|
||||
port4: { x: 32, y: 36.8 },
|
||||
// AME P4NODE2 geometry 1 has port 1 opposite port 3 and port 2 opposite port 4.
|
||||
port3: { x: 32, y: 36.8 },
|
||||
port4: { x: 44.8, y: 24 },
|
||||
} as const;
|
||||
|
||||
/** Three-port pneumatic junction with one inlet-side and two outlet-side ports. */
|
||||
|
||||
@@ -459,97 +459,6 @@ export function moveOrthogonalSegment(
|
||||
return simplifyOrthogonalPoints(nextPoints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an internal bend in both axes while preserving an orthogonal route.
|
||||
* Moving the bend also slides its two neighbouring bends along their existing
|
||||
* perpendicular legs, which keeps the two live port endpoints fixed.
|
||||
*/
|
||||
export function moveOrthogonalCorner(
|
||||
points: EdgeRoutePoint[],
|
||||
cornerIndex: number,
|
||||
delta: EdgeRoutePoint,
|
||||
) {
|
||||
const nextPoints = points.map((point) => ({ ...point }));
|
||||
if (cornerIndex <= 0 || cornerIndex >= nextPoints.length - 1) {
|
||||
return nextPoints;
|
||||
}
|
||||
const previous = nextPoints[cornerIndex - 1];
|
||||
const corner = nextPoints[cornerIndex];
|
||||
const incomingAxis = edgeSegmentAxis(previous, corner);
|
||||
const outgoingAxis = edgeSegmentAxis(
|
||||
corner,
|
||||
nextPoints[cornerIndex + 1],
|
||||
);
|
||||
|
||||
corner.x += delta.x;
|
||||
corner.y += delta.y;
|
||||
|
||||
if (cornerIndex > 1) {
|
||||
if (incomingAxis === "horizontal") {
|
||||
previous.y = corner.y;
|
||||
} else {
|
||||
previous.x = corner.x;
|
||||
}
|
||||
} else if (incomingAxis === "horizontal") {
|
||||
const middleX =
|
||||
Math.abs(previous.x - corner.x) > ROUTE_EPSILON
|
||||
? (previous.x + corner.x) / 2
|
||||
: previous.x + 20;
|
||||
nextPoints.splice(
|
||||
cornerIndex,
|
||||
0,
|
||||
{ x: middleX, y: previous.y },
|
||||
{ x: middleX, y: corner.y },
|
||||
);
|
||||
cornerIndex += 2;
|
||||
} else {
|
||||
const middleY =
|
||||
Math.abs(previous.y - corner.y) > ROUTE_EPSILON
|
||||
? (previous.y + corner.y) / 2
|
||||
: previous.y + 20;
|
||||
nextPoints.splice(
|
||||
cornerIndex,
|
||||
0,
|
||||
{ x: previous.x, y: middleY },
|
||||
{ x: corner.x, y: middleY },
|
||||
);
|
||||
cornerIndex += 2;
|
||||
}
|
||||
|
||||
const movedCorner = nextPoints[cornerIndex];
|
||||
const following = nextPoints[cornerIndex + 1];
|
||||
if (cornerIndex < nextPoints.length - 2) {
|
||||
if (outgoingAxis === "horizontal") {
|
||||
following.y = movedCorner.y;
|
||||
} else {
|
||||
following.x = movedCorner.x;
|
||||
}
|
||||
} else if (outgoingAxis === "horizontal") {
|
||||
const middleX =
|
||||
Math.abs(movedCorner.x - following.x) > ROUTE_EPSILON
|
||||
? (movedCorner.x + following.x) / 2
|
||||
: following.x - 20;
|
||||
nextPoints.splice(
|
||||
cornerIndex + 1,
|
||||
0,
|
||||
{ x: middleX, y: movedCorner.y },
|
||||
{ x: middleX, y: following.y },
|
||||
);
|
||||
} else {
|
||||
const middleY =
|
||||
Math.abs(movedCorner.y - following.y) > ROUTE_EPSILON
|
||||
? (movedCorner.y + following.y) / 2
|
||||
: following.y - 20;
|
||||
nextPoints.splice(
|
||||
cornerIndex + 1,
|
||||
0,
|
||||
{ x: movedCorner.x, y: middleY },
|
||||
{ x: following.x, y: middleY },
|
||||
);
|
||||
}
|
||||
return simplifyOrthogonalPoints(nextPoints);
|
||||
}
|
||||
|
||||
/** Creates a movable dogleg when a selected route is still a straight line. */
|
||||
export function createOrthogonalSegmentDetour(
|
||||
points: EdgeRoutePoint[],
|
||||
|
||||
@@ -3767,23 +3767,6 @@ textarea {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.manual-edge-route-handle,
|
||||
.manual-edge-corner-handle {
|
||||
fill: #ffffff;
|
||||
stroke: #1675c1;
|
||||
stroke-width: 2;
|
||||
cursor: move;
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.manual-edge-corner-handle:hover,
|
||||
.manual-edge-route-handle:hover {
|
||||
fill: #d9edff;
|
||||
stroke: #0f5f9f;
|
||||
}
|
||||
|
||||
.flow-canvas.connection-planning .react-flow__pane {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { expandSimulationConsole } from "./fixtures";
|
||||
|
||||
@@ -2439,8 +2440,8 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
|
||||
anchors: [
|
||||
{ portName: "port_1", x: 32, y: 11.2 },
|
||||
{ portName: "port_2", x: 19.2, y: 24 },
|
||||
{ portName: "port_3", x: 44.8, y: 24 },
|
||||
{ portName: "port_4", x: 32, y: 36.8 },
|
||||
{ portName: "port_3", x: 32, y: 36.8 },
|
||||
{ portName: "port_4", x: 44.8, y: 24 },
|
||||
],
|
||||
center: { x: 32, y: 24 },
|
||||
finePath: "M32 11.2V36.8M32 24H44.8M19.2 24H22.1",
|
||||
@@ -2448,8 +2449,8 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
|
||||
positions: {
|
||||
port_1: "top",
|
||||
port_2: "left",
|
||||
port_3: "right",
|
||||
port_4: "bottom",
|
||||
port_3: "bottom",
|
||||
port_4: "right",
|
||||
},
|
||||
thickPath: "M22.1 24H32",
|
||||
},
|
||||
@@ -2505,6 +2506,57 @@ test("PN3NODE2 与 P4NODE2 使用参考图直线汇流结构且粗线对应 port
|
||||
}
|
||||
});
|
||||
|
||||
test("P4NODE2 的真实 Handle 在 180° 时匹配 AME 四口方位,镜像后参考粗线仍指向 port_2", async ({ page }) => {
|
||||
const source = JSON.parse(readFileSync(new URL(
|
||||
"../../../tests/model_audit/p4node2-ame-geometry.json", import.meta.url,
|
||||
), "utf8")) as {
|
||||
components: Array<{ geometry: number; ports: Record<string, { x: number; y: number }> }>;
|
||||
};
|
||||
// Independent AME COMP_PORT/PORT_POS coordinates, rather than frontend anchors.
|
||||
const amePorts = source.components[0].ports;
|
||||
for (const component of source.components) {
|
||||
expect(component.geometry).toBe(1);
|
||||
expect(component.ports).toEqual(amePorts);
|
||||
}
|
||||
const ameCenter = Object.values(amePorts).reduce(
|
||||
(sum, port) => ({ x: sum.x + port.x / 4, y: sum.y + port.y / 4 }),
|
||||
{ x: 0, y: 0 },
|
||||
);
|
||||
await page.goto("/");
|
||||
const node = await dragSymbolToCanvas(page, "amesim_p4node2", { x: 460, y: 320 });
|
||||
const rotate = page.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true });
|
||||
await rotate.click();
|
||||
await rotate.click();
|
||||
const svg = node.locator('[data-component-symbol="amesim_p4node2"] svg');
|
||||
const getDirection = async (portName: string) => {
|
||||
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
|
||||
const handle = await locatorCenter(node.locator(`.port-handle[data-port-name="${portName}"]`));
|
||||
const length = Math.hypot(handle.x - center.x, handle.y - center.y);
|
||||
expect(length).toBeGreaterThan(1);
|
||||
return { x: (handle.x - center.x) / length, y: (handle.y - center.y) / length };
|
||||
};
|
||||
for (const [portName, coordinate] of Object.entries(amePorts)) {
|
||||
const length = Math.hypot(coordinate.x - ameCenter.x, coordinate.y - ameCenter.y);
|
||||
await expect.poll(async () => {
|
||||
const actual = await getDirection(portName);
|
||||
return Math.hypot(actual.x - (coordinate.x - ameCenter.x) / length,
|
||||
actual.y - (coordinate.y - ameCenter.y) / length);
|
||||
}).toBeLessThan(0.02);
|
||||
}
|
||||
const expectReferenceStroke = async () => {
|
||||
const center = await locatorCenter(svg.locator('[data-junction-part="center"]'));
|
||||
const stroke = await locatorCenter(svg.locator('[data-junction-part="collector-branch"]'));
|
||||
const reference = await getDirection("port_2");
|
||||
const length = Math.hypot(stroke.x - center.x, stroke.y - center.y);
|
||||
expect((stroke.x - center.x) * reference.x + (stroke.y - center.y) * reference.y).toBeGreaterThan(0);
|
||||
expect(Math.abs((stroke.x - center.x) * reference.y - (stroke.y - center.y) * reference.x) / length).toBeLessThan(0.02);
|
||||
};
|
||||
await expectReferenceStroke();
|
||||
await page.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true }).click();
|
||||
await expect.poll(async () => (await getDirection("port_2")).x).toBeLessThan(-0.99);
|
||||
await expectReferenceStroke();
|
||||
});
|
||||
|
||||
test("PNL00R 与 PNL0001-0003 使用 standard 画布和 60% 工作区占比", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -272,7 +272,7 @@ test("复制块保留内部连接,同时未连接端口仍参与待放置吸
|
||||
await expect(targetPort).toHaveAttribute("data-connected", "true");
|
||||
});
|
||||
|
||||
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
|
||||
test("选中正交连接线不显示拐点,仍可拖动线段、撤销并保存跨线桥路由", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = {
|
||||
@@ -342,6 +342,7 @@ test("正交连接线可拖动内部线段,且无连接交叉点显示跨线
|
||||
.locator(".react-flow__edge-interaction")
|
||||
.dispatchEvent("click");
|
||||
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
||||
await expect(horizontalEdge.locator("circle")).toHaveCount(0);
|
||||
const horizontalSegment = horizontalEdge
|
||||
.locator(".manual-edge-segment-handle.horizontal")
|
||||
.first();
|
||||
@@ -404,125 +405,21 @@ test("正交连接线可拖动内部线段,且无连接交叉点显示跨线
|
||||
pathBeforeVerticalDrag ?? "",
|
||||
);
|
||||
|
||||
const cornerHandle = horizontalEdge.locator(
|
||||
'.manual-edge-corner-handle[data-corner-index="2"]',
|
||||
);
|
||||
await expect(cornerHandle).toHaveCount(1);
|
||||
const cornerBefore = await cornerHandle.evaluate(
|
||||
(circle: SVGCircleElement) => ({
|
||||
flowX: circle.cx.baseVal.value,
|
||||
flowY: circle.cy.baseVal.value,
|
||||
screen: (() => {
|
||||
const matrix = circle.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取连接线拐点坐标");
|
||||
}
|
||||
const point = new DOMPoint(
|
||||
circle.cx.baseVal.value,
|
||||
circle.cy.baseVal.value,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
})(),
|
||||
}),
|
||||
);
|
||||
const pathBeforeCornerDrag = await horizontalPath.getAttribute("d");
|
||||
await page.mouse.move(cornerBefore.screen.x, cornerBefore.screen.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
cornerBefore.screen.x + 32,
|
||||
cornerBefore.screen.y + 24,
|
||||
{ steps: 6 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(horizontalPath).not.toHaveAttribute(
|
||||
"d",
|
||||
pathBeforeCornerDrag ?? "",
|
||||
);
|
||||
const cornerAfter = await cornerHandle.evaluate(
|
||||
(circle: SVGCircleElement) => ({
|
||||
flowX: circle.cx.baseVal.value,
|
||||
flowY: circle.cy.baseVal.value,
|
||||
}),
|
||||
);
|
||||
expect(Math.abs(cornerAfter.flowX - cornerBefore.flowX)).toBeGreaterThan(1);
|
||||
expect(Math.abs(cornerAfter.flowY - cornerBefore.flowY)).toBeGreaterThan(1);
|
||||
|
||||
const pathAfterSegmentDrags = await horizontalPath.getAttribute("d");
|
||||
await expect(horizontalEdge.locator("circle")).toHaveCount(0);
|
||||
await page.keyboard.press("Control+z");
|
||||
await expect(horizontalPath).toHaveAttribute(
|
||||
"d",
|
||||
pathBeforeCornerDrag ?? "",
|
||||
pathBeforeVerticalDrag ?? "",
|
||||
);
|
||||
await horizontalEdge
|
||||
.locator(".react-flow__edge-interaction")
|
||||
.dispatchEvent("click");
|
||||
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
||||
|
||||
const boundaryCorner = horizontalEdge.locator(
|
||||
'.manual-edge-corner-handle[data-corner-index="1"]',
|
||||
);
|
||||
await expect(boundaryCorner).toHaveCount(1);
|
||||
const boundaryStart = await boundaryCorner.evaluate(
|
||||
(circle: SVGCircleElement) => {
|
||||
const matrix = circle.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取端点相邻拐点坐标");
|
||||
}
|
||||
const point = new DOMPoint(
|
||||
circle.cx.baseVal.value,
|
||||
circle.cy.baseVal.value,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
},
|
||||
);
|
||||
const boundaryPathLocator = horizontalEdge.locator(".react-flow__edge-path");
|
||||
const boundaryPathBefore = await boundaryPathLocator.getAttribute("d");
|
||||
const boundaryTarget = {
|
||||
x: boundaryStart.x + 38,
|
||||
y: boundaryStart.y + 30,
|
||||
};
|
||||
await page.mouse.move(boundaryStart.x, boundaryStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
(boundaryStart.x + boundaryTarget.x) / 2,
|
||||
(boundaryStart.y + boundaryTarget.y) / 2,
|
||||
{ steps: 4 },
|
||||
);
|
||||
await page.mouse.move(boundaryTarget.x, boundaryTarget.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await expect(boundaryPathLocator).not.toHaveAttribute(
|
||||
await page.keyboard.press("Control+y");
|
||||
await expect(horizontalPath).toHaveAttribute(
|
||||
"d",
|
||||
boundaryPathBefore ?? "",
|
||||
pathAfterSegmentDrags ?? "",
|
||||
);
|
||||
await expect(boundaryPathLocator).toHaveAttribute("d", / Q /);
|
||||
|
||||
const cornerScreenPoints = await horizontalEdge
|
||||
.locator(".manual-edge-corner-handle")
|
||||
.evaluateAll((circles: SVGCircleElement[]) =>
|
||||
circles.map((circle) => {
|
||||
const matrix = circle.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取调整后的拐点坐标");
|
||||
}
|
||||
const point = new DOMPoint(
|
||||
circle.cx.baseVal.value,
|
||||
circle.cy.baseVal.value,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
}),
|
||||
);
|
||||
const closestCornerDistance = Math.min(
|
||||
...cornerScreenPoints.map((point) =>
|
||||
Math.hypot(point.x - boundaryTarget.x, point.y - boundaryTarget.y),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
closestCornerDistance,
|
||||
JSON.stringify({ boundaryStart, boundaryTarget, cornerScreenPoints }),
|
||||
).toBeLessThan(3);
|
||||
|
||||
const boundaryPathAfter = await boundaryPathLocator.getAttribute("d");
|
||||
const linePoints = [
|
||||
...(boundaryPathAfter ?? "").matchAll(
|
||||
...(pathAfterSegmentDrags ?? "").matchAll(
|
||||
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||
),
|
||||
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||
@@ -623,110 +520,127 @@ test("缩小画布后连接线端点仍与端口图标边缘对齐", async ({ pa
|
||||
expect(alignment!.targetGapY).toBeLessThan(0.75);
|
||||
});
|
||||
|
||||
test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = {
|
||||
...structuredClone(wideProject),
|
||||
nodes: [
|
||||
projectNode("straight-source", 0, 260),
|
||||
projectNode("straight-target", 700, 260),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "straight-edge",
|
||||
source: "straight-source",
|
||||
target: "straight-target",
|
||||
sourceHandle: "port_b",
|
||||
targetHandle: "port_a",
|
||||
data: { isContactEdge: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
await page.addInitScript((storedProject) => {
|
||||
window.localStorage.setItem(
|
||||
"system-simulation-flow:project:demo-system",
|
||||
JSON.stringify(storedProject),
|
||||
for (const route of [
|
||||
{ label: "直线", targetY: 260, targetRotation: 0, pointCount: 2 },
|
||||
{ label: "L 型连接线", targetY: 480, targetRotation: 90, pointCount: 3 },
|
||||
]) {
|
||||
test(`${route.label}可直接拖动线条,首次折弯后仍连续且保持正交`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = {
|
||||
...structuredClone(wideProject),
|
||||
nodes: [
|
||||
projectNode("straight-source", 0, 260),
|
||||
{
|
||||
...projectNode("straight-target", 700, route.targetY),
|
||||
data: {
|
||||
...projectNode("straight-target", 700, route.targetY).data,
|
||||
rotation: route.targetRotation,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "straight-edge",
|
||||
source: "straight-source",
|
||||
target: "straight-target",
|
||||
sourceHandle: "port_b",
|
||||
targetHandle: "port_a",
|
||||
data: { isContactEdge: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
await page.addInitScript((storedProject) => {
|
||||
window.localStorage.setItem(
|
||||
"system-simulation-flow:project:demo-system",
|
||||
JSON.stringify(storedProject),
|
||||
);
|
||||
}, project);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
|
||||
const edge = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
|
||||
);
|
||||
}, project);
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
|
||||
const edge = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
|
||||
);
|
||||
const edgePath = edge.locator(".react-flow__edge-path");
|
||||
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
|
||||
const routeHandle = edge.locator(".manual-edge-route-handle");
|
||||
await expect(routeHandle).toHaveCount(1);
|
||||
const dragStart = await routeHandle.evaluate((circle: SVGCircleElement) => {
|
||||
const matrix = circle.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取直线路由手柄坐标");
|
||||
}
|
||||
const point = new DOMPoint(
|
||||
circle.cx.baseVal.value,
|
||||
circle.cy.baseVal.value,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
const pathBefore = await edgePath.getAttribute("d");
|
||||
await page.mouse.move(dragStart.x, dragStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
|
||||
|
||||
await expect(routeHandle).toHaveCount(0);
|
||||
const verticalSegment = edge
|
||||
.locator(".manual-edge-segment-handle.vertical")
|
||||
.first();
|
||||
await expect(verticalSegment).toHaveCount(1);
|
||||
const segmentXAtHalfMove = await verticalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const edgePath = edge.locator(".react-flow__edge-path");
|
||||
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
|
||||
await expect(edge.locator("circle")).toHaveCount(0);
|
||||
const routeHandle = edge.locator(
|
||||
'.manual-edge-segment-handle[data-segment-index="0"]',
|
||||
);
|
||||
await expect(routeHandle).toHaveCount(1);
|
||||
await expect(routeHandle).toHaveCSS("pointer-events", "stroke");
|
||||
const dragStart = await routeHandle.evaluate((line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取首次折弯后的竖直线段坐标");
|
||||
throw new Error("无法获取待拖动连接线段坐标");
|
||||
}
|
||||
return new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix).x;
|
||||
},
|
||||
);
|
||||
const point = new DOMPoint(
|
||||
line.x1.baseVal.value * 0.75 + line.x2.baseVal.value * 0.25,
|
||||
line.y1.baseVal.value * 0.75 + line.y2.baseVal.value * 0.25,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
const pathBefore = await edgePath.getAttribute("d");
|
||||
expect([...(pathBefore ?? "").matchAll(/[ML]\s/g)]).toHaveLength(route.pointCount);
|
||||
await page.mouse.move(dragStart.x, dragStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
|
||||
|
||||
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
|
||||
const segmentXAtFullMove = await verticalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取连续拖动后的竖直线段坐标");
|
||||
}
|
||||
return new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix).x;
|
||||
},
|
||||
);
|
||||
await page.mouse.up();
|
||||
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
|
||||
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
|
||||
await expect(
|
||||
edge.locator(".manual-edge-segment-handle.horizontal").first(),
|
||||
).toHaveCount(1);
|
||||
await expect(routeHandle).toHaveCount(0);
|
||||
const verticalSegment = edge
|
||||
.locator(".manual-edge-segment-handle.vertical")
|
||||
.first();
|
||||
await expect(verticalSegment).toHaveCount(1);
|
||||
const segmentXAtHalfMove = await verticalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取首次折弯后的竖直线段坐标");
|
||||
}
|
||||
return new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix).x;
|
||||
},
|
||||
);
|
||||
|
||||
const routeAfter = await edgePath.getAttribute("d");
|
||||
const routePoints = [
|
||||
...(routeAfter ?? "").matchAll(
|
||||
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||
),
|
||||
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||
routePoints.slice(1).forEach((point, index) => {
|
||||
const previous = routePoints[index];
|
||||
expect(
|
||||
Math.abs(point.x - previous.x) < 0.01 ||
|
||||
Math.abs(point.y - previous.y) < 0.01,
|
||||
).toBe(true);
|
||||
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
|
||||
const segmentXAtFullMove = await verticalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取连续拖动后的竖直线段坐标");
|
||||
}
|
||||
return new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix).x;
|
||||
},
|
||||
);
|
||||
await page.mouse.up();
|
||||
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
|
||||
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
|
||||
await expect(edge.locator("circle")).toHaveCount(0);
|
||||
await expect(
|
||||
edge.locator(".manual-edge-segment-handle.horizontal").first(),
|
||||
).toHaveCount(1);
|
||||
|
||||
const routeAfter = await edgePath.getAttribute("d");
|
||||
const routePoints = [
|
||||
...(routeAfter ?? "").matchAll(
|
||||
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||
),
|
||||
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||
routePoints.slice(1).forEach((point, index) => {
|
||||
const previous = routePoints[index];
|
||||
expect(
|
||||
Math.abs(point.x - previous.x) < 0.01 ||
|
||||
Math.abs(point.y - previous.y) < 0.01,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("同一组件不同端口发出的线路在远端交叉时仍显示跨线桥", async ({
|
||||
page,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
expectAllNodesInsideCanvas,
|
||||
@@ -22,12 +22,9 @@ type ImportedProject = {
|
||||
edges: ImportedProjectEdge[];
|
||||
};
|
||||
|
||||
const MQL_8_PROJECT_PATHS = [
|
||||
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
|
||||
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
||||
].map(fileURLToPath);
|
||||
const MQL_8_PROJECT_PATH =
|
||||
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
|
||||
const MQL_8_PROJECT_PATH = fileURLToPath(
|
||||
new URL("../../../tests/fixtures/legacy/test-mql-8.json", import.meta.url),
|
||||
);
|
||||
const mql8Project = JSON.parse(
|
||||
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
|
||||
) as ImportedProject;
|
||||
|
||||
@@ -210,8 +210,8 @@ test("组件参数和仿真设置使用共享的三列表格", async ({ page })
|
||||
const solver = page.getByLabel("求解器", { exact: true });
|
||||
await expect(solver).toHaveCSS("text-align", "right");
|
||||
await expect(solver).toHaveCSS("text-align-last", "right");
|
||||
await solver.selectOption("Radau");
|
||||
await expect(solver).toHaveValue("Radau");
|
||||
await solver.selectOption("RK45");
|
||||
await expect(solver).toHaveValue("RK45");
|
||||
});
|
||||
|
||||
test("通用离散选项按多条件控制参数行且保留隐藏值和未知旧值", async ({
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expandSimulationConsole, prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
const componentId = "amesim_pneumatic_test_1";
|
||||
const pressureInput = (page: Page) =>
|
||||
page.getByLabel("参考压力", { exact: true });
|
||||
const pressureUnit = (page: Page) =>
|
||||
page.getByLabel("参考压力单位", { exact: true });
|
||||
const selectPressureComponent = (page: Page) =>
|
||||
page.locator(`.flow-canvas .react-flow__node[data-id="${componentId}"]`).click();
|
||||
|
||||
type ExportedProject = {
|
||||
nodes: Array<{
|
||||
data: {
|
||||
parameters: Record<string, number | string>;
|
||||
parameterUnits: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
async function exportProject(page: Page) {
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
if (!stream) throw new Error("无法读取导出的工程 JSON");
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
const buffer = Buffer.concat(chunks);
|
||||
return {
|
||||
buffer,
|
||||
project: JSON.parse(buffer.toString("utf8")) as ExportedProject,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectPressureXml(page: Page, expectedValue: number) {
|
||||
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||
await expandSimulationConsole(page);
|
||||
await expect(page.getByLabel("生成的系统 XML").last()).toContainText(
|
||||
`<Parameter name="reference_pressure" value="${expectedValue}"`,
|
||||
);
|
||||
}
|
||||
|
||||
async function addPressureComponent(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: "AMESim 气动测试元件", exact: true })
|
||||
.dragTo(page.locator(".flow-canvas .react-flow__pane"), {
|
||||
targetPosition: { x: 620, y: 330 },
|
||||
});
|
||||
await selectPressureComponent(page);
|
||||
}
|
||||
|
||||
function legacyPressureProject(value: number | string, unit?: string) {
|
||||
return {
|
||||
...wideProject,
|
||||
nodes: [{
|
||||
...wideProject.nodes[0],
|
||||
id: componentId,
|
||||
data: {
|
||||
...wideProject.nodes[0].data,
|
||||
label: componentId,
|
||||
componentType: "amesim_pneumatic_test",
|
||||
modelType: "amesim_pneumatic_test",
|
||||
parameters: {
|
||||
gi: 0,
|
||||
reference_pressure: value,
|
||||
reference_temperature: 300,
|
||||
},
|
||||
parameterUnits: unit ? { reference_pressure: unit } : undefined,
|
||||
},
|
||||
}],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "AMESim 气动测试元件", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("压力默认显示 Pa,bar 与 Pa 换算保持绝压且无大气压偏移", async ({ page }) => {
|
||||
await addPressureComponent(page);
|
||||
await expect(pressureUnit(page)).toHaveValue("Pa");
|
||||
await expect(pressureInput(page)).toHaveValue("100000");
|
||||
await expect(pressureUnit(page).locator('option[value="barA"]')).toHaveCount(0);
|
||||
await pressureUnit(page).selectOption("bar");
|
||||
await expect(pressureInput(page)).toHaveValue("1");
|
||||
await pressureInput(page).fill("2.5");
|
||||
await pressureInput(page).press("Enter");
|
||||
await expectPressureXml(page, 250000);
|
||||
|
||||
for (const { unit, value } of [
|
||||
{ unit: "Pa", value: "250000" },
|
||||
{ unit: "kPa", value: "250" },
|
||||
{ unit: "MPa", value: "0.25" },
|
||||
{ unit: "bar", value: "2.5" },
|
||||
]) {
|
||||
await pressureUnit(page).selectOption(unit);
|
||||
await expect(pressureInput(page)).toHaveValue(value);
|
||||
await expectPressureXml(page, 250000);
|
||||
}
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
|
||||
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
|
||||
});
|
||||
|
||||
test("bar 压力公式在保存、加载和 JSON 导入导出后保留原式与单位", async ({ page }) => {
|
||||
await addPressureComponent(page);
|
||||
await pressureUnit(page).selectOption("bar");
|
||||
const expression = "1+1.5";
|
||||
await pressureInput(page).fill(expression);
|
||||
await pressureInput(page).press("Enter");
|
||||
await expect(pressureUnit(page)).toHaveValue("bar");
|
||||
await expectPressureXml(page, 250000);
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
|
||||
await pressureInput(page).fill("7");
|
||||
await pressureInput(page).press("Enter");
|
||||
await pressureUnit(page).selectOption("kPa");
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await selectPressureComponent(page);
|
||||
await expect(pressureInput(page)).toHaveValue(expression);
|
||||
await expect(pressureUnit(page)).toHaveValue("bar");
|
||||
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(expression);
|
||||
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
|
||||
await pressureInput(page).fill("9");
|
||||
await pressureInput(page).press("Enter");
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "pressure-expression.json",
|
||||
mimeType: "application/json",
|
||||
buffer: exported.buffer,
|
||||
});
|
||||
// Wait for the imported project before selecting its component.
|
||||
await expect(page.getByRole("textbox", { name: "工程", exact: true })).toHaveValue(
|
||||
"pressure-expression",
|
||||
);
|
||||
await selectPressureComponent(page);
|
||||
await expect(pressureInput(page)).toHaveValue(expression);
|
||||
await expect(pressureUnit(page)).toHaveValue("bar");
|
||||
await expectPressureXml(page, 250000);
|
||||
});
|
||||
|
||||
for (const { storedUnit, displayedUnit, displayedValue } of [
|
||||
{ storedUnit: "Pa", displayedUnit: "Pa", displayedValue: "250000" },
|
||||
{ storedUnit: undefined, displayedUnit: "Pa", displayedValue: "250000" },
|
||||
]) {
|
||||
test(`旧 JSON 数值参数单位 ${storedUnit ?? "未记录"} 加载为 ${displayedUnit},SI 数值不变`, async ({ page }) => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "legacy-pressure.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(legacyPressureProject(250000, storedUnit))),
|
||||
});
|
||||
await selectPressureComponent(page);
|
||||
await expect(pressureUnit(page)).toHaveValue(displayedUnit);
|
||||
await expect(pressureInput(page)).toHaveValue(displayedValue);
|
||||
await expectPressureXml(page, 250000);
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
|
||||
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe(displayedUnit);
|
||||
});
|
||||
}
|
||||
|
||||
test("旧 JSON 公式没有记录单位时沿用原 Pa 含义", async ({ page }) => {
|
||||
const expression = "100000+150000";
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "legacy-pressure-expression.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(legacyPressureProject(expression))),
|
||||
});
|
||||
await selectPressureComponent(page);
|
||||
await expect(pressureUnit(page)).toHaveValue("Pa");
|
||||
await expect(pressureInput(page)).toHaveValue(expression);
|
||||
await expectPressureXml(page, 250000);
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(expression);
|
||||
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("Pa");
|
||||
});
|
||||
|
||||
test("弹性接触的剪切模量 G 保持 Pa 默认单位", async ({ page }) => {
|
||||
await page
|
||||
.getByRole("button", { name: "LSTP00A 弹性接触", exact: true })
|
||||
.dragTo(page.locator(".flow-canvas .react-flow__pane"), {
|
||||
targetPosition: { x: 620, y: 330 },
|
||||
});
|
||||
await page.locator('.flow-canvas .react-flow__node[data-id="amesim_lstp00a_1"]').click();
|
||||
await page.getByLabel("刚度模式", { exact: true }).selectOption("2");
|
||||
await page.getByRole("table", { name: "组件参数" }).getByRole("button", { name: /刚度/ }).click();
|
||||
await expect(page.getByLabel("剪切模量单位", { exact: true })).toHaveValue("Pa");
|
||||
await expect(page.getByLabel("剪切模量", { exact: true })).toHaveValue("85700000000");
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project.nodes[0].data.parameters.G).toBe(8.57e10);
|
||||
expect(exported.project.nodes[0].data.parameterUnits.G).toBe("Pa");
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
const projectNameField = (page: Page) =>
|
||||
page.getByRole("textbox", { name: "工程", exact: true });
|
||||
|
||||
async function exportProject(page: Page) {
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
if (!stream) throw new Error("无法读取导出的工程 JSON");
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
const buffer = Buffer.concat(chunks);
|
||||
return {
|
||||
filename: download.suggestedFilename(),
|
||||
buffer,
|
||||
project: JSON.parse(buffer.toString("utf8")) as typeof wideProject,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFirstNode(page: Page) {
|
||||
await page
|
||||
.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]')
|
||||
.click();
|
||||
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length - 1,
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("外部重命名的工程 JSON 导入后名称一致,保存和刷新后均可加载", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: `${wideProject.name}.json`,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(wideProject)),
|
||||
});
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
const original = await exportProject(page);
|
||||
expect(original.filename).toBe(`${wideProject.name}.json`);
|
||||
expect(original.project.name).toBe(wideProject.name);
|
||||
|
||||
// Only the external filename changes; the exported JSON still has its old name.
|
||||
const renamedProject = "气动 回路.v2";
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: `${renamedProject}.JSON`,
|
||||
mimeType: "application/json",
|
||||
buffer: original.buffer,
|
||||
});
|
||||
await expect(projectNameField(page)).toHaveValue(renamedProject);
|
||||
const renamed = await exportProject(page);
|
||||
expect(renamed.filename).toBe(`${renamedProject}.json`);
|
||||
expect(renamed.project.name).toBe(renamedProject);
|
||||
expect(renamed.project.nodes).toEqual(original.project.nodes);
|
||||
expect(renamed.project.simulation).toEqual(original.project.simulation);
|
||||
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const saved = await page.evaluate((name) => {
|
||||
const raw = localStorage.getItem(
|
||||
`system-simulation-flow:project:${encodeURIComponent(name)}`,
|
||||
);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}, renamedProject);
|
||||
expect(saved).toMatchObject({ name: renamedProject });
|
||||
expect(saved.nodes).toHaveLength(wideProject.nodes.length);
|
||||
|
||||
await deleteFirstNode(page);
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
await expect(projectNameField(page)).toHaveValue(renamedProject);
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "忽略", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(0);
|
||||
await projectNameField(page).fill(renamedProject);
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
await expect(projectNameField(page)).toHaveValue(renamedProject);
|
||||
});
|
||||
|
||||
test("浏览器工程内容保留旧名称时,按存储名称连续加载仍能恢复工程", async ({
|
||||
page,
|
||||
}) => {
|
||||
const storedName = "已重命名 工程.v3";
|
||||
await page.evaluate(
|
||||
({ name, project }) => {
|
||||
localStorage.setItem(
|
||||
`system-simulation-flow:project:${encodeURIComponent(name)}`,
|
||||
JSON.stringify(project),
|
||||
);
|
||||
},
|
||||
{ name: storedName, project: wideProject },
|
||||
);
|
||||
await projectNameField(page).fill(storedName);
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
await expect(projectNameField(page)).toHaveValue(storedName);
|
||||
|
||||
await deleteFirstNode(page);
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
await expect(projectNameField(page)).toHaveValue(storedName);
|
||||
});
|
||||
|
||||
for (const { internalName, expectedName } of [
|
||||
{ internalName: " 内部 工程.v1 ", expectedName: "内部 工程.v1" },
|
||||
{ internalName: " ", expectedName: "untitled" },
|
||||
]) {
|
||||
test(`外部文件名没有基名时,工程名回退为 ${expectedName}`, async ({ page }) => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: " .JSON",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ ...wideProject, name: internalName })),
|
||||
});
|
||||
await expect(projectNameField(page)).toHaveValue(expectedName);
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.filename).toBe(`${expectedName}.json`);
|
||||
expect(exported.project.name).toBe(expectedName);
|
||||
});
|
||||
}
|
||||
@@ -211,6 +211,114 @@ test.beforeEach(async ({ page }) => {
|
||||
}, chartWindowSnapshot);
|
||||
});
|
||||
|
||||
for (const quantity of ["pressure", "absolute_pressure"]) {
|
||||
for (const kind of ["single", "multi", "mixed"] as const) {
|
||||
test(`${quantity} 的 ${kind} 图默认 Pa,绝压换算及单位持久化不改变原始数据`, async ({ page }) => {
|
||||
await page.addInitScript(({ snapshot, pressureQuantity }) => {
|
||||
const pressure = snapshot.result.variables.find(
|
||||
(variable) => variable.key === "generic_sensor_1.pressure",
|
||||
);
|
||||
if (pressure) pressure.quantity = pressureQuantity;
|
||||
window.sessionStorage.setItem(
|
||||
"system-simulation-flow:latest-result",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
}, { snapshot: chartWindowSnapshot, pressureQuantity: quantity });
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
|
||||
const pressureVariable = page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "压力" })
|
||||
.first();
|
||||
await expect(pressureVariable.locator("small")).toHaveText("Pa");
|
||||
const chartWindow = page.locator(
|
||||
`.result-chart-window[data-chart-kind="${kind}"]`,
|
||||
);
|
||||
if (kind === "single") {
|
||||
await pressureVariable.click();
|
||||
} else {
|
||||
await page.getByRole("button", {
|
||||
name: kind === "multi" ? "新建同单位多曲线对比窗口" : "新建异单位上下对比窗口",
|
||||
exact: true,
|
||||
}).click();
|
||||
if (kind === "mixed") {
|
||||
await page.locator(".results-variable-list button")
|
||||
.filter({ hasText: "数值" }).first().dragTo(chartWindow);
|
||||
}
|
||||
await pressureVariable.dragTo(chartWindow);
|
||||
}
|
||||
const chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||
const unitSelect = chartWindow.getByRole("combobox", {
|
||||
name: kind === "mixed"
|
||||
? "generic_sensor_1.pressure纵轴单位"
|
||||
: kind === "multi" ? "多曲线纵轴单位" : "压力纵轴单位",
|
||||
exact: true,
|
||||
});
|
||||
const showUnits = async () => {
|
||||
if (kind === "mixed") {
|
||||
await chartWindow.getByRole("button", {
|
||||
name: "管理多曲线窗口中的曲线", exact: true,
|
||||
}).click();
|
||||
}
|
||||
};
|
||||
const readPressureRange = () => chart.evaluate((element) => {
|
||||
const ranges = element.getAttribute("data-view-y-ranges");
|
||||
return ranges
|
||||
? JSON.parse(ranges)["generic_sensor_1.pressure"] as number[]
|
||||
: [
|
||||
Number(element.getAttribute("data-view-y-min")),
|
||||
Number(element.getAttribute("data-view-y-max")),
|
||||
];
|
||||
});
|
||||
await showUnits();
|
||||
await expect(unitSelect).toHaveValue("Pa");
|
||||
await expect(unitSelect.locator('option[value="barA"]')).toHaveCount(0);
|
||||
await expect(chart.locator(".result-chart-unit")).toContainText(["Pa"]);
|
||||
const pascalRange = await readPressureRange();
|
||||
expect(pascalRange[0]).toBeCloseTo(98400, 6);
|
||||
expect(pascalRange[1]).toBeCloseTo(121600, 6);
|
||||
|
||||
await unitSelect.selectOption("bar");
|
||||
await expect(unitSelect).toHaveValue("bar");
|
||||
const barRange = await readPressureRange();
|
||||
expect(barRange[0]).toBeCloseTo(0.984, 6);
|
||||
expect(barRange[1]).toBeCloseTo(1.216, 6);
|
||||
if (kind === "multi") {
|
||||
await expect(chartWindow.locator(".result-chart-window-title")).toContainText("bar");
|
||||
await chartWindow.getByRole("button", {
|
||||
name: "管理多曲线窗口中的曲线", exact: true,
|
||||
}).click();
|
||||
await expect(chartWindow.locator(".result-chart-multi-menu-label small")).toContainText("bar");
|
||||
}
|
||||
|
||||
const storedPressure = await page.evaluate(() => {
|
||||
const snapshot = JSON.parse(sessionStorage.getItem(
|
||||
"system-simulation-flow:latest-result",
|
||||
) ?? "null");
|
||||
return {
|
||||
values: snapshot.result.series["generic_sensor_1.pressure"],
|
||||
final: snapshot.result.final["generic_sensor_1.pressure"],
|
||||
unit: snapshot.result.variables.find(
|
||||
(variable: { key: string }) => variable.key === "generic_sensor_1.pressure",
|
||||
).unit,
|
||||
};
|
||||
});
|
||||
expect(storedPressure).toEqual({
|
||||
values: [100000, 110000, 120000], final: 120000, unit: "Pa",
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await openResults(page);
|
||||
await showUnits();
|
||||
await expect(unitSelect).toHaveValue("bar");
|
||||
expect(await readPressureRange()).toEqual(barRange);
|
||||
await unitSelect.selectOption("Pa");
|
||||
expect(await readPressureRange()).toEqual(pascalRange);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("机械状态事件力点不参与连续曲线纵轴,但原始数据与非力状态保持不变", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ type BrowserStreamEvidence = {
|
||||
};
|
||||
|
||||
const PROJECT_PATH = fileURLToPath(
|
||||
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
|
||||
new URL("../../../tests/fixtures/legacy/test-mql-8.json", import.meta.url),
|
||||
);
|
||||
const EVIDENCE_PATH = fileURLToPath(
|
||||
new URL(
|
||||
@@ -514,7 +514,7 @@ test("test-mql-8 在真实浏览器流式链路完成 0.2 s BDF 仿真", async (
|
||||
servingMode: "isolated-vite-dev-proxy",
|
||||
},
|
||||
source: {
|
||||
path: "tests/data/test-mql-8.json",
|
||||
path: "tests/fixtures/legacy/test-mql-8.json",
|
||||
bytes: statSync(PROJECT_PATH).size,
|
||||
sha256: createHash("sha256").update(projectBytes).digest("hex"),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
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:project:vertical-mirror",
|
||||
)!)) 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);
|
||||
});
|
||||
Reference in new issue
Block a user