完善通用求解器回归与前端交互
- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
1 parent
143e8dd309
commit
b435daecf2
65 files changed
+172271
-701
No files matched your search
+889
-288
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,59 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
BaseEdge,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
type EdgeProps,
|
||||
type EdgeTypes,
|
||||
} from "@xyflow/react";
|
||||
import {
|
||||
createOrthogonalSegmentDetour,
|
||||
edgeSegmentAxis,
|
||||
moveOrthogonalCorner,
|
||||
moveOrthogonalSegment,
|
||||
orthogonalEdgePoints,
|
||||
orthogonalPolylinePath,
|
||||
type EdgeRouteData,
|
||||
type EdgeRoutePoint,
|
||||
} from "./edgeRouting";
|
||||
|
||||
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
||||
|
||||
export function ContactAwareEdge({
|
||||
type RouteDrag = {
|
||||
basePoints: EdgeRoutePoint[];
|
||||
cleanup: () => void;
|
||||
kind: "corner" | "detour" | "segment";
|
||||
pointerId: number;
|
||||
routeIndex: number;
|
||||
startPointer: EdgeRoutePoint;
|
||||
};
|
||||
|
||||
function longestSegmentIndex(points: EdgeRoutePoint[]) {
|
||||
let bestIndex = 0;
|
||||
let bestLength = -1;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const length = Math.hypot(
|
||||
points[index + 1].x - points[index].x,
|
||||
points[index + 1].y - points[index].y,
|
||||
);
|
||||
if (length > bestLength) {
|
||||
bestIndex = index;
|
||||
bestLength = length;
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function RoutedEdge({
|
||||
data,
|
||||
id,
|
||||
interactionWidth,
|
||||
markerEnd,
|
||||
markerStart,
|
||||
selected,
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -21,32 +62,198 @@ export function ContactAwareEdge({
|
||||
targetX,
|
||||
targetY,
|
||||
}: EdgeProps) {
|
||||
const isContactEdge = data?.isContactEdge === true;
|
||||
if (isContactEdge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
const reactFlow = useReactFlow();
|
||||
const dragRef = useRef<RouteDrag | null>(null);
|
||||
const edgeData = (data ?? {}) as EdgeRouteData;
|
||||
useEffect(
|
||||
() => () => {
|
||||
dragRef.current?.cleanup();
|
||||
dragRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const points = orthogonalEdgePoints({
|
||||
routePoints: edgeData.routePoints,
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
borderRadius: 0,
|
||||
});
|
||||
const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps);
|
||||
|
||||
const beginRouteDrag = (
|
||||
event: ReactPointerEvent<SVGElement>,
|
||||
basePoints: EdgeRoutePoint[],
|
||||
kind: RouteDrag["kind"],
|
||||
routeIndex: number,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragRef.current?.cleanup();
|
||||
|
||||
const drag: RouteDrag = {
|
||||
basePoints: basePoints.map((point) => ({ ...point })),
|
||||
cleanup: () => undefined,
|
||||
kind,
|
||||
pointerId: event.pointerId,
|
||||
routeIndex,
|
||||
startPointer: reactFlow.screenToFlowPosition(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
{ snapToGrid: false },
|
||||
),
|
||||
};
|
||||
const moveRoute = (pointerEvent: PointerEvent) => {
|
||||
if (
|
||||
dragRef.current !== drag ||
|
||||
pointerEvent.pointerId !== drag.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pointerEvent.preventDefault();
|
||||
pointerEvent.stopPropagation();
|
||||
const pointer = reactFlow.screenToFlowPosition(
|
||||
{ x: pointerEvent.clientX, y: pointerEvent.clientY },
|
||||
{ snapToGrid: false },
|
||||
);
|
||||
const delta = {
|
||||
x: pointer.x - drag.startPointer.x,
|
||||
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,
|
||||
);
|
||||
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
|
||||
};
|
||||
const finishRoute = (pointerEvent: PointerEvent) => {
|
||||
if (
|
||||
dragRef.current !== drag ||
|
||||
pointerEvent.pointerId !== drag.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pointerEvent.preventDefault();
|
||||
pointerEvent.stopPropagation();
|
||||
drag.cleanup();
|
||||
dragRef.current = null;
|
||||
};
|
||||
drag.cleanup = () => {
|
||||
window.removeEventListener("pointermove", moveRoute);
|
||||
window.removeEventListener("pointerup", finishRoute);
|
||||
window.removeEventListener("pointercancel", finishRoute);
|
||||
};
|
||||
dragRef.current = drag;
|
||||
window.addEventListener("pointermove", moveRoute, { passive: false });
|
||||
window.addEventListener("pointerup", finishRoute);
|
||||
window.addEventListener("pointercancel", finishRoute);
|
||||
edgeData.onRouteEditStart?.(id);
|
||||
};
|
||||
|
||||
const editable = selected && edgeData.editable === true;
|
||||
const internalSegments = points
|
||||
.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];
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
id={id}
|
||||
interactionWidth={interactionWidth}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
path={edgePath}
|
||||
style={style}
|
||||
/>
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
interactionWidth={interactionWidth}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
path={edgePath}
|
||||
style={style}
|
||||
/>
|
||||
{editable
|
||||
? internalSegments.map(({ end, index, start }) => (
|
||||
<g
|
||||
className="manual-edge-segment-control"
|
||||
key={`${id}-segment-${index}`}
|
||||
>
|
||||
<line
|
||||
aria-label={`调整连接线段 ${index}`}
|
||||
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
|
||||
data-edge-id={id}
|
||||
data-segment-index={index}
|
||||
onPointerDown={(event) =>
|
||||
beginRouteDrag(event, points, "segment", index)
|
||||
}
|
||||
x1={start.x}
|
||||
x2={end.x}
|
||||
y1={start.y}
|
||||
y2={end.y}
|
||||
/>
|
||||
<line
|
||||
aria-hidden="true"
|
||||
className="manual-edge-segment-guide"
|
||||
x1={start.x}
|
||||
x2={end.x}
|
||||
y1={start.y}
|
||||
y2={end.y}
|
||||
/>
|
||||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactAwareEdge(props: EdgeProps) {
|
||||
if (props.data?.isContactEdge === true) {
|
||||
return null;
|
||||
}
|
||||
return <RoutedEdge {...props} />;
|
||||
}
|
||||
|
||||
export const contactAwareEdgeTypes: EdgeTypes = {
|
||||
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
||||
};
|
||||
+1702
-230
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,536 @@
|
||||
import { Position } from "@xyflow/react";
|
||||
|
||||
export type EdgeRoutePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type EdgeCrossingJump = EdgeRoutePoint & {
|
||||
orientation: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
export type EdgeRouteData = {
|
||||
crossingJumps?: EdgeCrossingJump[];
|
||||
editable?: boolean;
|
||||
isContactEdge?: boolean;
|
||||
onRouteEditStart?: (edgeId: string) => void;
|
||||
onRoutePointsChange?: (edgeId: string, routePoints: EdgeRoutePoint[]) => void;
|
||||
routePoints?: EdgeRoutePoint[];
|
||||
};
|
||||
|
||||
export type EdgePointAxis = "horizontal" | "vertical";
|
||||
|
||||
const ROUTE_EPSILON = 0.01;
|
||||
const CROSSING_JUMP_RADIUS = 6;
|
||||
|
||||
export function edgeAxisForPosition(position: Position): EdgePointAxis {
|
||||
return position === Position.Left || position === Position.Right
|
||||
? "horizontal"
|
||||
: "vertical";
|
||||
}
|
||||
|
||||
export function edgePointIsFinite(point: EdgeRoutePoint) {
|
||||
return Number.isFinite(point.x) && Number.isFinite(point.y);
|
||||
}
|
||||
|
||||
export function edgePointsMatch(
|
||||
first: EdgeRoutePoint,
|
||||
second: EdgeRoutePoint,
|
||||
epsilon = ROUTE_EPSILON,
|
||||
) {
|
||||
return (
|
||||
Math.abs(first.x - second.x) <= epsilon &&
|
||||
Math.abs(first.y - second.y) <= epsilon
|
||||
);
|
||||
}
|
||||
|
||||
function appendDistinctPoint(
|
||||
points: EdgeRoutePoint[],
|
||||
point: EdgeRoutePoint,
|
||||
) {
|
||||
if (!edgePointIsFinite(point)) {
|
||||
return;
|
||||
}
|
||||
const previous = points.at(-1);
|
||||
if (!previous || !edgePointsMatch(previous, point)) {
|
||||
points.push({ x: point.x, y: point.y });
|
||||
}
|
||||
}
|
||||
|
||||
export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
|
||||
const distinct: EdgeRoutePoint[] = [];
|
||||
points.forEach((point) => appendDistinctPoint(distinct, point));
|
||||
if (distinct.length <= 2) {
|
||||
return distinct;
|
||||
}
|
||||
const simplified: EdgeRoutePoint[] = [distinct[0]];
|
||||
for (let index = 1; index < distinct.length - 1; index += 1) {
|
||||
const previous = simplified.at(-1) as EdgeRoutePoint;
|
||||
const current = distinct[index];
|
||||
const next = distinct[index + 1];
|
||||
const collinearX =
|
||||
Math.abs(previous.x - current.x) <= ROUTE_EPSILON &&
|
||||
Math.abs(current.x - next.x) <= ROUTE_EPSILON;
|
||||
const collinearY =
|
||||
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
|
||||
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
|
||||
if (!collinearX && !collinearY) {
|
||||
simplified.push(current);
|
||||
}
|
||||
}
|
||||
simplified.push(distinct.at(-1) as EdgeRoutePoint);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
function appendOrthogonalLeg(
|
||||
points: EdgeRoutePoint[],
|
||||
target: EdgeRoutePoint,
|
||||
firstAxis: EdgePointAxis,
|
||||
) {
|
||||
const source = points.at(-1);
|
||||
if (!source || edgePointsMatch(source, target)) {
|
||||
appendDistinctPoint(points, target);
|
||||
return;
|
||||
}
|
||||
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
|
||||
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
|
||||
if (changesX && changesY) {
|
||||
appendDistinctPoint(
|
||||
points,
|
||||
firstAxis === "horizontal"
|
||||
? { x: target.x, y: source.y }
|
||||
: { x: source.x, y: target.y },
|
||||
);
|
||||
}
|
||||
appendDistinctPoint(points, target);
|
||||
}
|
||||
|
||||
function appendEndpointAwareLeg(
|
||||
points: EdgeRoutePoint[],
|
||||
target: EdgeRoutePoint,
|
||||
sourceAxis: EdgePointAxis,
|
||||
targetAxis: EdgePointAxis,
|
||||
) {
|
||||
const source = points.at(-1);
|
||||
if (!source || edgePointsMatch(source, target)) {
|
||||
appendDistinctPoint(points, target);
|
||||
return;
|
||||
}
|
||||
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
|
||||
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
|
||||
if (!changesX || !changesY) {
|
||||
appendDistinctPoint(points, target);
|
||||
return;
|
||||
}
|
||||
if (sourceAxis !== targetAxis) {
|
||||
appendDistinctPoint(
|
||||
points,
|
||||
sourceAxis === "horizontal"
|
||||
? { x: target.x, y: source.y }
|
||||
: { x: source.x, y: target.y },
|
||||
);
|
||||
} else if (sourceAxis === "horizontal") {
|
||||
const middleX = (source.x + target.x) / 2;
|
||||
appendDistinctPoint(points, { x: middleX, y: source.y });
|
||||
appendDistinctPoint(points, { x: middleX, y: target.y });
|
||||
} else {
|
||||
const middleY = (source.y + target.y) / 2;
|
||||
appendDistinctPoint(points, { x: source.x, y: middleY });
|
||||
appendDistinctPoint(points, { x: target.x, y: middleY });
|
||||
}
|
||||
appendDistinctPoint(points, target);
|
||||
}
|
||||
|
||||
/** Builds a persisted orthogonal route through each exact user waypoint. */
|
||||
export function buildOrthogonalRoutePoints(
|
||||
source: EdgeRoutePoint,
|
||||
target: EdgeRoutePoint,
|
||||
sourcePosition: Position,
|
||||
targetPosition: Position,
|
||||
waypoints: EdgeRoutePoint[] = [],
|
||||
) {
|
||||
const sourceAxis = edgeAxisForPosition(sourcePosition);
|
||||
const targetAxis = edgeAxisForPosition(targetPosition);
|
||||
const points: EdgeRoutePoint[] = [{ ...source }];
|
||||
let nextAxis = sourceAxis;
|
||||
waypoints.filter(edgePointIsFinite).forEach((waypoint) => {
|
||||
appendOrthogonalLeg(points, waypoint, nextAxis);
|
||||
nextAxis = nextAxis === "horizontal" ? "vertical" : "horizontal";
|
||||
});
|
||||
appendEndpointAwareLeg(points, target, nextAxis, targetAxis);
|
||||
return simplifyOrthogonalPoints(points).slice(1, -1);
|
||||
}
|
||||
|
||||
/** Returns the rendered polyline, including both live port endpoints. */
|
||||
export function orthogonalEdgePoints({
|
||||
routePoints,
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
}: {
|
||||
routePoints?: EdgeRoutePoint[];
|
||||
sourcePosition: Position;
|
||||
sourceX: number;
|
||||
sourceY: number;
|
||||
targetPosition: Position;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
}) {
|
||||
const source = { x: sourceX, y: sourceY };
|
||||
const target = { x: targetX, y: targetY };
|
||||
const storedPoints = routePoints?.filter(edgePointIsFinite) ?? [];
|
||||
if (storedPoints.length === 0) {
|
||||
return [
|
||||
source,
|
||||
...buildOrthogonalRoutePoints(
|
||||
source,
|
||||
target,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
),
|
||||
target,
|
||||
];
|
||||
}
|
||||
|
||||
const points: EdgeRoutePoint[] = [source];
|
||||
const first = storedPoints[0];
|
||||
if (
|
||||
Math.abs(source.x - first.x) > ROUTE_EPSILON &&
|
||||
Math.abs(source.y - first.y) > ROUTE_EPSILON
|
||||
) {
|
||||
appendDistinctPoint(
|
||||
points,
|
||||
edgeAxisForPosition(sourcePosition) === "horizontal"
|
||||
? { x: first.x, y: source.y }
|
||||
: { x: source.x, y: first.y },
|
||||
);
|
||||
}
|
||||
storedPoints.forEach((point) => appendDistinctPoint(points, point));
|
||||
const last = points.at(-1) as EdgeRoutePoint;
|
||||
if (
|
||||
Math.abs(last.x - target.x) > ROUTE_EPSILON &&
|
||||
Math.abs(last.y - target.y) > ROUTE_EPSILON
|
||||
) {
|
||||
appendDistinctPoint(
|
||||
points,
|
||||
edgeAxisForPosition(targetPosition) === "horizontal"
|
||||
? { x: last.x, y: target.y }
|
||||
: { x: target.x, y: last.y },
|
||||
);
|
||||
}
|
||||
appendDistinctPoint(points, target);
|
||||
return simplifyOrthogonalPoints(points);
|
||||
}
|
||||
|
||||
function pointOnSegmentInterior(
|
||||
point: EdgeRoutePoint,
|
||||
start: EdgeRoutePoint,
|
||||
end: EdgeRoutePoint,
|
||||
) {
|
||||
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
|
||||
const fromStart = Math.hypot(point.x - start.x, point.y - start.y);
|
||||
const fromEnd = Math.hypot(point.x - end.x, point.y - end.y);
|
||||
return (
|
||||
segmentLength > CROSSING_JUMP_RADIUS * 2 + 2 &&
|
||||
fromStart > CROSSING_JUMP_RADIUS + 1 &&
|
||||
fromEnd > CROSSING_JUMP_RADIUS + 1 &&
|
||||
Math.abs(fromStart + fromEnd - segmentLength) <= 0.2
|
||||
);
|
||||
}
|
||||
|
||||
export function edgeSegmentAxis(
|
||||
start: EdgeRoutePoint,
|
||||
end: EdgeRoutePoint,
|
||||
): EdgePointAxis {
|
||||
return Math.abs(start.x - end.x) >= Math.abs(start.y - end.y)
|
||||
? "horizontal"
|
||||
: "vertical";
|
||||
}
|
||||
|
||||
export function orthogonalPolylinePath(
|
||||
points: EdgeRoutePoint[],
|
||||
crossingJumps: EdgeCrossingJump[] = [],
|
||||
) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let path = `M ${points[0].x} ${points[0].y}`;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const start = points[index];
|
||||
const end = points[index + 1];
|
||||
const orientation = edgeSegmentAxis(start, end);
|
||||
const horizontal = orientation === "horizontal";
|
||||
const direction = horizontal
|
||||
? Math.sign(end.x - start.x)
|
||||
: Math.sign(end.y - start.y);
|
||||
if (direction === 0) {
|
||||
continue;
|
||||
}
|
||||
const jumps = crossingJumps
|
||||
.filter(
|
||||
(jump) =>
|
||||
jump.orientation === orientation &&
|
||||
pointOnSegmentInterior(jump, start, end),
|
||||
)
|
||||
.sort((first, second) =>
|
||||
horizontal
|
||||
? (first.x - second.x) * direction
|
||||
: (first.y - second.y) * direction,
|
||||
);
|
||||
jumps.forEach((jump) => {
|
||||
const before = horizontal
|
||||
? { x: jump.x - direction * CROSSING_JUMP_RADIUS, y: start.y }
|
||||
: { x: start.x, y: jump.y - direction * CROSSING_JUMP_RADIUS };
|
||||
const after = horizontal
|
||||
? { x: jump.x + direction * CROSSING_JUMP_RADIUS, y: start.y }
|
||||
: { x: start.x, y: jump.y + direction * CROSSING_JUMP_RADIUS };
|
||||
path += ` L ${before.x} ${before.y}`;
|
||||
path += horizontal
|
||||
? ` Q ${jump.x} ${jump.y - CROSSING_JUMP_RADIUS * 1.8} ${after.x} ${after.y}`
|
||||
: ` Q ${jump.x + CROSSING_JUMP_RADIUS * 1.8} ${jump.y} ${after.x} ${after.y}`;
|
||||
});
|
||||
path += ` L ${end.x} ${end.y}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
export type RoutedEdgeCrossingInput = {
|
||||
id: string;
|
||||
nodeIds: readonly [string, string];
|
||||
points: EdgeRoutePoint[];
|
||||
};
|
||||
|
||||
function valueInsideSegment(
|
||||
value: number,
|
||||
first: number,
|
||||
second: number,
|
||||
margin = CROSSING_JUMP_RADIUS + 2,
|
||||
) {
|
||||
const minimum = Math.min(first, second) + margin;
|
||||
const maximum = Math.max(first, second) - margin;
|
||||
return value > minimum && value < maximum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives display-only circuit-style jump arcs. The horizontal route is chosen
|
||||
* consistently, so recalculation never makes the bridge alternate between
|
||||
* lines as the user edits nearby geometry.
|
||||
*/
|
||||
export function detectEdgeCrossingJumps(
|
||||
edges: RoutedEdgeCrossingInput[],
|
||||
) {
|
||||
const jumpsByEdgeId = new Map<string, EdgeCrossingJump[]>();
|
||||
for (let firstIndex = 0; firstIndex < edges.length; firstIndex += 1) {
|
||||
const first = edges[firstIndex];
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < edges.length; secondIndex += 1) {
|
||||
const second = edges[secondIndex];
|
||||
for (let firstSegment = 0; firstSegment < first.points.length - 1; firstSegment += 1) {
|
||||
const firstStart = first.points[firstSegment];
|
||||
const firstEnd = first.points[firstSegment + 1];
|
||||
const firstAxis = edgeSegmentAxis(firstStart, firstEnd);
|
||||
for (let secondSegment = 0; secondSegment < second.points.length - 1; secondSegment += 1) {
|
||||
const secondStart = second.points[secondSegment];
|
||||
const secondEnd = second.points[secondSegment + 1];
|
||||
const secondAxis = edgeSegmentAxis(secondStart, secondEnd);
|
||||
if (firstAxis === secondAxis) {
|
||||
continue;
|
||||
}
|
||||
const horizontal =
|
||||
firstAxis === "horizontal"
|
||||
? { edge: first, start: firstStart, end: firstEnd }
|
||||
: { edge: second, start: secondStart, end: secondEnd };
|
||||
const vertical =
|
||||
firstAxis === "vertical"
|
||||
? { start: firstStart, end: firstEnd }
|
||||
: { start: secondStart, end: secondEnd };
|
||||
const crossing = { x: vertical.start.x, y: horizontal.start.y };
|
||||
if (
|
||||
!valueInsideSegment(
|
||||
crossing.x,
|
||||
horizontal.start.x,
|
||||
horizontal.end.x,
|
||||
) ||
|
||||
!valueInsideSegment(
|
||||
crossing.y,
|
||||
vertical.start.y,
|
||||
vertical.end.y,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = jumpsByEdgeId.get(horizontal.edge.id) ?? [];
|
||||
if (
|
||||
!current.some(
|
||||
(jump) =>
|
||||
Math.abs(jump.x - crossing.x) <= ROUTE_EPSILON &&
|
||||
Math.abs(jump.y - crossing.y) <= ROUTE_EPSILON,
|
||||
)
|
||||
) {
|
||||
current.push({ ...crossing, orientation: "horizontal" });
|
||||
jumpsByEdgeId.set(horizontal.edge.id, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jumpsByEdgeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an internal orthogonal segment along its perpendicular axis. Both live
|
||||
* endpoints remain untouched; the neighbouring perpendicular legs absorb the
|
||||
* displacement.
|
||||
*/
|
||||
export function moveOrthogonalSegment(
|
||||
points: EdgeRoutePoint[],
|
||||
segmentIndex: number,
|
||||
delta: EdgeRoutePoint,
|
||||
) {
|
||||
const nextPoints = points.map((point) => ({ ...point }));
|
||||
if (segmentIndex <= 0 || segmentIndex >= nextPoints.length - 2) {
|
||||
return nextPoints;
|
||||
}
|
||||
const start = nextPoints[segmentIndex];
|
||||
const end = nextPoints[segmentIndex + 1];
|
||||
if (edgeSegmentAxis(start, end) === "horizontal") {
|
||||
start.y += delta.y;
|
||||
end.y += delta.y;
|
||||
} else {
|
||||
start.x += delta.x;
|
||||
end.x += delta.x;
|
||||
}
|
||||
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[],
|
||||
segmentIndex: number,
|
||||
delta: EdgeRoutePoint,
|
||||
) {
|
||||
const start = points[segmentIndex];
|
||||
const end = points[segmentIndex + 1];
|
||||
if (!start || !end) {
|
||||
return points.map((point) => ({ ...point }));
|
||||
}
|
||||
const firstThird = {
|
||||
x: start.x + (end.x - start.x) / 3,
|
||||
y: start.y + (end.y - start.y) / 3,
|
||||
};
|
||||
const secondThird = {
|
||||
x: start.x + ((end.x - start.x) * 2) / 3,
|
||||
y: start.y + ((end.y - start.y) * 2) / 3,
|
||||
};
|
||||
const detour =
|
||||
edgeSegmentAxis(start, end) === "horizontal"
|
||||
? [
|
||||
{ x: firstThird.x + delta.x, y: start.y },
|
||||
{ x: firstThird.x + delta.x, y: start.y + delta.y },
|
||||
{ x: secondThird.x + delta.x, y: end.y + delta.y },
|
||||
{ x: secondThird.x + delta.x, y: end.y },
|
||||
]
|
||||
: [
|
||||
{ x: start.x, y: firstThird.y + delta.y },
|
||||
{ x: start.x + delta.x, y: firstThird.y + delta.y },
|
||||
{ x: end.x + delta.x, y: secondThird.y + delta.y },
|
||||
{ x: end.x, y: secondThird.y + delta.y },
|
||||
];
|
||||
return simplifyOrthogonalPoints([
|
||||
...points.slice(0, segmentIndex + 1),
|
||||
...detour,
|
||||
...points.slice(segmentIndex + 1),
|
||||
]);
|
||||
}
|
||||
@@ -3647,3 +3647,133 @@ textarea {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Result chart viewport controls and AMESim-style box zoom. */
|
||||
.result-chart-window-header button:disabled,
|
||||
.result-chart-window-header button:disabled:hover {
|
||||
border-color: transparent;
|
||||
color: #8a97a6;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.result-chart-window-header button.zoom.active {
|
||||
border-color: #74a9d7;
|
||||
color: #0f5f9f;
|
||||
background: #e8f3fc;
|
||||
}
|
||||
|
||||
.result-chart-body > svg.zoom-enabled {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.result-chart-body > svg.zoom-enabled[data-panning="true"],
|
||||
.result-chart-body > svg.zoom-enabled[data-panning="true"] .result-chart-zoom-axis-hit {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
.result-chart-zoom-axis-hit {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.result-chart-zoom-axis-hit.x {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.result-chart-zoom-axis-hit.y {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.result-chart-zoom-axis-hit:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.result-chart-body > svg:focus,
|
||||
.result-chart-body > svg:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.result-chart-zoom-selection {
|
||||
fill: rgba(29, 111, 184, 0.16);
|
||||
stroke: #1d6fb8;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* AMESim-style click routing, segment editing and circuit crossing bridges. */
|
||||
.manual-connection-draft {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.manual-connection-draft .react-flow__connection-path {
|
||||
fill: none;
|
||||
stroke: #1675c1;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.manual-connection-waypoint {
|
||||
fill: #ffffff;
|
||||
stroke: #1675c1;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.manual-edge-segment-handle {
|
||||
stroke: transparent;
|
||||
stroke-width: 14;
|
||||
pointer-events: stroke;
|
||||
touch-action: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.manual-edge-segment-guide {
|
||||
stroke: rgba(22, 117, 193, 0.34);
|
||||
stroke-width: 1.25;
|
||||
stroke-dasharray: 4 3;
|
||||
pointer-events: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.manual-edge-segment-handle:hover + .manual-edge-segment-guide,
|
||||
.manual-edge-segment-handle:active + .manual-edge-segment-guide {
|
||||
stroke: rgba(15, 95, 159, 0.78);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.manual-edge-segment-handle.horizontal {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.manual-edge-segment-handle.vertical {
|
||||
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;
|
||||
}
|
||||
@@ -3077,10 +3077,25 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
||||
await page.locator(".flow-canvas .react-flow__pane").click({
|
||||
position: { x: 20, y: 20 },
|
||||
});
|
||||
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||
"data-connection-state",
|
||||
"origin",
|
||||
);
|
||||
const manualDraft = page.locator(".flow-canvas .manual-connection-draft");
|
||||
await expect(manualDraft).toBeVisible();
|
||||
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
|
||||
"data-waypoint-count",
|
||||
"1",
|
||||
);
|
||||
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||
"data-connection-state",
|
||||
"idle",
|
||||
);
|
||||
await expect(manualDraft).toHaveCount(0);
|
||||
|
||||
await compatiblePhysicalPort.click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||
@@ -3094,8 +3109,16 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
||||
);
|
||||
|
||||
await firstPhysicalOutput.click();
|
||||
await page.locator(".flow-canvas .react-flow__pane").click({
|
||||
position: { x: 70, y: 90 },
|
||||
});
|
||||
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
|
||||
"data-waypoint-count",
|
||||
"1",
|
||||
);
|
||||
await compatiblePhysicalPort.click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||
await expect(manualDraft).toHaveCount(0);
|
||||
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
|
||||
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
|
||||
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
|
||||
@@ -3107,6 +3130,15 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
|
||||
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
|
||||
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
|
||||
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const routedEdge = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem(
|
||||
"system-simulation-flow:project:demo-system",
|
||||
);
|
||||
const project = raw ? JSON.parse(raw) : null;
|
||||
return project?.edges?.[0] ?? null;
|
||||
});
|
||||
expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0);
|
||||
|
||||
await signalOutput.click();
|
||||
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
});
|
||||
|
||||
async function locatorCenter(locator: Locator) {
|
||||
const bounds = await locator.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
return {
|
||||
x: bounds!.x + bounds!.width / 2,
|
||||
y: bounds!.y + bounds!.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function distanceBetween(first: Locator, second: Locator) {
|
||||
const [firstCenter, secondCenter] = await Promise.all([
|
||||
locatorCenter(first),
|
||||
locatorCenter(second),
|
||||
]);
|
||||
return Math.hypot(
|
||||
secondCenter.x - firstCenter.x,
|
||||
secondCenter.y - firstCenter.y,
|
||||
);
|
||||
}
|
||||
|
||||
async function dragGenericToCanvas(
|
||||
page: Page,
|
||||
targetPosition: { x: number; y: number },
|
||||
) {
|
||||
const currentCount = await page
|
||||
.locator('.flow-canvas .react-flow__node[data-id^="generic_sensor_"]')
|
||||
.count();
|
||||
await page
|
||||
.getByRole("button", { name: /通用测试元件/ })
|
||||
.dragTo(page.locator(".flow-canvas .react-flow__pane"), { targetPosition });
|
||||
const node = page.locator(
|
||||
`.flow-canvas .react-flow__node[data-id="generic_sensor_${currentCount + 1}"]`,
|
||||
);
|
||||
await expect(node).toBeVisible();
|
||||
return node;
|
||||
}
|
||||
|
||||
async function moveNodePortNearTarget(
|
||||
page: Page,
|
||||
movingNode: Locator,
|
||||
movingPort: Locator,
|
||||
targetPort: Locator,
|
||||
remainingGap = 8,
|
||||
) {
|
||||
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
|
||||
locatorCenter(movingNode.locator(".sim-node")),
|
||||
locatorCenter(movingPort),
|
||||
locatorCenter(targetPort),
|
||||
]);
|
||||
const deltaX = targetPortCenter.x - movingPortCenter.x;
|
||||
const deltaY = targetPortCenter.y - movingPortCenter.y;
|
||||
const distance = Math.hypot(deltaX, deltaY);
|
||||
const ratio = (distance - remainingGap) / distance;
|
||||
let pointerX = dragStart.x + deltaX * ratio;
|
||||
let pointerY = dragStart.y + deltaY * ratio;
|
||||
|
||||
await page.mouse.move(dragStart.x, dragStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(pointerX, pointerY, { steps: 12 });
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const [movingCenter, targetCenter] = await Promise.all([
|
||||
locatorCenter(movingPort),
|
||||
locatorCenter(targetPort),
|
||||
]);
|
||||
const correctionX = targetCenter.x - movingCenter.x;
|
||||
const correctionY = targetCenter.y - movingCenter.y;
|
||||
const correctionDistance = Math.hypot(correctionX, correctionY);
|
||||
if (correctionDistance <= remainingGap + 1) {
|
||||
break;
|
||||
}
|
||||
const correctionRatio =
|
||||
(correctionDistance - remainingGap) / correctionDistance;
|
||||
pointerX += correctionX * correctionRatio;
|
||||
pointerY += correctionY * correctionRatio;
|
||||
await page.mouse.move(pointerX, pointerY, { steps: 4 });
|
||||
}
|
||||
return { pointerX, pointerY };
|
||||
}
|
||||
|
||||
async function movePointerUntilPortsMeet(
|
||||
page: Page,
|
||||
pointer: { x: number; y: number },
|
||||
movingPort: Locator,
|
||||
targetPort: Locator,
|
||||
) {
|
||||
const nextPointer = { ...pointer };
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const [movingCenter, targetCenter] = await Promise.all([
|
||||
locatorCenter(movingPort),
|
||||
locatorCenter(targetPort),
|
||||
]);
|
||||
const correction = {
|
||||
x: targetCenter.x - movingCenter.x,
|
||||
y: targetCenter.y - movingCenter.y,
|
||||
};
|
||||
if (Math.hypot(correction.x, correction.y) < 2) {
|
||||
break;
|
||||
}
|
||||
nextPointer.x += correction.x;
|
||||
nextPointer.y += correction.y;
|
||||
await page.mouse.move(nextPointer.x, nextPointer.y, { steps: 6 });
|
||||
}
|
||||
return nextPointer;
|
||||
}
|
||||
|
||||
function projectNode(id: string, x: number, y: number) {
|
||||
const template = structuredClone(wideProject.nodes[0]);
|
||||
return {
|
||||
...template,
|
||||
id,
|
||||
position: { x, y },
|
||||
data: {
|
||||
...template.data,
|
||||
label: id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("多选块拖动会即时断开边界接触,并由块内空闲端口整体吸附", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||
|
||||
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
|
||||
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
|
||||
const secondMovingNode = await dragGenericToCanvas(page, { x: 410, y: 430 });
|
||||
const snapTargetNode = await dragGenericToCanvas(page, { x: 720, y: 430 });
|
||||
const fixedPort = fixedNode.locator('.port-handle[data-port-name="port_b"]');
|
||||
const boundaryPort = firstMovingNode.locator(
|
||||
'.port-handle[data-port-name="port_a"]',
|
||||
);
|
||||
const blockFreePort = secondMovingNode.locator(
|
||||
'.port-handle[data-port-name="port_b"]',
|
||||
);
|
||||
const targetFreePort = snapTargetNode.locator(
|
||||
'.port-handle[data-port-name="port_a"]',
|
||||
);
|
||||
|
||||
await moveNodePortNearTarget(
|
||||
page,
|
||||
firstMovingNode,
|
||||
boundaryPort,
|
||||
fixedPort,
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => distanceBetween(boundaryPort, fixedPort)).toBeLessThan(2);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||
|
||||
await firstMovingNode.locator(".sim-node").click();
|
||||
await secondMovingNode.locator(".sim-node").click({ modifiers: ["Control"] });
|
||||
await expect(firstMovingNode).toHaveClass(/selected/);
|
||||
await expect(secondMovingNode).toHaveClass(/selected/);
|
||||
const [firstBefore, secondBefore] = await Promise.all([
|
||||
firstMovingNode.boundingBox(),
|
||||
secondMovingNode.boundingBox(),
|
||||
]);
|
||||
expect(firstBefore).not.toBeNull();
|
||||
expect(secondBefore).not.toBeNull();
|
||||
|
||||
await moveNodePortNearTarget(
|
||||
page,
|
||||
firstMovingNode,
|
||||
blockFreePort,
|
||||
targetFreePort,
|
||||
);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||
await expect(blockFreePort).toHaveAttribute("data-connection-state", "origin");
|
||||
await expect(targetFreePort).toHaveAttribute(
|
||||
"data-connection-state",
|
||||
"compatible",
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(() => distanceBetween(blockFreePort, targetFreePort)).toBeLessThan(2);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||
await expect(boundaryPort).toHaveAttribute("data-connected", "false");
|
||||
await expect(fixedPort).toHaveAttribute("data-connected", "false");
|
||||
await expect(blockFreePort).toHaveAttribute("data-connected", "true");
|
||||
await expect(targetFreePort).toHaveAttribute("data-connected", "true");
|
||||
|
||||
const [firstAfter, secondAfter] = await Promise.all([
|
||||
firstMovingNode.boundingBox(),
|
||||
secondMovingNode.boundingBox(),
|
||||
]);
|
||||
expect(firstAfter).not.toBeNull();
|
||||
expect(secondAfter).not.toBeNull();
|
||||
expect(firstAfter!.x - firstBefore!.x).toBeCloseTo(
|
||||
secondAfter!.x - secondBefore!.x,
|
||||
1,
|
||||
);
|
||||
expect(firstAfter!.y - firstBefore!.y).toBeCloseTo(
|
||||
secondAfter!.y - secondBefore!.y,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("复制块保留内部连接,同时未连接端口仍参与待放置吸附", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||
|
||||
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
|
||||
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
|
||||
const target = await dragGenericToCanvas(page, { x: 720, y: 370 });
|
||||
await first.locator('.port-handle[data-port-name="port_b"]').click();
|
||||
await second.locator('.port-handle[data-port-name="port_a"]').click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||
|
||||
await first.locator(".sim-node").click();
|
||||
await second.locator(".sim-node").click({ modifiers: ["Control"] });
|
||||
await expect(first).toHaveClass(/selected/);
|
||||
await expect(second).toHaveClass(/selected/);
|
||||
await page.keyboard.press("Control+c");
|
||||
|
||||
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
|
||||
expect(paneBounds).not.toBeNull();
|
||||
let pointer = {
|
||||
x: paneBounds!.x + paneBounds!.width * 0.5,
|
||||
y: paneBounds!.y + paneBounds!.height * 0.72,
|
||||
};
|
||||
await page.mouse.move(pointer.x, pointer.y);
|
||||
await page.keyboard.press("Control+v");
|
||||
|
||||
const firstPreview = page.locator(
|
||||
'.flow-canvas .react-flow__node[data-id="generic_sensor_4"]',
|
||||
);
|
||||
const secondPreview = page.locator(
|
||||
'.flow-canvas .react-flow__node[data-id="generic_sensor_5"]',
|
||||
);
|
||||
await expect(firstPreview).toHaveClass(/pending-paste-node/);
|
||||
await expect(secondPreview).toHaveClass(/pending-paste-node/);
|
||||
await expect(page.locator(".flow-canvas .pending-paste-edge")).toHaveCount(1);
|
||||
await expect(
|
||||
firstPreview.locator('.port-handle[data-port-name="port_b"]'),
|
||||
).toHaveAttribute("data-connection-state", "connected");
|
||||
await expect(
|
||||
secondPreview.locator('.port-handle[data-port-name="port_a"]'),
|
||||
).toHaveAttribute("data-connection-state", "connected");
|
||||
|
||||
const copiedFreePort = secondPreview.locator(
|
||||
'.port-handle[data-port-name="port_b"]',
|
||||
);
|
||||
const targetPort = target.locator('.port-handle[data-port-name="port_a"]');
|
||||
pointer = await movePointerUntilPortsMeet(
|
||||
page,
|
||||
pointer,
|
||||
copiedFreePort,
|
||||
targetPort,
|
||||
);
|
||||
await expect.poll(() => distanceBetween(copiedFreePort, targetPort)).toBeLessThan(2);
|
||||
await expect(copiedFreePort).toHaveAttribute("data-connection-state", "origin");
|
||||
await expect(targetPort).toHaveAttribute("data-connection-state", "compatible");
|
||||
|
||||
await page.mouse.click(pointer.x, pointer.y);
|
||||
await expect(firstPreview).not.toHaveClass(/pending-paste-node/);
|
||||
await expect(secondPreview).not.toHaveClass(/pending-paste-node/);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(3);
|
||||
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(2);
|
||||
await expect(copiedFreePort).toHaveAttribute("data-connected", "true");
|
||||
await expect(targetPort).toHaveAttribute("data-connected", "true");
|
||||
});
|
||||
|
||||
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = {
|
||||
...structuredClone(wideProject),
|
||||
nodes: [
|
||||
projectNode("left", 0, 260),
|
||||
projectNode("right", 700, 260),
|
||||
projectNode("top", 350, 0),
|
||||
projectNode("bottom", 350, 520),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge-horizontal",
|
||||
source: "left",
|
||||
target: "right",
|
||||
sourceHandle: "port_b",
|
||||
targetHandle: "port_a",
|
||||
data: {
|
||||
isContactEdge: false,
|
||||
routePoints: [
|
||||
{ x: 250, y: 360 },
|
||||
{ x: 650, y: 360 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "edge-vertical",
|
||||
source: "top",
|
||||
target: "bottom",
|
||||
sourceHandle: "port_b",
|
||||
targetHandle: "port_a",
|
||||
data: {
|
||||
isContactEdge: false,
|
||||
routePoints: [
|
||||
{ x: 450, y: 150 },
|
||||
{ x: 450, y: 550 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
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 horizontalEdge = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="edge-horizontal"]',
|
||||
);
|
||||
const verticalEdge = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="edge-vertical"]',
|
||||
);
|
||||
await expect(horizontalEdge).toBeVisible();
|
||||
await expect(verticalEdge).toBeVisible();
|
||||
const horizontalPath = horizontalEdge.locator(".react-flow__edge-path");
|
||||
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||
await expect(verticalEdge.locator(".react-flow__edge-path")).not.toHaveAttribute(
|
||||
"d",
|
||||
/ Q /,
|
||||
);
|
||||
|
||||
await horizontalEdge
|
||||
.locator(".react-flow__edge-interaction")
|
||||
.dispatchEvent("click");
|
||||
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
|
||||
const horizontalSegment = horizontalEdge
|
||||
.locator(".manual-edge-segment-handle.horizontal")
|
||||
.first();
|
||||
const verticalSegment = horizontalEdge
|
||||
.locator(".manual-edge-segment-handle.vertical")
|
||||
.first();
|
||||
await expect(horizontalSegment).toHaveCount(1);
|
||||
await expect(verticalSegment).toHaveCount(1);
|
||||
await expect(horizontalSegment).toHaveCSS("pointer-events", "stroke");
|
||||
await expect(verticalSegment).toHaveCSS("pointer-events", "stroke");
|
||||
|
||||
const horizontalDragPoint = await horizontalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取水平连接线段坐标");
|
||||
}
|
||||
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 pathBeforeHorizontalDrag = await horizontalPath.getAttribute("d");
|
||||
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y + 36, {
|
||||
steps: 6,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(horizontalPath).not.toHaveAttribute(
|
||||
"d",
|
||||
pathBeforeHorizontalDrag ?? "",
|
||||
);
|
||||
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||
|
||||
const verticalDragPoint = await verticalSegment.evaluate(
|
||||
(line: SVGLineElement) => {
|
||||
const matrix = line.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("无法获取竖直连接线段坐标");
|
||||
}
|
||||
const point = new DOMPoint(
|
||||
(line.x1.baseVal.value + line.x2.baseVal.value) / 2,
|
||||
(line.y1.baseVal.value + line.y2.baseVal.value) / 2,
|
||||
).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
},
|
||||
);
|
||||
const pathBeforeVerticalDrag = await horizontalPath.getAttribute("d");
|
||||
await page.mouse.move(verticalDragPoint.x, verticalDragPoint.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(verticalDragPoint.x + 28, verticalDragPoint.y, {
|
||||
steps: 6,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(horizontalPath).not.toHaveAttribute(
|
||||
"d",
|
||||
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);
|
||||
|
||||
await page.keyboard.press("Control+z");
|
||||
await expect(horizontalPath).toHaveAttribute(
|
||||
"d",
|
||||
pathBeforeCornerDrag ?? "",
|
||||
);
|
||||
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(
|
||||
"d",
|
||||
boundaryPathBefore ?? "",
|
||||
);
|
||||
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(
|
||||
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
|
||||
),
|
||||
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
|
||||
expect(linePoints.length).toBeGreaterThan(2);
|
||||
linePoints.slice(1).forEach((point, index) => {
|
||||
const previous = linePoints[index];
|
||||
expect(
|
||||
Math.abs(point.x - previous.x) < 0.01 ||
|
||||
Math.abs(point.y - previous.y) < 0.01,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const savedRoute = await page.evaluate((projectKey) => {
|
||||
const raw = window.localStorage.getItem(projectKey);
|
||||
const saved = raw ? JSON.parse(raw) : null;
|
||||
return saved?.edges?.find(
|
||||
(edge: { id?: string }) => edge.id === "edge-horizontal",
|
||||
)?.data?.routePoints ?? null;
|
||||
}, PROJECT_KEY);
|
||||
expect(savedRoute).not.toEqual(project.edges[0].data.routePoints);
|
||||
expect(savedRoute?.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
}, 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 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.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);
|
||||
|
||||
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,
|
||||
}) => {
|
||||
const project = {
|
||||
...structuredClone(wideProject),
|
||||
nodes: [
|
||||
projectNode("shared-source", 0, 260),
|
||||
projectNode("right-target", 700, 260),
|
||||
projectNode("bottom-target", 350, 520),
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "shared-horizontal",
|
||||
source: "shared-source",
|
||||
target: "right-target",
|
||||
sourceHandle: "port_b",
|
||||
targetHandle: "port_a",
|
||||
data: {
|
||||
isContactEdge: false,
|
||||
routePoints: [
|
||||
{ x: 250, y: 360 },
|
||||
{ x: 650, y: 360 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shared-vertical",
|
||||
source: "shared-source",
|
||||
target: "bottom-target",
|
||||
sourceHandle: "port_a",
|
||||
targetHandle: "port_a",
|
||||
data: {
|
||||
isContactEdge: false,
|
||||
routePoints: [
|
||||
{ x: 250, y: 360 },
|
||||
{ x: 250, y: 180 },
|
||||
{ x: 450, y: 180 },
|
||||
{ x: 450, y: 540 },
|
||||
{ x: 350, y: 540 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
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 horizontalPath = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="shared-horizontal"] .react-flow__edge-path',
|
||||
);
|
||||
const verticalPath = page.locator(
|
||||
'.flow-canvas .react-flow__edge[data-id="shared-vertical"] .react-flow__edge-path',
|
||||
);
|
||||
await expect(horizontalPath).toBeVisible();
|
||||
await expect(horizontalPath).toHaveAttribute("d", / Q /);
|
||||
await expect(verticalPath).not.toHaveAttribute("d", / Q /);
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
expectAllNodesInsideCanvas,
|
||||
prepareApp,
|
||||
@@ -6,6 +8,35 @@ import {
|
||||
wideProject,
|
||||
} from "./fixtures";
|
||||
|
||||
type ImportedProjectEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle: string;
|
||||
targetHandle: string;
|
||||
data?: { isContactEdge?: boolean };
|
||||
};
|
||||
|
||||
type ImportedProject = {
|
||||
nodes: Array<{ id: string }>;
|
||||
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 mql8Project = JSON.parse(
|
||||
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
|
||||
) as ImportedProject;
|
||||
const mql8ResultSnapshot = {
|
||||
...resultSnapshot,
|
||||
id: "e2e-mql-8-result-system-bridges",
|
||||
project: mql8Project as unknown as typeof resultSnapshot.project,
|
||||
};
|
||||
|
||||
async function readViewport(page: Page, canvasSelector: string) {
|
||||
return page
|
||||
.locator(`${canvasSelector} .react-flow__viewport`)
|
||||
@@ -26,6 +57,110 @@ async function readModelingViewport(page: Page) {
|
||||
return readViewport(page, ".flow-canvas");
|
||||
}
|
||||
|
||||
async function readVisibleEdgeEndpointAlignment(
|
||||
page: Page,
|
||||
edges: ImportedProjectEdge[],
|
||||
) {
|
||||
return page.evaluate((projectEdges) => {
|
||||
const nodeElements = new Map(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".flow-canvas .react-flow__node[data-id]",
|
||||
),
|
||||
).flatMap((element) =>
|
||||
element.dataset.id ? [[element.dataset.id, element] as const] : [],
|
||||
),
|
||||
);
|
||||
const edgeElements = new Map(
|
||||
Array.from(
|
||||
document.querySelectorAll<SVGGElement>(
|
||||
".flow-canvas .react-flow__edge[data-id]",
|
||||
),
|
||||
).flatMap((element) =>
|
||||
element.dataset.id ? [[element.dataset.id, element] as const] : [],
|
||||
),
|
||||
);
|
||||
const measurements: Array<{
|
||||
edgeId: string;
|
||||
sourceDistance: number;
|
||||
targetDistance: number;
|
||||
}> = [];
|
||||
const missingHandles: string[] = [];
|
||||
|
||||
for (const edge of projectEdges) {
|
||||
if (edge.data?.isContactEdge === true) {
|
||||
continue;
|
||||
}
|
||||
const path = edgeElements
|
||||
.get(edge.id)
|
||||
?.querySelector<SVGPathElement>(".react-flow__edge-path");
|
||||
if (!path) {
|
||||
// A geometrically touching edge is intentionally rendered without a path.
|
||||
continue;
|
||||
}
|
||||
const sourceHandle = Array.from(
|
||||
nodeElements
|
||||
.get(edge.source)
|
||||
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
|
||||
).find((handle) => handle.dataset.portName === edge.sourceHandle);
|
||||
const targetHandle = Array.from(
|
||||
nodeElements
|
||||
.get(edge.target)
|
||||
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
|
||||
).find((handle) => handle.dataset.portName === edge.targetHandle);
|
||||
const screenMatrix = path.getScreenCTM();
|
||||
if (!sourceHandle || !targetHandle || !screenMatrix) {
|
||||
missingHandles.push(edge.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathLength = path.getTotalLength();
|
||||
const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix);
|
||||
const targetPoint = path
|
||||
.getPointAtLength(pathLength)
|
||||
.matrixTransform(screenMatrix);
|
||||
const sourceBounds = sourceHandle.getBoundingClientRect();
|
||||
const targetBounds = targetHandle.getBoundingClientRect();
|
||||
const sourceCenter = {
|
||||
x: sourceBounds.left + sourceBounds.width / 2,
|
||||
y: sourceBounds.top + sourceBounds.height / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: targetBounds.left + targetBounds.width / 2,
|
||||
y: targetBounds.top + targetBounds.height / 2,
|
||||
};
|
||||
measurements.push({
|
||||
edgeId: edge.id,
|
||||
sourceDistance: Math.hypot(
|
||||
sourcePoint.x - sourceCenter.x,
|
||||
sourcePoint.y - sourceCenter.y,
|
||||
),
|
||||
targetDistance: Math.hypot(
|
||||
targetPoint.x - targetCenter.x,
|
||||
targetPoint.y - targetCenter.y,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const worst = measurements
|
||||
.map((measurement) => ({
|
||||
...measurement,
|
||||
maxDistance: Math.max(
|
||||
measurement.sourceDistance,
|
||||
measurement.targetDistance,
|
||||
),
|
||||
}))
|
||||
.sort((first, second) => second.maxDistance - first.maxDistance)
|
||||
.slice(0, 8);
|
||||
return {
|
||||
checkedEdgeCount: measurements.length,
|
||||
maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY,
|
||||
missingHandles,
|
||||
worst,
|
||||
};
|
||||
}, edges);
|
||||
}
|
||||
|
||||
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
@@ -225,6 +360,50 @@ test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
|
||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||
});
|
||||
|
||||
test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({
|
||||
page,
|
||||
}) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
|
||||
|
||||
await expectAllNodesInsideCanvas(
|
||||
page,
|
||||
".flow-canvas",
|
||||
mql8Project.nodes.length,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await readVisibleEdgeEndpointAlignment(page, mql8Project.edges)
|
||||
).checkedEdgeCount,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBeGreaterThan(20);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
|
||||
const alignment = await readVisibleEdgeEndpointAlignment(
|
||||
page,
|
||||
mql8Project.edges,
|
||||
);
|
||||
expect(alignment.missingHandles).toEqual([]);
|
||||
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
|
||||
expect(
|
||||
alignment.maxDistance,
|
||||
`偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`,
|
||||
// React Flow anchors to the outer edge of the 6 px port glyph rather
|
||||
// than its visual center; allow that radius plus subpixel rounding.
|
||||
).toBeLessThanOrEqual(4);
|
||||
|
||||
});
|
||||
|
||||
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.addInitScript((project) => {
|
||||
@@ -434,6 +613,95 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
|
||||
page,
|
||||
}) => {
|
||||
await prepareApp(page);
|
||||
await page.addInitScript((snapshot) => {
|
||||
window.sessionStorage.setItem(
|
||||
"system-simulation-flow:latest-result",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
}, mql8ResultSnapshot);
|
||||
await page.goto("/");
|
||||
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.locator(".results-system-canvas .react-flow__node"),
|
||||
).toHaveCount(mql8Project.nodes.length);
|
||||
await expect(
|
||||
page.locator(
|
||||
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path',
|
||||
),
|
||||
).toHaveAttribute("d", / Q /);
|
||||
});
|
||||
|
||||
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();
|
||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||
|
||||
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
|
||||
const modelingPaneBox = await modelingPane.boundingBox();
|
||||
expect(modelingPaneBox).not.toBeNull();
|
||||
const fittedViewport = await readModelingViewport(page);
|
||||
const pointer = {
|
||||
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
|
||||
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
|
||||
};
|
||||
await page.mouse.move(pointer.x, pointer.y);
|
||||
await page.mouse.down({ button: "middle" });
|
||||
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
|
||||
await page.mouse.up({ button: "middle" });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const viewport = await readModelingViewport(page);
|
||||
return Math.hypot(
|
||||
viewport.x - fittedViewport.x,
|
||||
viewport.y - fittedViewport.y,
|
||||
);
|
||||
})
|
||||
.toBeGreaterThan(100);
|
||||
|
||||
const beforeZoom = await readModelingViewport(page);
|
||||
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 adjustedViewport = await readModelingViewport(page);
|
||||
|
||||
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||
await page.getByRole("tab", { name: "建模" }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
|
||||
wideProject.nodes.length,
|
||||
);
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const restoredViewport = await readModelingViewport(page);
|
||||
expect(
|
||||
Math.hypot(
|
||||
restoredViewport.x - adjustedViewport.x,
|
||||
restoredViewport.y - adjustedViewport.y,
|
||||
),
|
||||
).toBeLessThan(2);
|
||||
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
|
||||
});
|
||||
|
||||
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { prepareApp, resultSnapshot } from "./fixtures";
|
||||
|
||||
@@ -39,6 +39,86 @@ async function openResults(page: Page) {
|
||||
await expect(page.locator(".results-chart-workspace")).toBeVisible();
|
||||
}
|
||||
|
||||
async function readChartDomain(chart: Locator) {
|
||||
return chart.evaluate((element) => ({
|
||||
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||
yMin: Number(element.getAttribute("data-view-y-min")),
|
||||
yMax: Number(element.getAttribute("data-view-y-max")),
|
||||
}));
|
||||
}
|
||||
|
||||
async function dragChartSelection(
|
||||
page: Page,
|
||||
chart: Locator,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
) {
|
||||
const bounds = await chart.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
bounds!.x + bounds!.width * start.x,
|
||||
bounds!.y + bounds!.height * start.y,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
bounds!.x + bounds!.width * end.x,
|
||||
bounds!.y + bounds!.height * end.y,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function wheelAtLocator(
|
||||
page: Page,
|
||||
target: Locator,
|
||||
deltaY = -240,
|
||||
position = { x: 0.5, y: 0.5 },
|
||||
) {
|
||||
const bounds = await target.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
bounds!.x + bounds!.width * position.x,
|
||||
bounds!.y + bounds!.height * position.y,
|
||||
);
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
}
|
||||
|
||||
async function dragMiddleAtLocator(
|
||||
page: Page,
|
||||
target: Locator,
|
||||
delta = { x: 0.08, y: 0.08 },
|
||||
) {
|
||||
const bounds = await target.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
const startX = bounds!.x + bounds!.width * 0.5;
|
||||
const startY = bounds!.y + bounds!.height * 0.5;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down({ button: "middle" });
|
||||
await page.mouse.move(
|
||||
startX + bounds!.width * delta.x,
|
||||
startY + bounds!.height * delta.y,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up({ button: "middle" });
|
||||
}
|
||||
|
||||
async function readFlowViewport(page: Page) {
|
||||
return page
|
||||
.locator(".results-system-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.beforeEach(async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.addInitScript((snapshot) => {
|
||||
@@ -155,6 +235,553 @@ test("a pending window recovered from stale variable keys accepts a new first cu
|
||||
);
|
||||
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
|
||||
|
||||
await page.locator(".results-variable-list button").first().dragTo(pendingWindow);
|
||||
await page
|
||||
.locator(".results-variable-list button")
|
||||
.first()
|
||||
.dragTo(pendingWindow);
|
||||
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
|
||||
});
|
||||
|
||||
test("single curve supports box zoom, original-size restore, and persisted interaction mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
|
||||
await page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "数值" })
|
||||
.first()
|
||||
.click();
|
||||
let chartWindow = page.locator(
|
||||
'.result-chart-window[data-chart-kind="single"]',
|
||||
);
|
||||
let chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||
await expect(chart).toBeVisible();
|
||||
const initial = await readChartDomain(chart);
|
||||
|
||||
const zoomButton = chartWindow.getByRole("button", {
|
||||
name: "打开 数值 曲线缩放",
|
||||
exact: true,
|
||||
});
|
||||
await zoomButton.click();
|
||||
await expect(
|
||||
chartWindow.getByRole("button", {
|
||||
name: "关闭 数值 曲线缩放",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await dragChartSelection(
|
||||
page,
|
||||
chart,
|
||||
{ x: 0.32, y: 0.3 },
|
||||
{ x: 0.76, y: 0.72 },
|
||||
);
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||
await expect(chart.locator(".result-chart-zoom-selection")).toHaveCount(0);
|
||||
await expect(chart.locator('[data-chart-clipped-series="true"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
const zoomed = await readChartDomain(chart);
|
||||
expect(zoomed.xMin).toBeGreaterThan(initial.xMin);
|
||||
expect(zoomed.xMax).toBeLessThan(initial.xMax);
|
||||
expect(zoomed.yMin).toBeGreaterThan(initial.yMin);
|
||||
expect(zoomed.yMax).toBeLessThan(initial.yMax);
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((snapshotId) => {
|
||||
const raw = sessionStorage.getItem(
|
||||
`system-simulation-flow:result-layout:${snapshotId}`,
|
||||
);
|
||||
const windows = raw ? JSON.parse(raw) : [];
|
||||
return windows[0]?.viewport?.x?.start ?? 0;
|
||||
}, chartWindowSnapshot.id),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("tab", { name: "建模", exact: true }).click();
|
||||
await openResults(page);
|
||||
chartWindow = page.locator('.result-chart-window[data-chart-kind="single"]');
|
||||
chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||
const restored = await readChartDomain(chart);
|
||||
expect(restored.xMin).toBeCloseTo(zoomed.xMin, 8);
|
||||
expect(restored.xMax).toBeCloseTo(zoomed.xMax, 8);
|
||||
await expect(
|
||||
chartWindow.getByRole("button", {
|
||||
name: "关闭 数值 曲线缩放",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "打开 数值 曲线游标", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
chartWindow.getByRole("button", {
|
||||
name: "打开 数值 曲线缩放",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await chartWindow
|
||||
.getByRole("button", {
|
||||
name: "恢复 数值 原始尺寸",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||
const undone = await readChartDomain(chart);
|
||||
expect(undone.xMin).toBeCloseTo(initial.xMin, 8);
|
||||
expect(undone.xMax).toBeCloseTo(initial.xMax, 8);
|
||||
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||
.click();
|
||||
await dragChartSelection(
|
||||
page,
|
||||
chart,
|
||||
{ x: 0.3, y: 0.28 },
|
||||
{ x: 0.7, y: 0.68 },
|
||||
);
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||
.click();
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||
});
|
||||
|
||||
test("combined curves share X zoom while stacked Y zoom stays in the selected band", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
const valueVariable = page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "数值" })
|
||||
.first();
|
||||
const pressureVariable = page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "压力" })
|
||||
.first();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true })
|
||||
.click();
|
||||
const multiWindow = page.locator(
|
||||
'.result-chart-window[data-chart-kind="multi"]',
|
||||
);
|
||||
await valueVariable.dragTo(multiWindow);
|
||||
const multiChart = multiWindow.locator('svg[data-result-chart="true"]');
|
||||
await multiWindow
|
||||
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
|
||||
.click();
|
||||
await dragChartSelection(
|
||||
page,
|
||||
multiChart,
|
||||
{ x: 0.31, y: 0.34 },
|
||||
{ x: 0.73, y: 0.72 },
|
||||
);
|
||||
await expect(multiChart).toHaveAttribute("data-zoomed", "true");
|
||||
await expect(
|
||||
multiChart.locator('[data-chart-clipped-series="true"] path'),
|
||||
).toHaveCount(1);
|
||||
|
||||
const multiCursor = multiWindow.locator("button.cursor");
|
||||
await multiCursor.click();
|
||||
await expect(multiWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||
await multiWindow
|
||||
.getByRole("button", { name: "管理多曲线窗口中的曲线", exact: true })
|
||||
.click();
|
||||
const visibilityToggle = multiWindow.locator(
|
||||
'.result-chart-multi-menu-row input[type="checkbox"]',
|
||||
);
|
||||
await visibilityToggle.click();
|
||||
await expect(multiCursor).toBeDisabled();
|
||||
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await visibilityToggle.click();
|
||||
await expect(multiCursor).toBeEnabled();
|
||||
await expect(multiCursor).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "新建异单位上下对比窗口", exact: true })
|
||||
.click();
|
||||
const mixedWindow = page.locator(
|
||||
'.result-chart-window[data-chart-kind="mixed"]',
|
||||
);
|
||||
await valueVariable.dragTo(mixedWindow);
|
||||
await pressureVariable.dragTo(mixedWindow);
|
||||
const mixedChart = mixedWindow.locator('svg[data-result-chart="true"]');
|
||||
const beforeXRanges = await mixedChart.evaluate((element) => ({
|
||||
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||
}));
|
||||
await mixedWindow
|
||||
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
|
||||
.click();
|
||||
await dragChartSelection(
|
||||
page,
|
||||
mixedChart,
|
||||
{ x: 0.31, y: 0.13 },
|
||||
{ x: 0.72, y: 0.42 },
|
||||
);
|
||||
await expect(mixedChart).toHaveAttribute("data-zoomed", "true");
|
||||
const afterRanges = await mixedChart.evaluate((element) => ({
|
||||
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||
}));
|
||||
expect(afterRanges.xMin).toBeGreaterThan(beforeXRanges.xMin);
|
||||
expect(afterRanges.xMax).toBeLessThan(beforeXRanges.xMax);
|
||||
expect(afterRanges.y["generic_sensor_1.value"]).not.toEqual(
|
||||
beforeXRanges.y["generic_sensor_1.value"],
|
||||
);
|
||||
expect(afterRanges.y["generic_sensor_1.pressure"]).toEqual(
|
||||
beforeXRanges.y["generic_sensor_1.pressure"],
|
||||
);
|
||||
|
||||
const secondYAxis = mixedChart.locator('[data-chart-zoom-axis="y"]').nth(1);
|
||||
await expect(secondYAxis).toBeVisible();
|
||||
await wheelAtLocator(page, secondYAxis);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
mixedChart.evaluate((element) => {
|
||||
const ranges = JSON.parse(
|
||||
element.getAttribute("data-view-y-ranges") ?? "{}",
|
||||
);
|
||||
return JSON.stringify(ranges["generic_sensor_1.pressure"]);
|
||||
}),
|
||||
)
|
||||
.not.toBe(JSON.stringify(afterRanges.y["generic_sensor_1.pressure"]));
|
||||
const axisWheelRanges = await mixedChart.evaluate((element) => ({
|
||||
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||
}));
|
||||
|
||||
expect(axisWheelRanges.xMin).toBeCloseTo(afterRanges.xMin, 8);
|
||||
expect(axisWheelRanges.xMax).toBeCloseTo(afterRanges.xMax, 8);
|
||||
expect(axisWheelRanges.y["generic_sensor_1.value"]).toEqual(
|
||||
afterRanges.y["generic_sensor_1.value"],
|
||||
);
|
||||
expect(axisWheelRanges.y["generic_sensor_1.pressure"]).not.toEqual(
|
||||
afterRanges.y["generic_sensor_1.pressure"],
|
||||
);
|
||||
await dragMiddleAtLocator(page, secondYAxis, { x: 0, y: 0.08 });
|
||||
const bandPannedRanges = await mixedChart.evaluate((element) => ({
|
||||
xMin: Number(element.getAttribute("data-view-x-min")),
|
||||
xMax: Number(element.getAttribute("data-view-x-max")),
|
||||
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
|
||||
}));
|
||||
expect(bandPannedRanges.xMin).toBeCloseTo(axisWheelRanges.xMin, 8);
|
||||
expect(bandPannedRanges.xMax).toBeCloseTo(axisWheelRanges.xMax, 8);
|
||||
expect(bandPannedRanges.y["generic_sensor_1.value"]).toEqual(
|
||||
axisWheelRanges.y["generic_sensor_1.value"],
|
||||
);
|
||||
const beforePressure = axisWheelRanges.y["generic_sensor_1.pressure"];
|
||||
const afterPressure = bandPannedRanges.y["generic_sensor_1.pressure"];
|
||||
expect(afterPressure[1] - afterPressure[0]).toBeCloseTo(
|
||||
beforePressure[1] - beforePressure[0],
|
||||
8,
|
||||
);
|
||||
expect(afterPressure[0]).toBeGreaterThan(beforePressure[0]);
|
||||
|
||||
await expect(
|
||||
mixedChart.locator('[data-chart-clipped-series="true"]'),
|
||||
).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("result system viewport survives modeling and results tab switches", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
const pane = page.locator(".results-system-canvas .react-flow__pane");
|
||||
const paneBounds = await pane.boundingBox();
|
||||
expect(paneBounds).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
paneBounds!.x + paneBounds!.width * 0.55,
|
||||
paneBounds!.y + paneBounds!.height * 0.5,
|
||||
);
|
||||
const fitted = await readFlowViewport(page);
|
||||
await page.mouse.wheel(0, -260);
|
||||
await expect
|
||||
.poll(async () => (await readFlowViewport(page)).zoom)
|
||||
.not.toBeCloseTo(fitted.zoom, 3);
|
||||
const adjusted = await readFlowViewport(page);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((snapshotId) => {
|
||||
const raw = sessionStorage.getItem(
|
||||
`system-simulation-flow:result-system-viewport:${snapshotId}`,
|
||||
);
|
||||
return raw ? JSON.parse(raw).zoom : 0;
|
||||
}, chartWindowSnapshot.id),
|
||||
)
|
||||
.toBeCloseTo(adjusted.zoom, 3);
|
||||
|
||||
await page.getByRole("tab", { name: "建模", exact: true }).click();
|
||||
await openResults(page);
|
||||
const restored = await readFlowViewport(page);
|
||||
expect(restored.x).toBeCloseTo(adjusted.x, 1);
|
||||
expect(restored.y).toBeCloseTo(adjusted.y, 1);
|
||||
expect(restored.zoom).toBeCloseTo(adjusted.zoom, 3);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "适应系统图窗口", exact: true })
|
||||
.click();
|
||||
await expect
|
||||
.poll(async () => (await readFlowViewport(page)).zoom)
|
||||
.not.toBeCloseTo(restored.zoom, 3);
|
||||
});
|
||||
|
||||
test("zoom wheel follows the pointer and axis hit areas while cursor keeps the viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
await page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "数值" })
|
||||
.first()
|
||||
.click();
|
||||
const chartWindow = page.locator(
|
||||
'.result-chart-window[data-chart-kind="single"]',
|
||||
);
|
||||
const chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||
await expect(chart).toBeVisible();
|
||||
const cursorButton = chartWindow.getByRole("button", {
|
||||
name: "打开 数值 曲线游标",
|
||||
exact: true,
|
||||
});
|
||||
await cursorButton.click();
|
||||
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||
const zoomButton = chartWindow.getByRole("button", {
|
||||
name: "打开 数值 曲线缩放",
|
||||
exact: true,
|
||||
});
|
||||
await zoomButton.click();
|
||||
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
const initial = await readChartDomain(chart);
|
||||
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
|
||||
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
|
||||
await expect(xAxis).toBeVisible();
|
||||
await expect(yAxis).toBeVisible();
|
||||
|
||||
const xAxisBounds = await xAxis.boundingBox();
|
||||
const yAxisBounds = await yAxis.boundingBox();
|
||||
expect(xAxisBounds).not.toBeNull();
|
||||
expect(yAxisBounds).not.toBeNull();
|
||||
const plotPointer = { x: 0.25, y: 0.35 };
|
||||
const initialXAnchor =
|
||||
initial.xMin + (initial.xMax - initial.xMin) * plotPointer.x;
|
||||
const initialYAnchor =
|
||||
initial.yMax - (initial.yMax - initial.yMin) * plotPointer.y;
|
||||
await page.mouse.move(
|
||||
xAxisBounds!.x + xAxisBounds!.width * plotPointer.x,
|
||||
yAxisBounds!.y + yAxisBounds!.height * plotPointer.y,
|
||||
);
|
||||
await page.mouse.wheel(0, -240);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const current = await readChartDomain(chart);
|
||||
return current.xMax - current.xMin;
|
||||
})
|
||||
.toBeLessThan(initial.xMax - initial.xMin);
|
||||
const plotZoomed = await readChartDomain(chart);
|
||||
expect(plotZoomed.yMax - plotZoomed.yMin).toBeLessThan(
|
||||
initial.yMax - initial.yMin,
|
||||
);
|
||||
const zoomedXAnchor =
|
||||
plotZoomed.xMin + (plotZoomed.xMax - plotZoomed.xMin) * plotPointer.x;
|
||||
const zoomedYAnchor =
|
||||
plotZoomed.yMax - (plotZoomed.yMax - plotZoomed.yMin) * plotPointer.y;
|
||||
expect(
|
||||
Math.abs(zoomedXAnchor - initialXAnchor) / (initial.xMax - initial.xMin),
|
||||
).toBeLessThan(0.002);
|
||||
expect(
|
||||
Math.abs(zoomedYAnchor - initialYAnchor) / (initial.yMax - initial.yMin),
|
||||
).toBeLessThan(0.002);
|
||||
|
||||
await wheelAtLocator(page, xAxis);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const current = await readChartDomain(chart);
|
||||
return current.xMax - current.xMin;
|
||||
})
|
||||
.toBeLessThan(plotZoomed.xMax - plotZoomed.xMin);
|
||||
const xOnlyZoomed = await readChartDomain(chart);
|
||||
expect(xOnlyZoomed.yMin).toBeCloseTo(plotZoomed.yMin, 8);
|
||||
expect(xOnlyZoomed.yMax).toBeCloseTo(plotZoomed.yMax, 8);
|
||||
|
||||
await wheelAtLocator(page, yAxis);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const current = await readChartDomain(chart);
|
||||
return current.yMax - current.yMin;
|
||||
})
|
||||
.toBeLessThan(xOnlyZoomed.yMax - xOnlyZoomed.yMin);
|
||||
const yOnlyZoomed = await readChartDomain(chart);
|
||||
expect(yOnlyZoomed.xMin).toBeCloseTo(xOnlyZoomed.xMin, 8);
|
||||
expect(yOnlyZoomed.xMax).toBeCloseTo(xOnlyZoomed.xMax, 8);
|
||||
|
||||
const beforePlotPan = yOnlyZoomed;
|
||||
await dragMiddleAtLocator(page, chart, { x: 0.05, y: 0.04 });
|
||||
const plotPanned = await readChartDomain(chart);
|
||||
expect(plotPanned.xMax - plotPanned.xMin).toBeCloseTo(
|
||||
beforePlotPan.xMax - beforePlotPan.xMin,
|
||||
8,
|
||||
);
|
||||
expect(plotPanned.yMax - plotPanned.yMin).toBeCloseTo(
|
||||
beforePlotPan.yMax - beforePlotPan.yMin,
|
||||
8,
|
||||
);
|
||||
expect(plotPanned.xMin).toBeLessThan(beforePlotPan.xMin);
|
||||
expect(plotPanned.yMin).toBeGreaterThan(beforePlotPan.yMin);
|
||||
|
||||
const beforeXAxisPan = plotPanned;
|
||||
await dragMiddleAtLocator(page, xAxis, { x: 0.06, y: 0 });
|
||||
const xAxisPanned = await readChartDomain(chart);
|
||||
expect(xAxisPanned.xMax - xAxisPanned.xMin).toBeCloseTo(
|
||||
beforeXAxisPan.xMax - beforeXAxisPan.xMin,
|
||||
8,
|
||||
);
|
||||
expect(xAxisPanned.xMin).toBeLessThan(beforeXAxisPan.xMin);
|
||||
expect(xAxisPanned.yMin).toBeCloseTo(beforeXAxisPan.yMin, 8);
|
||||
expect(xAxisPanned.yMax).toBeCloseTo(beforeXAxisPan.yMax, 8);
|
||||
|
||||
const beforeYAxisPan = xAxisPanned;
|
||||
await dragMiddleAtLocator(page, yAxis, { x: 0, y: 0.06 });
|
||||
const yAxisPanned = await readChartDomain(chart);
|
||||
expect(yAxisPanned.yMax - yAxisPanned.yMin).toBeCloseTo(
|
||||
beforeYAxisPan.yMax - beforeYAxisPan.yMin,
|
||||
8,
|
||||
);
|
||||
expect(yAxisPanned.yMin).toBeGreaterThan(beforeYAxisPan.yMin);
|
||||
expect(yAxisPanned.xMin).toBeCloseTo(beforeYAxisPan.xMin, 8);
|
||||
expect(yAxisPanned.xMax).toBeCloseTo(beforeYAxisPan.xMax, 8);
|
||||
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||
.click();
|
||||
await page.mouse.move(
|
||||
xAxisBounds!.x + xAxisBounds!.width * 0.5,
|
||||
yAxisBounds!.y + yAxisBounds!.height * 0.5,
|
||||
);
|
||||
await page.mouse.wheel(0, -180);
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||
|
||||
const beforeCursor = await readChartDomain(chart);
|
||||
await cursorButton.click();
|
||||
await expect(
|
||||
chartWindow.getByRole("button", {
|
||||
name: "打开 数值 曲线缩放",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
|
||||
const withCursor = await readChartDomain(chart);
|
||||
expect(withCursor).toEqual(beforeCursor);
|
||||
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||
.click();
|
||||
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||
.click();
|
||||
await page.mouse.move(
|
||||
xAxisBounds!.x + xAxisBounds!.width * 0.5,
|
||||
yAxisBounds!.y + yAxisBounds!.height * 0.5,
|
||||
);
|
||||
await page.mouse.wheel(0, 900);
|
||||
await expect
|
||||
.poll(async () => (await readChartDomain(chart)).xMin)
|
||||
.toBeLessThan(initial.xMin);
|
||||
const expandedDomain = await readChartDomain(chart);
|
||||
expect(expandedDomain.xMax).toBeGreaterThan(initial.xMax);
|
||||
expect(expandedDomain.yMin).toBeLessThan(initial.yMin);
|
||||
expect(expandedDomain.yMax).toBeGreaterThan(initial.yMax);
|
||||
const outline = await chart.evaluate((element) => {
|
||||
element.focus();
|
||||
return getComputedStyle(element).outlineStyle;
|
||||
});
|
||||
expect(outline).toBe("none");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((snapshotId) => {
|
||||
const raw = sessionStorage.getItem(
|
||||
`system-simulation-flow:result-layout:${snapshotId}`,
|
||||
);
|
||||
const windows = raw ? JSON.parse(raw) : [];
|
||||
const single = windows.find(
|
||||
(item: { kind?: string }) => item.kind === "single",
|
||||
);
|
||||
return single?.viewport?.x?.start ?? 0;
|
||||
}, chartWindowSnapshot.id),
|
||||
)
|
||||
.toBeLessThan(0);
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||
.click();
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "false");
|
||||
const restoredDomain = await readChartDomain(chart);
|
||||
expect(restoredDomain).toEqual(initial);
|
||||
});
|
||||
|
||||
test("cursor is unavailable when a zoomed viewport contains no curve", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await openResults(page);
|
||||
await page
|
||||
.locator(".results-variable-list button")
|
||||
.filter({ hasText: "数值" })
|
||||
.first()
|
||||
.click();
|
||||
const chartWindow = page.locator(
|
||||
'.result-chart-window[data-chart-kind="single"]',
|
||||
);
|
||||
const chart = chartWindow.locator('svg[data-result-chart="true"]');
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
|
||||
.click();
|
||||
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
|
||||
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
|
||||
const xBounds = await xAxis.boundingBox();
|
||||
const yBounds = await yAxis.boundingBox();
|
||||
expect(xBounds).not.toBeNull();
|
||||
expect(yBounds).not.toBeNull();
|
||||
await page.mouse.move(
|
||||
xBounds!.x + xBounds!.width * 0.08,
|
||||
yBounds!.y + yBounds!.height * 0.08,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
xBounds!.x + xBounds!.width * 0.32,
|
||||
yBounds!.y + yBounds!.height * 0.28,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(chart).toHaveAttribute("data-zoomed", "true");
|
||||
const cursorButton = chartWindow.locator("button.cursor");
|
||||
await expect(cursorButton).toBeDisabled();
|
||||
await chartWindow
|
||||
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
|
||||
.click();
|
||||
await expect(cursorButton).toBeEnabled();
|
||||
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
Reference in new issue
Block a user