Fixed zoom-aware graph pan bounds in the dependency graph plugin, correcting edge-case behavior when the graph is zoomed or panned. Added regression tests for graph interactions and updated documentation to cover the corrected behavior. Fusion-Task-Id: FN-4036
237 lines
7.6 KiB
TypeScript
237 lines
7.6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import type React from "react";
|
|
import { useGraphInteraction } from "../useGraphInteraction";
|
|
|
|
function createKeyEvent(
|
|
key: string,
|
|
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; target?: EventTarget | null },
|
|
) {
|
|
return {
|
|
key,
|
|
ctrlKey: Boolean(options?.ctrlKey),
|
|
metaKey: Boolean(options?.metaKey),
|
|
shiftKey: Boolean(options?.shiftKey),
|
|
target: options?.target ?? document.createElement("div"),
|
|
preventDefault: vi.fn(),
|
|
} as unknown as React.KeyboardEvent;
|
|
}
|
|
|
|
describe("useGraphInteraction", () => {
|
|
it("starts with default pan/zoom", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
expect(result.current.zoom).toBe(1);
|
|
expect(result.current.zoomPercent).toBe(100);
|
|
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it("clamps zoom between 0.1 and 3", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
for (let i = 0; i < 100; i += 1) result.current.zoomOut();
|
|
});
|
|
expect(result.current.zoom).toBe(0.1);
|
|
|
|
act(() => {
|
|
for (let i = 0; i < 100; i += 1) result.current.zoomIn();
|
|
});
|
|
expect(result.current.zoom).toBe(3);
|
|
});
|
|
|
|
it("keeps wheel zoom anchored to cursor position", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.onWheelZoom(-120, { x: 200, y: 150 }, 800, 600);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(1.1);
|
|
expect(result.current.pan.x).toBeCloseTo(-20, 5);
|
|
expect(result.current.pan.y).toBeCloseTo(-15, 5);
|
|
});
|
|
|
|
it("supports pinch zoom with stationary midpoint", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.onPointerDown(1, { x: 100, y: 100 });
|
|
result.current.onPointerDown(2, { x: 200, y: 100 });
|
|
result.current.onPointerMove(2, { x: 250, y: 100 }, 800, 600);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(1.5);
|
|
expect(result.current.pan).toEqual({ x: -50, y: -50 });
|
|
});
|
|
|
|
it("continues with pan after pinch when one pointer remains", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.onPointerDown(1, { x: 100, y: 100 });
|
|
result.current.onPointerDown(2, { x: 200, y: 100 });
|
|
result.current.onPointerMove(2, { x: 250, y: 100 }, 800, 600);
|
|
result.current.onPointerUp(2);
|
|
result.current.onPointerMove(1, { x: 120, y: 130 }, 800, 600);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(1.5);
|
|
expect(result.current.pan).toEqual({ x: 20, y: 30 });
|
|
});
|
|
|
|
it("applies animation state for fit and reset", () => {
|
|
vi.useFakeTimers();
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
|
|
});
|
|
expect(result.current.transitioning).toBe(true);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(210);
|
|
});
|
|
expect(result.current.transitioning).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.resetView();
|
|
});
|
|
expect(result.current.transitioning).toBe(true);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(210);
|
|
});
|
|
expect(result.current.transitioning).toBe(false);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("fits wide graph", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
act(() => {
|
|
result.current.fitToGraph(new Map([
|
|
["A", { x: 0, y: 0 }],
|
|
["B", { x: 2000, y: 0 }],
|
|
]), 800, 600);
|
|
});
|
|
|
|
expect(result.current.zoom).toBeLessThan(1);
|
|
});
|
|
|
|
it("handles keyboard shortcuts for zoom in/out, reset, fit, and escape", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
const positions = new Map([
|
|
["A", { x: 0, y: 0 }],
|
|
["B", { x: 500, y: 200 }],
|
|
]);
|
|
|
|
act(() => {
|
|
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true }), 800, 600, positions);
|
|
});
|
|
expect(result.current.zoom).toBe(1.2);
|
|
|
|
act(() => {
|
|
result.current.handleKeyDown(createKeyEvent("-", { ctrlKey: true }), 800, 600, positions);
|
|
});
|
|
expect(result.current.zoom).toBeCloseTo(1, 5);
|
|
|
|
act(() => {
|
|
result.current.handleKeyDown(createKeyEvent("F", { ctrlKey: true, shiftKey: true }), 800, 600, positions, { nodeWidth: 280, nodeHeight: 100 });
|
|
});
|
|
expect(result.current.zoom).toBeLessThan(1);
|
|
|
|
act(() => {
|
|
result.current.handleKeyDown(createKeyEvent("0", { ctrlKey: true }), 800, 600, positions);
|
|
});
|
|
expect(result.current.zoom).toBe(1);
|
|
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
|
|
act(() => {
|
|
result.current.zoomIn();
|
|
result.current.handleKeyDown(createKeyEvent("Escape"), 800, 600, positions);
|
|
});
|
|
expect(result.current.zoom).toBe(1);
|
|
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it("does not run shortcuts when focused on editable targets", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
const input = document.createElement("input");
|
|
const positions = new Map([["A", { x: 0, y: 0 }]]);
|
|
|
|
act(() => {
|
|
result.current.handleKeyDown(createKeyEvent("=", { ctrlKey: true, target: input }), 800, 600, positions);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(1);
|
|
});
|
|
|
|
it("resets when positions are empty", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.zoomIn();
|
|
result.current.onPointerDown(1, { x: 10, y: 10 });
|
|
result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
|
|
result.current.onPointerUp(1);
|
|
result.current.fitToGraph(new Map(), 800, 600);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(1);
|
|
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
});
|
|
|
|
it("allows panning across full graph width when zoomed to 2x", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2000, maxY: 1200 });
|
|
for (let i = 0; i < 10; i += 1) {
|
|
result.current.zoomIn();
|
|
}
|
|
result.current.onPointerDown(1, { x: 200, y: 200 });
|
|
result.current.onPointerMove(1, { x: 1200, y: 200 }, 800, 600);
|
|
result.current.onPointerMove(1, { x: -2200, y: 200 }, 800, 600);
|
|
result.current.onPointerUp(1);
|
|
});
|
|
|
|
expect(result.current.zoom).toBeGreaterThanOrEqual(2);
|
|
expect(result.current.pan.x).toBeGreaterThanOrEqual(800 - 2000 * result.current.zoom);
|
|
expect(result.current.pan.x).toBeLessThanOrEqual(0);
|
|
});
|
|
|
|
it("uses zoom-aware pan limits at max zoom", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.setGraphBounds({ minX: 0, minY: 0, maxX: 2000, maxY: 1200 });
|
|
for (let i = 0; i < 30; i += 1) {
|
|
result.current.zoomIn();
|
|
}
|
|
});
|
|
|
|
act(() => {
|
|
result.current.onPointerDown(1, { x: 300, y: 200 });
|
|
result.current.onPointerMove(1, { x: -6000, y: 200 }, 800, 600);
|
|
result.current.onPointerUp(1);
|
|
});
|
|
|
|
expect(result.current.zoom).toBe(3);
|
|
expect(result.current.pan.x).toBe(800 - 2000 * 3);
|
|
});
|
|
|
|
it("fits graphs with negative coordinates using full min/max bounds", () => {
|
|
const { result } = renderHook(() => useGraphInteraction());
|
|
|
|
act(() => {
|
|
result.current.fitToGraph(new Map([
|
|
["A", { x: -500, y: -200 }],
|
|
["B", { x: 900, y: 500 }],
|
|
]), 900, 700, { nodeWidth: 280, nodeHeight: 100 });
|
|
});
|
|
|
|
expect(result.current.zoom).toBeGreaterThan(0.1);
|
|
expect(result.current.pan.x).toBeLessThanOrEqual(550);
|
|
expect(result.current.pan.y).toBeLessThanOrEqual(260);
|
|
});
|
|
});
|