Files
Genarrative/src/components/image-editor/ImageCanvasInteractionModel.test.ts
T
kdletters fab9f21aa7 完善画布任务列表与远程抠图接入
新增图片画布任务侧栏页签、滚轮隔离、完成时间、后端计时和运行中状态展示。

限制外部生成任务只显示当前项目,并让任务点击聚焦画布内容和快速编辑源图。

接入外部生成任务列表查询状态过滤、提示词摘要和后端任务映射。

补充 BiRefNet 抠图请求日志、任务耗时提示和相关文档。

补充侧栏、画布聚焦、外部任务契约和 api-server 定向测试。

(cherry picked from commit 12ef866b66)
2026-06-30 17:12:13 +08:00

467 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
createMinimapModel,
fitViewportToBounds,
fitViewportToLayers,
getCanvasDropPoint,
getCanvasPointFromClient,
getWorldPointFromClient,
moveGenerationFrameFromDrag,
moveGenerationFramesFromDrag,
moveDragSelectedLayers,
moveLayersFromDrag,
moveViewportFromMinimapDrag,
moveViewportFromMinimapPointer,
moveViewportFromPan,
scaleViewportFromScreenPoint,
scrollViewportVertically,
selectCanvasObjectsInsideMarquee,
selectLayersInsideMarquee,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
DragState,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
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 createGenerationDialog(
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
const id = overrides.id ?? 'dialog-a';
return {
id,
mode: 'generate',
prompt: id,
status: 'idle',
placeholder: {
x: 100,
y: 120,
width: 200,
height: 100,
originalWidth: 200,
originalHeight: 100,
},
...overrides,
};
}
describe('ImageCanvasInteractionModel', () => {
it('fits layers into the visible canvas without upscaling past 100%', () => {
const viewport = fitViewportToLayers({
layers: [
createLayer({ id: 'left', x: 0, y: 0, width: 400, height: 300 }),
createLayer({ id: 'right', x: 600, y: 100, width: 200, height: 200 }),
],
canvasSize: { width: 900, height: 640 },
});
expect(viewport).toEqual({
x: 50,
y: 170,
scale: 1,
});
expect(
fitViewportToLayers({
layers: [createLayer({ width: 50000, height: 30000 })],
canvasSize: { width: 900, height: 640 },
})?.scale,
).toBe(0.025);
});
it('fits bounds inside reserved viewport insets', () => {
const viewport = fitViewportToBounds({
bounds: { x: 100, y: 100, width: 400, height: 300 },
canvasSize: { width: 900, height: 640 },
insets: { top: 80 },
padding: 20,
});
expect(viewport).toEqual({
x: 150,
y: 110,
scale: 1,
});
});
it('maps client points to canvas and world coordinates with safe fallbacks', () => {
const rect = { left: 100, top: 50, right: 700, bottom: 450 };
expect(
getCanvasPointFromClient({ clientX: 260, clientY: 190, rect }),
).toEqual({ x: 160, y: 140 });
expect(
getCanvasPointFromClient({ clientX: 80, clientY: 190, rect }),
).toBeNull();
expect(
getCanvasDropPoint({
clientX: 80,
clientY: 190,
rect,
canvasSize: { width: 900, height: 640 },
}),
).toEqual({ x: 450, y: 320 });
expect(
getWorldPointFromClient({
clientX: 260,
clientY: 190,
rect,
viewport: { x: -40, y: 20, scale: 2 },
}),
).toEqual({ x: 100, y: 60 });
});
it('scrolls vertically and zooms around a screen point', () => {
const viewport = { x: 10, y: 20, scale: 1 };
expect(scrollViewportVertically(viewport, 120)).toEqual({
x: 10,
y: -100,
scale: 1,
});
expect(
scaleViewportFromScreenPoint({
viewport,
nextScale: 2,
screenPoint: { x: 210, y: 120 },
}),
).toEqual({ x: -190, y: -80, scale: 2 });
const zoomedViewport = zoomViewportFromWheel({
viewport,
deltaY: -120,
screenPoint: { x: 210, y: 120 },
});
expect(zoomedViewport.x).toBeCloseTo(-10);
expect(zoomedViewport.y).toBeCloseTo(10);
expect(zoomedViewport.scale).toBe(1.1);
expect(
zoomViewportFromWheel({
viewport: { x: 10, y: 20, scale: 0.03 },
deltaY: 120,
screenPoint: { x: 210, y: 120 },
}).scale,
).toBe(0.027);
expect(
zoomViewportFromWheel({
viewport: { x: 10, y: 20, scale: 0.027 },
deltaY: 120,
screenPoint: { x: 210, y: 120 },
}).scale,
).toBe(0.025);
});
it('selects layers intersecting the marquee in screen space', () => {
const selectedIds = selectLayersInsideMarquee({
marquee: {
pointerId: 1,
startX: 90,
startY: 90,
currentX: 90,
currentY: 90,
},
currentPoint: { x: 360, y: 260 },
viewport: { x: 20, y: 10, scale: 1 },
layers: [
createLayer({ id: 'inside', x: 100, y: 120 }),
createLayer({ id: 'outside', x: 600, y: 600 }),
],
});
expect(selectedIds).toEqual(['inside']);
expect(
selectCanvasObjectsInsideMarquee({
marquee: {
pointerId: 1,
startX: 90,
startY: 90,
currentX: 90,
currentY: 90,
},
currentPoint: { x: 360, y: 260 },
viewport: { x: 20, y: 10, scale: 1 },
layers: [createLayer({ id: 'inside', x: 100, y: 120 })],
generationDialogs: [
createGenerationDialog({
id: 'dialog-inside',
placeholder: {
x: 130,
y: 130,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
},
}),
createGenerationDialog({
id: 'dialog-outside',
placeholder: {
x: 900,
y: 900,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
},
}),
],
}),
).toEqual(['inside', getCanvasGenerationSelectionId('dialog-inside')]);
});
it('moves pan, generation frames, and layer groups from drag state', () => {
expect(
moveViewportFromPan(
{
kind: 'pan',
pointerId: 1,
startClientX: 100,
startClientY: 200,
startViewport: { x: 10, y: 20, scale: 1 },
},
{ x: 140, y: 170 },
),
).toEqual({ x: 50, y: -10, scale: 1 });
expect(
moveGenerationFrameFromDrag(
{
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1'],
layerIds: [],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 300,
startFrameY: 400,
startFrames: [{ id: 'dialog-1', x: 300, y: 400 }],
startLayers: [],
startScale: 2,
},
{ x: 140, y: 170 },
),
).toEqual({ x: 320, y: 385 });
const layers = [
createLayer({ id: 'moving', x: 90, y: 90, width: 100, height: 100 }),
createLayer({ id: 'follower', x: 220, y: 90, width: 100, height: 100 }),
createLayer({ id: 'anchor', x: 300, y: 100, width: 100, height: 100 }),
];
const result = moveLayersFromDrag({
layers,
pointer: { x: 210, y: 100 },
dragState: {
kind: 'layer',
pointerId: 1,
layerId: 'moving',
layerIds: ['moving', 'follower'],
startClientX: 100,
startClientY: 100,
startLayerX: 90,
startLayerY: 90,
startLayers: [
{ id: 'moving', x: 90, y: 90 },
{ id: 'follower', x: 220, y: 90 },
],
startScale: 1,
},
});
expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({
x: 200,
y: 100,
});
expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({
x: 330,
y: 100,
});
expect(result?.snapGuide).toEqual({
vertical: 300,
horizontal: 100,
});
expect(result?.snapOffset).toEqual({
x: 0,
y: 10,
});
const generationDialogs = [
createGenerationDialog({ id: 'dialog-1' }),
createGenerationDialog({
id: 'dialog-2',
placeholder: {
x: 400,
y: 120,
width: 200,
height: 100,
originalWidth: 200,
originalHeight: 100,
},
}),
];
expect(
moveGenerationFramesFromDrag({
generationDialogs,
pointer: { x: 140, y: 170 },
snapOffset: { x: 6, y: -4 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1', 'dialog-2'],
layerIds: ['moving'],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 100,
startFrameY: 120,
startFrames: [
{ id: 'dialog-1', x: 100, y: 120 },
{ id: 'dialog-2', x: 400, y: 120 },
],
startLayers: [{ id: 'moving', x: 90, y: 90 }],
startScale: 2,
},
}).generationDialogs.map((dialog) => [
dialog.id,
dialog.placeholder?.x,
dialog.placeholder?.y,
]),
).toEqual([
['dialog-1', 126, 101],
['dialog-2', 426, 101],
]);
expect(
moveDragSelectedLayers({
layers,
pointer: { x: 140, y: 170 },
snapOffset: { x: 6, y: -4 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
dialogIds: ['dialog-1'],
layerIds: ['moving'],
pointerId: 1,
startClientX: 100,
startClientY: 200,
startFrameX: 100,
startFrameY: 120,
startFrames: [{ id: 'dialog-1', x: 100, y: 120 }],
startLayers: [{ id: 'moving', x: 90, y: 90 }],
startScale: 2,
},
}).find((layer) => layer.id === 'moving'),
).toMatchObject({ x: 116, y: 71 });
});
it('snaps generation frames to nearby layer guides while dragging', () => {
const [anchorLayer] = [
createLayer({ id: 'anchor', x: 420, y: 300, width: 120, height: 100 }),
];
const [movingDialog] = [
createGenerationDialog({
id: 'dialog-moving',
placeholder: {
x: 100,
y: 120,
width: 120,
height: 100,
originalWidth: 120,
originalHeight: 100,
},
}),
];
const result = moveGenerationFramesFromDrag({
layers: [anchorLayer],
generationDialogs: [movingDialog],
pointer: { x: 414, y: 292 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-moving',
dialogIds: ['dialog-moving'],
layerIds: [],
pointerId: 1,
startClientX: 100,
startClientY: 120,
startFrameX: 100,
startFrameY: 120,
startFrames: [{ id: 'dialog-moving', x: 100, y: 120 }],
startLayers: [],
startScale: 1,
},
});
expect(result.generationDialogs[0]?.placeholder).toMatchObject({
x: 420,
y: 300,
});
expect(result.snapGuide).toEqual({
vertical: 420,
horizontal: 300,
});
});
it('builds minimap projection and moves the viewport from minimap interactions', () => {
const layers = [
createLayer({ id: 'one', x: 100, y: 100, width: 200, height: 100 }),
createLayer({ id: 'two', x: 700, y: 400, width: 100, height: 100 }),
];
const minimap = createMinimapModel({
layers,
viewport: { x: -100, y: -50, scale: 1 },
canvasSize: { width: 900, height: 640 },
});
expect(minimap?.layers).toHaveLength(2);
expect(minimap?.viewport.width).toBeGreaterThan(2);
const nextViewport = moveViewportFromMinimapPointer({
viewport: { x: -100, y: -50, scale: 1 },
canvasSize: { width: 900, height: 640 },
minimapModel: minimap!,
pointer: { x: 100, y: 66 },
});
expect(nextViewport.x).not.toBe(-100);
expect(nextViewport.y).not.toBe(-50);
const dragState: Extract<DragState, { kind: 'minimap' }> = {
kind: 'minimap',
pointerId: 1,
startClientX: 100,
startClientY: 100,
startViewport: { x: -100, y: -50, scale: 1 },
minimapScale: minimap!.scale,
moved: true,
};
const draggedViewport = moveViewportFromMinimapDrag(dragState, {
x: 103,
y: 102,
});
expect(draggedViewport.x).toBeLessThan(-100);
expect(draggedViewport.y).toBeLessThan(-50);
});
});