fix(agent-graph): stabilize drag and pan interactions
This commit is contained in:
parent
2fd06fcd48
commit
fb3d1ceb27
8 changed files with 692 additions and 69 deletions
|
|
@ -4,7 +4,7 @@
|
|||
* All state in refs — no React re-renders.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||
import type { WorldBounds } from '../layout/launchAnchor';
|
||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
|||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
}),
|
||||
[
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Delegates hit testing to strategy pattern.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { ANIM } from '../constants/canvas-constants';
|
||||
import { findNodeAt } from '../canvas/hit-detection';
|
||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
|||
return findNodeAt(wx, wy, nodes);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDoubleClick,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDoubleClick,
|
||||
}),
|
||||
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||
import { getStateColor } from '../constants/colors';
|
||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function applySnapshotToNodes(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
findNodeAt,
|
||||
getEdgeMidpoint,
|
||||
} from '../canvas/hit-detection';
|
||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||
|
||||
export interface GraphViewProps {
|
||||
|
|
@ -148,13 +148,6 @@ export function GraphView({
|
|||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||
const simulation = useGraphSimulation();
|
||||
const camera = useGraphCamera();
|
||||
|
||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||
const simulationRef = useRef(simulation);
|
||||
simulationRef.current = simulation;
|
||||
const cameraRef = useRef(camera);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const interaction = useGraphInteraction(
|
||||
useCallback(
|
||||
(nodeId: string, x: number, y: number) => {
|
||||
|
|
@ -164,6 +157,20 @@ export function GraphView({
|
|||
)
|
||||
);
|
||||
|
||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||
const simulationRef = useRef(simulation);
|
||||
simulationRef.current = simulation;
|
||||
const cameraRef = useRef(camera);
|
||||
cameraRef.current = camera;
|
||||
const interactionRef = useRef(interaction);
|
||||
interactionRef.current = interaction;
|
||||
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||
null
|
||||
);
|
||||
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const getVisibleNodes = useCallback(
|
||||
(nodes: GraphNode[]): GraphNode[] =>
|
||||
nodes.filter((node) => {
|
||||
|
|
@ -433,16 +440,16 @@ export function GraphView({
|
|||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isSurfaceActive) {
|
||||
if (isSurfaceActive) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
simulation.clearTransientOwnerPositions();
|
||||
interactionRef.current.handleMouseUp();
|
||||
simulationRef.current.clearTransientOwnerPositions();
|
||||
dragPreviewRef.current = null;
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
setInteractionGuards(false);
|
||||
}, [interaction, isSurfaceActive, simulation]);
|
||||
}, [isSurfaceActive, setInteractionGuards]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
|
|
@ -454,7 +461,13 @@ export function GraphView({
|
|||
|
||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||
const isPanningRef = useRef(false);
|
||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
||||
const edgeMouseDownRef = useRef<{
|
||||
id: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -491,7 +504,13 @@ export function GraphView({
|
|||
if (hitEdge) {
|
||||
markUserInteracted();
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
|
||||
edgeMouseDownRef.current = {
|
||||
id: hitEdge,
|
||||
worldX: world.x,
|
||||
worldY: world.y,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
};
|
||||
hoveredEdgeIdRef.current = hitEdge;
|
||||
} else {
|
||||
// Hit empty space → pan
|
||||
|
|
@ -518,11 +537,6 @@ export function GraphView({
|
|||
|
||||
const processActivePointerMove = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!activePrimaryInteractionRef.current) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPanningRef.current) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
|
|
@ -531,6 +545,36 @@ export function GraphView({
|
|||
return true;
|
||||
}
|
||||
|
||||
const edgeMouseDown = edgeMouseDownRef.current;
|
||||
if (
|
||||
edgeMouseDown &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current
|
||||
) {
|
||||
const dx = clientX - edgeMouseDown.clientX;
|
||||
const dy = clientY - edgeMouseDown.clientY;
|
||||
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
hoveredEdgeIdRef.current = null;
|
||||
edgeMouseDownRef.current = null;
|
||||
isPanningRef.current = true;
|
||||
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
||||
camera.handlePanMove(clientX, clientY);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current
|
||||
) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) {
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -627,8 +671,8 @@ export function GraphView({
|
|||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
const dx = world.x - edgeMouseDownRef.current.x;
|
||||
const dy = world.y - edgeMouseDownRef.current.y;
|
||||
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||
if (dx * dx + dy * dy <= 25) {
|
||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||
}
|
||||
|
|
@ -656,6 +700,8 @@ export function GraphView({
|
|||
},
|
||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||
);
|
||||
processActivePointerMoveRef.current = processActivePointerMove;
|
||||
completePointerInteractionRef.current = completePointerInteraction;
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -711,36 +757,40 @@ export function GraphView({
|
|||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
processActivePointerMove(event.clientX, event.clientY);
|
||||
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
setInteractionGuards(false);
|
||||
return;
|
||||
}
|
||||
completePointerInteraction(event.clientX, event.clientY);
|
||||
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const clearInteraction = (): void => {
|
||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interactionRef.current.isDragging.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
camera.handlePanEnd();
|
||||
interactionRef.current.handleMouseUp();
|
||||
cameraRef.current.handlePanEnd();
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -756,9 +806,14 @@ export function GraphView({
|
|||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||
window.removeEventListener('blur', clearInteraction);
|
||||
window.removeEventListener('dragstart', clearInteraction);
|
||||
};
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setInteractionGuards(false);
|
||||
};
|
||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
|
|||
400
test/renderer/features/agent-graph/GraphView.test.ts
Normal file
400
test/renderer/features/agent-graph/GraphView.test.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
handlePanStart: vi.fn(),
|
||||
handlePanMove: vi.fn(),
|
||||
handlePanEnd: vi.fn(),
|
||||
zoomToFit: vi.fn(),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
updateInertia: vi.fn(),
|
||||
interaction: {
|
||||
hoveredNodeId: { current: null as string | null },
|
||||
dragNodeId: { current: null as string | null },
|
||||
isDragging: { current: false },
|
||||
handleMouseDown: vi.fn(),
|
||||
handleMouseMove: vi.fn(),
|
||||
handleMouseUp: vi.fn(() => null),
|
||||
handleDoubleClick: vi.fn(() => null),
|
||||
},
|
||||
simulationState: {
|
||||
nodes: [] as GraphNode[],
|
||||
edges: [] as GraphEdge[],
|
||||
particles: [],
|
||||
effects: [],
|
||||
time: 0,
|
||||
},
|
||||
clearTransientOwnerPositions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({
|
||||
useGraphCamera: () => ({
|
||||
transformRef: { current: { x: 0, y: 0, zoom: 1 } },
|
||||
screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }),
|
||||
worldToScreen: (wx: number, wy: number) => ({ x: wx, y: wy }),
|
||||
handleWheel: vi.fn(),
|
||||
handlePanStart: hoisted.handlePanStart,
|
||||
handlePanMove: hoisted.handlePanMove,
|
||||
handlePanEnd: hoisted.handlePanEnd,
|
||||
zoomToFit: hoisted.zoomToFit,
|
||||
zoomIn: hoisted.zoomIn,
|
||||
zoomOut: hoisted.zoomOut,
|
||||
updateInertia: hoisted.updateInertia,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphInteraction', () => ({
|
||||
useGraphInteraction: () => hoisted.interaction,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => ({
|
||||
useGraphSimulation: () => ({
|
||||
stateRef: { current: hoisted.simulationState },
|
||||
updateData: vi.fn(),
|
||||
tick: vi.fn(),
|
||||
getExtraWorldBounds: vi.fn(() => []),
|
||||
getLaunchAnchorWorldPosition: vi.fn(() => null),
|
||||
getActivityWorldRect: vi.fn(() => null),
|
||||
resolveNearestOwnerSlot: vi.fn(() => null),
|
||||
clearNodePosition: vi.fn(),
|
||||
clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions,
|
||||
setNodePosition: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({
|
||||
GraphControls: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({
|
||||
GraphOverlay: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../packages/agent-graph/src/ui/GraphEdgeOverlay', () => ({
|
||||
GraphEdgeOverlay: () => null,
|
||||
}));
|
||||
|
||||
import { GraphView } from '../../../../packages/agent-graph/src/ui/GraphView';
|
||||
|
||||
describe('GraphView pan interactions', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let originalGetBoundingClientRect: typeof HTMLCanvasElement.prototype.getBoundingClientRect;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
hoisted.interaction.hoveredNodeId.current = null;
|
||||
hoisted.interaction.dragNodeId.current = null;
|
||||
hoisted.interaction.isDragging.current = false;
|
||||
hoisted.simulationState.nodes = [];
|
||||
hoisted.simulationState.edges = [];
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
);
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
originalGetBoundingClientRect = HTMLCanvasElement.prototype.getBoundingClientRect;
|
||||
HTMLCanvasElement.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
|
||||
return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 600 });
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
HTMLCanvasElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => {
|
||||
const source: GraphNode = {
|
||||
id: 'member:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||
};
|
||||
const target: GraphNode = {
|
||||
id: 'task:1',
|
||||
kind: 'task',
|
||||
label: 'Task 1',
|
||||
state: 'idle',
|
||||
x: 160,
|
||||
y: 90,
|
||||
domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' },
|
||||
};
|
||||
const edge: GraphEdge = {
|
||||
id: 'edge:blocking',
|
||||
source: source.id,
|
||||
target: target.id,
|
||||
type: 'blocking',
|
||||
};
|
||||
hoisted.simulationState.nodes = [source, target];
|
||||
hoisted.simulationState.edges = [edge];
|
||||
|
||||
const midpoint = getEdgeMidpoint(edge, new Map([
|
||||
[source.id, source],
|
||||
[target.id, target],
|
||||
]));
|
||||
expect(midpoint).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source, target],
|
||||
edges: [edge],
|
||||
particles: [],
|
||||
},
|
||||
config: { animationEnabled: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: midpoint!.x,
|
||||
clientY: midpoint!.y,
|
||||
})
|
||||
);
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: midpoint!.x + 24,
|
||||
clientY: midpoint!.y + 4,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.handlePanStart).toHaveBeenCalledWith(midpoint!.x, midpoint!.y);
|
||||
expect(hoisted.handlePanMove).toHaveBeenCalledWith(midpoint!.x + 24, midpoint!.y + 4);
|
||||
});
|
||||
|
||||
it('does not clear pan state on the rerender triggered by interaction lock', async () => {
|
||||
const source: GraphNode = {
|
||||
id: 'member:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||
};
|
||||
hoisted.simulationState.nodes = [source];
|
||||
hoisted.simulationState.edges = [];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source],
|
||||
edges: [],
|
||||
particles: [],
|
||||
},
|
||||
config: { animationEnabled: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 320,
|
||||
clientY: 220,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 352,
|
||||
clientY: 248,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.handlePanStart).toHaveBeenCalledWith(320, 220);
|
||||
expect(hoisted.handlePanMove).toHaveBeenCalledWith(352, 248);
|
||||
});
|
||||
|
||||
it('does not force-handleMouseUp when props rerender during an active member drag', async () => {
|
||||
const source: GraphNode = {
|
||||
id: 'member:demo-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
x: 80,
|
||||
y: 80,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||
};
|
||||
hoisted.simulationState.nodes = [source];
|
||||
hoisted.simulationState.edges = [];
|
||||
hoisted.interaction.handleMouseDown.mockImplementation(() => {
|
||||
hoisted.interaction.dragNodeId.current = source.id;
|
||||
});
|
||||
hoisted.interaction.handleMouseMove.mockImplementation(() => {
|
||||
hoisted.interaction.isDragging.current = true;
|
||||
});
|
||||
|
||||
const firstEvents = {};
|
||||
const secondEvents = {};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source],
|
||||
edges: [],
|
||||
particles: [],
|
||||
},
|
||||
events: firstEvents,
|
||||
config: { animationEnabled: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 80,
|
||||
clientY: 80,
|
||||
})
|
||||
);
|
||||
canvas!.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 95,
|
||||
clientY: 95,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.interaction.isDragging.current).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source],
|
||||
edges: [],
|
||||
particles: [],
|
||||
},
|
||||
events: secondEvents,
|
||||
config: { animationEnabled: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 112,
|
||||
clientY: 112,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.interaction.handleMouseMove).toHaveBeenCalled();
|
||||
expect(hoisted.interaction.isDragging.current).toBe(true);
|
||||
});
|
||||
|
||||
it('clears drag state when the graph surface becomes inactive', async () => {
|
||||
const source: GraphNode = {
|
||||
id: 'member:demo-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'idle',
|
||||
x: 80,
|
||||
y: 80,
|
||||
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
|
||||
};
|
||||
hoisted.simulationState.nodes = [source];
|
||||
hoisted.simulationState.edges = [];
|
||||
hoisted.interaction.dragNodeId.current = source.id;
|
||||
hoisted.interaction.isDragging.current = true;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source],
|
||||
edges: [],
|
||||
particles: [],
|
||||
},
|
||||
config: { animationEnabled: false },
|
||||
isSurfaceActive: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
|
||||
expect(hoisted.clearTransientOwnerPositions).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(GraphView, {
|
||||
data: {
|
||||
teamName: 'demo-team',
|
||||
nodes: [source],
|
||||
edges: [],
|
||||
particles: [],
|
||||
},
|
||||
config: { animationEnabled: false },
|
||||
isSurfaceActive: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,15 +7,29 @@ import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/
|
|||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
let capturedCamera: UseGraphCameraResult | null = null;
|
||||
let firstCamera: UseGraphCameraResult | null = null;
|
||||
let secondCamera: UseGraphCameraResult | null = null;
|
||||
|
||||
function CameraHarness(): React.JSX.Element | null {
|
||||
capturedCamera = useGraphCamera();
|
||||
return null;
|
||||
}
|
||||
|
||||
function CameraIdentityHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||
const camera = useGraphCamera();
|
||||
if (pass === 1) {
|
||||
firstCamera = camera;
|
||||
} else {
|
||||
secondCamera = camera;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useGraphCamera zoomToFit', () => {
|
||||
afterEach(() => {
|
||||
capturedCamera = null;
|
||||
firstCamera = null;
|
||||
secondCamera = null;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
|
|
@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a referentially stable result across rerenders', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CameraIdentityHarness, { pass: 1 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CameraIdentityHarness, { pass: 2 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(firstCamera).toBeTruthy();
|
||||
expect(secondCamera).toBe(firstCamera);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useGraphInteraction, type UseGraphInteractionResult } from '../../../../packages/agent-graph/src/hooks/useGraphInteraction';
|
||||
|
||||
let firstInteraction: UseGraphInteractionResult | null = null;
|
||||
let secondInteraction: UseGraphInteractionResult | null = null;
|
||||
|
||||
function InteractionHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||
const interaction = useGraphInteraction();
|
||||
if (pass === 1) {
|
||||
firstInteraction = interaction;
|
||||
} else {
|
||||
secondInteraction = interaction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useGraphInteraction', () => {
|
||||
afterEach(() => {
|
||||
firstInteraction = null;
|
||||
secondInteraction = null;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns a referentially stable result across rerenders', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(InteractionHarness, { pass: 1 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(InteractionHarness, { pass: 2 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(firstInteraction).toBeTruthy();
|
||||
expect(secondInteraction).toBe(firstInteraction);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation';
|
||||
|
||||
let firstSimulation: UseGraphSimulationResult | null = null;
|
||||
let secondSimulation: UseGraphSimulationResult | null = null;
|
||||
|
||||
function SimulationHarness({ pass }: { pass: number }): React.JSX.Element | null {
|
||||
const simulation = useGraphSimulation();
|
||||
if (pass === 1) {
|
||||
firstSimulation = simulation;
|
||||
} else {
|
||||
secondSimulation = simulation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useGraphSimulation', () => {
|
||||
afterEach(() => {
|
||||
firstSimulation = null;
|
||||
secondSimulation = null;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns a referentially stable result across rerenders', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(SimulationHarness, { pass: 1 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(SimulationHarness, { pass: 2 }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(firstSimulation).toBeTruthy();
|
||||
expect(secondSimulation).toBe(firstSimulation);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue