071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
434 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|