ec34f85006
将画布相关 hook 测试改为直接验证公开返回值 移除组件测试中对 class、图标和测试专用 DOM 状态的脆弱断言 新增 React 组件测试准则并同步项目记忆
292 lines
8.2 KiB
TypeScript
292 lines
8.2 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, fireEvent, renderHook } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
|
|
|
|
function createLayer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
|
const id = overrides.id ?? 'layer-a';
|
|
return {
|
|
id,
|
|
resourceId: `resource-${id}`,
|
|
title: id,
|
|
src: `data:image/png;base64,${id}`,
|
|
x: 100,
|
|
y: 120,
|
|
width: 200,
|
|
height: 100,
|
|
originalWidth: 200,
|
|
originalHeight: 100,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function setElementBox(
|
|
element: HTMLElement,
|
|
box: { width: number; height: number; left?: number; top?: number },
|
|
) {
|
|
Object.defineProperty(element, 'clientWidth', {
|
|
configurable: true,
|
|
value: box.width,
|
|
});
|
|
Object.defineProperty(element, 'clientHeight', {
|
|
configurable: true,
|
|
value: box.height,
|
|
});
|
|
element.getBoundingClientRect = () =>
|
|
({
|
|
x: box.left ?? 0,
|
|
y: box.top ?? 0,
|
|
left: box.left ?? 0,
|
|
top: box.top ?? 0,
|
|
right: (box.left ?? 0) + box.width,
|
|
bottom: (box.top ?? 0) + box.height,
|
|
width: box.width,
|
|
height: box.height,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
}
|
|
|
|
function createViewportLayers() {
|
|
return [
|
|
createLayer({ id: 'one', x: 0, y: 0, width: 400, height: 300 }),
|
|
createLayer({ id: 'two', x: 600, y: 100, width: 200, height: 200 }),
|
|
];
|
|
}
|
|
|
|
function renderViewportControls({
|
|
captureCanvasHistory = vi.fn(),
|
|
layers = createViewportLayers(),
|
|
}: {
|
|
captureCanvasHistory?: () => void;
|
|
layers?: CanvasLayer[];
|
|
} = {}) {
|
|
const viewportElement = document.createElement('div');
|
|
setElementBox(viewportElement, { width: 900, height: 640 });
|
|
document.body.append(viewportElement);
|
|
|
|
const minimapElement = document.createElement('div');
|
|
minimapElement.className = 'image-canvas-editor__minimap';
|
|
setElementBox(minimapElement, {
|
|
left: 20,
|
|
top: 30,
|
|
width: 160,
|
|
height: 120,
|
|
});
|
|
document.body.append(minimapElement);
|
|
|
|
const canvasViewportRef = { current: viewportElement };
|
|
const hook = renderHook(() =>
|
|
useImageCanvasViewportControls({
|
|
canvasViewportRef,
|
|
layers,
|
|
captureCanvasHistory,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
...hook,
|
|
viewportElement,
|
|
captureCanvasHistory,
|
|
};
|
|
}
|
|
|
|
function createMinimapDragState(minimapScale: number) {
|
|
return {
|
|
kind: 'minimap' as const,
|
|
pointerId: 1,
|
|
startClientX: 100,
|
|
startClientY: 100,
|
|
startViewport: { x: -100, y: -50, scale: 1 },
|
|
minimapScale,
|
|
moved: true,
|
|
};
|
|
}
|
|
|
|
function expectViewport(viewport: CanvasViewport, expected: CanvasViewport) {
|
|
expect(viewport.x).toBeCloseTo(expected.x);
|
|
expect(viewport.y).toBeCloseTo(expected.y);
|
|
expect(viewport.scale).toBeCloseTo(expected.scale);
|
|
}
|
|
|
|
describe('useImageCanvasViewportControls', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it('owns canvas size, minimap model and canvas point helpers', () => {
|
|
const { result } = renderViewportControls();
|
|
|
|
expect(result.current.canvasSize).toEqual({ width: 900, height: 640 });
|
|
expect(result.current.minimapModel?.layers).toHaveLength(2);
|
|
expect(result.current.getCanvasDropPoint(260, 190)).toEqual({
|
|
x: 260,
|
|
y: 190,
|
|
});
|
|
const worldPoint = result.current.getCanvasPointFromClient(260, 190);
|
|
expect(worldPoint.x).toBeCloseTo(1040);
|
|
expect(worldPoint.y).toBeCloseTo(240);
|
|
});
|
|
|
|
it('fits layers and zooms from the viewport center', () => {
|
|
const captureCanvasHistory = vi.fn();
|
|
const { result } = renderViewportControls({ captureCanvasHistory });
|
|
|
|
act(() => {
|
|
result.current.fitLayers();
|
|
});
|
|
expectViewport(result.current.viewport, {
|
|
x: 50,
|
|
y: 170,
|
|
scale: 1,
|
|
});
|
|
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
result.current.updateScaleFromCenter(2);
|
|
});
|
|
expectViewport(result.current.viewport, {
|
|
x: -350,
|
|
y: 20,
|
|
scale: 2,
|
|
});
|
|
expect(captureCanvasHistory).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('handles vertical wheel scroll, ctrl wheel zoom and minimap movement', () => {
|
|
const rafCallbacks: FrameRequestCallback[] = [];
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
|
(callback: FrameRequestCallback) => {
|
|
rafCallbacks.push(callback);
|
|
return rafCallbacks.length;
|
|
},
|
|
);
|
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
|
const { result, viewportElement } = renderViewportControls();
|
|
|
|
act(() => {
|
|
fireEvent.wheel(viewportElement, {
|
|
deltaY: 120,
|
|
clientX: 260,
|
|
clientY: 190,
|
|
});
|
|
});
|
|
expectViewport(result.current.viewport, {
|
|
x: -260,
|
|
y: -50,
|
|
scale: 0.5,
|
|
});
|
|
|
|
act(() => {
|
|
fireEvent.wheel(viewportElement, {
|
|
ctrlKey: true,
|
|
deltaY: -120,
|
|
clientX: 260,
|
|
clientY: 190,
|
|
});
|
|
});
|
|
expectViewport(result.current.viewport, {
|
|
x: -312,
|
|
y: -74,
|
|
scale: 0.55,
|
|
});
|
|
|
|
const beforeDrag = result.current.viewport;
|
|
act(() => {
|
|
result.current.updateViewportFromMinimapDrag(
|
|
createMinimapDragState(result.current.minimapModel?.scale ?? 1),
|
|
104,
|
|
103,
|
|
);
|
|
});
|
|
act(() => {
|
|
rafCallbacks.shift()?.(performance.now());
|
|
});
|
|
expect(result.current.viewport).not.toEqual(beforeDrag);
|
|
|
|
const beforeClick = result.current.viewport;
|
|
act(() => {
|
|
result.current.moveViewportFromMinimapPointer(100, 90);
|
|
});
|
|
expect(result.current.viewport).not.toEqual(beforeClick);
|
|
});
|
|
|
|
it('coalesces minimap drag viewport updates into one animation frame', () => {
|
|
const rafCallbacks: FrameRequestCallback[] = [];
|
|
const requestAnimationFrameSpy = vi
|
|
.spyOn(window, 'requestAnimationFrame')
|
|
.mockImplementation((callback: FrameRequestCallback) => {
|
|
rafCallbacks.push(callback);
|
|
return rafCallbacks.length;
|
|
});
|
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
|
const { result } = renderViewportControls();
|
|
|
|
const initialViewport = result.current.viewport;
|
|
const dragState = createMinimapDragState(
|
|
result.current.minimapModel?.scale ?? 1,
|
|
);
|
|
act(() => {
|
|
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
|
result.current.updateViewportFromMinimapDrag(dragState, 112, 109);
|
|
});
|
|
|
|
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
|
expect(result.current.viewport).toEqual(initialViewport);
|
|
|
|
act(() => {
|
|
rafCallbacks.shift()?.(performance.now());
|
|
});
|
|
const coalescedViewport = result.current.viewport;
|
|
expect(coalescedViewport).not.toEqual(initialViewport);
|
|
|
|
act(() => {
|
|
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
|
});
|
|
act(() => {
|
|
rafCallbacks.shift()?.(performance.now());
|
|
});
|
|
expect(result.current.viewport).not.toEqual(coalescedViewport);
|
|
});
|
|
|
|
it('flushes a pending minimap drag before the next animation frame', () => {
|
|
const rafCallbacks: FrameRequestCallback[] = [];
|
|
const cancelAnimationFrameSpy = vi
|
|
.spyOn(window, 'cancelAnimationFrame')
|
|
.mockImplementation(() => {});
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
|
(callback: FrameRequestCallback) => {
|
|
rafCallbacks.push(callback);
|
|
return rafCallbacks.length;
|
|
},
|
|
);
|
|
const { result } = renderViewportControls();
|
|
const initialViewport = result.current.viewport;
|
|
const dragState = createMinimapDragState(
|
|
result.current.minimapModel?.scale ?? 1,
|
|
);
|
|
|
|
act(() => {
|
|
result.current.updateViewportFromMinimapDrag(dragState, 104, 103);
|
|
result.current.updateViewportFromMinimapDrag(dragState, 112, 109);
|
|
});
|
|
act(() => {
|
|
result.current.flushMinimapViewportDrag();
|
|
});
|
|
|
|
expect(cancelAnimationFrameSpy).toHaveBeenCalledTimes(1);
|
|
const flushedViewport = result.current.viewport;
|
|
expect(flushedViewport).not.toEqual(initialViewport);
|
|
|
|
act(() => {
|
|
rafCallbacks.shift()?.(performance.now());
|
|
});
|
|
expect(result.current.viewport).toEqual(flushedViewport);
|
|
});
|
|
});
|