Files
Genarrative/src/components/image-editor/useImageCanvasViewportControls.test.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

434 lines
12 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 = 'genarrative-image-canvas__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();
vi.unstubAllGlobals();
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('animates, retargets from the current frame and lets manual input cancel the transition', () => {
const rafCallbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
nextFrameId += 1;
rafCallbacks.set(nextFrameId, callback);
return nextFrameId;
});
const cancelAnimationFrameSpy = vi
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation((frameId) => {
rafCallbacks.delete(frameId);
});
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia,
);
const { result, viewportElement, captureCanvasHistory } =
renderViewportControls();
const targetViewport = { x: 120, y: -80, scale: 1.25 };
const runNextFrame = (timestamp: number) => {
const entry = rafCallbacks.entries().next().value as
| [number, FrameRequestCallback]
| undefined;
expect(entry).toBeTruthy();
if (!entry) {
return;
}
rafCallbacks.delete(entry[0]);
entry[1](timestamp);
};
act(() => {
result.current.animateViewportTo(targetViewport);
});
expectViewport(result.current.viewport, {
x: -260,
y: 70,
scale: 0.5,
});
act(() => runNextFrame(0));
act(() => runNextFrame(210));
expect(result.current.viewport.x).toBeGreaterThan(-260);
expect(result.current.viewport.x).toBeLessThan(120);
expect(result.current.viewport.scale).toBeGreaterThan(0.5);
expect(result.current.viewport.scale).toBeLessThan(1.25);
const retargetedViewport = { x: 420, y: 260, scale: 1.6 };
act(() => {
result.current.animateViewportTo(retargetedViewport);
});
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
act(() => runNextFrame(300));
act(() => runNextFrame(720));
expectViewport(result.current.viewport, retargetedViewport);
expect(captureCanvasHistory).not.toHaveBeenCalled();
act(() => {
result.current.animateViewportTo({ x: 600, y: 400, scale: 2 });
});
act(() => runNextFrame(800));
act(() => {
fireEvent.wheel(viewportElement, { deltaY: 40 });
});
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(rafCallbacks.size).toBe(0);
});
it('finishes viewport animation immediately when reduced motion is requested', () => {
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame');
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true })) as unknown as typeof window.matchMedia,
);
const { result } = renderViewportControls();
const targetViewport = { x: 20, y: 30, scale: 0.8 };
act(() => {
result.current.animateViewportTo(targetViewport);
});
expectViewport(result.current.viewport, targetViewport);
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
});
it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => {
const { result, viewportElement } = renderViewportControls();
act(() => {
fireEvent.wheel(viewportElement, {
deltaX: 0,
deltaY: 120,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -260,
y: -50,
scale: 0.5,
});
act(() => {
fireEvent.wheel(viewportElement, {
shiftKey: true,
deltaX: 0,
deltaY: 80,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -340,
y: -50,
scale: 0.5,
});
});
it('does not pan the canvas when a generation prompt receives wheel input', () => {
const { result, viewportElement } = renderViewportControls();
const prompt = document.createElement('textarea');
viewportElement.append(prompt);
const initialViewport = result.current.viewport;
const wheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 260,
clientY: 190,
});
act(() => {
prompt.dispatchEvent(wheelEvent);
});
expect(result.current.viewport).toEqual(initialViewport);
expect(wheelEvent.defaultPrevented).toBe(false);
});
it('handles native two-axis wheel pan, 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, {
deltaX: 40,
deltaY: 120,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -300,
y: -50,
scale: 0.5,
});
act(() => {
fireEvent.wheel(viewportElement, {
ctrlKey: true,
deltaY: -120,
clientX: 260,
clientY: 190,
});
});
expectViewport(result.current.viewport, {
x: -356,
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);
});
});