cfbc6c36a5
默认以 2 倍内容分辨率渲染并保持 viewport.scale 的最终视觉比例 为 world 内 SVG 几何元素启用非缩放描边 补充超采样 transform 与 inverse-scale 契约测试
166 lines
5.1 KiB
TypeScript
166 lines
5.1 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import type { CanvasLayer } from '@genarrative/image-canvas-core';
|
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
import { createRef } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { CanvasWorld } from './CanvasWorld';
|
|
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
|
|
|
|
function layer(id: string, x: number): CanvasLayer {
|
|
return {
|
|
id,
|
|
resourceId: `resource-${id}`,
|
|
title: id,
|
|
src: `media:${id}`,
|
|
x,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
originalWidth: 100,
|
|
originalHeight: 100,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
};
|
|
}
|
|
|
|
function createViewport() {
|
|
const viewport = document.createElement('div');
|
|
Object.defineProperties(viewport, {
|
|
clientWidth: { configurable: true, value: 800 },
|
|
clientHeight: { configurable: true, value: 600 },
|
|
});
|
|
viewport.getBoundingClientRect = () => ({
|
|
left: 0,
|
|
top: 0,
|
|
right: 800,
|
|
bottom: 600,
|
|
width: 800,
|
|
height: 600,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => undefined,
|
|
});
|
|
document.body.append(viewport);
|
|
return viewport;
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.body.replaceChildren();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('shared canvas React lifecycle', () => {
|
|
it('renders a supersampled world while preserving the public viewport scale', () => {
|
|
render(
|
|
<CanvasWorld viewport={{ x: 12, y: 24, scale: 2 }}>
|
|
<span>content</span>
|
|
</CanvasWorld>,
|
|
);
|
|
|
|
const world = screen.getByText('content').parentElement?.parentElement;
|
|
const content = screen.getByText('content').parentElement;
|
|
expect(world?.style.width).toBe('12000px');
|
|
expect(world?.style.height).toBe('12000px');
|
|
expect(world?.style.transform).toBe('translate(12px, 24px) scale(1)');
|
|
expect(
|
|
world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'),
|
|
).toBe('0.5');
|
|
expect(content?.style.zoom).toBe('2');
|
|
});
|
|
|
|
it('removes every native listener across repeated mounts', () => {
|
|
const viewport = createViewport();
|
|
const addListener = vi.spyOn(viewport, 'addEventListener');
|
|
const removeListener = vi.spyOn(viewport, 'removeEventListener');
|
|
const ref = createRef<HTMLDivElement>();
|
|
ref.current = viewport;
|
|
const options = {
|
|
canvasViewportRef: ref,
|
|
layers: [layer('a', 0)],
|
|
captureCanvasHistory: vi.fn(),
|
|
};
|
|
|
|
const first = renderHook(() => useImageCanvasViewportControls(options));
|
|
first.unmount();
|
|
const second = renderHook(() => useImageCanvasViewportControls(options));
|
|
second.unmount();
|
|
|
|
expect(
|
|
addListener.mock.calls.filter(([type]) => type === 'wheel'),
|
|
).toHaveLength(2);
|
|
expect(
|
|
removeListener.mock.calls.filter(([type]) => type === 'wheel'),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it('disconnects the instance ResizeObserver on unmount', () => {
|
|
const viewport = createViewport();
|
|
const disconnect = vi.fn();
|
|
const observe = vi.fn();
|
|
vi.stubGlobal(
|
|
'ResizeObserver',
|
|
class {
|
|
observe = observe;
|
|
disconnect = disconnect;
|
|
},
|
|
);
|
|
const ref = createRef<HTMLDivElement>();
|
|
ref.current = viewport;
|
|
const hook = renderHook(() =>
|
|
useImageCanvasViewportControls({
|
|
canvasViewportRef: ref,
|
|
layers: [layer('a', 0)],
|
|
captureCanvasHistory: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
expect(observe).toHaveBeenCalledWith(viewport);
|
|
hook.unmount();
|
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the minimap inside each viewport instead of another canvas instance', () => {
|
|
const firstViewport = createViewport();
|
|
const secondViewport = createViewport();
|
|
const firstMinimap = document.createElement('button');
|
|
const secondMinimap = document.createElement('button');
|
|
firstMinimap.className = 'genarrative-image-canvas__minimap';
|
|
secondMinimap.className = 'genarrative-image-canvas__minimap';
|
|
firstMinimap.getBoundingClientRect = () =>
|
|
({ left: 10, top: 10, width: 132, height: 84 }) as DOMRect;
|
|
secondMinimap.getBoundingClientRect = () =>
|
|
({ left: 300, top: 200, width: 132, height: 84 }) as DOMRect;
|
|
firstViewport.append(firstMinimap);
|
|
secondViewport.append(secondMinimap);
|
|
const firstRef = createRef<HTMLDivElement>();
|
|
const secondRef = createRef<HTMLDivElement>();
|
|
firstRef.current = firstViewport;
|
|
secondRef.current = secondViewport;
|
|
|
|
const first = renderHook(() =>
|
|
useImageCanvasViewportControls({
|
|
canvasViewportRef: firstRef,
|
|
layers: [layer('a', 0), layer('b', 500)],
|
|
captureCanvasHistory: vi.fn(),
|
|
}),
|
|
);
|
|
const second = renderHook(() =>
|
|
useImageCanvasViewportControls({
|
|
canvasViewportRef: secondRef,
|
|
layers: [layer('c', 1000), layer('d', 1600)],
|
|
captureCanvasHistory: vi.fn(),
|
|
}),
|
|
);
|
|
const firstBefore = first.result.current.viewport;
|
|
const secondBefore = second.result.current.viewport;
|
|
|
|
act(() => first.result.current.moveViewportFromMinimapPointer(130, 80));
|
|
|
|
expect(first.result.current.viewport).not.toEqual(firstBefore);
|
|
expect(second.result.current.viewport).toEqual(secondBefore);
|
|
});
|
|
});
|